Compare commits
58 Commits
4180c3d215
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fcfa3e5bb | |||
| e8c8ddd48d | |||
| b77c6d1195 | |||
| 489e8e3bc9 | |||
| 1ba9c9eee2 | |||
|
|
0f3c63c4de | ||
|
|
aa089975df | ||
|
|
a6c04e52af | ||
|
|
d82738e7ea | ||
|
|
e005872ba0 | ||
|
|
d3e82a3ebb | ||
|
|
af8cbc1c96 | ||
|
|
ee6467a7ac | ||
|
|
cdf9a8bf18 | ||
|
|
f767bb5175 | ||
|
|
444aa37be2 | ||
|
|
15051cfa7a | ||
|
|
c5e78311e6 | ||
|
|
60b76c6d97 | ||
|
|
d12681b79f | ||
|
|
6c498c5f40 | ||
|
|
310370ef66 | ||
|
|
f4e6238176 | ||
|
|
d8cf7814ab | ||
|
|
50b69aadbf | ||
|
|
6cd121fa80 | ||
|
|
28a5d65f1a | ||
|
|
b4e97e14f3 | ||
|
|
78b4df1028 | ||
|
|
96101e4310 | ||
|
|
9c07d3195f | ||
|
|
4b2162505c | ||
|
|
8f1ba08e54 | ||
|
|
a16e1cc42a | ||
|
|
e19582201f | ||
|
|
f09afd2d9e | ||
|
|
f4eb278692 | ||
|
|
3ec501388c | ||
|
|
50b7fa784c | ||
|
|
5445bb0eec | ||
|
|
77b94e2b27 | ||
|
|
2cb2f0e4e8 | ||
|
|
e70982c8b6 | ||
|
|
7a1aec0d9f | ||
|
|
5eb19e022e | ||
|
|
00a43e0fbc | ||
|
|
c1aa6829c9 | ||
|
|
98acf6220e | ||
|
|
2308158976 | ||
|
|
7860ca6ad1 | ||
|
|
40ac075633 | ||
|
|
c75788503f | ||
|
|
5bd9d72cc6 | ||
|
|
ab517549a9 | ||
|
|
e92d58a46e | ||
|
|
3f1208f5ad | ||
|
|
8c1fb54afd | ||
|
|
99140c2c48 |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
166
.claude/CLAUDE.md
Normal file
166
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && npm start # Dev with hot-reload
|
||||
source ~/.nvm/nvm.sh && npm run make # Build distributable packages
|
||||
source ~/.nvm/nvm.sh && npm run package # Package without installers
|
||||
source ~/.nvm/nvm.sh && npm run lint # ESLint (.ts/.tsx)
|
||||
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema
|
||||
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
|
||||
```
|
||||
|
||||
No test suite currently.
|
||||
|
||||
## Architecture
|
||||
|
||||
Adiuva is a local-first Electron desktop app. The three processes communicate via a custom tRPC v11 ↔ IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11).
|
||||
|
||||
```
|
||||
Renderer (React 19) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite)
|
||||
```
|
||||
|
||||
### Main Process (`src/main/`)
|
||||
|
||||
Owns the database and all business logic.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `index.ts` | Window creation, app lifecycle |
|
||||
| `ipc.ts` | Bridges `ipcMain` to tRPC procedures |
|
||||
| `router/index.ts` | All tRPC sub-routers merged into `appRouter` |
|
||||
| `db/index.ts` | Drizzle + better-sqlite3, WAL mode, singleton `getDb()` |
|
||||
| `db/schema.ts` | Table definitions: clients, projects, tasks, checkpoints, notes, taskComments |
|
||||
| `db/vectordb.ts` | LanceDB vector store for note embeddings |
|
||||
| `store.ts` | electron-store for persistent UI settings |
|
||||
|
||||
### Preload (`src/preload/trpc.ts`)
|
||||
|
||||
Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()`.
|
||||
|
||||
### Renderer (`src/renderer/`)
|
||||
|
||||
React 19 — never accesses Node APIs directly. All data through `trpc.*.useQuery()` / `trpc.*.useMutation()`.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `lib/ipcLink.ts` | Custom TRPCLink routing through `window.electronTRPC` |
|
||||
| `lib/trpc.ts` | `createTRPCReact<AppRouter>()` typed client |
|
||||
| `index.tsx` | QueryClient + tRPC + Router providers |
|
||||
|
||||
### Routing
|
||||
|
||||
File-based via TanStack Router (`tsr.config.json` at root). Route tree auto-generated at `routeTree.gen.ts`.
|
||||
|
||||
Routes: `__root.tsx` (AppShell layout), `index`, `tasks`, `timeline`, `projects`, `notes.$noteId`
|
||||
|
||||
### tRPC Routers
|
||||
|
||||
`health`, `settings`, `clients`, `projects`, `tasks`, `checkpoints`, `notes`, `taskComments`, `ai`
|
||||
|
||||
### Database
|
||||
|
||||
Schema 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 table/column: edit `schema.ts` → `drizzle-kit generate` → `drizzle-kit push` (dev) or commit the migration.
|
||||
|
||||
### Adding a Feature (end-to-end)
|
||||
|
||||
1. **Schema** — `src/main/db/schema.ts`
|
||||
2. **Router** — Add sub-router in `src/main/router/index.ts`, merge into `appRouter`
|
||||
3. **Types** — Flow automatically via `AppRouter` export
|
||||
4. **UI** — Components in `src/renderer/components/<feature>/`, data via `trpc.*.useQuery()`
|
||||
|
||||
## AI Subsystem (`src/main/ai/`)
|
||||
|
||||
LangGraph-based agentic system with pluggable LLM providers.
|
||||
|
||||
### Orchestrator (`orchestrator.ts`)
|
||||
|
||||
Classifies user intent → routes to a specialist agent:
|
||||
|
||||
| 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` |
|
||||
|
||||
All providers use LangChain `bindTools()` + ToolMessage loop (max 5 iterations).
|
||||
|
||||
Also exports `dailyBrief()` for AI-generated daily summaries (`ai.dailyBrief` tRPC mutation).
|
||||
|
||||
### Streaming
|
||||
|
||||
`sendStreamChunk(sender, token, done)` over IPC `'ai:stream'`. Renderer subscribes via `window.electronAI.onStreamChunk()` in `AIChatPanel.tsx`. `<tool_call>` blocks are filtered before display.
|
||||
|
||||
### Providers (`llm.ts`)
|
||||
|
||||
| Provider | Model | Notes |
|
||||
|---|---|---|
|
||||
| OpenAI | `gpt-4o-mini` | Via LangChain |
|
||||
| Anthropic | `claude-sonnet-4-20250514` | Via LangChain |
|
||||
| Copilot | `ChatCopilot` wrapper | `copilot.ts` / `chat-copilot.ts` |
|
||||
|
||||
All use `temperature: 0.3`, streaming enabled. Provider management in `provider.ts`.
|
||||
|
||||
### Vector Embeddings (`db/vectordb.ts`)
|
||||
**Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled.
|
||||
|
||||
**Token storage** (`token.ts`) — two-tier fallback:
|
||||
1. electron-store + `safeStorage` — encrypted at rest (preferred)
|
||||
2. Plain electron-store — last resort (e.g. WSL with no keyring)
|
||||
|
||||
**AI approval pattern**: Tasks and checkpoints have `isAiSuggested` (bool) and `isApproved` (bool) columns. AI-suggested items appear in the UI pending user approval before being treated as real records.
|
||||
|
||||
### Vector Embeddings (`src/main/db/vectordb.ts`)
|
||||
|
||||
LanceDB in `{userData}/vectors/`. Schema: `{ id, projectId, content, vector }` (1536-dim, `text-embedding-3-small` via `embeddings.ts`). Embedding priority: Copilot CLI token → OpenAI token.
|
||||
|
||||
- `upsertNoteEmbedding()` on note create/update (fire-and-forget)
|
||||
- `migrateNotesIfNeeded()` backfills on first startup
|
||||
- `searchNotes(query, limit=5)` used by Knowledge agent
|
||||
|
||||
### AI Approval Pattern
|
||||
|
||||
Tasks and checkpoints have `isAiSuggested` + `isApproved` columns. AI suggestions appear pending user approval (dashed borders in UI).
|
||||
|
||||
## Config Notes
|
||||
|
||||
- Vite configs use `.mts` (not `.ts`) — avoids ESM/CJS conflicts with electron-forge
|
||||
- `@/*` path alias → `src/renderer/*` (TypeScript + Vite + shadcn/ui)
|
||||
- **shadcn/ui**: new-york style, neutral base color
|
||||
- **Icons**: lucide-react only — do not introduce other icon libraries
|
||||
- **Tailwind 4** — CSS variable theming in `globals.css`, no `tailwind.config.js`
|
||||
- **Notes editor**: Milkdown (`@milkdown/crepe`) at `src/renderer/components/notes/MilkdownEditor.tsx`
|
||||
|
||||
## Design Context
|
||||
|
||||
### Target User
|
||||
Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier.
|
||||
|
||||
### Brand
|
||||
**Calm, intelligent, warm.** Thoughtful companion, not flashy tool. Confident and understated, never loud or gamified.
|
||||
|
||||
### Palette
|
||||
|
||||
| | Canvas | Primary | Secondary | Borders |
|
||||
|---|---|---|---|---|
|
||||
| **Light** | Pinkish-white `#f4edf3` | Golden yellow `#fbc881` | Slate blue-gray `#8a8ea9` | Dusty lavender `#c8c3cd` |
|
||||
| **Dark** | Near-black `#0c0c0c` | Pure white | — | Dark gray `#323232` |
|
||||
|
||||
### Typography
|
||||
Geist sans-serif, weights 400/500/600. Tight tracking (`-1px`) on headings. Body `text-sm`, metadata `text-xs`.
|
||||
|
||||
### Visual Language
|
||||
- 10px border-radius, `rounded-2xl` for chat elements
|
||||
- Glassmorphism on AI inputs (`backdrop-blur-xl`, transparency)
|
||||
- Spring animations (stiffness 400, damping 30), scale-and-fade transitions
|
||||
- No gamification (badges, streaks, confetti). Mature and professional
|
||||
|
||||
### Design Principles
|
||||
1. **Clarity over cleverness** — Clear hierarchy, generous whitespace, comfortable density
|
||||
2. **AI as quiet partner** — Deeply integrated but never intrusive. Dashed borders for pending AI items, Sparkles icon as AI marker
|
||||
3. **Warmth in restraint** — Warm palette feels approachable without being playful. Dark mode trades warmth for focus
|
||||
4. **Motion with purpose** — Animations reinforce spatial relationships, never decorative
|
||||
5. **Confidence through consistency** — CSS variable tokens, shadcn/ui primitives, Geist font. Predictable, keyboard-first
|
||||
8
.claude/settings.json
Normal file
8
.claude/settings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add AI_REFACTOR_PLAN.md)",
|
||||
"Bash(git commit:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
124
.gitea/workflows/build.yaml
Normal file
124
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,124 @@
|
||||
name: Release Electron App
|
||||
run-name: Releasing ${{ gitea.ref_name }}
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release-desktop:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: electronuserland/builder:wine
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install System Dependencies for Linux Makers
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y fakeroot dpkg mono-complete
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Set version from tag
|
||||
run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Make App (Linux)
|
||||
run: npm run make -- --platform=linux --arch=x64
|
||||
|
||||
- name: Initialize Wine
|
||||
run: |
|
||||
export WINEDEBUG=-all
|
||||
export DISPLAY=
|
||||
wineboot --init
|
||||
env:
|
||||
WINEDEBUG: '-all'
|
||||
|
||||
- name: Make App (Windows)
|
||||
run: npm run make -- --platform=win32 --arch=x64
|
||||
env:
|
||||
WINEDEBUG: '-all'
|
||||
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
GITEA_URL="http://10.0.0.119:3000"
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
TOKEN="${{ gitea.token }}"
|
||||
|
||||
# Check if release exists, create if not
|
||||
RELEASE_ID=$(curl -sf \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" \
|
||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
RELEASE_ID=$(curl -sf \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \
|
||||
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
|
||||
fi
|
||||
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
echo "RELEASE_ID=${RELEASE_ID}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload Release Assets
|
||||
shell: bash
|
||||
run: |
|
||||
GITEA_URL="http://10.0.0.119:3000"
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
TOKEN="${{ gitea.token }}"
|
||||
MAX_RETRIES=3
|
||||
|
||||
upload_file() {
|
||||
local file="$1"
|
||||
local name
|
||||
name=$(basename "$file")
|
||||
local encoded_name
|
||||
encoded_name=$(printf '%s' "$name" | sed 's/ /%20/g')
|
||||
local attempt=1
|
||||
|
||||
while [ $attempt -le $MAX_RETRIES ]; do
|
||||
local filesize
|
||||
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file" 2>/dev/null || echo "unknown")
|
||||
echo "Uploading ${name} (${filesize} bytes, attempt ${attempt}/${MAX_RETRIES})..."
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" \
|
||||
--max-time 300 \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Expect:" \
|
||||
-F "attachment=@${file}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||
BODY=$(echo "$RESPONSE" | head -n -1)
|
||||
|
||||
if [ "$HTTP_CODE" -ge 200 ] 2>/dev/null && [ "$HTTP_CODE" -lt 300 ] 2>/dev/null; then
|
||||
echo "✅ Uploaded ${name}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "⚠️ Upload failed (HTTP ${HTTP_CODE}), body: ${BODY}"
|
||||
echo "Retrying in 5s..."
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "❌ Failed to upload ${name} after ${MAX_RETRIES} attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
FAILED=0
|
||||
while IFS= read -r -d '' file; do
|
||||
upload_file "$file" || FAILED=1
|
||||
done < <(find out/make -type f \( -name "*.exe" -o -name "*.zip" -o -name "*.deb" -o -name "*.rpm" \) -print0)
|
||||
|
||||
if [ $FAILED -eq 1 ]; then
|
||||
echo "Some uploads failed"
|
||||
exit 1
|
||||
fi
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -90,3 +90,7 @@ typings/
|
||||
|
||||
# Electron-Forge
|
||||
out/
|
||||
|
||||
# local config files
|
||||
.vscode/
|
||||
|
||||
|
||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
526
AI_REFACTOR_PLAN.md
Normal file
526
AI_REFACTOR_PLAN.md
Normal file
@@ -0,0 +1,526 @@
|
||||
# AI Refactor Plan — Adiuva Electron App
|
||||
|
||||
> **Objective:** Transform the Electron app into a hybrid-first multi-agent client. The user controls where data is stored (local / cloud / sync), which AI provider to use (BYOK multi-provider), and which automations to run — either custom batch agents built with the LLM-powered Batch Builder, or pre-built plugins from the marketplace. All data access is opt-in, transparent, and auditable.
|
||||
>
|
||||
> **Backend:** Lives in a separate repository. See `../adiuva-api/BACKEND_PLAN.md` for the API contract and backend implementation guide.
|
||||
>
|
||||
> **Protocol:** Execute steps sequentially. Each step is atomic and committable. Mark `[x]` when done.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — API Contracts & Types
|
||||
|
||||
### Step 0.1 — Define backend API contract types
|
||||
- [ ] Create `src/shared/api-types.ts` with all interfaces the Electron app needs to communicate with the backend:
|
||||
- `ExecutionPlan`, `PlanStep`, `PlanAction` (action types: `create_record`, `update_record`, `delete_record`, `index_document`, `send_notification`, `call_agent`)
|
||||
- `ChatRequest` (message, context, execution_mode: `'direct'` | `'plan'`)
|
||||
- `ChatResponse` (response, actions)
|
||||
- `ChatContext` (user_profile, relevant_documents, recent_tasks, conversation_history)
|
||||
- `AgentManifest` (name, description, permissions, schedule)
|
||||
- `PermissionGrant` (plugin, permission type, resource path, granted_at)
|
||||
- `BackupMetadata` (version, timestamp, checksum, chunk_count)
|
||||
- `BillingTier` enum (`free`, `pro`, `power`, `team`)
|
||||
- `AuthTokens` (access_token, refresh_token, expires_at)
|
||||
- `UserProfile` (id, email, tier)
|
||||
- [ ] Create `src/shared/batch-types.ts` with all types for the batch builder and storage layer:
|
||||
- `StorageTarget` — `'local'` | `'cloud'` | `'sync'` | `'none'`
|
||||
- `ConnectorType` — `'imap'` | `'filesystem'` | `'calendar'` | `'api'` | `'gmail'` | `'gdrive'` | `'outlook'`
|
||||
- `BatchActionType` — `'create_record'` | `'update_record'` | `'delete_record'` | `'index_document'` | `'send_notification'` | `'call_agent'`
|
||||
- `BatchSource` — `{ connector: ConnectorType, config: Record<string, unknown> }`
|
||||
- `BatchTrigger` — `{ type: 'cron' | 'event', schedule?: string, timezone?: string }`
|
||||
- `BatchAnalysis` — `{ prompt: string, model_override?: string, output_schema?: object }`
|
||||
- `BatchAction` — `{ type: BatchActionType, table?: string, mapping?: Record<string, string> }`
|
||||
- `BatchStorage` — `{ records: StorageTarget, vectors: StorageTarget, raw_data: StorageTarget }`
|
||||
- `BatchConfig` — full config object: `id`, `name`, `description`, `enabled`, `source`, `trigger`, `analysis`, `actions`, `storage`, `permissions`
|
||||
- `BatchStatus` — `'idle'` | `'running'` | `'error'` | `'disabled'`
|
||||
- `BatchRunResult` — `{ batchId, runAt, status, itemsProcessed, errors }`
|
||||
- `PluginListing` — `{ id, name, description, author, version, rating, installs, category, permissions, price }`
|
||||
- `InstalledPlugin` — `{ listing: PluginListing, installedAt, enabled, storageConfig: BatchStorage }`
|
||||
- `DataSourceInfo` — `{ type: ConnectorType, label, recordCount, sizeBytes, storageTarget: StorageTarget }`
|
||||
- `StorageStats` — `{ localUsedBytes, cloudUsedBytes, cloudLimitBytes, sources: DataSourceInfo[] }`
|
||||
- [ ] Update `tsconfig.json` paths if needed to include `src/shared/`
|
||||
- **Files:** `src/shared/api-types.ts`, `src/shared/batch-types.ts`, `tsconfig.json`
|
||||
- **Outcome:** Type-safe contracts for all backend communication and the batch/storage subsystem. Backend repo mirrors these as Pydantic schemas.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — LiteLLM Multi-Provider Client
|
||||
|
||||
### Step 1.1 — Create unified LLM client wrapper
|
||||
- [ ] Create `src/main/llm/litellm-client.ts`:
|
||||
- `LiteLLMClient` class with unified interface:
|
||||
- `complete(messages: Message[], options?: CompletionOptions): Promise<CompletionResponse>`
|
||||
- `stream(messages: Message[], options?: CompletionOptions): AsyncGenerator<string>`
|
||||
- `embed(text: string): Promise<number[]>`
|
||||
- `CompletionOptions`: model override, temperature, max_tokens, tools
|
||||
- Provider-agnostic: internally maps to the correct provider SDK
|
||||
- Fallback chain: tries primary provider, on failure tries secondary, logs each attempt
|
||||
- Timeout handling: per-provider configurable timeouts
|
||||
- [ ] Create `src/main/llm/providers.ts`:
|
||||
- `ProviderConfig` interface: name, apiKey, model, endpoint (for Ollama), timeout, isLocal
|
||||
- `ProviderRegistry`: manages configured providers, persists to electron-store
|
||||
- `getActiveProvider()`, `setActiveProvider(name)`, `addProvider(config)`, `removeProvider(name)`
|
||||
- `getFallbackChain(): ProviderConfig[]`
|
||||
- Supported providers: OpenAI, Anthropic, Google (Gemini), Mistral, Groq, Ollama (local)
|
||||
- [ ] Create `src/main/llm/embeddings.ts` (refactored):
|
||||
- Support multiple embedding providers (OpenAI text-embedding-3-small, local ONNX with all-MiniLM-L6-v2)
|
||||
- Auto-select: use local ONNX if available, fall back to API
|
||||
- Same `embedText(text): Promise<number[]>` interface
|
||||
- **Files:** `src/main/llm/litellm-client.ts`, `src/main/llm/providers.ts`, `src/main/llm/embeddings.ts`
|
||||
- **Outcome:** Single LLM interface that all local components use. Supports 6+ providers with fallback.
|
||||
|
||||
### Step 1.2 — Migrate existing AI code to use new LLM client
|
||||
- [ ] Update `src/main/ai/orchestrator.ts`:
|
||||
- Replace direct `getLLM()` calls with `LiteLLMClient.complete()` / `LiteLLMClient.stream()`
|
||||
- Keep local orchestration working with the new client (backend delegation comes in Phase 3)
|
||||
- [ ] Update `src/main/ai/llm.ts`:
|
||||
- Deprecate. Redirect `getLLM()` to instantiate via `LiteLLMClient` as a thin compatibility shim
|
||||
- [ ] Update `src/main/ai/embeddings.ts` to delegate to `src/main/llm/embeddings.ts`
|
||||
- [ ] Update `src/main/ai/token.ts`:
|
||||
- Add `listStoredProviders(): Promise<string[]>` to enumerate which providers have tokens
|
||||
- [ ] Ensure all existing AI features (chat, daily brief, tool calling) continue to work
|
||||
- **Files:** `src/main/ai/orchestrator.ts`, `src/main/ai/llm.ts`, `src/main/ai/embeddings.ts`, `src/main/ai/token.ts`
|
||||
- **Outcome:** Existing AI features work identically but go through the new unified LLM client.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Local Plugin System & Batch Agents
|
||||
|
||||
### Step 2.1 — Create plugin manifest system and permission manager
|
||||
- [ ] Create `src/main/permissions/manifest-validator.ts`:
|
||||
- `PluginManifest` interface: `name`, `description`, `version`, `permissions: PermissionRequest[]`, `schedule?: string` (cron), `entryPoint: string`
|
||||
- `PermissionRequest`: `type` (read_folder, read_email, read_calendar, read_browser_history), `resource?: string` (path, account), `reason: string`
|
||||
- `validateManifest(manifest): ValidationResult` — validates structure, checks for dangerous permissions
|
||||
- [ ] Create `src/main/permissions/permission-manager.ts`:
|
||||
- `PermissionManager` class (singleton):
|
||||
- `grantPermission(pluginName, permission): void` — persists to SQLite
|
||||
- `revokePermission(pluginName, permission): void`
|
||||
- `checkPermission(pluginName, permission): boolean`
|
||||
- `getPluginPermissions(pluginName): PermissionGrant[]`
|
||||
- `getAllGrants(): PermissionGrant[]`
|
||||
- `logAccess(pluginName, permission, resource, timestamp): void` — activity log
|
||||
- `getActivityLog(pluginName?, limit?): ActivityLogEntry[]`
|
||||
- Permission grants stored in a new `plugin_permissions` SQLite table
|
||||
- Activity log stored in a new `plugin_activity_log` SQLite table
|
||||
- [ ] Add `plugin_permissions` and `plugin_activity_log` tables to `src/main/db/schema.ts`
|
||||
- [ ] Generate and apply migration
|
||||
- **Files:** `src/main/permissions/manifest-validator.ts`, `src/main/permissions/permission-manager.ts`, `src/main/db/schema.ts`, `src/main/db/migrations/`
|
||||
- **Outcome:** Granular, opt-in permission system for plugins. Every access is logged.
|
||||
|
||||
### Step 2.2 — Create worker pool and batch runner
|
||||
- [ ] Create `src/main/workers/worker-pool.ts`:
|
||||
- `WorkerPool` class:
|
||||
- Manages a pool of Node.js `worker_threads`
|
||||
- `runPlugin(manifest, context): Promise<PluginResult>` — spawns or reuses a worker, sends manifest + context, receives result
|
||||
- Worker lifecycle: create, send message, receive result, terminate on timeout
|
||||
- Max concurrent workers: configurable (default 4)
|
||||
- Error isolation: worker crash doesn't affect main process
|
||||
- [ ] Create `src/main/workers/batch-runner.ts`:
|
||||
- `BatchRunner` class:
|
||||
- `registerPlugin(manifest): void` — validates manifest, stores in registry
|
||||
- `startScheduler(): void` — cron-based scheduler using `node-cron` or simple setInterval
|
||||
- `runPlugin(name, triggerContext?): Promise<PluginResult>` — manual trigger
|
||||
- `stopAll(): void` — graceful shutdown of all scheduled plugins
|
||||
- Scheduler checks permissions before each run; skips if revoked
|
||||
- Results logged to activity log
|
||||
- [ ] Create `src/main/workers/plugin-worker.ts`:
|
||||
- Worker thread entry point
|
||||
- Receives plugin config + context via `parentPort.on('message')`
|
||||
- Dynamically imports the plugin entry point
|
||||
- Executes `run(context)` with sandboxed access (only permitted resources)
|
||||
- Posts result back via `parentPort.postMessage()`
|
||||
- **Files:** `src/main/workers/worker-pool.ts`, `src/main/workers/batch-runner.ts`, `src/main/workers/plugin-worker.ts`
|
||||
- **Outcome:** Isolated plugin execution environment with scheduling, permissions enforcement, and error isolation.
|
||||
|
||||
### Step 2.3 — Implement batch agent plugins
|
||||
- [ ] Create `src/plugins/email-scanner.ts`:
|
||||
- Manifest: requires `read_email` permission
|
||||
- Connects to IMAP via `imapflow` (account configured in settings)
|
||||
- Scans for new emails since last run
|
||||
- Uses `LiteLLMClient` to classify each email (has actionable task? extract title, priority, description)
|
||||
- Returns extracted task metadata (never raw email content) for execution via backend or local playbook
|
||||
- [ ] Create `src/plugins/file-watcher.ts`:
|
||||
- Manifest: requires `read_folder` permission for each watched path
|
||||
- Uses `chokidar` to watch approved directories
|
||||
- On new/modified file: reads content, generates embedding, upserts into vector store
|
||||
- Supports: .txt, .md, .pdf (text extraction), .docx (basic extraction)
|
||||
- [ ] Create `src/plugins/calendar-sync.ts`:
|
||||
- Manifest: requires `read_calendar` permission
|
||||
- Parses ICS files or connects to CalDAV endpoint
|
||||
- Detects scheduling conflicts
|
||||
- Suggests reorganizations via LLM analysis
|
||||
- Returns calendar events + conflict reports
|
||||
- [ ] Create `src/plugins/browser-agent.ts`:
|
||||
- Manifest: requires `read_browser_history` permission (explicit opt-in)
|
||||
- Reads browser bookmarks and history from known browser paths (Chrome, Firefox, Edge)
|
||||
- Indexes relevant entries into vector store
|
||||
- Privacy-first: only indexes URLs and titles, not page content
|
||||
- **Files:** `src/plugins/email-scanner.ts`, `src/plugins/file-watcher.ts`, `src/plugins/calendar-sync.ts`, `src/plugins/browser-agent.ts`
|
||||
- **Outcome:** Four local batch agents running as isolated worker threads, using LiteLLM for analysis.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Backend Integration
|
||||
|
||||
### Step 3.1 — Create backend HTTP/WebSocket client
|
||||
- [ ] Create `src/main/api/backend-client.ts`:
|
||||
- `BackendClient` class:
|
||||
- `baseUrl` configurable (default: production cloud URL, overridable for dev)
|
||||
- `setAuthToken(jwt: string): void`
|
||||
- `chat(request: ChatRequest): Promise<ChatResponse>` — POST /api/v1/chat
|
||||
- `chatStream(request: ChatRequest): AsyncGenerator<string>` — WebSocket /api/v1/chat/stream
|
||||
- `getPlaybooks(): Promise<ExecutionPlan[]>` — GET /api/v1/plans/playbook
|
||||
- `uploadBackup(blob: Buffer, metadata: BackupMetadata): Promise<void>` — PUT /api/v1/backup
|
||||
- `downloadBackup(): Promise<{ blob: Buffer, metadata: BackupMetadata }>` — GET /api/v1/backup
|
||||
- Automatic retry with exponential backoff (max 3 attempts)
|
||||
- Offline detection: returns cached playbook responses when offline
|
||||
- `isOnline(): boolean` — connectivity check
|
||||
- [ ] Create `src/main/api/plan-runner.ts`:
|
||||
- `PlanRunner` class:
|
||||
- `execute(plan: ExecutionPlan): Promise<PlanResult>` — executes plan steps locally
|
||||
- Step handlers: `create_record` (inserts into SQLite), `update_record`, `delete_record`, `index_document` (upserts into vector store), `send_notification` (Electron notification API)
|
||||
- Each step logs to activity log
|
||||
- Supports `data_from_step` references (pipeline execution)
|
||||
- Validates plan structure before execution
|
||||
- **Files:** `src/main/api/backend-client.ts`, `src/main/api/plan-runner.ts`
|
||||
- **Outcome:** Electron can communicate with the cloud backend and execute returned plans locally.
|
||||
|
||||
### Step 3.2 — Refactor orchestrator to delegate to backend
|
||||
- [ ] Update `src/main/ai/orchestrator.ts`:
|
||||
- When online: forward chat requests to backend via `BackendClient.chatStream()`
|
||||
- Build `ChatRequest` from local context: query SQLite for user profile, relevant documents (from vector store), recent tasks, conversation history
|
||||
- Stream backend response tokens to renderer via existing `ai:stream` IPC channel
|
||||
- Execute any returned actions via `PlanRunner`
|
||||
- When offline: fall back to local orchestration (existing LangGraph pipeline) with degraded capabilities
|
||||
- Remove direct agent logic (project agent, knowledge agent, general agent tool definitions) — these now live on the backend
|
||||
- Keep `buildProjectContext()` and `buildGlobalContext()` as context builders for the request payload
|
||||
- [ ] Update `src/main/router/index.ts` `ai` sub-router:
|
||||
- `chat` mutation: call refactored orchestrator (which now delegates to backend)
|
||||
- Add `getPlaybooks` query: fetches cached playbooks
|
||||
- Keep `dailyBrief` mutation: sends daily brief request to backend
|
||||
- [ ] Add IPC handler for plan execution results
|
||||
- **Files:** `src/main/ai/orchestrator.ts`, `src/main/router/index.ts`, `src/main/ipc.ts`
|
||||
- **Outcome:** Chat intelligence lives on the backend; Electron is the execution layer.
|
||||
|
||||
### Step 3.3 — Implement Shared Memory (three-tier local memory)
|
||||
- [ ] Create `src/main/database/shared-memory.ts`:
|
||||
- **Short-term memory**: In-memory conversation buffer
|
||||
- `ConversationBuffer` class: stores last N messages per session
|
||||
- `addMessage(sessionId, role, content)`, `getHistory(sessionId, limit?) -> Message[]`
|
||||
- Cleared on session end
|
||||
- **Long-term KV store**: SQLite-backed key-value store
|
||||
- New `agent_memory` table: `id`, `namespace` (agent name), `key`, `value` (JSON text), `updated_at`
|
||||
- `AgentMemoryStore` class: `get(namespace, key)`, `set(namespace, key, value)`, `delete(namespace, key)`, `listKeys(namespace)`
|
||||
- Used by agents to persist learned facts, user preferences
|
||||
- **Vector store**: Already exists (LanceDB). Enhance with:
|
||||
- Multi-collection support: separate tables for notes, emails, files, calendar
|
||||
- `searchByCollection(collection, query, limit) -> SearchResult[]`
|
||||
- [ ] Add `agent_memory` table to `src/main/db/schema.ts`
|
||||
- [ ] Generate migration
|
||||
- **Files:** `src/main/database/shared-memory.ts`, `src/main/db/schema.ts`, `src/main/db/migrations/`
|
||||
- **Outcome:** Three-tier memory system supporting short-term conversation, long-term agent facts, and semantic search.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Security: E2E Backup & Offline Mode
|
||||
|
||||
### Step 4.1 — Implement E2E encrypted backup
|
||||
- [ ] Create `src/main/backup/e2e-crypto.ts`:
|
||||
- `generatePassphrase(): string` — BIP39-compatible 12-word recovery phrase
|
||||
- `deriveKey(passphrase: string, salt: Buffer): Promise<Buffer>` — Argon2id key derivation (time cost 3, memory 64MB, parallelism 1)
|
||||
- `encrypt(data: Buffer, key: Buffer): { ciphertext: Buffer, iv: Buffer, authTag: Buffer }` — AES-256-GCM
|
||||
- `decrypt(ciphertext: Buffer, key: Buffer, iv: Buffer, authTag: Buffer): Buffer`
|
||||
- Uses `node:crypto` for AES and `argon2` npm package for key derivation
|
||||
- [ ] Create `src/main/backup/backup-manager.ts`:
|
||||
- `BackupManager` class:
|
||||
- `createBackup(passphrase: string): Promise<BackupBlob>` — Exports SQLite DB, encrypts, returns blob + metadata
|
||||
- `restoreBackup(blob: Buffer, passphrase: string): Promise<void>` — Decrypts blob, replaces local DB, re-initializes
|
||||
- `uploadBackup(passphrase: string): Promise<void>` — Creates backup, uploads via `BackendClient`
|
||||
- `downloadAndRestore(passphrase: string): Promise<void>` — Downloads from backend, decrypts, restores
|
||||
- Incremental backup: chunks DB into segments, encrypts each separately, tracks content hashes to skip unchanged chunks
|
||||
- Metadata header: version, timestamp, checksum (SHA-256 of plaintext), chunk count
|
||||
- **Files:** `src/main/backup/e2e-crypto.ts`, `src/main/backup/backup-manager.ts`
|
||||
- **Outcome:** User data never leaves the device unencrypted. Backend stores only opaque blobs.
|
||||
|
||||
### Step 4.2 — Implement offline sync queue
|
||||
- [ ] Create `src/main/backup/sync-queue.ts`:
|
||||
- `SyncQueue` class:
|
||||
- `enqueue(action: QueuedAction): void` — Adds action to persistent queue (SQLite table `sync_queue`)
|
||||
- `processQueue(): Promise<void>` — Processes queued actions in FIFO order when online
|
||||
- `getQueueSize(): number`
|
||||
- `clearQueue(): void`
|
||||
- Conflict resolution: last-write-wins with timestamps
|
||||
- New `sync_queue` table: `id`, `action_type`, `payload` (JSON), `created_at`, `status` (pending/processing/failed), `retry_count`, `last_error`
|
||||
- Auto-drain: watches connectivity, starts processing when online
|
||||
- Failed actions: retry up to 3 times with exponential backoff, then mark as `failed` for user review
|
||||
- [ ] Add `sync_queue` table to schema
|
||||
- [ ] Integrate with `BackendClient`: when offline, chat/backup calls enqueue instead of failing
|
||||
- **Files:** `src/main/backup/sync-queue.ts`, `src/main/db/schema.ts`, `src/main/api/backend-client.ts`
|
||||
- **Outcome:** App works offline; queued actions sync automatically when connectivity returns.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Auth Integration & Database Encryption
|
||||
|
||||
### Step 5.1 — Integrate auth into Electron app
|
||||
- [ ] Create `src/main/auth/auth-manager.ts`:
|
||||
- `AuthManager` class:
|
||||
- `login(email, password): Promise<void>` — Calls backend POST /api/v1/auth/login, stores JWT in secure storage (via token.ts)
|
||||
- `register(email, password): Promise<void>` — Calls POST /api/v1/auth/register
|
||||
- `logout(): void` — Clears stored JWT
|
||||
- `getToken(): string | null` — Returns current JWT
|
||||
- `refreshToken(): Promise<void>` — Auto-refresh before expiry
|
||||
- `isAuthenticated(): boolean`
|
||||
- `getCurrentTier(): BillingTier`
|
||||
- Auto-refresh: checks token expiry every 5 minutes, refreshes if < 10 minutes remaining
|
||||
- [ ] Add tRPC procedures: `auth.login`, `auth.register`, `auth.logout`, `auth.status`, `auth.tier`
|
||||
- [ ] Wire `BackendClient` to use `AuthManager.getToken()` for all requests
|
||||
- **Files:** `src/main/auth/auth-manager.ts`, `src/main/router/index.ts`, `src/main/api/backend-client.ts`
|
||||
- **Outcome:** Electron app has full auth flow; backend requests are authenticated.
|
||||
|
||||
### Step 5.2 — Migrate from better-sqlite3 to SQLCipher
|
||||
- [ ] Add `@journeyapps/sqlcipher` to dependencies (replaces `better-sqlite3`)
|
||||
- [ ] Update `src/main/db/index.ts`:
|
||||
- Replace `better-sqlite3` import with `@journeyapps/sqlcipher`
|
||||
- On first launch: derive DB key from OS keychain or prompt user
|
||||
- `initDb(password)`: opens DB with `PRAGMA key = 'password'`
|
||||
- Migration path for existing unencrypted DBs: detect → export → create encrypted → import → delete old
|
||||
- WAL mode still enabled after keying
|
||||
- [ ] Update `src/main/index.ts`: pass password to `initDb()`
|
||||
- [ ] Test that all existing Drizzle operations work with SQLCipher
|
||||
- **Files:** `package.json`, `src/main/db/index.ts`, `src/main/index.ts`
|
||||
- **Outcome:** All local data encrypted at rest with SQLCipher.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Renderer UI Updates
|
||||
|
||||
> **Navigation model:** The app has a sidebar with top-level routes matching the pages below. Each page is a full-screen view. Shared hooks live in `src/renderer/hooks/`. All data access goes through tRPC procedures — no direct IPC calls from components.
|
||||
|
||||
### Step 6.1 — Restructure app shell and routing
|
||||
- [ ] Update `src/renderer/App.tsx`:
|
||||
- Define top-level routes: `/chat`, `/batch-builder`, `/plugins`, `/data-manager`, `/settings`, `/activity`
|
||||
- Add sidebar navigation with icons and labels for each route
|
||||
- Persist last active route in electron-store
|
||||
- [ ] Create `src/renderer/hooks/useProvider.ts`:
|
||||
- `useProvider()` — returns active provider config, `setProvider()`, `testProvider()`, list of configured providers
|
||||
- Backed by tRPC `provider.*` procedures (to be added in Phase 1)
|
||||
- [ ] Create `src/renderer/hooks/useStorage.ts`:
|
||||
- `useStorage()` — returns `StorageStats`, `setStorageTarget(source, target)`, `migrateData(source, from, to)`
|
||||
- Backed by tRPC `storage.*` procedures (to be added in Phase 2)
|
||||
- **Files:** `src/renderer/App.tsx`, `src/renderer/hooks/useProvider.ts`, `src/renderer/hooks/useStorage.ts`
|
||||
- **Outcome:** App shell with all top-level routes and shared data hooks.
|
||||
|
||||
### Step 6.2 — ChatPage with context panel
|
||||
- [ ] Create `src/renderer/pages/ChatPage.tsx`:
|
||||
- Two-column layout: chat area (left/main) + collapsible `ContextPanel` (right)
|
||||
- Wraps `ChatWindow` and `ContextPanel` components
|
||||
- Online/offline status bar at top
|
||||
- [ ] Create `src/renderer/components/chat/ChatWindow.tsx`:
|
||||
- Message list rendering `MessageBubble` for each entry
|
||||
- Input bar with send button and attachment support
|
||||
- Handles streaming tokens from `useChat` hook
|
||||
- Plan approval UI inline: expandable plan steps with approve/reject per-step
|
||||
- Error states: offline, auth expired, rate limited, server error (distinct UI for each)
|
||||
- [ ] Create `src/renderer/components/chat/MessageBubble.tsx`:
|
||||
- Renders user / assistant / system messages
|
||||
- Supports markdown rendering for assistant messages
|
||||
- Shows tool-call indicators when the agent uses a tool
|
||||
- Timestamp and copy-to-clipboard action
|
||||
- [ ] Create `src/renderer/components/chat/ContextPanel.tsx`:
|
||||
- Shows what context the agent used for the last response: matched documents, recent tasks, memory entries
|
||||
- Each context item links to its source (note, file, batch result)
|
||||
- Collapsible, persists open/closed state
|
||||
- [ ] Create `src/renderer/hooks/useChat.ts`:
|
||||
- `useChat(sessionId)` — message list, `sendMessage()`, streaming state, connection mode (`'backend'` | `'local'`)
|
||||
- Automatically falls back to local orchestrator when offline
|
||||
- Exposes `approveStep(stepId)` / `rejectStep(stepId)` for plan execution
|
||||
- **Files:** `src/renderer/pages/ChatPage.tsx`, `src/renderer/components/chat/ChatWindow.tsx`, `src/renderer/components/chat/MessageBubble.tsx`, `src/renderer/components/chat/ContextPanel.tsx`, `src/renderer/hooks/useChat.ts`
|
||||
- **Outcome:** Full chat UI with context transparency, plan approval, and seamless online/offline fallback.
|
||||
|
||||
### Step 6.3 — BatchBuilderPage
|
||||
- [ ] Create `src/renderer/pages/BatchBuilderPage.tsx`:
|
||||
- Two views: **Active Batches** list (default) and **Create New Batch** wizard
|
||||
- Active list renders `BatchCard` for each active batch config
|
||||
- "Create" button opens the wizard
|
||||
- [ ] Create `src/renderer/components/batch-builder/NaturalLanguageInput.tsx`:
|
||||
- Textarea where the user describes the batch in plain language
|
||||
- "Generate" button calls `useBatchBuilder().generate(description)`
|
||||
- Loading skeleton while the LLM generates the config
|
||||
- [ ] Create `src/renderer/components/batch-builder/ConfigPreview.tsx`:
|
||||
- Shows the generated `BatchConfig` as an editable form (not raw JSON)
|
||||
- Sections: Source, Trigger, Analysis, Actions, Storage — each collapsible
|
||||
- Inline editing for every field (prompt textarea, cron expression with human-readable label, mapping table)
|
||||
- "Edit raw JSON" toggle for power users
|
||||
- [ ] Create `src/renderer/components/batch-builder/ConnectorPicker.tsx`:
|
||||
- Dropdown of available connector types (IMAP, Filesystem, Gmail, GDrive, Outlook, Calendar, Generic API)
|
||||
- When selected, shows connector-specific config fields (e.g. IMAP: host, folder, filter_from; Filesystem: path picker)
|
||||
- OAuth connectors show "Connect account" button that opens the OAuth flow
|
||||
- [ ] Create `src/renderer/components/batch-builder/StoragePicker.tsx`:
|
||||
- Three-way toggle per storage dimension: **Local** / **Cloud** / **Sync** / **None**
|
||||
- Dimensions: Records, Vectors, Raw data
|
||||
- Shows storage impact estimate per option
|
||||
- Disabled options grayed out with tier tooltip if current tier doesn't support cloud
|
||||
- [ ] Create `src/renderer/components/batch-builder/SchedulePicker.tsx`:
|
||||
- Mode toggle: **Cron** (with human-readable label, e.g. "Every day at 08:00") / **Event** (on new data from connector)
|
||||
- Timezone selector (defaults to system timezone)
|
||||
- Visual cron builder for non-technical users (with raw cron input fallback)
|
||||
- [ ] Create `src/renderer/components/batch-builder/BatchCard.tsx`:
|
||||
- Shows batch name, connector icon, last run time, next run time, status badge (`idle` / `running` / `error` / `disabled`)
|
||||
- Actions: Run now, Edit, Disable/Enable, Delete
|
||||
- Expandable to show last run summary (items processed, errors)
|
||||
- [ ] Create `src/renderer/components/batch-builder/BatchTestRunner.tsx`:
|
||||
- "Dry Run" panel: picks one real item from the source, runs the full analysis pipeline, shows output without saving
|
||||
- Shows LLM output, action mapping preview, what would be stored and where
|
||||
- Pass/Fail indicator with detailed error on failure
|
||||
- [ ] Create `src/renderer/hooks/useBatchBuilder.ts`:
|
||||
- `useBatchBuilder()` — `generate(description): Promise<BatchConfig>`, `validate(config)`, `save(config)`, `activate(id)`, `deactivate(id)`, `runNow(id)`, `dryRun(id)`, `delete(id)`, list of saved configs with live status
|
||||
- Backed by tRPC `batch.*` procedures
|
||||
- **Files:** `src/renderer/pages/BatchBuilderPage.tsx`, `src/renderer/components/batch-builder/{NaturalLanguageInput,ConfigPreview,ConnectorPicker,StoragePicker,SchedulePicker,BatchCard,BatchTestRunner}.tsx`, `src/renderer/hooks/useBatchBuilder.ts`
|
||||
- **Outcome:** Full Batch Builder UI — users can describe a batch in natural language, review/edit the generated config, dry-run it, and activate it with a single flow.
|
||||
|
||||
### Step 6.4 — PluginStorePage
|
||||
- [ ] Create `src/renderer/pages/PluginStorePage.tsx`:
|
||||
- Two tabs: **Marketplace** (browse available plugins) and **Installed** (manage installed plugins)
|
||||
- Marketplace: search bar, category filter chips, grid of plugin cards sorted by rating/installs
|
||||
- Installed: list of `InstalledPlugin` entries with enable/disable toggles and settings links
|
||||
- [ ] Create plugin card component (inline or shared `common/`):
|
||||
- Shows name, author, description, rating (stars), install count, category badge, price/free badge
|
||||
- "Install" button → triggers permission request dialog → installs plugin
|
||||
- "Settings" button (installed) → opens plugin-specific config drawer
|
||||
- [ ] Plugin install flow:
|
||||
- On install click: fetch plugin manifest from backend
|
||||
- Show `PermissionDialog` with the permissions the plugin requires
|
||||
- On approve: call tRPC `plugins.install(id)`, download and register the plugin worker
|
||||
- Show `StoragePicker` for the plugin's data (what goes local/cloud/sync)
|
||||
- **Files:** `src/renderer/pages/PluginStorePage.tsx`
|
||||
- **Outcome:** Users can discover and install pre-built plugins from the marketplace with full permission visibility.
|
||||
|
||||
### Step 6.5 — DataManagerPage
|
||||
- [ ] Create `src/renderer/pages/DataManagerPage.tsx`:
|
||||
- Top section: `StorageOverview` dashboard
|
||||
- Below: list of `DataSourceCard` for each active data source (one card per connector/plugin)
|
||||
- "Migrate" button opens `MigrationWizard`
|
||||
- [ ] Create `src/renderer/components/data-manager/StorageOverview.tsx`:
|
||||
- Visual breakdown: local disk used vs. cloud used vs. cloud limit
|
||||
- Per-category breakdown (emails, files, notes, calendar, vectors)
|
||||
- Tier upgrade CTA if approaching cloud limit
|
||||
- [ ] Create `src/renderer/components/data-manager/DataSourceCard.tsx`:
|
||||
- Card per data source (e.g. "Gmail Scanner", "Documenti/Fatture watcher")
|
||||
- Shows record count, size, last sync time
|
||||
- Inline `StoragePicker` toggle for that source (where its data lives)
|
||||
- "Clear local cache" / "Delete all data" actions with confirmation
|
||||
- [ ] Create `src/renderer/components/data-manager/MigrationWizard.tsx`:
|
||||
- Step wizard: select source → select direction (local → cloud or cloud → local) → confirm
|
||||
- Shows estimated data size and time
|
||||
- Progress indicator during migration
|
||||
- Rolls back on error
|
||||
- **Files:** `src/renderer/pages/DataManagerPage.tsx`, `src/renderer/components/data-manager/{StorageOverview,DataSourceCard,MigrationWizard}.tsx`
|
||||
- **Outcome:** Users have full visibility and control over where every piece of their data lives.
|
||||
|
||||
### Step 6.6 — ActivityLogPage
|
||||
- [ ] Create `src/renderer/pages/ActivityLogPage.tsx`:
|
||||
- Full-page filterable table of all batch/plugin activity entries
|
||||
- Columns: timestamp, source (batch name / plugin name), action type, data accessed, storage destination, status
|
||||
- Filters: source, date range, action type, status (success/error)
|
||||
- Row expand: shows full detail — which records were created/updated, which files were read, LLM calls made
|
||||
- Export as CSV button
|
||||
- **Files:** `src/renderer/pages/ActivityLogPage.tsx`
|
||||
- **Outcome:** Complete transparency log so users can audit exactly what each agent did and when.
|
||||
|
||||
### Step 6.7 — SettingsPage (multi-provider, auth, backup, embeddings)
|
||||
- [ ] Create `src/renderer/pages/SettingsPage.tsx` with tabbed sections:
|
||||
- **AI Providers** tab:
|
||||
- List of configured providers with status badge (active / inactive / error)
|
||||
- Add provider form: name dropdown (OpenAI, Anthropic, Google, Mistral, Groq, Ollama), API key input, model selection, endpoint (for Ollama)
|
||||
- Set primary provider and fallback chain
|
||||
- Test connection button per provider
|
||||
- Separate "Embeddings provider" section: provider + model for embeddings (OpenAI, Cohere, Voyage, Mistral Embed)
|
||||
- Info callout: "Text sent to the embeddings provider to generate vectors — make sure you trust this provider with your data"
|
||||
- **Account & Billing** tab:
|
||||
- Login/register form (when not authenticated)
|
||||
- Current tier display with feature list and upgrade CTA
|
||||
- Usage indicators (batch count, cloud storage used)
|
||||
- Logout button
|
||||
- **Backup & Sync** tab:
|
||||
- Recovery passphrase: generate new / view existing (masked, reveal on click)
|
||||
- Manual backup trigger with last backup timestamp
|
||||
- Auto-backup schedule toggle + interval picker
|
||||
- Backup history table (timestamp, size, restore button)
|
||||
- **Permissions** tab:
|
||||
- Table of all active permission grants (plugin/batch, permission type, resource, granted date)
|
||||
- Revoke button per grant
|
||||
- Links to ActivityLogPage for per-source audit
|
||||
- [ ] Create `src/renderer/components/common/ProviderSelector.tsx`:
|
||||
- Reusable dropdown that lists configured LLM providers
|
||||
- Used in BatchBuilder (model_override field) and Settings
|
||||
- [ ] Create `src/renderer/components/common/PermissionDialog.tsx`:
|
||||
- Modal triggered when a plugin/batch requests new permissions
|
||||
- Lists each requested permission with its reason and resource path
|
||||
- Per-permission approve/deny toggles (deny is default)
|
||||
- Shows plugin/batch manifest info (name, description, version)
|
||||
- "Approve selected" confirms; "Deny all" closes without granting
|
||||
- **Files:** `src/renderer/pages/SettingsPage.tsx`, `src/renderer/components/common/PermissionDialog.tsx`, `src/renderer/components/common/ProviderSelector.tsx`
|
||||
- **Outcome:** Centralised settings covering providers, embeddings, auth, backup, and permissions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Cleanup & Hardening
|
||||
|
||||
### Step 7.1 — Remove deprecated AI code
|
||||
- [ ] Delete `src/main/ai/copilot.ts` (Copilot SDK replaced by LiteLLM)
|
||||
- [ ] Delete `src/main/ai/chat-copilot.ts` (LangChain adapter no longer needed)
|
||||
- [ ] Delete or archive `src/main/ai/llm.ts` (replaced by `src/main/llm/litellm-client.ts`)
|
||||
- [ ] Remove `@github/copilot-sdk`, `@langchain/langgraph` from dependencies (if unused)
|
||||
- [ ] Clean up `src/main/ai/provider.ts`: simplify to delegate to `src/main/llm/providers.ts`
|
||||
- [ ] Remove `currentSender` module-level mutable state from orchestrator (proper context passing)
|
||||
- [ ] Update `src/main/index.ts` startup: remove `import './ai/copilot'`, add `BatchRunner.startScheduler()`, add `AuthManager` init
|
||||
- **Files:** Multiple files under `src/main/ai/`, `package.json`, `src/main/index.ts`
|
||||
- **Outcome:** No dead code; clean, maintainable codebase.
|
||||
|
||||
### Step 7.2 — Add error handling and logging
|
||||
- [ ] Implement structured logging in main process:
|
||||
- Log levels: debug, info, warn, error
|
||||
- Log destinations: console (dev), file (production, rotated)
|
||||
- Correlation IDs for request tracing across IPC → backend → response
|
||||
- [ ] Add error boundaries in renderer:
|
||||
- Per-route error boundaries
|
||||
- AI chat error boundary (graceful degradation)
|
||||
- Plugin error boundary (shows which plugin failed)
|
||||
- **Files:** `src/main/utils/logger.ts` (new), `src/renderer/components/ErrorBoundary.tsx` (new)
|
||||
- **Outcome:** Production-ready error handling and observability.
|
||||
|
||||
### Step 7.3 — Electron integration tests
|
||||
- [ ] Test BackendClient with mocked HTTP responses
|
||||
- [ ] Test PlanRunner with sample execution plans
|
||||
- [ ] Test SyncQueue offline → online transition
|
||||
- [ ] Test BackupManager encrypt → decrypt round-trip
|
||||
- [ ] Test PermissionManager grant → check → revoke cycle
|
||||
- **Files:** `src/main/__tests__/` (new test directory)
|
||||
- **Outcome:** Confidence that all Electron-side components work correctly.
|
||||
|
||||
---
|
||||
|
||||
## New Dependencies (package.json)
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `@journeyapps/sqlcipher` | Encrypted SQLite (replaces `better-sqlite3`) |
|
||||
| `argon2` | Key derivation for E2E backup |
|
||||
| `node-cron` | Batch agent scheduling |
|
||||
| `chokidar` | File watching (FileWatcher plugin) |
|
||||
| `imapflow` | IMAP client (IMAP connector) |
|
||||
| `googleapis` | Gmail + GDrive OAuth connectors |
|
||||
| `lancedb` | Local vector store |
|
||||
| `onnxruntime-node` | Local embeddings (optional, future) |
|
||||
|
||||
---
|
||||
|
||||
## Execution Notes
|
||||
|
||||
- **Each step is independently committable** and produces working code.
|
||||
- **Phases 1-2** (LLM client + plugins) are independent of the backend — can start immediately.
|
||||
- **Phase 3** (backend integration) requires the backend repo to have the `/api/v1/chat` endpoint ready.
|
||||
- **Phase 5.2** (SQLCipher) is intentionally late to avoid encryption overhead during active schema changes.
|
||||
- **The existing app continues to work** throughout the migration. Local orchestration is preserved until backend is ready (Step 3.2).
|
||||
- **One step at a time.** Implement one numbered step per session. When the step is fully done, mark all its checkboxes as `[x]` in this file and commit with message `step N complete: <outcome line>`.
|
||||
@@ -4,7 +4,7 @@
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"config": "",
|
||||
"css": "src/renderer/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
|
||||
1230
docs/floating-ai-integration-guide.md
Normal file
1230
docs/floating-ai-integration-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
222
forge.config.ts
222
forge.config.ts
@@ -7,12 +7,232 @@ import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-nati
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
// Packages externalized in vite.main.config.mts that must be installed at runtime.
|
||||
// Keep this list in sync with the Vite external array.
|
||||
const externalPackages = [
|
||||
'better-sqlite3',
|
||||
'@github/copilot-sdk',
|
||||
'@langchain/core',
|
||||
'@langchain/langgraph',
|
||||
'@langchain/openai',
|
||||
'@langchain/anthropic',
|
||||
'vectordb',
|
||||
'electron-squirrel-startup',
|
||||
'electron-store',
|
||||
];
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
asar: {
|
||||
unpack: '**/{*.node,*.dll,*.so,*.dylib}',
|
||||
},
|
||||
name: 'adiuva',
|
||||
},
|
||||
rebuildConfig: {},
|
||||
hooks: {
|
||||
packageAfterCopy: async (_forgeConfig, buildPath, _electronVersion, platform, arch) => {
|
||||
// The VitePlugin's ignore filter only copies .vite/ into the build.
|
||||
// Externalized packages need to be installed into node_modules here.
|
||||
// At this point, only .vite/ exists. The VitePlugin writes package.json
|
||||
// in its own afterCopy hook (which may run after ours). Read from source.
|
||||
const srcPjPath = path.resolve(__dirname, 'package.json');
|
||||
const pjPath = path.resolve(buildPath, 'package.json');
|
||||
const pj = JSON.parse(fs.readFileSync(srcPjPath, 'utf-8'));
|
||||
|
||||
// Keep only externalized packages in dependencies
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const pkg of externalPackages) {
|
||||
if (pj.dependencies?.[pkg]) {
|
||||
filtered[pkg] = pj.dependencies[pkg];
|
||||
}
|
||||
}
|
||||
pj.dependencies = filtered;
|
||||
delete pj.devDependencies;
|
||||
fs.writeFileSync(pjPath, JSON.stringify(pj, null, 2));
|
||||
|
||||
// Copy lockfile for reproducible installs
|
||||
const lockSrc = path.resolve(buildPath, '..', '..', 'package-lock.json');
|
||||
if (fs.existsSync(lockSrc)) {
|
||||
fs.copyFileSync(lockSrc, path.resolve(buildPath, 'package-lock.json'));
|
||||
}
|
||||
|
||||
// Install only the externalized runtime deps
|
||||
console.log('[forge] Installing externalized dependencies...');
|
||||
execSync('npm install --omit=dev', {
|
||||
cwd: buildPath,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, npm_config_nodedir: '' },
|
||||
});
|
||||
|
||||
const targetKey = `${platform}-${arch}`;
|
||||
|
||||
// vectordb uses platform-specific optional deps (@lancedb/vectordb-<platform>-<arch>-*).
|
||||
// npm install on Linux only pulls the Linux variant. Force-install the target's.
|
||||
const platformNativePackages: Record<string, Record<string, string>> = {
|
||||
'win32-x64': {
|
||||
'@lancedb/vectordb-win32-x64-msvc': '',
|
||||
},
|
||||
'linux-x64': {
|
||||
'@lancedb/vectordb-linux-x64-gnu': '',
|
||||
},
|
||||
'darwin-x64': {
|
||||
'@lancedb/vectordb-darwin-x64': '',
|
||||
},
|
||||
'darwin-arm64': {
|
||||
'@lancedb/vectordb-darwin-arm64': '',
|
||||
},
|
||||
};
|
||||
const nativePkgs = platformNativePackages[targetKey];
|
||||
if (nativePkgs) {
|
||||
// Remove wrong-platform lancedb native packages
|
||||
const nmPath = path.join(buildPath, 'node_modules', '@lancedb');
|
||||
if (fs.existsSync(nmPath)) {
|
||||
for (const entry of fs.readdirSync(nmPath)) {
|
||||
if (entry.startsWith('vectordb-') && !Object.keys(nativePkgs).includes(`@lancedb/${entry}`)) {
|
||||
fs.rmSync(path.join(nmPath, entry), { recursive: true, force: true });
|
||||
console.log(`[forge] Removed non-target native package: @lancedb/${entry}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Install correct platform packages
|
||||
const pkgsToInstall = Object.keys(nativePkgs).join(' ');
|
||||
console.log(`[forge] Installing platform-specific packages for ${targetKey}: ${pkgsToInstall}`);
|
||||
execSync(`npm install ${pkgsToInstall} --omit=dev --no-save --force`, {
|
||||
cwd: buildPath,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
// Remove cross-platform prebuilt binaries that don't match the target.
|
||||
// Packages like @github/copilot ship prebuilds for all platforms;
|
||||
// keeping foreign-arch .node files breaks rpmbuild's strip step.
|
||||
const nodeModulesPath = path.join(buildPath, 'node_modules');
|
||||
const findPrebuilds = (dir: string): string[] => {
|
||||
const results: string[] = [];
|
||||
if (!fs.existsSync(dir)) return results;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'prebuilds') {
|
||||
results.push(full);
|
||||
} else {
|
||||
results.push(...findPrebuilds(full));
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
for (const prebuildsDir of findPrebuilds(nodeModulesPath)) {
|
||||
for (const entry of fs.readdirSync(prebuildsDir)) {
|
||||
if (entry !== targetKey) {
|
||||
const fullPath = path.join(prebuildsDir, entry);
|
||||
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||
console.log(`[forge] Removed non-target prebuild: ${entry}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @github/copilot ships @teddyzhu/clipboard-* platform packages outside
|
||||
// of prebuilds/. Remove non-target variants to avoid bundling wrong binaries.
|
||||
const clipboardDir = path.join(buildPath, 'node_modules', '@github', 'copilot', 'clipboard', 'node_modules', '@teddyzhu');
|
||||
if (fs.existsSync(clipboardDir)) {
|
||||
const targetClipboardMap: Record<string, string> = {
|
||||
'win32-x64': 'clipboard-win32-x64-msvc',
|
||||
'win32-arm64': 'clipboard-win32-arm64-msvc',
|
||||
'linux-x64': 'clipboard-linux-x64-gnu',
|
||||
'linux-arm64': 'clipboard-linux-arm64-gnu',
|
||||
'darwin-x64': 'clipboard-darwin-x64',
|
||||
'darwin-arm64': 'clipboard-darwin-arm64',
|
||||
};
|
||||
const wantedPkg = targetClipboardMap[targetKey];
|
||||
for (const entry of fs.readdirSync(clipboardDir)) {
|
||||
if (entry.startsWith('clipboard-') && entry !== wantedPkg) {
|
||||
fs.rmSync(path.join(clipboardDir, entry), { recursive: true, force: true });
|
||||
console.log(`[forge] Removed non-target clipboard package: @teddyzhu/${entry}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Post-rebuild: fix native binaries for cross-compilation ──────
|
||||
// Forge runs @electron/rebuild AFTER packageAfterCopy, which
|
||||
// recompiles native addons for the BUILD platform (Linux).
|
||||
// packageAfterPrune runs AFTER rebuild+prune, so we can safely
|
||||
// replace the Linux .node files with the correct target prebuilts.
|
||||
packageAfterPrune: async (_forgeConfig, buildPath, _electronVersion, platform, arch) => {
|
||||
const targetKey = `${platform}-${arch}`;
|
||||
const buildKey = `${process.platform}-${process.arch}`;
|
||||
if (targetKey === buildKey) return; // native build — nothing to fix
|
||||
|
||||
console.log(`[forge:afterPrune] Cross-compile fixup: ${buildKey} → ${targetKey}`);
|
||||
const electronVersion = JSON.parse(
|
||||
fs.readFileSync(path.resolve(__dirname, 'node_modules', 'electron', 'package.json'), 'utf-8'),
|
||||
).version;
|
||||
|
||||
// Replace native addons that @electron/rebuild compiled for the host.
|
||||
const nativeModules = ['better-sqlite3'];
|
||||
for (const mod of nativeModules) {
|
||||
const modDir = path.join(buildPath, 'node_modules', mod);
|
||||
if (!fs.existsSync(modDir)) continue;
|
||||
|
||||
// Remove the host-platform binary left by @electron/rebuild
|
||||
const buildRelease = path.join(modDir, 'build', 'Release');
|
||||
if (fs.existsSync(buildRelease)) {
|
||||
fs.rmSync(buildRelease, { recursive: true, force: true });
|
||||
console.log(`[forge:afterPrune] Cleaned host-platform build/Release for ${mod}`);
|
||||
}
|
||||
|
||||
// Download the correct prebuilt for the TARGET platform
|
||||
console.log(`[forge:afterPrune] Downloading ${mod} prebuilt for ${targetKey} (Electron ${electronVersion})...`);
|
||||
execSync(
|
||||
`npx --yes prebuild-install -r electron -t ${electronVersion} ` +
|
||||
`--platform ${platform} --arch ${arch} --tag-prefix v --verbose`,
|
||||
{ cwd: modDir, stdio: 'inherit' },
|
||||
);
|
||||
|
||||
// Verify the binary exists and is for the correct platform.
|
||||
const releaseDir = path.join(modDir, 'build', 'Release');
|
||||
if (!fs.existsSync(releaseDir)) {
|
||||
throw new Error(
|
||||
`[forge] FATAL: build/Release/ not found for ${mod} after prebuild-install. ` +
|
||||
`The native binary was not downloaded.`,
|
||||
);
|
||||
}
|
||||
const nodeFiles = fs.readdirSync(releaseDir).filter((f) => f.endsWith('.node'));
|
||||
if (nodeFiles.length === 0) {
|
||||
throw new Error(
|
||||
`[forge] FATAL: No .node files in build/Release/ for ${mod} after prebuild-install.`,
|
||||
);
|
||||
}
|
||||
for (const f of nodeFiles) {
|
||||
const buf = Buffer.alloc(4);
|
||||
const fd = fs.openSync(path.join(releaseDir, f), 'r');
|
||||
fs.readSync(fd, buf, 0, 4, 0);
|
||||
fs.closeSync(fd);
|
||||
// ELF magic: 0x7f 'E' 'L' 'F'
|
||||
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
|
||||
throw new Error(
|
||||
`[forge] FATAL: ${mod} build/Release/${f} is an ELF binary! ` +
|
||||
`Cross-compilation failed — refusing to package a Linux .node for Windows.`,
|
||||
);
|
||||
}
|
||||
// PE magic: 'M' 'Z' (0x4d 0x5a) — expected for win32
|
||||
if (platform === 'win32' && !(buf[0] === 0x4d && buf[1] === 0x5a)) {
|
||||
throw new Error(
|
||||
`[forge] FATAL: ${mod} build/Release/${f} is not a PE (Windows) binary! ` +
|
||||
`Magic bytes: ${buf.toString('hex')}. Refusing to package.`,
|
||||
);
|
||||
}
|
||||
console.log(`[forge:afterPrune] Verified ${mod}/${f} — correct platform ✓`);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
makers: [
|
||||
new MakerSquirrel({}),
|
||||
new MakerZIP({}, ['darwin']),
|
||||
|
||||
22
knip.json
Normal file
22
knip.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"tags": ["-lintignore"],
|
||||
"entry": [
|
||||
"src/main/index.ts",
|
||||
"src/preload/index.ts",
|
||||
"src/preload/trpc.ts",
|
||||
"forge.config.ts",
|
||||
"vite.main.config.mts",
|
||||
"vite.preload.config.mts",
|
||||
"vite.renderer.config.mts"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"postcss",
|
||||
"@electron-forge/shared-types",
|
||||
"@milkdown/plugin-upload",
|
||||
"@milkdown/prose"
|
||||
],
|
||||
"ignore": [
|
||||
"src/renderer/components/ui/**"
|
||||
]
|
||||
}
|
||||
9629
package-lock.json
generated
9629
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
38
package.json
38
package.json
@@ -10,10 +10,11 @@
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "eslint --ext .ts,.tsx ."
|
||||
"lint": "eslint --ext .ts,.tsx .",
|
||||
"knip": "knip"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "rmusso",
|
||||
"author": "roberto",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.11.1",
|
||||
@@ -28,34 +29,35 @@
|
||||
"@tanstack/router-vite-plugin": "^1.161.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/electron-squirrel-startup": "^1.0.2",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"electron": "40.6.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"knip": "^5.85.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@github/copilot-sdk": "^0.1.25",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@langchain/anthropic": "^1.3.19",
|
||||
"@langchain/core": "^1.1.27",
|
||||
"@langchain/langgraph": "^1.1.5",
|
||||
"@langchain/openai": "^1.2.9",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-router": "^1.161.1",
|
||||
"@trpc/client": "^11.10.0",
|
||||
@@ -64,15 +66,21 @@
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-trpc": "^0.7.1",
|
||||
"framer-motion": "^12.34.2",
|
||||
"lucide-react": "^0.575.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vectordb": "^0.21.2",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
# Ralph Agent Instructions
|
||||
|
||||
You are an autonomous coding agent working on a software project.
|
||||
|
||||
## Your Task
|
||||
|
||||
1. Read the full app PRD at `prd-main.md` (in the same directory as this file)
|
||||
2. Read the PRD at `prd.json` (in the same directory as this file)
|
||||
3. Read the progress log at `progress.txt` (check Codebase Patterns section first)
|
||||
4. Check you're on the correct branch from PRD `branchName`. If not, check it out or create from main.
|
||||
5. Pick the **highest priority** user story where `passes: false`
|
||||
6. Implement that single user story
|
||||
7. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires)
|
||||
8. Update CLAUDE.md files if you discover reusable patterns (see below)
|
||||
9. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]`
|
||||
10. Update the PRD to set `passes: true` for the completed story
|
||||
11. Append your progress to `progress.txt`
|
||||
|
||||
## Progress Report Format
|
||||
|
||||
APPEND to progress.txt (never replace, always append):
|
||||
```
|
||||
## [Date/Time] - [Story ID]
|
||||
- What was implemented
|
||||
- Files changed
|
||||
- **Learnings for future iterations:**
|
||||
- Patterns discovered (e.g., "this codebase uses X for Y")
|
||||
- Gotchas encountered (e.g., "don't forget to update Z when changing W")
|
||||
- Useful context (e.g., "the evaluation panel is in component X")
|
||||
---
|
||||
```
|
||||
|
||||
The learnings section is critical - it helps future iterations avoid repeating mistakes and understand the codebase better.
|
||||
|
||||
## Consolidate Patterns
|
||||
|
||||
If you discover a **reusable pattern** that future iterations should know, add it to the `## Codebase Patterns` section at the TOP of progress.txt (create it if it doesn't exist). This section should consolidate the most important learnings:
|
||||
|
||||
```
|
||||
## Codebase Patterns
|
||||
- Example: Use `sql<number>` template for aggregations
|
||||
- Example: Always use `IF NOT EXISTS` for migrations
|
||||
- Example: Export types from actions.ts for UI components
|
||||
```
|
||||
|
||||
Only add patterns that are **general and reusable**, not story-specific details.
|
||||
|
||||
## Update CLAUDE.md Files
|
||||
|
||||
Before committing, check if any edited files have learnings worth preserving in nearby CLAUDE.md files:
|
||||
|
||||
1. **Identify directories with edited files** - Look at which directories you modified
|
||||
2. **Check for existing CLAUDE.md** - Look for CLAUDE.md in those directories or parent directories
|
||||
3. **Add valuable learnings** - If you discovered something future developers/agents should know:
|
||||
- API patterns or conventions specific to that module
|
||||
- Gotchas or non-obvious requirements
|
||||
- Dependencies between files
|
||||
- Testing approaches for that area
|
||||
- Configuration or environment requirements
|
||||
|
||||
**Examples of good CLAUDE.md additions:**
|
||||
- "When modifying X, also update Y to keep them in sync"
|
||||
- "This module uses pattern Z for all API calls"
|
||||
- "Tests require the dev server running on PORT 3000"
|
||||
- "Field names must match the template exactly"
|
||||
|
||||
**Do NOT add:**
|
||||
- Story-specific implementation details
|
||||
- Temporary debugging notes
|
||||
- Information already in progress.txt
|
||||
|
||||
Only update CLAUDE.md if you have **genuinely reusable knowledge** that would help future work in that directory.
|
||||
|
||||
## Quality Requirements
|
||||
|
||||
- ALL commits must pass your project's quality checks (typecheck, lint, test)
|
||||
- Do NOT commit broken code
|
||||
- Keep changes focused and minimal
|
||||
- Follow existing code patterns
|
||||
|
||||
## Browser Testing (If Available)
|
||||
|
||||
For any story that changes UI, verify it works in the browser if you have browser testing tools configured (e.g., via MCP):
|
||||
|
||||
1. Navigate to the relevant page
|
||||
2. Verify the UI changes work as expected
|
||||
3. Take a screenshot if helpful for the progress log
|
||||
|
||||
If no browser tools are available, note in your progress report that manual browser verification is needed.
|
||||
|
||||
## Stop Condition
|
||||
|
||||
After completing a user story, check if ALL stories have `passes: true`.
|
||||
|
||||
If ALL stories are complete and passing, reply with:
|
||||
<promise>COMPLETE</promise>
|
||||
|
||||
If there are still stories with `passes: false`, end your response normally (another iteration will pick up the next story).
|
||||
|
||||
## Important
|
||||
|
||||
- Work on ONE story per iteration
|
||||
- Commit frequently
|
||||
- Keep CI green
|
||||
- Read the Codebase Patterns section in progress.txt before starting
|
||||
@@ -1,487 +0,0 @@
|
||||
# PRD: Adiuva — MVP Implementation
|
||||
|
||||
> **Status:** APPROVED / READY FOR DEV
|
||||
> **Version:** 1.0 (MVP)
|
||||
> **Date:** 2026-02-19
|
||||
> **Stack:** Electron · React · TypeScript · shadcn/ui · Tailwind · Drizzle ORM · SQLite · LanceDB · GitHub Copilot SDK
|
||||
> **Figma:** [Full File](https://www.figma.com/design/FxyJG9kpou4DfD7jM9WHKP/Desk)
|
||||
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
Adiuva is a local-first desktop workspace acting as a "Digital Executive Secretary." It centralizes notes, tasks, and project context into a local SQLite database and exposes a multi-agent AI layer (via GitHub Copilot SDK) that proactively surfaces insights and drafts actions. Data never leaves the machine, making it safe for enterprise environments.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
- Ship a working Electron desktop app with five sections: Home, Timeline, Tasks, Projects, Notes.
|
||||
- All data persisted locally in SQLite (zero cloud dependency for data storage).
|
||||
- Hierarchical Client → Sub-Client → Project structure fully navigable from a sidebar tree.
|
||||
- "Fluid Curtain" pull-down gesture that transitions any view into a full-screen AI chat scoped to the current context.
|
||||
- Multi-agent AI system (@Orchestrator, @ProjectAgent, @EmailAgent, @KnowledgeAgent) integrated via GitHub Copilot SDK.
|
||||
- Milestone completion = every section functional at the level described in User Stories below.
|
||||
|
||||
---
|
||||
|
||||
## UI Summary (from Figma)
|
||||
|
||||
### Shared Shell
|
||||
- **Left sidebar:** 240px, `#fafafa` background, border-right `#e5e5e5`.
|
||||
- Top: Adiuva logo/wordmark.
|
||||
- Nav items: Home (house icon), Timeline (chart-gantt icon), Tasks (clipboard-check icon), Projects (folder-kanban icon). Active item gets `#f5f5f5` accent + no extra border.
|
||||
- Bottom: Collapse button (panel-left icon).
|
||||
- **Right edge:** Vertical rotated label "keep scrolling for AI / next section" + chevron-down. This is the visual affordance for the Fluid Curtain pull-down gesture.
|
||||
- **Font:** Geist (Regular 400, Medium 500, Semibold 600). Sizes: sm=14px, base=16px.
|
||||
- **Colors:** bg=`#ffffff`, foreground=`#0a0a0a`, muted=`#737373`, border=`#e5e5e5`, sidebar=`#fafafa`, sidebar-accent=`#f5f5f5`, primary=`#171717`, primary-fg=`#fafafa`.
|
||||
|
||||
### HOME
|
||||
- Top-right corner: stat chip showing "N Task due" count.
|
||||
- Main area (centered, max-w ~1088px): AI greeting `✦ Hello, {name}` (Heading 2, Geist Semibold 30px, -1px tracking).
|
||||
- Below: AI-generated daily brief paragraph with **bold** key phrases inline.
|
||||
- Chat input box: white, border `#d4d4d4`, shadow-lg, 109px tall, placeholder "Ask me anythings...", Send button (black, icon + label) bottom-right of the box.
|
||||
- Below chat: 4 suggestion chips (`Item` component) — icon badge + short prompt text — in a 4-column flex row.
|
||||
|
||||
### TIMELINE
|
||||
- Main content area: placeholder background `#fef2f2` (the Gantt chart is not yet designed in Figma; implementation is free to choose a library).
|
||||
- Same sidebar + right-edge Fluid Curtain affordance.
|
||||
|
||||
### TASKS
|
||||
- Header row: 4 stat cards — "Total task", "To Do", "In Progress", "Completed" — each with an icon and count.
|
||||
- Below: search bar (full-width, placeholder "Search tasks or projects...") + "Order by" dropdown (right) + status filter tabs (All | To Do | In Progress | Completed).
|
||||
- Task list rows (flat, full-width):
|
||||
- Checkbox (left)
|
||||
- Title (bold, 14px) + description subtitle (gray, 14px)
|
||||
- Priority chip: `HIGH` (up-arrow, red-toned) | `MEDIUM` (right-arrow, gray) | `LOW` (down-arrow, green-toned)
|
||||
- Due date chip (calendar icon + "Due Mon DD")
|
||||
- Breadcrumb path (Client > Sub-Client > Project, chevron-separated)
|
||||
- Assignee (person icon + name string)
|
||||
- Completed tasks show row with green-tinted background.
|
||||
|
||||
### PROJECTS
|
||||
- **Left panel (tree):** "Projects" heading + `+` new button + search input. Hierarchical tree: Client (folder, bold) → Sub-Client (folder) → Project (circle/file). Expand/collapse chevrons. Active project highlighted.
|
||||
- **Right panel (project detail):**
|
||||
- Breadcrumb (Client > Sub-Client) at top.
|
||||
- Project name as H1.
|
||||
- 3 stat cards: Notes count | Tasks Complete (x/y fraction) | Checkpoints (x/y fraction).
|
||||
- AI Project Summary card (sparkle icon + generated paragraph).
|
||||
- **Project Timeline:** inline Gantt — months across top (Feb 2026, Mar 2026 …), horizontal bar with dot markers for each checkpoint. Legend: To Do (dark) / Completed (green). "+ Add" button top-right.
|
||||
- **Tasks (Kanban):** 3 columns — To Do / In progress / Completed. Task cards: title, description, priority chip, due date, assignee. "+ Add" per column header.
|
||||
- **Notes list:** flat list of note entries + "+ Add" button.
|
||||
|
||||
### NOTES
|
||||
- Milkdown editor (standalone route) for writing/editing a single note.
|
||||
- Markdown-native, full-screen editor style.
|
||||
|
||||
---
|
||||
|
||||
## Data Schema
|
||||
|
||||
```typescript
|
||||
// clients — hierarchical (self-referencing parentId)
|
||||
export const clients = sqliteTable('clients', {
|
||||
id: text('id').primaryKey(), // UUID
|
||||
parentId: text('parent_id'), // null = top-level client
|
||||
name: text('name').notNull(),
|
||||
industry: text('industry'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
// projects — attached to a client/sub-client, or orphan
|
||||
export const projects = sqliteTable('projects', {
|
||||
id: text('id').primaryKey(),
|
||||
clientId: text('client_id').references(() => clients.id), // nullable
|
||||
name: text('name').notNull(),
|
||||
status: text('status').default('active'), // active | archived
|
||||
aiSummary: text('ai_summary'), // AI-generated paragraph
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
// tasks — belong to a project (or global/orphan if projectId null)
|
||||
export const tasks = sqliteTable('tasks', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').references(() => projects.id), // nullable
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
status: text('status').default('todo'), // todo | in_progress | done
|
||||
priority: text('priority').default('medium'), // high | medium | low
|
||||
assignee: text('assignee'), // plain string name
|
||||
dueDate: integer('due_date'), // unix timestamp
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
// checkpoints — milestones on the per-project timeline
|
||||
export const checkpoints = sqliteTable('checkpoints', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').references(() => projects.id).notNull(),
|
||||
title: text('title').notNull(),
|
||||
date: integer('date').notNull(), // unix timestamp
|
||||
isAiSuggested: integer('is_ai_suggested').default(0), // 0=manual, 1=AI
|
||||
isApproved: integer('is_approved').default(1), // 0=pending AI approval
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
// notes — markdown content attached to a project
|
||||
export const notes = sqliteTable('notes', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').references(() => projects.id), // nullable
|
||||
title: text('title').notNull(),
|
||||
content: text('content').notNull(), // raw Markdown
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
### PHASE 1 — Foundation
|
||||
|
||||
---
|
||||
|
||||
#### US-001: Electron + React scaffold
|
||||
**Description:** As a developer, I need a working Electron app with React+TypeScript and a shared main/renderer process setup so that all other features have a platform to run on.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `electron-builder` or `electron-vite` scaffold with hot-reload in dev.
|
||||
- [ ] Main process can open a `BrowserWindow` serving the React app.
|
||||
- [ ] TypeScript strict mode enabled, `tsconfig.json` configured.
|
||||
- [ ] `package.json` scripts: `dev`, `build`, `preview`.
|
||||
- [ ] App opens without errors on Linux, macOS, Windows.
|
||||
|
||||
---
|
||||
|
||||
#### US-002: SQLite database + Drizzle ORM setup
|
||||
**Description:** As a developer, I need the SQLite database initialized with Drizzle ORM so that all CRUD operations use a typed, schema-driven interface.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `better-sqlite3` (or `@electric-sql/pglite` alternative) installed in main process.
|
||||
- [ ] Drizzle schema file defines all 5 tables (clients, projects, tasks, checkpoints, notes).
|
||||
- [ ] Migration runs on app start; DB file created at `~/.adiuva/data.db` (or `app.getPath('userData')`).
|
||||
- [ ] Drizzle Studio accessible in dev mode (`drizzle-kit studio`).
|
||||
- [ ] TypeScript types inferred from schema (no manual type duplication).
|
||||
|
||||
---
|
||||
|
||||
#### US-003: App shell — sidebar navigation
|
||||
**Description:** As a user, I want a persistent left sidebar so that I can navigate between all sections of the app.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Sidebar renders at 240px with `#fafafa` background and right border.
|
||||
- [ ] Nav items: Home (house), Timeline (chart-gantt), Tasks (clipboard-check), Projects (folder-kanban). Each uses Lucide icon + label.
|
||||
- [ ] Active route highlights item with `#f5f5f5` accent background.
|
||||
- [ ] Collapse button at bottom toggles sidebar to icon-only mode (64px).
|
||||
- [ ] Router renders correct view for each nav item (React Router or TanStack Router).
|
||||
- [ ] Verify in browser using dev-browser skill.
|
||||
|
||||
---
|
||||
|
||||
#### US-004: Client CRUD (hierarchical)
|
||||
**Description:** As a user, I want to create, rename, and delete Clients and Sub-Clients so that I can mirror real-world corporate structures.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] "New Client" action creates a top-level client (parentId = null).
|
||||
- [ ] "New Sub-Client" action (available on a selected client) creates a child (parentId = selected client's id).
|
||||
- [ ] Client and Sub-Client names are editable via inline rename or modal.
|
||||
- [ ] Deleting a client warns if it has child clients or projects; cascade delete is opt-in.
|
||||
- [ ] Changes are immediately persisted to SQLite.
|
||||
- [ ] TypeScript types pass; no `any`.
|
||||
|
||||
---
|
||||
|
||||
#### US-005: Project CRUD
|
||||
**Description:** As a user, I want to create projects attached to a client/sub-client or as standalone orphans so that I can track both client and internal work.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] "New Project" dialog asks for name and optionally a client (dropdown, searchable).
|
||||
- [ ] Projects with no client appear under an "Internal / No Client" group in the tree.
|
||||
- [ ] Project can be re-parented (moved to a different client) via edit dialog.
|
||||
- [ ] Project status toggled between `active` and `archived`.
|
||||
- [ ] Archived projects hidden by default; toggle to show.
|
||||
- [ ] Persisted to SQLite immediately.
|
||||
|
||||
---
|
||||
|
||||
#### US-006: Projects sidebar tree view
|
||||
**Description:** As a user, I want to see all clients, sub-clients, and projects in a collapsible tree in the Projects section so that I can navigate the hierarchy at a glance.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Tree renders: Client (folder icon) → Sub-Client (folder icon) → Project (circle icon).
|
||||
- [ ] Clients and sub-clients expand/collapse independently.
|
||||
- [ ] Search input filters tree in real-time (client name, sub-client name, project name).
|
||||
- [ ] Clicking a project loads the Project Detail panel on the right.
|
||||
- [ ] Active project is highlighted in the tree.
|
||||
- [ ] Verify in browser using dev-browser skill.
|
||||
|
||||
---
|
||||
|
||||
#### US-007: Task CRUD (global Tasks view)
|
||||
**Description:** As a user, I want a global task list where I can create, filter, search, and update tasks so that I can manage all work across projects in one place.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] 4 stat cards at top: Total, To Do, In Progress, Completed — update reactively.
|
||||
- [ ] Search filters tasks by title or description, case-insensitive.
|
||||
- [ ] Status filter tabs (All | To Do | In Progress | Completed) filter list.
|
||||
- [ ] "Order by" dropdown supports: Due Date, Priority, Created Date.
|
||||
- [ ] Task rows show: checkbox, title, description, priority chip (HIGH/MEDIUM/LOW with color), due date chip, breadcrumb (Client > Sub-Client > Project), assignee.
|
||||
- [ ] Clicking checkbox toggles status: todo → done (skip in_progress for quick-complete).
|
||||
- [ ] Inline "New Task" button opens a creation modal with fields: title, description, priority, due date, project (optional), assignee (optional).
|
||||
- [ ] All changes persisted to SQLite.
|
||||
- [ ] Verify in browser using dev-browser skill.
|
||||
|
||||
---
|
||||
|
||||
#### US-008: Manual Timeline (global & per-project)
|
||||
**Description:** As a user, I want to view and manually create timeline checkpoints on a Gantt-style view so that I have full control over milestone dates.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Timeline view renders a horizontal time axis (months) with dot markers for each checkpoint.
|
||||
- [ ] Global Timeline shows checkpoints from all projects (color-coded by project or status).
|
||||
- [ ] Per-project timeline (in Project Detail) scoped to that project's checkpoints only.
|
||||
- [ ] "+ Add" button opens a dialog: title, date picker, project (in global view).
|
||||
- [ ] Checkpoint dots distinguish status: To Do (dark/filled) vs Completed (green).
|
||||
- [ ] "Today" marker line displayed on the timeline.
|
||||
- [ ] Clicking a checkpoint dot shows a popover with title, date, and delete action.
|
||||
- [ ] Persisted to SQLite.
|
||||
- [ ] Verify in browser using dev-browser skill.
|
||||
|
||||
---
|
||||
|
||||
#### US-009: Project Detail view
|
||||
**Description:** As a user, I want a rich project detail panel that shows notes count, tasks summary, AI summary, timeline, Kanban, and notes list in one scrollable view.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Breadcrumb (Client > Sub-Client) rendered at top.
|
||||
- [ ] Stat cards: Notes count | Tasks Complete (x/y) | Checkpoints (x/y).
|
||||
- [ ] AI Project Summary card shows sparkle icon + placeholder text ("AI summary will appear here") until agent generates it.
|
||||
- [ ] Inline Project Timeline (same Gantt component as US-008, scoped).
|
||||
- [ ] Kanban board: To Do / In Progress / Completed columns. Task cards show title, description, priority, due date, assignee. Drag between columns updates `status`.
|
||||
- [ ] Notes list: title + creation date for each note. Click opens Milkdown editor. "+ Add" creates new note.
|
||||
- [ ] Verify in browser using dev-browser skill.
|
||||
|
||||
---
|
||||
|
||||
#### US-010: Notes editor (Milkdown)
|
||||
**Description:** As a user, I want a full-screen Markdown editor for each note so that I can write rich content without leaving the app.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Milkdown editor renders in a dedicated route (`/notes/:noteId`).
|
||||
- [ ] Supports: headings, bold, italic, code blocks, bullet lists, ordered lists, blockquotes.
|
||||
- [ ] Auto-saves content to SQLite on change (debounced, 500ms).
|
||||
- [ ] Back navigation returns to the project detail view (or previous location).
|
||||
- [ ] Note title editable at the top (separate from Milkdown content).
|
||||
- [ ] Verify in browser using dev-browser skill.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 2 — The Fluid Curtain & Agents
|
||||
|
||||
---
|
||||
|
||||
#### US-011: Fluid Curtain — pull-down gesture + animation
|
||||
**Description:** As a user, I want to pull down from the top of any view to slide the app off-screen and reveal the AI chat layer beneath.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Scrolling up past the top of content (overscroll) OR pressing a keyboard shortcut (e.g., `Cmd/Ctrl+K` or `⌥↓`) triggers the curtain.
|
||||
- [ ] App panel slides down using Framer Motion spring animation, exiting the bottom of the viewport.
|
||||
- [ ] AI Chat view is fully revealed below (full-screen, no sidebar obstruction).
|
||||
- [ ] Pulling the app back up (swipe/scroll from bottom or shortcut) re-covers the chat.
|
||||
- [ ] Animation is smooth (no jank). Spring config: stiffness 300, damping 30.
|
||||
- [ ] Right-edge "keep scrolling for AI" label and chevron are visible in every section as the affordance.
|
||||
- [ ] Verify in browser using dev-browser skill.
|
||||
|
||||
---
|
||||
|
||||
#### US-012: Context-scoped AI chat
|
||||
**Description:** As a user, I want the AI chat (revealed by the curtain) to know the context I was in so that answers are scoped to the right project or global scope.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] When curtain is pulled from a Project Detail view, a context header displays "Chatting about: [Project Name]".
|
||||
- [ ] When pulled from Home, context is global (all data).
|
||||
- [ ] Context is passed as a system message to the GitHub Copilot SDK call (project notes, tasks, checkpoints as structured JSON).
|
||||
- [ ] Agent responses reference only documents within the scoped project.
|
||||
- [ ] Chat history is session-only (not persisted in MVP).
|
||||
|
||||
---
|
||||
|
||||
#### US-013: GitHub Copilot SDK integration + @Orchestrator
|
||||
**Description:** As a developer, I need the GitHub Copilot SDK wired up with an Orchestrator agent that routes user messages to the correct specialist agent.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] SDK initialized in main process with enterprise credentials (from env/config).
|
||||
- [ ] `@Orchestrator` reads user intent and calls `route_to_project`, `route_to_general`, or `route_to_email` tool.
|
||||
- [ ] Routing result invokes the correct specialist agent and returns its response.
|
||||
- [ ] Streaming responses supported (tokens shown incrementally in chat UI).
|
||||
- [ ] Errors handled gracefully (SDK timeout, auth failure) with user-facing message.
|
||||
|
||||
---
|
||||
|
||||
#### US-014: @ProjectAgent with project tools
|
||||
**Description:** As a user, I want the AI to answer project-specific questions and take actions (add task, suggest checkpoints) within the scoped project.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `read_project_notes` tool fetches all notes for the project from SQLite.
|
||||
- [ ] `add_task` tool creates a task in the project (writes to SQLite) and confirms in chat.
|
||||
- [ ] `suggest_checkpoints` tool returns a list of proposed checkpoints (title + date) as interactive cards in chat (Approve / Reject each).
|
||||
- [ ] Approved checkpoints are inserted into `checkpoints` table with `is_ai_suggested=1, is_approved=1`.
|
||||
- [ ] `get_summary` tool generates a 2-3 sentence project summary and updates `projects.ai_summary` in SQLite.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 3 — Intelligence & RAG
|
||||
|
||||
---
|
||||
|
||||
#### US-015: LanceDB vector store setup + note embedding
|
||||
**Description:** As a developer, I need notes and project content embedded into LanceDB so that semantic search is possible across all projects.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] LanceDB initialized in main process, storing vectors at `~/.adiuva/vectors/`.
|
||||
- [ ] On note save (create or update), content is embedded via GitHub Copilot SDK embeddings endpoint and stored in LanceDB with `{noteId, projectId, content}` metadata.
|
||||
- [ ] Existing notes are indexed on first startup (migration script).
|
||||
- [ ] Embedding errors logged but do not block the save operation.
|
||||
|
||||
---
|
||||
|
||||
#### US-016: @KnowledgeAgent — semantic search across all projects
|
||||
**Description:** As a user, I want to ask "what did we decide about X?" and get answers pulled from across all past project notes, not just the current one.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] `vector_search_all` tool accepts a query string, returns top-5 semantically similar note chunks from LanceDB.
|
||||
- [ ] Results include source note title and project name for attribution.
|
||||
- [ ] @Orchestrator routes knowledge queries to @KnowledgeAgent.
|
||||
- [ ] Response in chat includes inline citations ("From: Project A — Meeting Notes, Feb 12").
|
||||
|
||||
---
|
||||
|
||||
#### US-017: AI checkpoint suggestions from notes
|
||||
**Description:** As a user, I want the AI to proactively analyze my meeting notes and suggest timeline checkpoints I may have missed.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] Triggered manually ("Suggest checkpoints" button in Project Detail timeline header) or by @ProjectAgent tool call.
|
||||
- [ ] @ProjectAgent reads all notes for the project, extracts date-anchored commitments, returns as suggested checkpoints.
|
||||
- [ ] Suggestions appear as dismissible cards in the Timeline UI with `isAiSuggested=1, isApproved=0`.
|
||||
- [ ] Approve → `isApproved` set to 1, checkpoint appears on timeline.
|
||||
- [ ] Reject → checkpoint deleted.
|
||||
- [ ] Verify in browser using dev-browser skill.
|
||||
|
||||
---
|
||||
|
||||
#### US-018: Home dashboard — AI daily brief
|
||||
**Description:** As a user, I want the Home screen to greet me with an AI-generated daily brief summarizing my tasks and suggesting actions.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
- [ ] On app open, @Orchestrator queries tasks due today/this week and recent project activity.
|
||||
- [ ] AI generates a personalized paragraph with key highlights (tasks due, suggested calls/emails).
|
||||
- [ ] Brief is displayed below the greeting with **bold** key phrases inline (as in Figma).
|
||||
- [ ] 4 suggestion chips below the chat box are pre-populated with context-relevant queries.
|
||||
- [ ] Chat box on Home is scoped globally (no project context).
|
||||
- [ ] Verify in browser using dev-browser skill.
|
||||
|
||||
---
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
- **FR-01:** All data stored locally in SQLite at `app.getPath('userData')/adiuva.db`.
|
||||
- **FR-02:** App functions fully offline; AI features degrade gracefully when network is unavailable.
|
||||
- **FR-03:** Client tree supports unlimited nesting depth but UI only needs to display 3 levels (Client → Sub-Client → Project).
|
||||
- **FR-04:** Tasks table has a nullable `projectId`; global Tasks view shows all tasks regardless.
|
||||
- **FR-05:** The "Fluid Curtain" animation must not lose the underlying view state (app slides but remains mounted).
|
||||
- **FR-06:** GitHub Copilot SDK credentials are stored in OS keychain (not plaintext config).
|
||||
- **FR-07:** Milkdown auto-save uses a 500ms debounce; unsaved indicator shown if pending.
|
||||
- **FR-08:** All IDs are UUIDs (use `crypto.randomUUID()`).
|
||||
- **FR-09:** Drizzle migrations run automatically on startup; never destructive.
|
||||
- **FR-10:** Kanban drag-and-drop updates `tasks.status` and `tasks.updatedAt` immediately in SQLite.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals (Out of Scope for MVP)
|
||||
|
||||
- Email client / inbox integration (EmailAgent tools are stubs only).
|
||||
- Cloud sync or multi-device support.
|
||||
- Real assignee accounts (assignee is a plain name string, not a user entity).
|
||||
- Notifications or system tray alerts.
|
||||
- Dark mode.
|
||||
- Mobile or web version.
|
||||
- Export to PDF/CSV.
|
||||
- Notes version history.
|
||||
|
||||
---
|
||||
|
||||
## Design Considerations
|
||||
|
||||
- **Font:** Geist via `@fontsource/geist` or CDN. Apply globally via CSS variable.
|
||||
- **Icons:** Lucide React (house, chart-gantt, clipboard-check, folder-kanban, panel-left, send, sparkles, chevron-down).
|
||||
- **Gantt:** ✅ Custom SVG component. Month labels on X axis, `<circle>` dots for checkpoints, `<line>` baseline, `<TodayMarker>`. Use `ResizeObserver` for responsive width.
|
||||
- **Kanban:** ✅ `@hello-pangea/dnd` — `<DragDropContext>` wrapping 3 `<Droppable>` columns, each task a `<Draggable>`.
|
||||
- **Fluid Curtain:** ✅ `framer-motion` `useMotionValue` + `useSpring`. Trigger: `wheel` event at `scrollTop === 0 && deltaY < 0` OR `Cmd/Ctrl+K`. Right-edge "keep scrolling for AI" label is a **visual hint only** (not interactive).
|
||||
- **shadcn/ui components to reuse:** Button, Input, Badge, Card, Dialog, Separator, Tabs, Tooltip, DropdownMenu, Popover.
|
||||
|
||||
---
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
- **IPC:** ✅ `electron-trpc`. Define a single `appRouter` in main process exposing all domains (`tasks`, `projects`, `clients`, `checkpoints`, `notes`, `ai`). Renderer uses `trpc.[domain].[procedure].useQuery/useMutation()`. Zod validates all inputs at the boundary.
|
||||
- GitHub Copilot SDK may require enterprise SSO token; provide a settings screen for token input (US not in MVP scope, but infrastructure must exist).
|
||||
- LanceDB Node.js binding (`vectordb` package) runs in main process only.
|
||||
- Milkdown v7+ with React adapter. Plugin list: `commonmark`, `history`, `clipboard`, `math` (optional).
|
||||
- Use `electron-store` or `conf` for lightweight app settings (user name for greeting, sidebar collapsed state, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- All 5 sections navigable and functional with real SQLite-persisted data.
|
||||
- Fluid Curtain animation runs at 60fps with no layout shift on return.
|
||||
- @ProjectAgent correctly scopes a context query (zero responses sourcing from another project).
|
||||
- Note embedding + LanceDB retrieval returns relevant results for a simple semantic query.
|
||||
- App cold-start < 3 seconds on a modern machine.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. ~~**Gantt library vs. custom SVG?**~~ ✅ Resolved: custom SVG component.
|
||||
2. ~~**GitHub Copilot SDK auth flow:**~~ ✅ Resolved: `keytar` (OS keychain). A minimal "Settings" screen for token input writes to keychain on save.
|
||||
3. ~~**IPC architecture:**~~ ✅ Resolved: `electron-trpc` with Zod validation. All DB/AI operations exposed as tRPC procedures in main process; renderer uses typed React Query hooks.
|
||||
4. **Milkdown vs. simpler editor:** Milkdown is powerful but has a learning curve. Is a simpler `CodeMirror`-based Markdown editor acceptable for MVP?
|
||||
5. **"Fluid Curtain" on Linux:** Overscroll behavior differs across OS/window managers. What's the fallback trigger (keyboard shortcut only)?
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1 — Foundation (US-001 → US-010)
|
||||
|
||||
| Step | Story | Key Decision Point |
|
||||
|------|-------|-------------------|
|
||||
| 1.1 | US-001: Electron+React scaffold | ✅ **electron-forge + Vite plugin** (`npm init electron-app@latest -- --template=vite-typescript`) |
|
||||
| 1.2 | US-002: SQLite + Drizzle setup | Schema finalized; migrations strategy |
|
||||
| 1.3 | US-003: App shell + sidebar | ✅ **TanStack Router** (fully type-safe, `$projectId` params typed) |
|
||||
| 1.4 | US-004 + US-005: Client & Project CRUD | Data model confirmed |
|
||||
| 1.5 | US-006: Projects tree view | ✅ **Radix Collapsible + recursive `TreeNode`** (no extra dep, matches Figma) |
|
||||
| 1.6 | US-007: Tasks global view | |
|
||||
| 1.7 | US-008: Manual Timeline / Gantt | ✅ **Custom SVG component** (dot-on-axis, zero deps, matches Figma exactly) |
|
||||
| 1.8 | US-009: Project Detail view | ✅ **@hello-pangea/dnd** for Kanban drag-and-drop |
|
||||
| 1.9 | US-010: Milkdown editor | Plugin scope for MVP |
|
||||
|
||||
### Phase 2 — The Curtain & Agents (US-011 → US-014)
|
||||
|
||||
| Step | Story | Key Decision Point |
|
||||
|------|-------|-------------------|
|
||||
| 2.1 | US-011: Fluid Curtain animation | ✅ Wheel overscroll-up at `scrollTop=0` + `Cmd/Ctrl+K` shortcut. Right-edge label is visual-only (not a button). Framer Motion spring (`y` to viewport height). |
|
||||
| 2.2 | US-012: Context-scoped chat UI | Chat bubble components, streaming UI |
|
||||
| 2.3 | US-013: Copilot SDK + @Orchestrator | ✅ **`keytar`** for OS keychain token storage (main process only, IPC to renderer). |
|
||||
| 2.4 | US-014: @ProjectAgent tools | Tool schema definition + SQLite write-back |
|
||||
|
||||
### Phase 3 — Intelligence & RAG (US-015 → US-018)
|
||||
|
||||
| Step | Story | Key Decision Point |
|
||||
|------|-------|-------------------|
|
||||
| 3.1 | US-015: LanceDB setup + embedding | ✅ **GitHub Copilot SDK embeddings** (`text-embedding-3-small`). Chunk notes by paragraph (~500 tokens). |
|
||||
| 3.2 | US-016: @KnowledgeAgent search | Vector search tuning, k=5 default |
|
||||
| 3.3 | US-017: AI checkpoint suggestions | Prompt engineering for date extraction |
|
||||
| 3.4 | US-018: Home daily brief | Orchestrator routing for daily summary |
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
{
|
||||
"project": "Adiuva",
|
||||
"branchName": "ralph/adiuva-mvp",
|
||||
"description": "Adiuva MVP — Local-first desktop workspace with hierarchical project management, Fluid Curtain AI chat overlay, and multi-agent intelligence via GitHub Copilot SDK",
|
||||
"userStories": [
|
||||
{
|
||||
"id": "US-001",
|
||||
"title": "Electron + React scaffold",
|
||||
"description": "As a developer, I need a working Electron app with React+TypeScript and a shared main/renderer process setup so that all other features have a platform to run on.",
|
||||
"acceptanceCriteria": [
|
||||
"electron-forge + Vite plugin scaffold with hot-reload in dev",
|
||||
"Main process opens a BrowserWindow serving the React app",
|
||||
"TypeScript strict mode enabled, tsconfig.json configured",
|
||||
"package.json scripts: dev, build, preview",
|
||||
"App opens without errors",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 1,
|
||||
"passes": true,
|
||||
"notes": "Completed in initial scaffold commit (f6cc8bb)"
|
||||
},
|
||||
{
|
||||
"id": "US-002",
|
||||
"title": "SQLite + Drizzle ORM schema and migrations",
|
||||
"description": "As a developer, I need the SQLite database initialized with Drizzle ORM so that all CRUD operations use a typed, schema-driven interface.",
|
||||
"acceptanceCriteria": [
|
||||
"better-sqlite3 and drizzle-orm installed as main-process dependencies",
|
||||
"Drizzle schema file defines all 5 tables matching the PRD exactly: clients (id, parentId, name, industry, createdAt), projects (id, clientId, name, status, aiSummary, createdAt), tasks (id, projectId, title, description, status, priority, assignee, dueDate, createdAt), checkpoints (id, projectId, title, date, isAiSuggested, isApproved, createdAt), notes (id, projectId, title, content, createdAt, updatedAt)",
|
||||
"DB file created at app.getPath('userData')/adiuva.db on startup",
|
||||
"Migration runs automatically on app start (drizzle-kit migrate or push); never destructive",
|
||||
"All IDs are UUIDs generated via crypto.randomUUID()",
|
||||
"TypeScript types inferred from schema with no manual type duplication",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 2,
|
||||
"passes": true,
|
||||
"notes": "Completed: better-sqlite3 + drizzle-orm, 5-table schema, non-destructive push migration via CREATE TABLE IF NOT EXISTS, WAL mode enabled"
|
||||
},
|
||||
{
|
||||
"id": "US-003",
|
||||
"title": "electron-trpc IPC bridge and appRouter scaffold",
|
||||
"description": "As a developer, I need electron-trpc wired up between main and renderer processes so that all DB and AI operations are exposed as type-safe tRPC procedures callable from the renderer.",
|
||||
"acceptanceCriteria": [
|
||||
"electron-trpc installed; IPC bridge configured in main process and preload script",
|
||||
"appRouter defined in main process with stub routers for all domains: clients, projects, tasks, checkpoints, notes, ai",
|
||||
"Renderer-side trpc client created with the correct IPC link and wrapped in TRPCProvider + QueryClientProvider",
|
||||
"A health.ping procedure returns 'pong' and is successfully callable from the renderer (verified via console log or React component)",
|
||||
"Zod imported and used to validate at least one procedure input",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 3,
|
||||
"passes": true,
|
||||
"notes": "Completed: electron-trpc IPC bridge, appRouter with stub routers for all 7 domains (health, clients, projects, tasks, checkpoints, notes, ai), renderer TRPCProvider+QueryClientProvider, health.ping returns 'pong' displayed in HomePage, Zod validates all procedure inputs"
|
||||
},
|
||||
{
|
||||
"id": "US-004",
|
||||
"title": "App shell layout and sidebar navigation",
|
||||
"description": "As a user, I want a persistent left sidebar so that I can navigate between all sections of the app.",
|
||||
"acceptanceCriteria": [
|
||||
"Sidebar renders at 240px with #fafafa background and 1px right border (#e5e5e5)",
|
||||
"Nav items render with Lucide icons + labels: Home (house), Timeline (chart-gantt), Tasks (clipboard-check), Projects (folder-kanban)",
|
||||
"Active route highlights nav item with #f5f5f5 accent background (no extra border)",
|
||||
"Collapse button at bottom toggles sidebar to icon-only mode (64px wide); state persisted via electron-store",
|
||||
"TanStack Router renders the correct view component for each nav route: /, /timeline, /tasks, /projects",
|
||||
"Right-edge vertical rotated label 'keep scrolling for AI' with chevron-down icon is visible in every section as a non-interactive visual affordance",
|
||||
"Geist font applied globally via @fontsource/geist",
|
||||
"Global CSS variables set: bg=#ffffff, foreground=#0a0a0a, muted=#737373, border=#e5e5e5, sidebar=#fafafa, sidebar-accent=#f5f5f5, primary=#171717, primary-fg=#fafafa",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 4,
|
||||
"passes": true,
|
||||
"notes": "Completed: electron-store@8 sidebar collapse persistence via settings tRPC router, @fontsource/geist replacing Google Fonts CDN, right-edge 'keep scrolling for AI' label in all views, ESLint fixed with eslint-import-resolver-typescript"
|
||||
},
|
||||
{
|
||||
"id": "US-005",
|
||||
"title": "Client tRPC procedures (CRUD)",
|
||||
"description": "As a developer, I need tRPC procedures for client and sub-client CRUD so that the UI can create, read, update, and delete hierarchical client records.",
|
||||
"acceptanceCriteria": [
|
||||
"clients.list returns all clients ordered by name",
|
||||
"clients.create accepts { name: string, parentId?: string, industry?: string } and inserts with UUID and createdAt timestamp",
|
||||
"clients.update accepts { id: string, name?: string, industry?: string } and updates the record",
|
||||
"clients.delete accepts { id: string } and returns an error payload if the client has child clients or projects (does not delete)",
|
||||
"clients.deleteWithCascade accepts { id: string } and deletes the client, all descendant clients, and their projects (nulls projectId on orphaned tasks)",
|
||||
"All inputs validated with Zod schemas",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 5,
|
||||
"passes": true,
|
||||
"notes": "Completed: clients.list (ordered by name), clients.create (UUID + createdAt), clients.update (partial), clients.delete (guard returns error payload if children exist), clients.deleteWithCascade (BFS recursive — nulls orphaned tasks.projectId, deletes projects, then clients). All queries use .all()/.run() for drizzle better-sqlite3 sync driver."
|
||||
},
|
||||
{
|
||||
"id": "US-006",
|
||||
"title": "Project tRPC procedures (CRUD)",
|
||||
"description": "As a developer, I need tRPC procedures for project CRUD so that the UI can create, read, update, and archive projects attached to clients or as standalone.",
|
||||
"acceptanceCriteria": [
|
||||
"projects.list accepts optional { clientId?: string, includeArchived?: boolean } and returns matching projects",
|
||||
"projects.listAll returns all projects (for dropdowns) with id and name only",
|
||||
"projects.get accepts { id: string } and returns the full project record",
|
||||
"projects.create accepts { name: string, clientId?: string } and inserts with UUID, status='active', createdAt",
|
||||
"projects.update accepts { id: string, name?: string, clientId?: string, status?: 'active'|'archived', aiSummary?: string }",
|
||||
"projects.delete accepts { id: string } and deletes the project (nulls projectId on its tasks)",
|
||||
"All inputs validated with Zod",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 6,
|
||||
"passes": true,
|
||||
"notes": "Completed: projects.list (filters by clientId + includeArchived via drizzle and()), projects.listAll (id+name only), projects.get (returns null if not found), projects.create (UUID + status='active' + createdAt), projects.update (partial set object), projects.delete (nulls tasks.projectId then deletes project)"
|
||||
},
|
||||
{
|
||||
"id": "US-007",
|
||||
"title": "Task tRPC procedures (CRUD + filtering)",
|
||||
"description": "As a developer, I need tRPC procedures for task CRUD with search and filter support so that the global Tasks view can query tasks efficiently.",
|
||||
"acceptanceCriteria": [
|
||||
"tasks.list accepts { projectId?: string, status?: 'todo'|'in_progress'|'done', search?: string, orderBy?: 'dueDate'|'priority'|'createdAt' } and returns matching tasks",
|
||||
"tasks.list joins with projects and clients tables to return breadcrumb fields: projectName, clientName, subClientName",
|
||||
"tasks.create accepts { title: string, description?: string, status?: string, priority?: string, assignee?: string, dueDate?: number, projectId?: string }",
|
||||
"tasks.update accepts { id: string, ...partial task fields }",
|
||||
"tasks.delete accepts { id: string }",
|
||||
"All inputs validated with Zod",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 7,
|
||||
"passes": true,
|
||||
"notes": "Completed: tasks.list (LEFT JOIN projects+clients+parentClients for breadcrumb fields, and() filters for projectId/status/search via like(), orderBy CASE for priority), tasks.create (UUID + createdAt + defaults), tasks.update (partial set), tasks.delete. alias() from drizzle-orm/sqlite-core for self-join."
|
||||
},
|
||||
{
|
||||
"id": "US-008",
|
||||
"title": "Checkpoint and Note tRPC procedures (CRUD)",
|
||||
"description": "As a developer, I need tRPC procedures for checkpoints and notes so that the timeline and notes features have a typed backend.",
|
||||
"acceptanceCriteria": [
|
||||
"checkpoints.list accepts { projectId?: string } and returns matching checkpoints ordered by date",
|
||||
"checkpoints.create accepts { projectId: string, title: string, date: number, isAiSuggested?: number, isApproved?: number }",
|
||||
"checkpoints.update accepts { id: string, title?: string, date?: number, isApproved?: number }",
|
||||
"checkpoints.delete accepts { id: string }",
|
||||
"notes.list accepts { projectId?: string } and returns notes with id, title, createdAt, updatedAt — not content (for performance)",
|
||||
"notes.get accepts { id: string } and returns the full note including content",
|
||||
"notes.create accepts { title: string, content: string, projectId?: string }",
|
||||
"notes.update accepts { id: string, title?: string, content?: string } and always updates updatedAt",
|
||||
"notes.delete accepts { id: string }",
|
||||
"All inputs validated with Zod",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 8,
|
||||
"passes": true,
|
||||
"notes": "Completed: checkpoints.list (ordered by date, optional projectId filter), checkpoints.create (UUID + createdAt + defaults for isAiSuggested/isApproved), checkpoints.update (partial set), checkpoints.delete. notes.list (returns id/projectId/title/createdAt/updatedAt — no content), notes.get (full record or null), notes.create (UUID + createdAt + updatedAt), notes.update (partial set, always updates updatedAt), notes.delete."
|
||||
},
|
||||
{
|
||||
"id": "US-009",
|
||||
"title": "Project CRUD UI in Projects sidebar",
|
||||
"description": "As a user, I want to create, rename, and delete Projects from the Projects sidebar, where each project can optionally belong to a Client or Sub-Client.",
|
||||
"acceptanceCriteria": [
|
||||
"'New Project' button at top of Projects sidebar uses shadcn/ui Button component; creates a new project via clients.create tRPC mutation",
|
||||
"Each project item has a context menu using shadcn/ui DropdownMenu (triggered by kebab icon) with items: Rename, Delete",
|
||||
"Rename activates an inline editable field (shadcn/ui Input) replacing the label; pressing Enter or blurring saves via clients.update",
|
||||
"Delete shows a shadcn/ui AlertDialog confirmation; if the project has sub-projects, warns the user and offers cascade-delete option",
|
||||
"Each project can optionally be associated with a Client or Sub-Client",
|
||||
"Tree updates immediately after any mutation without full page reload",
|
||||
"All interactive elements use shadcn/ui primitives: install via 'npx shadcn@latest add button input dropdown-menu alert-dialog' before implementing",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 9,
|
||||
"passes": true,
|
||||
"notes": "Completed: ProjectSidebar component with New Project button (clients.create), kebab context menu (DropdownMenu with Rename/Delete/New Sub-Project), inline rename (Input with Enter/Escape/blur), AlertDialog delete with cascade-warn flow, collapsible tree with parent-child hierarchy, empty state. All shadcn/ui primitives: Button, Input, DropdownMenu, AlertDialog, Collapsible. Typecheck passes."
|
||||
},
|
||||
{
|
||||
"id": "US-010",
|
||||
"title": "Projects sidebar tree view and project detail routing",
|
||||
"description": "As a user, I want to see all projects in a collapsible tree in the sidebar, optionally grouped by client, and manage them from the Projects section.",
|
||||
"acceptanceCriteria": [
|
||||
"Tree renders projects as a flat list with folder icons; projects with a client show the client name as a grouping header; use shadcn/ui Collapsible for expand/collapse groups",
|
||||
"Search input at the top of the Projects sidebar uses shadcn/ui Input; filters the tree in real-time by project name",
|
||||
"Projects with no client appear under an 'Internal / No Client' group",
|
||||
"Project context menu uses shadcn/ui DropdownMenu with items: Edit (assign/change client), Archive/Unarchive, Delete",
|
||||
"Archived projects hidden by default; a shadcn/ui Switch or toggle reveals them",
|
||||
"Clicking a project node loads the Project Detail panel in the right pane",
|
||||
"Active project highlighted in tree",
|
||||
"Install shadcn/ui components via 'npx shadcn@latest add collapsible dialog select switch' before implementing",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 10,
|
||||
"passes": true,
|
||||
"notes": "Completed: ProjectSidebar reworked to show projects grouped by client (Collapsible groups), 'Internal / No Client' group for orphan projects, real-time search filter, archive toggle (Switch), context menu (Edit Client via Dialog+Select, Archive/Unarchive, Delete with AlertDialog), click selects project via search params, active project highlighted. ProjectDetail placeholder component. projects.update now accepts nullable clientId. shadcn/ui: dialog, select, switch installed."
|
||||
},
|
||||
{
|
||||
"id": "US-011",
|
||||
"title": "Global Tasks view UI",
|
||||
"description": "As a user, I want a global task list where I can create, filter, search, and update tasks across all projects in one place.",
|
||||
"acceptanceCriteria": [
|
||||
"4 stat cards using shadcn/ui Card (Card, CardHeader, CardTitle, CardContent) at top: Total Tasks, To Do, In Progress, Completed — each with a Lucide icon and count, reactively updated via tasks.list queries",
|
||||
"Search uses shadcn/ui Input; filters tasks by title or description (case-insensitive, 300ms debounce)",
|
||||
"Status filter uses shadcn/ui Tabs (Tabs, TabsList, TabsTrigger): All | To Do | In Progress | Completed",
|
||||
"'Order by' uses shadcn/ui DropdownMenu: Due Date | Priority | Created Date",
|
||||
"Task rows display: shadcn/ui Checkbox, title (bold 14px), description (muted 14px), priority chip using shadcn/ui Badge (HIGH=destructive variant, MEDIUM=secondary variant, LOW=outline variant with green), due date chip (calendar icon + 'Due Mon DD'), breadcrumb (Client > Sub-Client > Project, chevron-separated via shadcn/ui Breadcrumb if available), assignee (person icon + name)",
|
||||
"Completed task rows have green-tinted background (#f0fdf4 or similar)",
|
||||
"Clicking the shadcn/ui Checkbox calls tasks.update to set status='done' (or back to 'todo') immediately",
|
||||
"'New Task' shadcn/ui Button opens a shadcn/ui Dialog modal: shadcn/ui Input for title (required), Textarea for description, Select for priority, Popover+Calendar for due date, Select for project (optional searchable), Input for assignee (optional)",
|
||||
"Install shadcn/ui components via 'npx shadcn@latest add card tabs checkbox badge dialog textarea select popover calendar' before implementing",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 11,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-012",
|
||||
"title": "GanttChart SVG component and global Timeline view",
|
||||
"description": "As a user, I want to view and create timeline checkpoints on a Gantt-style view so that I have full control over project milestones.",
|
||||
"acceptanceCriteria": [
|
||||
"Reusable GanttChart component accepts { checkpoints: Checkpoint[], startDate: Date, endDate: Date } props",
|
||||
"Component renders a custom SVG: month labels on the X axis, a horizontal baseline <line>, and <circle> dots for each checkpoint positioned by date",
|
||||
"Dot fill: dark (#171717) = isApproved=1 + status todo, green (#16a34a) = done/approved, dashed outline = isApproved=0 (pending AI suggestion)",
|
||||
"A vertical 'Today' marker line rendered at the current date",
|
||||
"Component uses ResizeObserver for responsive SVG width",
|
||||
"Clicking a checkpoint dot opens a shadcn/ui Popover with: title, formatted date, and a shadcn/ui Button (variant=destructive, size=sm) for Delete (calls checkpoints.delete)",
|
||||
"Global Timeline route (/timeline) renders GanttChart with all checkpoints from all projects, color-coded or grouped by project",
|
||||
"'+ Add' shadcn/ui Button opens a shadcn/ui Dialog: shadcn/ui Input for title (required), Popover+Calendar for date picker (required), Select for project dropdown (required in global view)",
|
||||
"Install shadcn/ui components via 'npx shadcn@latest add popover calendar' before implementing (button, dialog, input, select already installed)",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 12,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-013",
|
||||
"title": "Project Detail view — layout, breadcrumb, stat cards, AI summary",
|
||||
"description": "As a user, I want a project detail panel showing breadcrumb navigation, project name, stat cards, and an AI summary card.",
|
||||
"acceptanceCriteria": [
|
||||
"Right panel renders when a project is selected in the Projects tree",
|
||||
"Breadcrumb at top uses shadcn/ui Breadcrumb (Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbSeparator) showing Client > Sub-Client path",
|
||||
"Project name renders as H1 below the breadcrumb",
|
||||
"3 stat cards using shadcn/ui Card displayed horizontally: Notes (count from notes.list), Tasks Complete (done/total fraction from tasks.list), Checkpoints (approved/total fraction from checkpoints.list)",
|
||||
"AI Project Summary card uses shadcn/ui Card with sparkle (sparkles) Lucide icon + placeholder text 'AI summary will appear here' when project.aiSummary is null/empty",
|
||||
"When project.aiSummary is populated, the card displays the AI-generated text instead",
|
||||
"Install shadcn/ui components via 'npx shadcn@latest add breadcrumb' before implementing (card already installed)",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 13,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-014",
|
||||
"title": "Kanban board in Project Detail",
|
||||
"description": "As a user, I want a Kanban board inside the project detail view with drag-and-drop task management between status columns.",
|
||||
"acceptanceCriteria": [
|
||||
"@hello-pangea/dnd installed; DragDropContext wraps 3 Droppable columns: To Do | In Progress | Completed",
|
||||
"Each task card is a Draggable wrapped in a shadcn/ui Card rendering: title, description (truncated), priority as shadcn/ui Badge, due date chip, assignee string",
|
||||
"Dragging a card to another column calls tasks.update({ id, status }) via tRPC and the UI updates immediately (optimistic or on success)",
|
||||
"'+ Add' shadcn/ui Button (variant=ghost, size=sm) in each column header opens the shadcn/ui Dialog new-task modal with the column's status pre-selected",
|
||||
"Columns show a task count in their header using shadcn/ui Badge (variant=secondary)",
|
||||
"All card content uses shadcn/ui primitives: Card, Badge, Button (already installed)",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 14,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-015",
|
||||
"title": "Inline project timeline and notes list in Project Detail",
|
||||
"description": "As a user, I want to see the project's Gantt timeline and a list of its notes within the project detail scrollable view.",
|
||||
"acceptanceCriteria": [
|
||||
"Project Detail view includes a 'Project Timeline' section using the GanttChart component (from US-012) scoped to the current project's checkpoints",
|
||||
"'+ Add' shadcn/ui Button (variant=outline, size=sm) in the timeline section header opens the add-checkpoint shadcn/ui Dialog with the project pre-selected",
|
||||
"Notes section below Kanban shows a flat list using shadcn/ui Separator between rows: each row has note title + formatted createdAt date",
|
||||
"'+ Add' shadcn/ui Button in notes header calls notes.create with a default title and navigates to /notes/:noteId",
|
||||
"Clicking a note title navigates to /notes/:noteId",
|
||||
"All buttons/dialogs use shadcn/ui components (already installed)",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 15,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-016",
|
||||
"title": "Milkdown note editor",
|
||||
"description": "As a user, I want a full-screen Markdown editor for each note so that I can write rich content without leaving the app.",
|
||||
"acceptanceCriteria": [
|
||||
"@milkdown/react and @milkdown/preset-commonmark installed; Milkdown editor renders at route /notes/:noteId",
|
||||
"Supported Markdown: headings (H1-H6), bold, italic, inline code, code blocks, bullet lists, ordered lists, blockquotes",
|
||||
"Note title editable as a shadcn/ui Input (variant borderless/ghost style) at the top of the page (separate from Milkdown content area)",
|
||||
"Content auto-saves to SQLite via notes.update on Milkdown onChange event, debounced 500ms",
|
||||
"Unsaved indicator shown using shadcn/ui Badge (variant=secondary, text 'Saving...') next to the title while save is pending",
|
||||
"Back button uses shadcn/ui Button (variant=ghost, size=icon) with ArrowLeft Lucide icon; navigates to the previous route",
|
||||
"All UI chrome uses shadcn/ui components (already installed)",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 16,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-017",
|
||||
"title": "Fluid Curtain pull-down animation",
|
||||
"description": "As a user, I want to pull down from the top of any view to slide the app panel off-screen and reveal the AI chat layer beneath.",
|
||||
"acceptanceCriteria": [
|
||||
"framer-motion useMotionValue + useSpring (stiffness: 300, damping: 30) controls a 'y' CSS transform on the main app panel wrapper",
|
||||
"Trigger 1: wheel event listener at document level — when the current route's scroll position is at 0 and deltaY < 0 (overscroll up), animate panel y from 0 to viewport height",
|
||||
"Trigger 2: Cmd/Ctrl+K keyboard shortcut toggles curtain open (y = viewport height) and closed (y = 0)",
|
||||
"AI chat view is rendered as a fixed full-screen layer behind the sliding panel and becomes fully visible when panel slides down",
|
||||
"App panel remains mounted during animation (no unmount/remount, no state loss)",
|
||||
"Returning from chat: wheel event with deltaY > 0 at chat-bottom OR Cmd/Ctrl+K slides panel back to y = 0",
|
||||
"Right-edge vertical 'keep scrolling for AI' label with chevron-down is visible in every section (non-interactive, visual hint only)",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 17,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-018",
|
||||
"title": "GitHub Copilot SDK setup and keytar token storage",
|
||||
"description": "As a developer, I need the GitHub Copilot SDK initialized in the main process with secure OS keychain token storage so that AI features can authenticate.",
|
||||
"acceptanceCriteria": [
|
||||
"keytar installed and imported in main process only (not renderer)",
|
||||
"ai.setToken tRPC mutation accepts { token: string } and stores it via keytar.setPassword('adiuva', 'copilot-token', token)",
|
||||
"ai.hasToken tRPC query returns a boolean indicating whether a token is stored",
|
||||
"On app start, main process reads the token from keychain and initializes the GitHub Copilot SDK client",
|
||||
"Settings dialog uses shadcn/ui Dialog (DialogTrigger as a SidebarMenuButton with Settings/gear icon in the sidebar footer); dialog content uses shadcn/ui Input for token paste + shadcn/ui Button to save via ai.setToken",
|
||||
"If no token is stored, AI-dependent features display a prompt using shadcn/ui Card with a shadcn/ui Button linking to the Settings dialog instead of throwing an error",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 18,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-019",
|
||||
"title": "@Orchestrator agent with intent routing",
|
||||
"description": "As a developer, I need an Orchestrator agent that receives user messages, reads intent, and routes to the correct specialist agent.",
|
||||
"acceptanceCriteria": [
|
||||
"ai.chat tRPC procedure accepts { message: string, context: { type: 'global' | 'project', projectId?: string } }",
|
||||
"@Orchestrator system prompt instructs the model to use one of three routing tool calls: route_to_project (when context is a project), route_to_knowledge (for cross-project questions), or route_to_general (for everything else)",
|
||||
"For project-scoped context, Orchestrator invokes @ProjectAgent logic and returns its response",
|
||||
"For global context, Orchestrator answers from task/project summaries directly",
|
||||
"Streaming tokens returned to renderer incrementally (use tRPC subscription or async generator pattern)",
|
||||
"SDK auth errors and timeouts caught; procedure returns { error: string } for user-facing display",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 19,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-020",
|
||||
"title": "Context-scoped AI chat UI",
|
||||
"description": "As a user, I want the AI chat (revealed by the Fluid Curtain) to display a context header, support message input, and stream AI responses.",
|
||||
"acceptanceCriteria": [
|
||||
"Chat panel shows a context header using shadcn/ui Badge (variant=outline): 'Chatting about: [Project Name]' when opened from a project detail view, or 'Global workspace' when opened from other sections",
|
||||
"Chat input box uses shadcn/ui Textarea: white background, border #d4d4d4, shadow-lg, min-height 109px, placeholder 'Ask me anything...'; Send uses shadcn/ui Button (black bg, Send Lucide icon + 'Send' label) anchored bottom-right",
|
||||
"User messages appear as right-aligned message bubbles using shadcn/ui Card; AI responses as left-aligned Cards",
|
||||
"Streaming: AI response tokens appended to the current AI bubble as they arrive from ai.chat",
|
||||
"A loading spinner or pulsing indicator (shadcn/ui Skeleton) shown while waiting for first token",
|
||||
"If ai.chat returns { error }, display the error message in a shadcn/ui Card with destructive border styling",
|
||||
"Chat history is session-only — cleared when the curtain closes or the app restarts",
|
||||
"Install shadcn/ui components via 'npx shadcn@latest add textarea' before implementing (card, badge, button, skeleton already installed)",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 20,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-021",
|
||||
"title": "@ProjectAgent with project action tools",
|
||||
"description": "As a user, I want the AI to answer project-specific questions and take actions like adding tasks, summarizing the project, and suggesting checkpoints.",
|
||||
"acceptanceCriteria": [
|
||||
"read_project_notes tool: fetches all notes for the scoped projectId from SQLite and returns combined content to the model",
|
||||
"add_task tool: creates a task in the project via tasks.create and confirms with 'Task added: [title]' in the chat response",
|
||||
"get_summary tool: calls the SDK to generate a 2-3 sentence summary of the project based on its notes and tasks, then calls projects.update to persist the result in project.aiSummary",
|
||||
"suggest_checkpoints tool: returns a JSON array of { title: string, date: string } proposed checkpoints based on date-anchored commitments found in notes",
|
||||
"@Orchestrator routes project-context messages to @ProjectAgent",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 21,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-022",
|
||||
"title": "LanceDB vector store setup and note embedding pipeline",
|
||||
"description": "As a developer, I need notes embedded into LanceDB so that semantic search across all project notes is possible.",
|
||||
"acceptanceCriteria": [
|
||||
"vectordb (LanceDB Node.js binding) installed and initialized in main process only; vector DB stored at app.getPath('userData')/vectors/",
|
||||
"After notes.create or notes.update, note content is embedded via the GitHub Copilot SDK embeddings endpoint and upserted in LanceDB with metadata: { noteId, projectId, content }",
|
||||
"On first app startup, a migration routine checks if the 'notes' LanceDB table exists; if not, embeds all existing SQLite notes and populates LanceDB",
|
||||
"Embedding errors are caught and logged to console (console.error) but do not reject the notes.update/create promise",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 22,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-023",
|
||||
"title": "@KnowledgeAgent semantic search across all projects",
|
||||
"description": "As a user, I want to ask questions that retrieve semantically relevant content from all past project notes regardless of project scope.",
|
||||
"acceptanceCriteria": [
|
||||
"vector_search_all tool accepts a query string and performs a LanceDB similarity search returning the top-5 results",
|
||||
"Each result includes: noteId, note title (joined from SQLite), projectId, project name (joined from SQLite), and matched text excerpt",
|
||||
"@Orchestrator routes cross-project knowledge queries to @KnowledgeAgent",
|
||||
"Chat response includes inline citations in the format: 'From: [Project Name] — [Note Title]'",
|
||||
"Typecheck passes"
|
||||
],
|
||||
"priority": 23,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-024",
|
||||
"title": "AI checkpoint suggestions UI",
|
||||
"description": "As a user, I want the AI to suggest timeline checkpoints from my meeting notes, which I can approve or reject directly in the timeline.",
|
||||
"acceptanceCriteria": [
|
||||
"'Suggest checkpoints' shadcn/ui Button (variant=outline, sparkles Lucide icon) in the Project Detail timeline header calls ai.chat with a suggest_checkpoints intent for the current project",
|
||||
"Suggested checkpoints returned by @ProjectAgent are inserted into the checkpoints table via checkpoints.create with isAiSuggested=1, isApproved=0",
|
||||
"Pending suggestions appear as shadcn/ui Card components (with dashed border via className 'border-dashed') above or below the GanttChart in the Project Detail timeline section",
|
||||
"'Approve' shadcn/ui Button (variant=default, size=sm) on each card calls checkpoints.update({ id, isApproved: 1 }); the checkpoint then appears as a normal dot on the Gantt",
|
||||
"'Reject' shadcn/ui Button (variant=ghost, size=sm) calls checkpoints.delete({ id }) and removes the card",
|
||||
"All UI uses shadcn/ui components (already installed)",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 24,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
"id": "US-025",
|
||||
"title": "Home dashboard — AI daily brief and suggestion chips",
|
||||
"description": "As a user, I want the Home screen to greet me with an AI-generated daily brief and pre-populated suggestion chips for quick queries.",
|
||||
"acceptanceCriteria": [
|
||||
"Greeting rendered as '✦ Hello, {name}' in Geist Semibold 30px with -1px letter-spacing; name sourced from electron-store (defaults to 'there' if not set)",
|
||||
"Top-right corner stat chip uses shadcn/ui Badge (variant=secondary) showing 'N Task due' where N = count of tasks with dueDate on or before end of today",
|
||||
"On app open, ai.chat called with global context to generate a daily brief paragraph highlighting tasks due today/this week and recent project activity",
|
||||
"Brief displayed below greeting in a shadcn/ui Card; bold key phrases rendered as <strong> (model wraps them in **markdown bold**)",
|
||||
"4 suggestion chips rendered in a 4-column flex row below the chat box using shadcn/ui Button (variant=outline); each chip has a Lucide icon + short prompt text",
|
||||
"Clicking a suggestion chip populates the chat input with the chip's prompt text",
|
||||
"Chat box uses shadcn/ui Textarea: white bg, border #d4d4d4, shadow-lg, min-height 109px, placeholder 'Ask me anything...'; Send uses shadcn/ui Button (black bg) bottom-right",
|
||||
"All UI uses shadcn/ui components (already installed)",
|
||||
"Typecheck passes",
|
||||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 25,
|
||||
"passes": false,
|
||||
"notes": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
## Codebase Patterns
|
||||
- `alias(table, 'alias_name')` from `drizzle-orm/sqlite-core` enables self-joins (e.g., clients → parentClients for hierarchy)
|
||||
- `sql<T>\`CASE WHEN ... THEN ... ELSE ... END\`` for conditional SELECT fields (e.g., clientName vs subClientName based on parentId)
|
||||
- `or(like(col1, pattern), like(col2, pattern))` for multi-column search; SQLite LIKE on NULL columns safely returns NULL (falsy) so OR is safe
|
||||
- Vite configs use `.mts` extension (not `.ts`) to avoid ESM/CJS conflict with electron-forge's externalize-deps plugin
|
||||
- electron-trpc uses `exposeElectronTRPC()` in preload and `createIPCHandler({ router, windows })` in main; renderer uses `ipcLink()` from `electron-trpc/renderer`
|
||||
- appRouter lives at `src/main/router/index.ts`; renderer client at `src/renderer/lib/trpc.ts`
|
||||
- `@/*` path alias maps to `src/renderer/*` (configured in tsconfig.json paths)
|
||||
- Drizzle ORM with better-sqlite3 (sync driver): SELECT queries MUST end with `.all()` to execute; INSERT/UPDATE/DELETE MUST end with `.run()`
|
||||
- `inArray(column, values)` works with nullable columns when values is `string[]` (TypeScript covariance allows string[] → (string | null)[])
|
||||
- All DB tables use `CREATE TABLE IF NOT EXISTS` for non-destructive migrations
|
||||
- All IDs are UUIDs generated via `crypto.randomUUID()`
|
||||
- TypeScript strict mode + noUncheckedIndexedAccess enabled; always account for possible undefined on array access
|
||||
- electron-store@8 (CJS) used for app settings; use lazy init pattern `getStore()` like `getDb()` to avoid calling before app ready
|
||||
- ESLint uses `eslint-import-resolver-typescript` to resolve `@/*` aliases; configured in `.eslintrc.json` under `settings.import/resolver`
|
||||
- App settings (sidebar state, etc.) exposed via `settings` tRPC sub-router for type-safe renderer access
|
||||
- `z.string().nullable().optional()` in tRPC inputs enables three-state semantics: undefined = don't change, null = clear, string = set value
|
||||
- TanStack Router `validateSearch` with Zod schema for passing selected-item IDs via URL search params (e.g., `?projectId=...`)
|
||||
---
|
||||
|
||||
## 2026-02-19 - US-002
|
||||
- Installed `better-sqlite3`, `drizzle-orm` (runtime) and `@types/better-sqlite3`, `drizzle-kit` (dev)
|
||||
- Created `src/main/db/schema.ts`: 5 tables (clients, projects, tasks, checkpoints, notes) with exported InferSelectModel/InferInsertModel types
|
||||
- Created `src/main/db/index.ts`: `initDb()` opens/creates `adiuva.db` at `app.getPath('userData')`, runs CREATE TABLE IF NOT EXISTS (non-destructive), enables WAL mode; `getDb()` singleton accessor
|
||||
- Updated `src/main/index.ts`: call `initDb()` in `app.on('ready')`
|
||||
- Updated `vite.main.config.mts`: externalized `better-sqlite3`
|
||||
- Updated `forge.config.ts`: added `AutoUnpackNativesPlugin`
|
||||
- Added `drizzle.config.ts` for drizzle-kit CLI
|
||||
- Typecheck: passes with zero errors
|
||||
- **Learnings for future iterations:**
|
||||
- better-sqlite3 is CommonJS with native addon; Vite must NOT bundle it — always add to rollupOptions.external
|
||||
- The CREATE TABLE IF NOT EXISTS approach satisfies "never destructive" and works perfectly in electron without needing migration file resolution
|
||||
- electron-forge rebuilds native modules automatically on `electron-forge start`; no manual rebuild step needed
|
||||
- `app.getPath('userData')` is only available after `app.on('ready')` fires — do not call earlier
|
||||
---
|
||||
|
||||
## 2026-02-19 - US-008
|
||||
- What was implemented:
|
||||
- Full `checkpointsRouter` replacing stubs in `src/main/router/index.ts`
|
||||
- Full `notesRouter` replacing stubs in `src/main/router/index.ts`
|
||||
- Added `checkpoints` and `notes` to the schema import
|
||||
- `checkpoints.list`: optional `projectId` filter, ordered by `asc(checkpoints.date)`
|
||||
- `checkpoints.create`: inserts with UUID, createdAt=Date.now(), defaults isAiSuggested/isApproved to 0
|
||||
- `checkpoints.update`: partial set for title/date/isApproved
|
||||
- `checkpoints.delete`: deletes by id, returns `{ success: true }`
|
||||
- `notes.list`: returns `{ id, projectId, title, createdAt, updatedAt }` only — no content (performance)
|
||||
- `notes.get`: returns full record or null via `.all()[0] ?? null` pattern
|
||||
- `notes.create`: inserts with UUID, createdAt=updatedAt=Date.now()
|
||||
- `notes.update`: partial set, always sets updatedAt=Date.now() regardless of which fields changed
|
||||
- `notes.delete`: deletes by id, returns `{ success: true }`
|
||||
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
|
||||
- **Learnings for future iterations:**
|
||||
- `notes.update` must always set `updatedAt` — build the set object with updatedAt outside the conditional block
|
||||
- `notes.list` intentionally excludes `content` column for performance; use `notes.get` for full record
|
||||
- `checkpoints.projectId` is `.notNull()` in schema (unlike tasks.projectId which is nullable) — no null coalescing needed
|
||||
---
|
||||
|
||||
## 2026-02-19 - US-003
|
||||
- What was implemented:
|
||||
- Installed: electron-trpc, @trpc/server, @trpc/client, @trpc/react-query, @tanstack/react-query, zod
|
||||
- Created `src/main/router/index.ts` with full appRouter: stub routers for health, clients, projects, tasks, checkpoints, notes, ai
|
||||
- Updated `src/preload/index.ts` to call `exposeElectronTRPC()`
|
||||
- Updated `src/main/index.ts` to call `createIPCHandler({ router: appRouter, windows: [win] })`; `createWindow()` now returns `BrowserWindow`
|
||||
- Created `src/renderer/lib/trpc.ts` with `createTRPCReact<AppRouter>()`
|
||||
- Updated `src/renderer/index.tsx` to wrap app in `TRPCProvider` + `QueryClientProvider`
|
||||
- Updated `src/renderer/routes/index.tsx` to call `trpc.health.ping.useQuery()` and display 'tRPC IPC bridge: pong'
|
||||
- Files changed: package.json, package-lock.json, prd.json, src/main/index.ts, src/main/router/index.ts (new), src/preload/index.ts, src/renderer/index.tsx, src/renderer/lib/trpc.ts (new), src/renderer/routes/index.tsx
|
||||
- **Learnings for future iterations:**
|
||||
- electron-trpc `exposeElectronTRPC` is imported from `electron-trpc/main` (not a separate package)
|
||||
- `ipcLink` is imported from `electron-trpc/renderer` in the renderer process
|
||||
- `createTRPCReact<AppRouter>()` requires importing the AppRouter type from the main process router — this is a type-only import so it doesn't bundle main process code into renderer
|
||||
- The TRPCProvider must wrap QueryClientProvider (or be a sibling); both need the same queryClient instance
|
||||
- Stub routers return empty arrays or null — they will be replaced in US-005 through US-008
|
||||
---
|
||||
|
||||
## 2026-02-19 - US-004
|
||||
- What was implemented:
|
||||
- Installed: electron-store@8 (CJS-compatible, for persistent app settings), @fontsource/geist (self-hosted Geist font), eslint-import-resolver-typescript (ESLint path alias fix)
|
||||
- Created `src/main/store.ts` with lazy `getStore()` pattern using electron-store
|
||||
- Added `settings` tRPC sub-router with `getSidebarCollapsed` query and `setSidebarCollapsed` mutation
|
||||
- Updated `src/renderer/components/layout/AppShell.tsx` to: persist sidebar collapse via tRPC, add right-edge 'keep scrolling for AI' vertical label with ChevronDown icon
|
||||
- Updated `src/renderer/globals.css`: replaced Google Fonts CDN with @fontsource/geist imports (weights 400/500/600)
|
||||
- Updated `index.html`: removed Google Fonts CDN links
|
||||
- Updated `.eslintrc.json`: added eslint-import-resolver-typescript to fix @/* alias resolution (fixed all 7 pre-existing lint errors)
|
||||
- Files changed: .eslintrc.json, index.html, package.json, package-lock.json, src/main/router/index.ts, src/main/store.ts (new), src/renderer/components/layout/AppShell.tsx, src/renderer/globals.css
|
||||
- **Learnings for future iterations:**
|
||||
- Use electron-store@8 (not v9+) — v9+ is ESM-only and breaks with CommonJS main process
|
||||
- electron-store must NOT be initialized at module import time (before app.ready); use lazy `getStore()` like `getDb()` pattern
|
||||
- For sidebar/UI state loaded from IPC: use `localState ?? queryData ?? default` pattern to avoid flash while query resolves
|
||||
- @fontsource packages are the npm equivalent of Google Fonts — import weight-specific CSS files (e.g., `@fontsource/geist/400.css`)
|
||||
- ESLint `import/no-unresolved` requires `eslint-import-resolver-typescript` with `alwaysTryTypes: true` to resolve TypeScript path aliases
|
||||
- The `writingMode: 'vertical-rl'` + `transform: 'rotate(180deg)'` CSS pattern creates bottom-to-top text for vertical affordance labels
|
||||
---
|
||||
|
||||
## 2026-02-19 - US-006
|
||||
- What was implemented:
|
||||
- Full `projectsRouter` replacing stubs in `src/main/router/index.ts`
|
||||
- Added `and` to drizzle-orm imports
|
||||
- `projects.list`: uses `and()` with optional conditions for `clientId` filter and archived filter (defaults to active only)
|
||||
- `projects.listAll`: returns only `{ id, name }` columns for dropdown use
|
||||
- `projects.get`: `.all()` then `result[0] ?? null` pattern for nullable single-record lookup
|
||||
- `projects.create`: inserts with UUID, status='active', createdAt=Date.now()
|
||||
- `projects.update`: partial set object — only sets defined fields
|
||||
- `projects.delete`: nulls `tasks.projectId` for all tasks in the project, then deletes the project
|
||||
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
|
||||
- **Learnings for future iterations:**
|
||||
- `and(...conditions)` from drizzle-orm accepts `(SQL | undefined)[]` — pass `undefined` for optional conditions and drizzle filters them out automatically
|
||||
- For nullable single-record queries: use `.all()` and `result[0] ?? null` (strict mode forbids `.get()` direct null return without this pattern)
|
||||
- `and()` returns `SQL<unknown> | undefined` which `.where()` accepts directly (no extra wrapping needed)
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-19 - US-005
|
||||
- What was implemented:
|
||||
- Full clients tRPC router replacing stubs in `src/main/router/index.ts`
|
||||
- Added imports: `eq`, `asc`, `inArray` from `drizzle-orm`; `getDb` from `../db`; `clients`, `projects`, `tasks` from `../db/schema`
|
||||
- `clients.list`: `db.select().from(clients).orderBy(asc(clients.name)).all()`
|
||||
- `clients.create`: inserts with `crypto.randomUUID()` + `Date.now()` via `.run()`
|
||||
- `clients.update`: partial update — only sets fields that are defined in input, skips if no-op
|
||||
- `clients.delete`: checks for child clients and child projects; returns `{ error: string }` payload if any exist; otherwise deletes and returns `{ success: true }`
|
||||
- `clients.deleteWithCascade`: BFS loop collects all descendant client IDs, finds their projects, nulls `projectId` on orphaned tasks, deletes projects, then deletes all clients
|
||||
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
|
||||
- **Learnings for future iterations:**
|
||||
- Drizzle ORM with better-sqlite3 sync driver: SELECT must call `.all()` to get an array; INSERT/UPDATE/DELETE must call `.run()` to execute — NOT calling these causes TypeScript errors (query builder ≠ result)
|
||||
- `inArray(nullableColumn, string[])` is TypeScript-safe because `string[]` is assignable to `(string | null)[]` via covariance
|
||||
- Guard against empty arrays before using `inArray` — while `allClientIds` is never empty (starts with input.id), `projectIds` could be empty; guarded with `if (projectIds.length > 0)` block
|
||||
- `@typescript-eslint/no-non-null-assertion` is configured as a warning (not error) in this project — `queue.shift()!` is fine after a `length > 0` check
|
||||
---
|
||||
|
||||
## 2026-02-19 - US-007
|
||||
- What was implemented:
|
||||
- Full `tasksRouter` replacing stubs in `src/main/router/index.ts`
|
||||
- Added imports: `or`, `like`, `sql` from `drizzle-orm`; `alias` from `drizzle-orm/sqlite-core`
|
||||
- `tasks.list`: LEFT JOINs projects → clients → parentClients (alias for self-join); CASE WHEN for clientName/subClientName breadcrumb fields; `and()` with optional conditions for projectId/status/search; `like()` OR search on title+description; CASE expression for priority ordering
|
||||
- `tasks.create`: inserts with UUID, defaults (status='todo', priority='medium'), createdAt=Date.now()
|
||||
- `tasks.update`: partial set object — only sets defined fields
|
||||
- `tasks.delete`: deletes by id, returns `{ success: true }`
|
||||
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
|
||||
- **Learnings for future iterations:**
|
||||
- `alias(table, 'alias_name')` is from `drizzle-orm/sqlite-core` (NOT `drizzle-orm`) for SQLite self-joins
|
||||
- `sql<T>\`CASE WHEN ${col} IS NOT NULL THEN ${alias.col} ELSE ${col} END\`` for conditional field selection using drizzle template literals
|
||||
- `or(like(col1, pattern), like(col2, pattern))` composes safely — null columns evaluate to NULL (falsy) in WHERE
|
||||
- For priority ordering: `asc(sql\`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END\`)` puts high priority first
|
||||
---
|
||||
|
||||
## 2026-02-19 - US-009
|
||||
- What was implemented:
|
||||
- Verified existing `ProjectSidebar` component at `src/renderer/components/projects/ProjectSidebar.tsx` satisfies all US-009 acceptance criteria
|
||||
- New Project button at top using shadcn/ui Button + `clients.create` mutation with auto-rename on success
|
||||
- Kebab context menu (DropdownMenu) with Rename, New Sub-Project, Delete actions
|
||||
- Inline rename: Input replaces label, Enter saves via `clients.update`, Escape cancels, blur saves
|
||||
- Delete: AlertDialog with two stages — initial confirm, then cascade-warn if children exist (uses `clients.delete` first, falls back to `clients.deleteWithCascade`)
|
||||
- Hierarchical tree via `buildTree()` function (parent-child via `clients.parentId`)
|
||||
- Empty state with EmptyMedia + call-to-action button
|
||||
- All mutations invalidate `clients.list` query for immediate tree refresh
|
||||
- Typecheck passes (zero errors), lint passes (1 non-null assertion warning, guarded)
|
||||
- Files changed: `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`
|
||||
- **Learnings for future iterations:**
|
||||
- The ProjectSidebar was built as part of US-004 app shell work but the US-009 story wasn't marked as passing — always check existing code before implementing
|
||||
- `useCallback` ref pattern (`ref={callbackRef}`) is used for auto-focus + select on mount without useEffect
|
||||
- The two-stage delete flow (try simple delete first → if error, show cascade option) maps well to the backend's `clients.delete` (guards) + `clients.deleteWithCascade` (force) pattern
|
||||
---
|
||||
|
||||
## 2026-02-19 - US-010
|
||||
- What was implemented:
|
||||
- Rewrote `ProjectSidebar` from a client-hierarchy tree to a project-centric sidebar grouped by client
|
||||
- Projects grouped by `clientId` using Collapsible headers; projects without a client appear under "Internal / No Client"
|
||||
- Search input filters projects by name in real-time (auto-expands all groups when searching)
|
||||
- Show/hide archived projects via Switch toggle (queries `projects.list` with `includeArchived`)
|
||||
- Context menu per project (DropdownMenu): Edit Client (Dialog + Select to assign/change/remove client), Archive/Unarchive, Delete (AlertDialog)
|
||||
- Clicking a project sets `projectId` in search params → renders ProjectDetail placeholder in right pane
|
||||
- Active project highlighted with `bg-sidebar-accent`
|
||||
- Updated `projects.update` tRPC procedure to accept `clientId: z.string().nullable().optional()` (allows unlinking from client)
|
||||
- Created placeholder `ProjectDetail` component (full implementation deferred to US-013)
|
||||
- Installed shadcn/ui: dialog, select, switch
|
||||
- Files changed: `src/renderer/components/projects/ProjectSidebar.tsx`, `src/renderer/routes/projects.tsx`, `src/renderer/components/projects/ProjectDetail.tsx` (new), `src/main/router/index.ts`, `src/renderer/components/ui/dialog.tsx` (new), `src/renderer/components/ui/select.tsx` (new), `src/renderer/components/ui/switch.tsx` (new), `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`
|
||||
- **Learnings for future iterations:**
|
||||
- TanStack Router `validateSearch` with Zod schema is the cleanest way to pass selected-item IDs via URL search params without creating nested routes
|
||||
- `Route.useNavigate()` returns a typed navigate fn; use `void navigate({ search: { ... } })` to avoid unhandled promise warnings
|
||||
- For project grouping, query both `projects.list` and `clients.list` separately then join in-memory via a Map — avoids complex SQL joins for display-only data
|
||||
- `projects.update` with `clientId: z.string().nullable().optional()` allows three states: undefined (don't change), null (unlink), string (assign)
|
||||
- Auto-expanding all groups during search (`effectiveExpanded` computed from grouped keys) gives a better UX than forcing users to manually expand
|
||||
---
|
||||
@@ -1,113 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Ralph Wiggum - Long-running AI agent loop
|
||||
# Usage: ./ralph.sh [--tool amp|claude] [max_iterations]
|
||||
|
||||
set -e
|
||||
|
||||
# Parse arguments
|
||||
TOOL="claude" # Default to claude for backwards compatibility
|
||||
MAX_ITERATIONS=10
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--tool)
|
||||
TOOL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--tool=*)
|
||||
TOOL="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
# Assume it's max_iterations if it's a number
|
||||
if [[ "$1" =~ ^[0-9]+$ ]]; then
|
||||
MAX_ITERATIONS="$1"
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate tool choice
|
||||
if [[ "$TOOL" != "amp" && "$TOOL" != "claude" ]]; then
|
||||
echo "Error: Invalid tool '$TOOL'. Must be 'amp' or 'claude'."
|
||||
exit 1
|
||||
fi
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PRD_FILE="$SCRIPT_DIR/prd.json"
|
||||
PROGRESS_FILE="$SCRIPT_DIR/progress.txt"
|
||||
ARCHIVE_DIR="$SCRIPT_DIR/archive"
|
||||
LAST_BRANCH_FILE="$SCRIPT_DIR/.last-branch"
|
||||
|
||||
# Archive previous run if branch changed
|
||||
if [ -f "$PRD_FILE" ] && [ -f "$LAST_BRANCH_FILE" ]; then
|
||||
CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
|
||||
LAST_BRANCH=$(cat "$LAST_BRANCH_FILE" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$CURRENT_BRANCH" ] && [ -n "$LAST_BRANCH" ] && [ "$CURRENT_BRANCH" != "$LAST_BRANCH" ]; then
|
||||
# Archive the previous run
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
# Strip "ralph/" prefix from branch name for folder
|
||||
FOLDER_NAME=$(echo "$LAST_BRANCH" | sed 's|^ralph/||')
|
||||
ARCHIVE_FOLDER="$ARCHIVE_DIR/$DATE-$FOLDER_NAME"
|
||||
|
||||
echo "Archiving previous run: $LAST_BRANCH"
|
||||
mkdir -p "$ARCHIVE_FOLDER"
|
||||
[ -f "$PRD_FILE" ] && cp "$PRD_FILE" "$ARCHIVE_FOLDER/"
|
||||
[ -f "$PROGRESS_FILE" ] && cp "$PROGRESS_FILE" "$ARCHIVE_FOLDER/"
|
||||
echo " Archived to: $ARCHIVE_FOLDER"
|
||||
|
||||
# Reset progress file for new run
|
||||
echo "# Ralph Progress Log" > "$PROGRESS_FILE"
|
||||
echo "Started: $(date)" >> "$PROGRESS_FILE"
|
||||
echo "---" >> "$PROGRESS_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Track current branch
|
||||
if [ -f "$PRD_FILE" ]; then
|
||||
CURRENT_BRANCH=$(jq -r '.branchName // empty' "$PRD_FILE" 2>/dev/null || echo "")
|
||||
if [ -n "$CURRENT_BRANCH" ]; then
|
||||
echo "$CURRENT_BRANCH" > "$LAST_BRANCH_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Initialize progress file if it doesn't exist
|
||||
if [ ! -f "$PROGRESS_FILE" ]; then
|
||||
echo "# Ralph Progress Log" > "$PROGRESS_FILE"
|
||||
echo "Started: $(date)" >> "$PROGRESS_FILE"
|
||||
echo "---" >> "$PROGRESS_FILE"
|
||||
fi
|
||||
|
||||
echo "Starting Ralph - Tool: $TOOL - Max iterations: $MAX_ITERATIONS"
|
||||
|
||||
for i in $(seq 1 $MAX_ITERATIONS); do
|
||||
echo ""
|
||||
echo "==============================================================="
|
||||
echo " Ralph Iteration $i of $MAX_ITERATIONS ($TOOL)"
|
||||
echo "==============================================================="
|
||||
|
||||
# Run the selected tool with the ralph prompt
|
||||
if [[ "$TOOL" == "amp" ]]; then
|
||||
OUTPUT=$(cat "$SCRIPT_DIR/prompt.md" | amp --dangerously-allow-all 2>&1 | tee /dev/stderr) || true
|
||||
else
|
||||
# Claude Code: use --dangerously-skip-permissions for autonomous operation, --print for output
|
||||
OUTPUT=$(claude --dangerously-skip-permissions --print < "$SCRIPT_DIR/CLAUDE.md" 2>&1 | tee /dev/stderr) || true
|
||||
fi
|
||||
|
||||
# Check for completion signal
|
||||
if echo "$OUTPUT" | grep -q "<promise>COMPLETE</promise>"; then
|
||||
echo ""
|
||||
echo "Ralph completed all tasks!"
|
||||
echo "Completed at iteration $i of $MAX_ITERATIONS"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Iteration $i complete. Continuing..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Ralph reached max iterations ($MAX_ITERATIONS) without completing all tasks."
|
||||
echo "Check $PROGRESS_FILE for status."
|
||||
exit 1
|
||||
230
src/main/ai/chat-copilot.ts
Normal file
230
src/main/ai/chat-copilot.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* ChatCopilot — LangChain-compatible ChatModel adapter for the GitHub Copilot SDK.
|
||||
*
|
||||
* Wraps the CopilotClient's session API so it can be used as a drop-in
|
||||
* BaseChatModel within LangGraph, making the orchestrator provider-agnostic.
|
||||
*
|
||||
* Accepts a client-getter function to avoid module duplication issues when
|
||||
* this file is code-split into a separate chunk by Vite.
|
||||
*/
|
||||
import { SimpleChatModel, type BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { AIMessageChunk } from '@langchain/core/messages';
|
||||
import { ChatGenerationChunk } from '@langchain/core/outputs';
|
||||
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
||||
import type { StructuredTool } from '@langchain/core/tools';
|
||||
|
||||
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
|
||||
|
||||
/** Minimal shape of a Copilot SDK Tool (avoids importing the full SDK type) */
|
||||
type CopilotNativeTool = {
|
||||
name: string;
|
||||
description?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parameters?: any;
|
||||
handler: (args: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const COPILOT_TIMEOUT = 120_000;
|
||||
|
||||
export class ChatCopilot extends SimpleChatModel<BaseChatModelCallOptions> {
|
||||
private getClient: () => CopilotClientType | null;
|
||||
/** Native Copilot SDK tools, populated by bindTools() */
|
||||
private _copilotTools: CopilotNativeTool[] = [];
|
||||
|
||||
constructor(getClient: () => CopilotClientType | null, tools: CopilotNativeTool[] = []) {
|
||||
super({});
|
||||
this.getClient = getClient;
|
||||
this._copilotTools = tools;
|
||||
}
|
||||
|
||||
_llmType(): string {
|
||||
return 'copilot';
|
||||
}
|
||||
|
||||
private requireClient(): CopilotClientType {
|
||||
const client = this.getClient();
|
||||
if (!client) {
|
||||
throw new Error('CopilotClient not initialized. Please check that Copilot CLI is authenticated (copilot auth login).');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert LangChain StructuredTools to Copilot SDK native tools and return a
|
||||
* new ChatCopilot instance that will pass them to createSession().
|
||||
* The SDK handles the full tool-calling loop internally — no LangChain ToolMessages needed.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
override bindTools(tools: StructuredTool[]): any {
|
||||
const copilotTools: CopilotNativeTool[] = tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? undefined,
|
||||
parameters: t.schema,
|
||||
handler: async (args: unknown) => {
|
||||
console.log(`[ChatCopilot] tool handler called: ${t.name}`, JSON.stringify(args));
|
||||
const result = await t.invoke(args as Record<string, unknown>);
|
||||
const output = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
console.log(`[ChatCopilot] tool handler result: ${t.name} →`, output.slice(0, 200));
|
||||
return output;
|
||||
},
|
||||
}));
|
||||
console.log(`[ChatCopilot] bindTools() called with:`, copilotTools.map((t) => t.name));
|
||||
return new ChatCopilot(this.getClient, copilotTools);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async _call(messages: BaseMessage[], _options: this['ParsedCallOptions'], _runManager?: CallbackManagerForLLMRun): Promise<string> {
|
||||
const client = this.requireClient();
|
||||
|
||||
// Extract system message and user prompt from LangChain messages
|
||||
const systemContent = messages
|
||||
.filter((m) => m._getType() === 'system')
|
||||
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
||||
.join('\n');
|
||||
|
||||
const userContent = messages
|
||||
.filter((m) => m._getType() === 'human')
|
||||
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
||||
.join('\n');
|
||||
|
||||
const hasTools = this._copilotTools.length > 0;
|
||||
|
||||
const session = await client.createSession({
|
||||
// When tools are registered, use append mode so the SDK can inject its tool-calling
|
||||
// instructions before our content. mode:'replace' strips those SDK-managed sections,
|
||||
// causing the model to never see/call registered tools.
|
||||
systemMessage: systemContent
|
||||
? hasTools
|
||||
? { content: systemContent }
|
||||
: { mode: 'replace', content: systemContent }
|
||||
: undefined,
|
||||
// Pass native tools when available — SDK handles the agentic tool-calling loop
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await session.sendAndWait({ prompt: userContent }, COPILOT_TIMEOUT);
|
||||
return result?.data.content ?? '';
|
||||
} finally {
|
||||
await session.destroy().catch(() => { /* ignore cleanup errors */ });
|
||||
}
|
||||
}
|
||||
|
||||
async *_streamResponseChunks(
|
||||
messages: BaseMessage[],
|
||||
_options: this['ParsedCallOptions'],
|
||||
_runManager?: CallbackManagerForLLMRun,
|
||||
): AsyncGenerator<ChatGenerationChunk> {
|
||||
const client = this.requireClient();
|
||||
|
||||
const systemContent = messages
|
||||
.filter((m) => m._getType() === 'system')
|
||||
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
||||
.join('\n');
|
||||
|
||||
const userContent = messages
|
||||
.filter((m) => m._getType() === 'human')
|
||||
.map((m) => (typeof m.content === 'string' ? m.content : ''))
|
||||
.join('\n');
|
||||
|
||||
const hasTools = this._copilotTools.length > 0;
|
||||
|
||||
console.log(`[ChatCopilot] _streamResponseChunks: hasTools=${hasTools}, tools=[${this._copilotTools.map((t) => t.name).join(', ')}]`);
|
||||
console.log(`[ChatCopilot] systemMessage mode: ${hasTools ? 'append' : 'replace'}`);
|
||||
|
||||
const session = await client.createSession({
|
||||
// Same append-vs-replace logic as _call: tools require append mode so the SDK
|
||||
// can inject its tool-calling instructions before our project context.
|
||||
systemMessage: systemContent
|
||||
? hasTools
|
||||
? { content: systemContent }
|
||||
: { mode: 'replace', content: systemContent }
|
||||
: undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(hasTools ? { tools: this._copilotTools as any[] } : { availableTools: [] }),
|
||||
streaming: true,
|
||||
});
|
||||
|
||||
console.log(`[ChatCopilot] session created: ${session.sessionId}`);
|
||||
|
||||
// Buffer chunks via event listener and yield them
|
||||
const chunks: string[] = [];
|
||||
let done = false;
|
||||
let sessionError: Error | null = null;
|
||||
let resolveNext: (() => void) | null = null;
|
||||
|
||||
const unsubDelta = session.on('assistant.message_delta', (event) => {
|
||||
const delta = event.data.deltaContent;
|
||||
if (delta) {
|
||||
chunks.push(delta);
|
||||
resolveNext?.();
|
||||
}
|
||||
});
|
||||
|
||||
const unsubEnd = session.on('session.idle', () => {
|
||||
console.log('[ChatCopilot] session.idle received');
|
||||
done = true;
|
||||
resolveNext?.();
|
||||
});
|
||||
|
||||
const unsubError = session.on('session.error', (event) => {
|
||||
console.error('[ChatCopilot] session.error received:', event.data.message);
|
||||
sessionError = new Error(event.data.message);
|
||||
done = true;
|
||||
resolveNext?.();
|
||||
});
|
||||
|
||||
// Log all events to understand SDK behaviour with tools
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const unsubAll = session.on((event: any) => {
|
||||
if (!['assistant.message_delta'].includes(event.type)) {
|
||||
console.log(`[ChatCopilot] SDK event: ${event.type}`, JSON.stringify(event.data ?? {}).slice(0, 300));
|
||||
}
|
||||
});
|
||||
|
||||
// Fire the request (don't await — we'll drain via events).
|
||||
const sendPromise = session.sendAndWait({ prompt: userContent }, COPILOT_TIMEOUT);
|
||||
|
||||
// If sendAndWait rejects before any session events fire (e.g. send() throws
|
||||
// internally due to a listModels/auth failure), wake up the while loop so it
|
||||
// doesn't hang waiting for session.idle that will never arrive.
|
||||
sendPromise.catch((err: unknown) => {
|
||||
if (!done) {
|
||||
sessionError = err instanceof Error ? err : new Error(String(err));
|
||||
done = true;
|
||||
resolveNext?.();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
while (!done || chunks.length > 0) {
|
||||
if (chunks.length > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const text = chunks.shift()!;
|
||||
const chunk = new ChatGenerationChunk({
|
||||
message: new AIMessageChunk({ content: text }),
|
||||
text,
|
||||
});
|
||||
await _runManager?.handleLLMNewToken(text);
|
||||
yield chunk;
|
||||
} else if (!done) {
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveNext = resolve;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate any error surfaced via session.error event or sendAndWait rejection
|
||||
if (sessionError) throw sessionError;
|
||||
} finally {
|
||||
unsubDelta();
|
||||
unsubEnd();
|
||||
unsubError();
|
||||
unsubAll();
|
||||
await session.destroy().catch(() => { /* ignore cleanup errors */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/main/ai/copilot.ts
Normal file
61
src/main/ai/copilot.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { app } from 'electron';
|
||||
import { registerProvider, type AIProvider } from './provider';
|
||||
|
||||
// Dynamic import type — @github/copilot-sdk is ESM-only
|
||||
type CopilotClientType = import('@github/copilot-sdk').CopilotClient;
|
||||
|
||||
let client: CopilotClientType | null = null;
|
||||
let isReady = false;
|
||||
|
||||
const copilotProvider: AIProvider = {
|
||||
name: 'copilot',
|
||||
displayName: 'GitHub Copilot',
|
||||
usesExternalAuth: true,
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
try {
|
||||
// Stop existing client if re-initializing
|
||||
if (client) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
await client.stop().catch(() => {});
|
||||
client = null;
|
||||
}
|
||||
|
||||
const { CopilotClient } = await import('@github/copilot-sdk');
|
||||
// No githubToken — uses stored OAuth credentials from Copilot CLI
|
||||
// (authenticate first with `copilot auth login`)
|
||||
client = new CopilotClient({
|
||||
autoStart: true,
|
||||
autoRestart: true,
|
||||
logLevel: 'warning',
|
||||
});
|
||||
await client.start();
|
||||
isReady = true;
|
||||
console.log('[AI] CopilotClient started (using CLI OAuth credentials)');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[AI] Failed to start CopilotClient:', err);
|
||||
client = null;
|
||||
isReady = false;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
isReady(): boolean {
|
||||
return isReady && client !== null;
|
||||
},
|
||||
};
|
||||
|
||||
/** Get the CopilotClient instance (null if not initialized). */
|
||||
export function getCopilotClient(): CopilotClientType | null {
|
||||
return client;
|
||||
}
|
||||
|
||||
// Clean shutdown on app quit
|
||||
app.on('before-quit', () => {
|
||||
if (client) {
|
||||
client.stop().catch((err: unknown) => console.error('[AI] Error stopping CopilotClient:', err));
|
||||
}
|
||||
});
|
||||
|
||||
registerProvider(copilotProvider);
|
||||
73
src/main/ai/embeddings.ts
Normal file
73
src/main/ai/embeddings.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getToken } from './token';
|
||||
|
||||
interface CopilotConfig {
|
||||
copilot_tokens?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the GitHub Copilot OAuth token from the CLI config file.
|
||||
* Stored at ~/.copilot/config.json under copilot_tokens["{host}:{login}"].
|
||||
* Returns the first available token, or null if unavailable.
|
||||
*/
|
||||
function readCopilotToken(): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(
|
||||
path.join(os.homedir(), '.copilot', 'config.json'),
|
||||
'utf-8',
|
||||
);
|
||||
const cfg = JSON.parse(raw) as CopilotConfig;
|
||||
const vals = Object.values(cfg.copilot_tokens ?? {});
|
||||
return vals[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed a single text string using the best available credentials.
|
||||
*
|
||||
* Priority:
|
||||
* 1. GitHub Copilot CLI token → OpenAI-compatible embeddings endpoint at
|
||||
* https://api.githubcopilot.com
|
||||
* 2. Stored OpenAI token → standard OpenAI embeddings API
|
||||
*
|
||||
* Throws if no credentials are available or the API call fails.
|
||||
* Callers must .catch() this and handle the error without rejecting
|
||||
* the surrounding tRPC mutation.
|
||||
*/
|
||||
export async function embedText(text: string): Promise<number[]> {
|
||||
const { OpenAIEmbeddings } = await import('@langchain/openai');
|
||||
|
||||
const copilotToken = readCopilotToken();
|
||||
|
||||
let embeddingsInstance;
|
||||
if (copilotToken) {
|
||||
embeddingsInstance = new OpenAIEmbeddings({
|
||||
apiKey: copilotToken,
|
||||
model: 'text-embedding-3-small',
|
||||
configuration: { baseURL: 'https://api.githubcopilot.com' },
|
||||
});
|
||||
} else {
|
||||
const openaiToken = await getToken('openai');
|
||||
if (!openaiToken) {
|
||||
throw new Error(
|
||||
'[Embeddings] No credentials available. Authenticate with Copilot CLI or add an OpenAI token in Settings.',
|
||||
);
|
||||
}
|
||||
embeddingsInstance = new OpenAIEmbeddings({
|
||||
apiKey: openaiToken,
|
||||
model: 'text-embedding-3-small',
|
||||
});
|
||||
}
|
||||
|
||||
// embedDocuments returns number[][] — cast explicitly to satisfy strict TS
|
||||
const results = (await embeddingsInstance.embedDocuments([text])) as number[][];
|
||||
const vector = results[0] as number[] | undefined;
|
||||
if (!vector || vector.length === 0) {
|
||||
throw new Error('[Embeddings] Empty vector returned from embedding API');
|
||||
}
|
||||
return vector;
|
||||
}
|
||||
81
src/main/ai/llm.ts
Normal file
81
src/main/ai/llm.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* LLM connector factory — returns a LangChain BaseChatModel for the active provider.
|
||||
*
|
||||
* The agent orchestration (LangGraph) is provider-independent. This module is
|
||||
* the only place that knows how to create provider-specific LLM instances.
|
||||
*/
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { getActiveProviderName, getActiveProvider } from './provider';
|
||||
import { getToken } from './token';
|
||||
import { getCopilotClient } from './copilot';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider-specific factory functions (lazy-loaded)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createOpenAIModel(token: string): Promise<BaseChatModel> {
|
||||
const { ChatOpenAI } = await import('@langchain/openai');
|
||||
return new ChatOpenAI({
|
||||
apiKey: token,
|
||||
model: 'gpt-4o-mini',
|
||||
temperature: 0.3,
|
||||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function createAnthropicModel(token: string): Promise<BaseChatModel> {
|
||||
const { ChatAnthropic } = await import('@langchain/anthropic');
|
||||
return new ChatAnthropic({
|
||||
apiKey: token,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
temperature: 0.3,
|
||||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function createCopilotModel(_token: string): Promise<BaseChatModel> {
|
||||
// GitHub Copilot uses the Copilot SDK subprocess for auth and API access.
|
||||
// We wrap it in a LangChain-compatible adapter.
|
||||
// Pass getCopilotClient from this chunk (same as copilot.ts) to avoid
|
||||
// module duplication when chat-copilot.ts is code-split by Vite.
|
||||
const { ChatCopilot } = await import('./chat-copilot');
|
||||
return new ChatCopilot(getCopilotClient);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODEL_FACTORIES: Record<string, (token: string) => Promise<BaseChatModel>> = {
|
||||
openai: createOpenAIModel,
|
||||
anthropic: createAnthropicModel,
|
||||
copilot: createCopilotModel,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a LangChain BaseChatModel for the currently active AI provider.
|
||||
* Returns null if no provider is configured or no token is available.
|
||||
*/
|
||||
export async function getLLM(): Promise<BaseChatModel | null> {
|
||||
const providerName = getActiveProviderName();
|
||||
const factory = MODEL_FACTORIES[providerName];
|
||||
if (!factory) {
|
||||
console.log(`[AI] No LLM factory for provider "${providerName}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = getActiveProvider();
|
||||
const token = provider?.usesExternalAuth ? '' : await getToken(providerName);
|
||||
if (!provider?.usesExternalAuth && !token) {
|
||||
console.log(`[AI] No token available for provider "${providerName}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await factory(token ?? '');
|
||||
} catch (err) {
|
||||
console.error(`[AI] Failed to create LLM for "${providerName}":`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
991
src/main/ai/orchestrator.ts
Normal file
991
src/main/ai/orchestrator.ts
Normal file
@@ -0,0 +1,991 @@
|
||||
/**
|
||||
* @Orchestrator agent — LangGraph-based intent routing.
|
||||
*
|
||||
* The agent logic (routing, state) lives here and is fully LLM-agnostic.
|
||||
* The LLM is a swappable connector obtained via `getLLM()`.
|
||||
*/
|
||||
import { Annotation, StateGraph, START, END } from '@langchain/langgraph';
|
||||
import { SystemMessage, HumanMessage, AIMessage, ToolMessage, type BaseMessage, type ToolCall } from '@langchain/core/messages';
|
||||
import { tool, type StructuredTool } from '@langchain/core/tools';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { getDb } from '../db';
|
||||
import { projects, tasks, checkpoints, notes, clients } from '../db/schema';
|
||||
import { getLLM } from './llm';
|
||||
import { getActiveProviderName } from './provider';
|
||||
import { searchNotes, type SearchResult } from '../db/vectordb';
|
||||
|
||||
/**
|
||||
* Providers with tool calling support.
|
||||
* OpenAI/Anthropic: LangChain bindTools + ToolMessage agent loop.
|
||||
* Copilot: ChatCopilot.bindTools() converts to SDK-native tools; the SDK handles
|
||||
* the agentic loop internally so tool_calls is always [] on the response.
|
||||
*/
|
||||
const TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic', 'copilot']);
|
||||
|
||||
const AI_STREAM_CHANNEL = 'ai:stream';
|
||||
const AI_ACTION_CHANNEL = 'ai:action';
|
||||
|
||||
/** Module-level sender ref — set at the start of orchestrate() so tool closures can emit actions. */
|
||||
let currentSender: Electron.WebContents | undefined;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OrchestrateInput {
|
||||
message: string;
|
||||
context: { type: 'global' | 'project'; projectId?: string; uiContext?: string };
|
||||
sender?: Electron.WebContents;
|
||||
}
|
||||
|
||||
interface OrchestrateResult {
|
||||
response: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context assembly (DB queries — provider-independent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildProjectContext(projectId: string): string {
|
||||
const db = getDb();
|
||||
|
||||
const project = db.select().from(projects).where(eq(projects.id, projectId)).all()[0];
|
||||
if (!project) return 'Project not found.';
|
||||
|
||||
let clientName = '';
|
||||
if (project.clientId) {
|
||||
const client = db.select().from(clients).where(eq(clients.id, project.clientId)).all()[0];
|
||||
if (client) clientName = client.name;
|
||||
}
|
||||
|
||||
const projectTasks = db
|
||||
.select({ title: tasks.title, status: tasks.status, priority: tasks.priority, dueDate: tasks.dueDate })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.projectId, projectId))
|
||||
.orderBy(asc(tasks.createdAt))
|
||||
.all();
|
||||
|
||||
const projectCheckpoints = db
|
||||
.select({ title: checkpoints.title, date: checkpoints.date, isApproved: checkpoints.isApproved })
|
||||
.from(checkpoints)
|
||||
.where(eq(checkpoints.projectId, projectId))
|
||||
.orderBy(asc(checkpoints.date))
|
||||
.all();
|
||||
|
||||
const projectNotes = db
|
||||
.select({ title: notes.title, content: notes.content })
|
||||
.from(notes)
|
||||
.where(eq(notes.projectId, projectId))
|
||||
.orderBy(asc(notes.createdAt))
|
||||
.all();
|
||||
|
||||
const lines: string[] = [
|
||||
`## Project: ${project.name}`,
|
||||
clientName ? `Client: ${clientName}` : '',
|
||||
`Status: ${project.status ?? 'active'}`,
|
||||
project.aiSummary ? `AI Summary: ${project.aiSummary}` : '',
|
||||
'',
|
||||
`### Tasks (${projectTasks.length})`,
|
||||
...projectTasks.map((t) => {
|
||||
const due = t.dueDate ? new Date(t.dueDate).toLocaleDateString() : 'no due date';
|
||||
return `- [${t.status}] ${t.title} (${t.priority}, ${due})`;
|
||||
}),
|
||||
'',
|
||||
`### Checkpoints (${projectCheckpoints.length})`,
|
||||
...projectCheckpoints.map((c) => {
|
||||
const approved = c.isApproved ? 'approved' : 'pending';
|
||||
return `- ${c.title} — ${new Date(c.date).toLocaleDateString()} (${approved})`;
|
||||
}),
|
||||
'',
|
||||
`### Notes (${projectNotes.length})`,
|
||||
...projectNotes.map((n) => {
|
||||
const excerpt = n.content.length > 500 ? n.content.slice(0, 500) + '…' : n.content;
|
||||
return `#### ${n.title}\n${excerpt}`;
|
||||
}),
|
||||
];
|
||||
|
||||
return lines.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function buildGlobalContext(): string {
|
||||
const db = getDb();
|
||||
|
||||
const allProjects = db
|
||||
.select({ id: projects.id, name: projects.name, status: projects.status })
|
||||
.from(projects)
|
||||
.where(eq(projects.status, 'active'))
|
||||
.orderBy(asc(projects.name))
|
||||
.all();
|
||||
|
||||
const allTasks = db.select().from(tasks).all();
|
||||
const todoCount = allTasks.filter((t) => t.status === 'todo').length;
|
||||
const inProgressCount = allTasks.filter((t) => t.status === 'in_progress').length;
|
||||
const doneCount = allTasks.filter((t) => t.status === 'done').length;
|
||||
|
||||
const now = Date.now();
|
||||
const weekFromNow = now + 7 * 24 * 60 * 60 * 1000;
|
||||
const upcomingTasks = allTasks
|
||||
.filter((t) => t.dueDate && t.dueDate >= now && t.dueDate <= weekFromNow && t.status !== 'done')
|
||||
.sort((a, b) => (a.dueDate ?? 0) - (b.dueDate ?? 0));
|
||||
|
||||
const projectMap = new Map(allProjects.map((p) => [p.id, p.name]));
|
||||
|
||||
const lines: string[] = [
|
||||
`## Workspace Overview`,
|
||||
`Active projects: ${allProjects.length}`,
|
||||
`Tasks: ${allTasks.length} total (${todoCount} todo, ${inProgressCount} in progress, ${doneCount} done)`,
|
||||
'',
|
||||
`### Active Projects`,
|
||||
...allProjects.map((p) => `- ${p.name}`),
|
||||
'',
|
||||
`### Tasks Due This Week (${upcomingTasks.length})`,
|
||||
...upcomingTasks.map((t) => {
|
||||
const projectName = t.projectId ? (projectMap.get(t.projectId) ?? 'Unknown') : 'No project';
|
||||
const due = t.dueDate ? new Date(t.dueDate).toLocaleDateString() : '';
|
||||
return `- ${t.title} [${projectName}] — due ${due}`;
|
||||
}),
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project action tools (built per-invocation, scoped to a project)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildProjectTools(projectId: string): StructuredTool[] {
|
||||
const db = getDb();
|
||||
|
||||
const readProjectNotesTool = tool(
|
||||
async (_input: Record<string, never>) => {
|
||||
const projectNotes = db
|
||||
.select({ title: notes.title, content: notes.content })
|
||||
.from(notes)
|
||||
.where(eq(notes.projectId, projectId))
|
||||
.orderBy(asc(notes.createdAt))
|
||||
.all();
|
||||
if (projectNotes.length === 0) return 'No notes found for this project.';
|
||||
return projectNotes.map((n) => `### ${n.title}\n${n.content}`).join('\n\n---\n\n');
|
||||
},
|
||||
{
|
||||
name: 'read_project_notes',
|
||||
description:
|
||||
'Fetches the full content of all notes for this project from the database. Use this when the user asks a detailed question about project notes or decisions.',
|
||||
schema: z.object({}),
|
||||
},
|
||||
);
|
||||
|
||||
const addTaskTool = tool(
|
||||
async (input: { title: string; description?: string; priority?: string; dueDate?: string }) => {
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(tasks)
|
||||
.values({
|
||||
id,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
status: 'todo',
|
||||
priority: input.priority ?? 'medium',
|
||||
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
|
||||
projectId,
|
||||
isAiSuggested: 1,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.run();
|
||||
sendAction(currentSender, { type: 'task_created', taskId: id });
|
||||
return `Task added: ${input.title}`;
|
||||
},
|
||||
{
|
||||
name: 'add_task',
|
||||
description:
|
||||
"Creates a new task in this project. Use when the user asks to add, create, or log a task. Returns 'Task added: [title]' on success.",
|
||||
schema: z.object({
|
||||
title: z.string().describe('Task title (required)'),
|
||||
description: z.string().optional().describe('Optional longer description'),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional().describe('Task priority, defaults to medium'),
|
||||
dueDate: z.string().optional().describe('ISO 8601 date or datetime string — include the time when specified, e.g. 2026-03-15 or 2026-03-15T17:00:00'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const getSummaryTool = tool(
|
||||
async (_input: Record<string, never>) => {
|
||||
const contextData = buildProjectContext(projectId);
|
||||
const summaryLlm = await getLLM();
|
||||
if (!summaryLlm) return 'Error: AI provider not available to generate summary.';
|
||||
const result = await summaryLlm.invoke([
|
||||
new SystemMessage(
|
||||
'Generate a concise 2-3 sentence project summary based on the data below. ' +
|
||||
'Focus on current status, key tasks, and notable milestones. Be direct and factual.',
|
||||
),
|
||||
new HumanMessage(contextData),
|
||||
]);
|
||||
const summary = typeof result.content === 'string' ? result.content : '';
|
||||
db.update(projects).set({ aiSummary: summary }).where(eq(projects.id, projectId)).run();
|
||||
return summary;
|
||||
},
|
||||
{
|
||||
name: 'get_summary',
|
||||
description:
|
||||
'Generates a 2-3 sentence AI summary of the project based on its notes and tasks, ' +
|
||||
'then saves it to the project record (project.aiSummary). Returns the summary text.',
|
||||
schema: z.object({}),
|
||||
},
|
||||
);
|
||||
|
||||
const suggestCheckpointsTool = tool(
|
||||
async (_input: Record<string, never>) => {
|
||||
const contextData = buildProjectContext(projectId);
|
||||
const suggestionLlm = await getLLM();
|
||||
if (!suggestionLlm) return '[]';
|
||||
const result = await suggestionLlm.invoke([
|
||||
new SystemMessage(
|
||||
'You are a project planning assistant. Analyze the project data below and extract ' +
|
||||
'date-anchored commitments from the notes (e.g. "deliver X by March 15", "review on April 2").\n\n' +
|
||||
'Return ONLY a valid JSON array of objects with shape { "title": string, "date": string } ' +
|
||||
'where date is ISO 8601 format (YYYY-MM-DD). Return [] if no date-anchored commitments are found.\n\n' +
|
||||
'Example: [{"title":"Client review","date":"2026-03-15"},{"title":"Beta launch","date":"2026-04-01"}]',
|
||||
),
|
||||
new HumanMessage(contextData),
|
||||
]);
|
||||
const content = typeof result.content === 'string' ? result.content.trim() : '[]';
|
||||
// Extract JSON array from response (model may wrap in markdown)
|
||||
const match = content.match(/\[[\s\S]*\]/);
|
||||
const jsonStr = match ? match[0] : '[]';
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr) as Array<{ title: string; date: string }>;
|
||||
for (const s of parsed) {
|
||||
const ts = new Date(s.date).getTime();
|
||||
if (Number.isNaN(ts)) continue;
|
||||
db.insert(checkpoints).values({
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
title: s.title,
|
||||
date: ts,
|
||||
isAiSuggested: 1,
|
||||
isApproved: 0,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
}
|
||||
sendAction(currentSender, { type: 'checkpoints_suggested', count: parsed.length });
|
||||
return jsonStr;
|
||||
} catch {
|
||||
return '[]';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'suggest_checkpoints',
|
||||
description:
|
||||
'Analyzes project notes for date-anchored commitments and returns a JSON array of ' +
|
||||
'{ title: string, date: string } suggested checkpoints. Use when the user asks for timeline or milestone suggestions.',
|
||||
schema: z.object({}),
|
||||
},
|
||||
);
|
||||
|
||||
const suggestTasksTool = tool(
|
||||
async (_input: Record<string, never>) => {
|
||||
const contextData = buildProjectContext(projectId);
|
||||
const suggestionLlm = await getLLM();
|
||||
if (!suggestionLlm) return '[]';
|
||||
const result = await suggestionLlm.invoke([
|
||||
new SystemMessage(
|
||||
'You are a project planning assistant. Analyze the project data below and extract ' +
|
||||
'actionable tasks from the notes (e.g. "set up CI pipeline", "draft proposal for client").\n\n' +
|
||||
'Return ONLY a valid JSON array of objects with shape { "title": string, "description"?: string, "priority"?: "low"|"medium"|"high", "dueDate"?: string } ' +
|
||||
'where dueDate is ISO 8601 format (YYYY-MM-DD) if mentioned. Return [] if no actionable tasks are found.\n\n' +
|
||||
'Example: [{"title":"Set up CI pipeline","description":"Configure GitHub Actions for automated testing","priority":"high"},{"title":"Draft client proposal","dueDate":"2026-03-20"}]',
|
||||
),
|
||||
new HumanMessage(contextData),
|
||||
]);
|
||||
const content = typeof result.content === 'string' ? result.content.trim() : '[]';
|
||||
const match = content.match(/\[[\s\S]*\]/);
|
||||
const jsonStr = match ? match[0] : '[]';
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr) as Array<{ title: string; description?: string; priority?: string; dueDate?: string }>;
|
||||
for (const s of parsed) {
|
||||
db.insert(tasks).values({
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
title: s.title,
|
||||
description: s.description ?? null,
|
||||
status: 'todo',
|
||||
priority: s.priority ?? 'medium',
|
||||
assignee: null,
|
||||
dueDate: s.dueDate ? new Date(s.dueDate).getTime() : null,
|
||||
isAiSuggested: 1,
|
||||
isApproved: 0,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
}
|
||||
sendAction(currentSender, { type: 'tasks_suggested', count: parsed.length });
|
||||
return jsonStr;
|
||||
} catch {
|
||||
return '[]';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'suggest_tasks',
|
||||
description:
|
||||
'Analyzes project notes for actionable tasks and returns a JSON array of ' +
|
||||
'{ title: string, description?: string, priority?: string, dueDate?: string } suggested tasks. ' +
|
||||
'Use when the user asks for task suggestions.',
|
||||
schema: z.object({}),
|
||||
},
|
||||
);
|
||||
|
||||
return [readProjectNotesTool, addTaskTool, getSummaryTool, suggestCheckpointsTool, suggestTasksTool] as StructuredTool[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global action tools (workspace-level, no project scope required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildGlobalTools(): StructuredTool[] {
|
||||
const db = getDb();
|
||||
|
||||
const addTaskTool = tool(
|
||||
async (input: { title: string; description?: string; priority?: string; dueDate?: string; projectId?: string }) => {
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(tasks)
|
||||
.values({
|
||||
id,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
status: 'todo',
|
||||
priority: input.priority ?? 'medium',
|
||||
dueDate: input.dueDate ? new Date(input.dueDate).getTime() : null,
|
||||
projectId: input.projectId ?? null,
|
||||
isAiSuggested: 1,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
.run();
|
||||
sendAction(currentSender, { type: 'task_created', taskId: id });
|
||||
return `Task added: ${input.title}`;
|
||||
},
|
||||
{
|
||||
name: 'add_task',
|
||||
description:
|
||||
"Creates a new task in the workspace. Use when the user asks to add, create, or register a task. " +
|
||||
"Returns 'Task added: [title]' on success.",
|
||||
schema: z.object({
|
||||
title: z.string().describe('Task title (required)'),
|
||||
description: z.string().optional().describe('Optional longer description'),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional().describe('Task priority, defaults to medium'),
|
||||
dueDate: z.string().optional().describe('ISO 8601 date or datetime string — include the time when specified, e.g. 2026-03-15 or 2026-03-15T17:00:00'),
|
||||
projectId: z.string().optional().describe('Optional project ID to associate the task with'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return [addTaskTool] as StructuredTool[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Knowledge tools (cross-project vector search)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildKnowledgeTools(): StructuredTool[] {
|
||||
const db = getDb();
|
||||
|
||||
const vectorSearchAllTool = tool(
|
||||
async (input: { query: string }) => {
|
||||
const results: SearchResult[] = await searchNotes(input.query, 5);
|
||||
|
||||
if (results.length === 0) {
|
||||
return 'No matching notes found across projects.';
|
||||
}
|
||||
|
||||
const enriched = results.map((r) => {
|
||||
const noteRow = db
|
||||
.select({ title: notes.title })
|
||||
.from(notes)
|
||||
.where(eq(notes.id, r.id))
|
||||
.all()[0];
|
||||
|
||||
let projectName = 'No project';
|
||||
if (r.projectId) {
|
||||
const projectRow = db
|
||||
.select({ name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, r.projectId))
|
||||
.all()[0];
|
||||
if (projectRow) projectName = projectRow.name;
|
||||
}
|
||||
|
||||
const title = noteRow?.title ?? 'Untitled';
|
||||
const excerpt =
|
||||
r.content.length > 300 ? r.content.slice(0, 300) + '…' : r.content;
|
||||
|
||||
return [
|
||||
`**From: ${projectName} — ${title}**`,
|
||||
`Note ID: ${r.id} | Project ID: ${r.projectId}`,
|
||||
excerpt,
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
return enriched.join('\n\n---\n\n');
|
||||
},
|
||||
{
|
||||
name: 'vector_search_all',
|
||||
description:
|
||||
'Performs a semantic search across ALL project notes in the workspace. ' +
|
||||
'Returns the top 5 most relevant notes with their project name, note title, and a text excerpt. ' +
|
||||
'Use this tool whenever the user asks a cross-project knowledge question.',
|
||||
schema: z.object({
|
||||
query: z.string().describe('The search query to find relevant notes across all projects'),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return [vectorSearchAllTool] as StructuredTool[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System prompts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeProjectAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
|
||||
const toolsSection = withTools ? `
|
||||
You also have access to the following tools — use them proactively when appropriate:
|
||||
- read_project_notes: Fetch full untruncated note content. Use for detailed note questions.
|
||||
- add_task: Create a new task in this project. Use when the user asks to add a task.
|
||||
- get_summary: Generate and save a 2-3 sentence project summary. Use when asked to summarize.
|
||||
- suggest_checkpoints: Extract date-based milestone suggestions from notes. Use for timeline/checkpoint questions.
|
||||
- suggest_tasks: Extract actionable task suggestions from notes. Use when the user asks for task suggestions.
|
||||
|
||||
When suggest_checkpoints or suggest_tasks returns a JSON array, present the items in a readable bullet-point format.` : '';
|
||||
|
||||
return `You are @ProjectAgent, an AI assistant specialized in a specific project within Adiuva.
|
||||
|
||||
You have access to the following project data:
|
||||
|
||||
${contextData}
|
||||
${toolsSection}
|
||||
Answer the user's question based on this project context. Be concise and helpful.
|
||||
When referencing tasks, notes, or checkpoints, mention them by name.
|
||||
If you don't have enough information, say so.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
|
||||
}
|
||||
|
||||
function makeGeneralAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
|
||||
const toolsSection = withTools ? `
|
||||
You also have access to the following tools — use them proactively when appropriate:
|
||||
- add_task: Create a new task. Use whenever the user asks to add, register, or note a to-do item or task.
|
||||
|
||||
When creating a task from a user request, infer a clear title and set dueDate if a specific date/time is mentioned.` : '';
|
||||
|
||||
return `You are @GeneralAgent, an AI assistant for the Adiuva workspace.
|
||||
|
||||
You have access to the following workspace data:
|
||||
|
||||
${contextData}
|
||||
${toolsSection}
|
||||
Help the user with their question based on this workspace context. Provide concise, actionable answers.
|
||||
When discussing tasks or projects, reference them by name.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
|
||||
}
|
||||
|
||||
function makeKnowledgeAgentPrompt(contextData: string, withTools = true, uiContext?: string): string {
|
||||
const toolsSection = withTools ? `
|
||||
You have access to the following tools — use them proactively:
|
||||
- vector_search_all: Performs semantic search across ALL project notes. Always use this tool when the user asks a knowledge question. Pass the user's question (or a refined version) as the query.
|
||||
|
||||
IMPORTANT: After receiving search results, format your response with inline citations.
|
||||
For each piece of information you reference, include the citation in this exact format:
|
||||
From: [Project Name] — [Note Title]
|
||||
|
||||
Example:
|
||||
"The team decided to use React for the frontend. (From: Website Redesign — Tech Stack Decision)"` : '';
|
||||
|
||||
return `You are @KnowledgeAgent, an AI assistant that searches across all project knowledge in Adiuva.
|
||||
|
||||
You have access to the following workspace data:
|
||||
|
||||
${contextData}
|
||||
${toolsSection}
|
||||
Your primary job is to find and synthesize information from notes across all projects.
|
||||
Always use the vector_search_all tool to search for relevant notes before answering.
|
||||
If no results are found, say so clearly.${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:<section-id>] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LangGraph State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OrchestratorState = Annotation.Root({
|
||||
/** The user's original message */
|
||||
userMessage: Annotation<string>(),
|
||||
/** Chat context (global vs project-scoped) */
|
||||
chatContext: Annotation<{ type: 'global' | 'project'; projectId?: string; uiContext?: string }>(),
|
||||
/** The route chosen by the orchestrator */
|
||||
route: Annotation<'project' | 'knowledge' | 'general'>(),
|
||||
/** Messages for the specialist agent */
|
||||
messages: Annotation<BaseMessage[]>({
|
||||
reducer: (existing, incoming) =>
|
||||
Array.isArray(incoming) ? existing.concat(incoming) : existing.concat([incoming]),
|
||||
default: () => [],
|
||||
}),
|
||||
/** The final response text */
|
||||
response: Annotation<string>(),
|
||||
});
|
||||
|
||||
type State = typeof OrchestratorState.State;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Node 1: Classify intent — always calls the LLM for every provider */
|
||||
async function classifyIntent(state: State): Promise<Partial<State>> {
|
||||
const llm = await getLLM();
|
||||
if (!llm) throw new Error('AI provider not configured. Please add your token in Settings.');
|
||||
|
||||
const response = await llm.invoke([
|
||||
new SystemMessage(
|
||||
`You are a routing classifier for Adiuva, a project management workspace.
|
||||
Classify the user's message into exactly one category. Reply with ONLY the category name, nothing else.
|
||||
|
||||
Categories:
|
||||
- project: Question about a specific project (tasks, notes, checkpoints, progress, summaries)
|
||||
- knowledge: Cross-project or historical question (e.g., "what did we decide about X?", "find notes about Y")
|
||||
- general: Everything else (general help, scheduling, task overviews, workspace summaries)`,
|
||||
),
|
||||
new HumanMessage(state.userMessage),
|
||||
]);
|
||||
|
||||
const text = (typeof response.content === 'string' ? response.content : '').trim().toLowerCase();
|
||||
const validRoutes = ['project', 'knowledge', 'general'] as const;
|
||||
const route = validRoutes.find((r) => text.includes(r)) ?? 'general';
|
||||
|
||||
console.log(`[Orchestrator] classifyIntent → route="${route}" (raw="${text}")`);
|
||||
return { route };
|
||||
}
|
||||
|
||||
/** Node 2a: Project agent — tools-enabled agentic loop for project-scoped questions */
|
||||
async function projectAgent(state: State): Promise<Partial<State>> {
|
||||
const llm = await getLLM();
|
||||
if (!llm) throw new Error('AI provider not configured.');
|
||||
|
||||
const projectId = state.chatContext.projectId;
|
||||
|
||||
// If no projectId in context, delegate to generalAgent
|
||||
if (!projectId) {
|
||||
return generalAgent(state);
|
||||
}
|
||||
|
||||
const contextData = buildProjectContext(projectId);
|
||||
|
||||
// Only providers with real tool calling support use the agent loop.
|
||||
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
||||
|
||||
console.log(`[Orchestrator] projectAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}, projectId=${projectId}`);
|
||||
|
||||
// Copilot tools are registered natively via the SDK (createSession({ tools })).
|
||||
// Including text tool descriptions in the system prompt causes the model to output
|
||||
// XML <tool_call> blocks instead of using the SDK's API-level mechanism.
|
||||
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
||||
const uiContext = state.chatContext.uiContext;
|
||||
const systemPrompt = makeProjectAgentPrompt(contextData, includeToolsInPrompt, uiContext);
|
||||
|
||||
if (!supportsTools) {
|
||||
console.log('[Orchestrator] projectAgent: using context-only fallback (no tool support)');
|
||||
// Providers without any tool calling support: answer from context data only
|
||||
const response = await llm.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(state.userMessage),
|
||||
]);
|
||||
const content = typeof response.content === 'string' ? response.content : '';
|
||||
return { messages: [response], response: content };
|
||||
}
|
||||
|
||||
// Build tools scoped to this projectId
|
||||
const projectTools = buildProjectTools(projectId);
|
||||
|
||||
console.log(`[Orchestrator] projectAgent: binding ${projectTools.length} tools: [${projectTools.map((t) => t.name).join(', ')}]`);
|
||||
|
||||
// Bind tools: OpenAI/Anthropic use LangChain's bindTools (ToolMessage loop);
|
||||
// Copilot uses ChatCopilot.bindTools() which registers tools with the SDK natively.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const llmWithTools = llm.bindTools!(projectTools);
|
||||
|
||||
// Agent loop: invoke LLM → execute tool calls → repeat until no tool_calls
|
||||
const MAX_ITERATIONS = 5;
|
||||
const messageHistory: BaseMessage[] = [
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(state.userMessage),
|
||||
];
|
||||
|
||||
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
||||
const response = await llmWithTools.invoke(messageHistory);
|
||||
messageHistory.push(response);
|
||||
|
||||
// Extract tool calls using type guard (no unsafe cast needed)
|
||||
const toolCalls: ToolCall[] = AIMessage.isInstance(response) ? (response.tool_calls ?? []) : [];
|
||||
|
||||
console.log(`[Orchestrator] agent loop iteration=${iteration}: tool_calls=[${toolCalls.map((c) => c.name).join(', ')}], content="${String(typeof response.content === 'string' ? response.content : '').slice(0, 100)}"`);
|
||||
|
||||
// No tool calls → LLM produced a final text response
|
||||
if (toolCalls.length === 0) {
|
||||
const content = typeof response.content === 'string' ? response.content : '';
|
||||
return { messages: messageHistory, response: content };
|
||||
}
|
||||
|
||||
// Execute each tool call and append ToolMessages to history
|
||||
for (const toolCall of toolCalls) {
|
||||
const matched = projectTools.find((t) => t.name === toolCall.name);
|
||||
if (!matched) {
|
||||
messageHistory.push(
|
||||
new ToolMessage({
|
||||
content: `Error: tool "${toolCall.name}" is not available.`,
|
||||
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Invoke with the ToolCall object — StructuredTool.invoke() detects _isToolCall()
|
||||
// and extracts args internally (same pattern used by LangGraph's own ToolNode).
|
||||
const output = await matched.invoke({ ...toolCall, type: 'tool_call' as const });
|
||||
const resultContent = typeof output === 'string' ? output : JSON.stringify(output);
|
||||
|
||||
messageHistory.push(
|
||||
new ToolMessage({
|
||||
content: resultContent,
|
||||
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Exceeded max iterations: extract last AI response text as best-effort fallback
|
||||
const lastAiMsg = [...messageHistory].reverse().find((m) => AIMessage.isInstance(m));
|
||||
const fallbackContent =
|
||||
lastAiMsg && typeof lastAiMsg.content === 'string' ? lastAiMsg.content : '';
|
||||
return { messages: messageHistory, response: fallbackContent };
|
||||
}
|
||||
|
||||
/** Node 2b: Knowledge agent — cross-project semantic search */
|
||||
async function knowledgeAgent(state: State): Promise<Partial<State>> {
|
||||
const llm = await getLLM();
|
||||
if (!llm) throw new Error('AI provider not configured.');
|
||||
|
||||
const contextData = buildGlobalContext();
|
||||
|
||||
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
||||
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
||||
const uiContext = state.chatContext.uiContext;
|
||||
const systemPrompt = makeKnowledgeAgentPrompt(contextData, includeToolsInPrompt, uiContext);
|
||||
|
||||
console.log(`[Orchestrator] knowledgeAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
||||
|
||||
if (!supportsTools) {
|
||||
const response = await llm.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(state.userMessage),
|
||||
]);
|
||||
const content = typeof response.content === 'string' ? response.content : '';
|
||||
return { messages: [response], response: content };
|
||||
}
|
||||
|
||||
const knowledgeTools = buildKnowledgeTools();
|
||||
|
||||
console.log(`[Orchestrator] knowledgeAgent: binding ${knowledgeTools.length} tools: [${knowledgeTools.map((t) => t.name).join(', ')}]`);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const llmWithTools = llm.bindTools!(knowledgeTools);
|
||||
|
||||
const MAX_ITERATIONS = 5;
|
||||
const messageHistory: BaseMessage[] = [
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(state.userMessage),
|
||||
];
|
||||
|
||||
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
||||
const response = await llmWithTools.invoke(messageHistory);
|
||||
messageHistory.push(response);
|
||||
|
||||
const toolCalls: ToolCall[] = AIMessage.isInstance(response) ? (response.tool_calls ?? []) : [];
|
||||
|
||||
console.log(`[Orchestrator] knowledgeAgent loop iteration=${iteration}: tool_calls=[${toolCalls.map((c) => c.name).join(', ')}], content="${String(typeof response.content === 'string' ? response.content : '').slice(0, 100)}"`);
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
const content = typeof response.content === 'string' ? response.content : '';
|
||||
return { messages: messageHistory, response: content };
|
||||
}
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const matched = knowledgeTools.find((t) => t.name === toolCall.name);
|
||||
if (!matched) {
|
||||
messageHistory.push(
|
||||
new ToolMessage({
|
||||
content: `Error: tool "${toolCall.name}" is not available.`,
|
||||
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const output = await matched.invoke({ ...toolCall, type: 'tool_call' as const });
|
||||
const resultContent = typeof output === 'string' ? output : JSON.stringify(output);
|
||||
|
||||
messageHistory.push(
|
||||
new ToolMessage({
|
||||
content: resultContent,
|
||||
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const lastAiMsg = [...messageHistory].reverse().find((m) => AIMessage.isInstance(m));
|
||||
const fallbackContent =
|
||||
lastAiMsg && typeof lastAiMsg.content === 'string' ? lastAiMsg.content : '';
|
||||
return { messages: messageHistory, response: fallbackContent };
|
||||
}
|
||||
|
||||
/** Node 2c: General agent — workspace-wide questions and global task actions */
|
||||
async function generalAgent(state: State): Promise<Partial<State>> {
|
||||
const llm = await getLLM();
|
||||
if (!llm) throw new Error('AI provider not configured.');
|
||||
|
||||
const contextData = buildGlobalContext();
|
||||
|
||||
const supportsTools = TOOL_CALLING_PROVIDERS.has(getActiveProviderName());
|
||||
const includeToolsInPrompt = supportsTools && getActiveProviderName() !== 'copilot';
|
||||
const uiContext = state.chatContext.uiContext;
|
||||
const systemPrompt = makeGeneralAgentPrompt(contextData, includeToolsInPrompt, uiContext);
|
||||
|
||||
console.log(`[Orchestrator] generalAgent: provider="${getActiveProviderName()}", supportsTools=${supportsTools}`);
|
||||
|
||||
if (!supportsTools) {
|
||||
const response = await llm.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(state.userMessage),
|
||||
]);
|
||||
const content = typeof response.content === 'string' ? response.content : '';
|
||||
return { messages: [response], response: content };
|
||||
}
|
||||
|
||||
const globalTools = buildGlobalTools();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const llmWithTools = llm.bindTools!(globalTools);
|
||||
|
||||
const MAX_ITERATIONS = 5;
|
||||
const messageHistory: BaseMessage[] = [
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(state.userMessage),
|
||||
];
|
||||
|
||||
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
||||
const response = await llmWithTools.invoke(messageHistory);
|
||||
messageHistory.push(response);
|
||||
|
||||
const toolCalls: ToolCall[] = AIMessage.isInstance(response) ? (response.tool_calls ?? []) : [];
|
||||
|
||||
console.log(`[Orchestrator] generalAgent loop iteration=${iteration}: tool_calls=[${toolCalls.map((c) => c.name).join(', ')}], content="${String(typeof response.content === 'string' ? response.content : '').slice(0, 100)}"`);
|
||||
|
||||
if (toolCalls.length === 0) {
|
||||
const content = typeof response.content === 'string' ? response.content : '';
|
||||
return { messages: messageHistory, response: content };
|
||||
}
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const matched = globalTools.find((t) => t.name === toolCall.name);
|
||||
if (!matched) {
|
||||
messageHistory.push(
|
||||
new ToolMessage({
|
||||
content: `Error: tool "${toolCall.name}" is not available.`,
|
||||
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const output = await matched.invoke({ ...toolCall, type: 'tool_call' as const });
|
||||
messageHistory.push(
|
||||
new ToolMessage({
|
||||
content: typeof output === 'string' ? output : JSON.stringify(output),
|
||||
tool_call_id: toolCall.id ?? crypto.randomUUID(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const lastAiMsg = [...messageHistory].reverse().find((m) => AIMessage.isInstance(m));
|
||||
const fallbackContent = lastAiMsg && typeof lastAiMsg.content === 'string' ? lastAiMsg.content : '';
|
||||
return { messages: messageHistory, response: fallbackContent };
|
||||
}
|
||||
|
||||
/** Routing function: reads state.route and returns the next node name */
|
||||
function routeDecision(state: State): string {
|
||||
switch (state.route) {
|
||||
case 'project': return 'projectAgent';
|
||||
case 'knowledge': return 'knowledgeAgent';
|
||||
case 'general': return 'generalAgent';
|
||||
default: return 'generalAgent';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compile the graph (singleton, reused across calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildGraph() {
|
||||
return new StateGraph(OrchestratorState)
|
||||
.addNode('classifyIntent', classifyIntent)
|
||||
.addNode('projectAgent', projectAgent)
|
||||
.addNode('knowledgeAgent', knowledgeAgent)
|
||||
.addNode('generalAgent', generalAgent)
|
||||
.addEdge(START, 'classifyIntent')
|
||||
.addConditionalEdges('classifyIntent', routeDecision, [
|
||||
'projectAgent', 'knowledgeAgent', 'generalAgent',
|
||||
])
|
||||
.addEdge('projectAgent', END)
|
||||
.addEdge('knowledgeAgent', END)
|
||||
.addEdge('generalAgent', END)
|
||||
.compile();
|
||||
}
|
||||
|
||||
let compiledGraph: ReturnType<typeof buildGraph> | null = null;
|
||||
|
||||
function getGraph() {
|
||||
if (!compiledGraph) {
|
||||
compiledGraph = buildGraph();
|
||||
}
|
||||
return compiledGraph;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sendStreamChunk(sender: Electron.WebContents | undefined, token: string, done: boolean): void {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(AI_STREAM_CHANNEL, { token, done });
|
||||
}
|
||||
|
||||
function sendAction(sender: Electron.WebContents | undefined, action: { type: string; taskId?: string; count?: number }): void {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(AI_ACTION_CHANNEL, action);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrate (public entry point)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function orchestrate(input: OrchestrateInput): Promise<OrchestrateResult> {
|
||||
const { message, context, sender } = input;
|
||||
currentSender = sender;
|
||||
|
||||
// Quick check: is an LLM available?
|
||||
const llm = await getLLM();
|
||||
if (!llm) {
|
||||
return { response: '', error: 'AI provider not configured. Please add your token in Settings.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const graph = getGraph();
|
||||
|
||||
// Use streaming to push tokens to the renderer in real-time
|
||||
const stream = await graph.stream(
|
||||
{
|
||||
userMessage: message,
|
||||
chatContext: context,
|
||||
route: 'general' as const,
|
||||
response: '',
|
||||
},
|
||||
{ streamMode: 'messages' as const },
|
||||
);
|
||||
|
||||
let fullResponse = '';
|
||||
|
||||
// Filter state: suppress <tool_call>...</tool_call> blocks that the SDK model emits
|
||||
// as raw text when it attempts to use its built-in tools (sql, bash, etc.) in a
|
||||
// non-tool session. We only want plain-text responses.
|
||||
let inToolCallBlock = false;
|
||||
let toolCallBuffer = '';
|
||||
|
||||
for await (const [chunk, metadata] of stream) {
|
||||
// Only stream tokens from the specialist agent nodes (not the classifier),
|
||||
// and only AI message chunks (not system/human/tool messages that are also
|
||||
// emitted by LangGraph when messageHistory is returned in the state update).
|
||||
if (
|
||||
metadata.langgraph_node !== 'classifyIntent' &&
|
||||
chunk.content &&
|
||||
typeof chunk.content === 'string' &&
|
||||
chunk._getType() === 'ai'
|
||||
) {
|
||||
const text = chunk.content as string;
|
||||
fullResponse += text;
|
||||
|
||||
if (inToolCallBlock) {
|
||||
toolCallBuffer += text;
|
||||
if (toolCallBuffer.includes('</tool_call>')) {
|
||||
const after = toolCallBuffer.split('</tool_call>').slice(1).join('</tool_call>');
|
||||
inToolCallBlock = false;
|
||||
toolCallBuffer = '';
|
||||
const afterClean = after.replace(/^\n/, '');
|
||||
if (afterClean) sendStreamChunk(sender, afterClean, false);
|
||||
}
|
||||
} else if (text.includes('<tool_call>')) {
|
||||
const before = text.split('<tool_call>')[0];
|
||||
if (before) sendStreamChunk(sender, before, false);
|
||||
inToolCallBlock = true;
|
||||
toolCallBuffer = '<tool_call>' + text.split('<tool_call>').slice(1).join('<tool_call>');
|
||||
if (toolCallBuffer.includes('</tool_call>')) {
|
||||
const after = toolCallBuffer.split('</tool_call>').slice(1).join('</tool_call>');
|
||||
inToolCallBlock = false;
|
||||
toolCallBuffer = '';
|
||||
const afterClean = after.replace(/^\n/, '');
|
||||
if (afterClean) sendStreamChunk(sender, afterClean, false);
|
||||
}
|
||||
} else {
|
||||
sendStreamChunk(sender, text, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Signal stream completion
|
||||
sendStreamChunk(sender, '', true);
|
||||
|
||||
return { response: fullResponse };
|
||||
} catch (err) {
|
||||
sendStreamChunk(sender, '', true);
|
||||
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
if (errMsg.includes('401') || errMsg.includes('403') || errMsg.includes('auth') || errMsg.includes('Unauthorized')) {
|
||||
return { response: '', error: 'Authentication failed. Please check your token in Settings.' };
|
||||
}
|
||||
if (errMsg.includes('timeout') || errMsg.includes('Timeout')) {
|
||||
return { response: '', error: 'Request timed out. Please try again.' };
|
||||
}
|
||||
if (errMsg.toLowerCase().includes('list models') || errMsg.toLowerCase().includes('listmodels')) {
|
||||
return { response: '', error: 'GitHub Copilot model service is unavailable. Please re-authenticate (copilot auth login) or switch to a different AI provider in Settings.' };
|
||||
}
|
||||
|
||||
return { response: '', error: errMsg };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daily Brief (dedicated entry point)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DAILY_BRIEF_PROMPT =
|
||||
`Act as a professional and efficient executive assistant. Give me a concise daily brief for today.
|
||||
|
||||
Strict Rules:
|
||||
- Adopt a polite, formal, and helpful tone. Do not use emojis, slang, or overly casual encouragement.
|
||||
- Focus strictly on actionable or critical items: tasks due today, upcoming deadlines this week, overdue items, and significant project activity.
|
||||
- Do NOT mention zero-counts (e.g., "no overdue items") or general statistics (e.g., "2 active projects", "2 completed tasks"). Only report what needs my attention.
|
||||
- Do NOT include any headers, titles, dates, or greetings.
|
||||
- Do NOT use labels like "Due today:" or "Overdue:". Integrate the information naturally into sentences.
|
||||
- Use **bold** for key phrases, task names, or project names.
|
||||
- Keep the entire response to 3-5 sentences.`;
|
||||
|
||||
export async function dailyBrief(sender?: Electron.WebContents): Promise<OrchestrateResult> {
|
||||
return orchestrate({
|
||||
message: DAILY_BRIEF_PROMPT,
|
||||
context: { type: 'global' },
|
||||
sender,
|
||||
});
|
||||
}
|
||||
93
src/main/ai/provider.ts
Normal file
93
src/main/ai/provider.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { getStore } from '../store';
|
||||
import { getToken, setToken as storeToken } from './token';
|
||||
|
||||
export interface AIProvider {
|
||||
/** Internal key, e.g. 'copilot', 'openai', 'anthropic' */
|
||||
name: string;
|
||||
/** Human-readable label shown in Settings UI */
|
||||
displayName: string;
|
||||
/** Initialize with a token. Returns true if the provider is ready. */
|
||||
initialize(token: string): Promise<boolean>;
|
||||
/** Whether the provider is initialized and ready to handle requests. */
|
||||
isReady(): boolean;
|
||||
/** If true, this provider uses external auth (e.g. CLI OAuth) and doesn't need a stored token. */
|
||||
usesExternalAuth?: boolean;
|
||||
}
|
||||
|
||||
const providers = new Map<string, AIProvider>();
|
||||
let activeProvider: AIProvider | null = null;
|
||||
|
||||
/** Register a provider implementation. Call at import time. */
|
||||
export function registerProvider(provider: AIProvider): void {
|
||||
providers.set(provider.name, provider);
|
||||
}
|
||||
|
||||
/** Get the currently active provider (may be null if none configured). */
|
||||
export function getActiveProvider(): AIProvider | null {
|
||||
return activeProvider;
|
||||
}
|
||||
|
||||
/** Get the active provider's name from electron-store. */
|
||||
export function getActiveProviderName(): string {
|
||||
return getStore().get('aiProvider');
|
||||
}
|
||||
|
||||
/** Switch to a different registered provider. */
|
||||
function setActiveProviderName(name: string): void {
|
||||
const provider = providers.get(name);
|
||||
if (!provider) throw new Error(`Unknown AI provider: ${name}`);
|
||||
activeProvider = provider;
|
||||
getStore().set('aiProvider', name);
|
||||
}
|
||||
|
||||
/** Store token for the active provider and re-initialize it. */
|
||||
export async function saveTokenAndInit(token: string): Promise<void> {
|
||||
const name = getActiveProviderName();
|
||||
await storeToken(name, token);
|
||||
const provider = providers.get(name);
|
||||
if (provider) {
|
||||
await provider.initialize(token);
|
||||
activeProvider = provider;
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether the active provider has credentials (stored token or external auth). */
|
||||
export async function hasActiveToken(): Promise<boolean> {
|
||||
const name = getActiveProviderName();
|
||||
const provider = providers.get(name);
|
||||
// Providers with external auth (e.g. Copilot CLI OAuth) don't need a stored token
|
||||
if (provider?.usesExternalAuth) return true;
|
||||
const token = await getToken(name);
|
||||
return token !== null && token.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the AI subsystem on app startup.
|
||||
* Reads the active provider from settings, loads its token from keychain,
|
||||
* and calls provider.initialize() if a token exists.
|
||||
*/
|
||||
export async function initAI(): Promise<void> {
|
||||
const name = getActiveProviderName();
|
||||
const provider = providers.get(name);
|
||||
if (!provider) {
|
||||
console.log(`[AI] No provider registered for "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Providers with external auth (e.g. Copilot CLI OAuth) initialize without a stored token
|
||||
if (provider.usesExternalAuth) {
|
||||
const ready = await provider.initialize('');
|
||||
activeProvider = provider;
|
||||
console.log(`[AI] Provider "${provider.displayName}" initialized (external auth): ready=${ready}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken(name);
|
||||
if (token) {
|
||||
const ready = await provider.initialize(token);
|
||||
activeProvider = provider;
|
||||
console.log(`[AI] Provider "${provider.displayName}" initialized: ready=${ready}`);
|
||||
} else {
|
||||
console.log(`[AI] No token stored for provider "${provider.displayName}"`);
|
||||
}
|
||||
}
|
||||
71
src/main/ai/token.ts
Normal file
71
src/main/ai/token.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { safeStorage } from 'electron';
|
||||
import { getStore } from '../store';
|
||||
|
||||
/**
|
||||
* Token storage with two-tier fallback:
|
||||
* 1. Electron safeStorage + electron-store (encrypted at rest)
|
||||
* 2. Plain electron-store (last resort — e.g. WSL with no keyring)
|
||||
*/
|
||||
|
||||
function canUseSafeStorage(): boolean {
|
||||
try {
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- electron-store helpers (with optional safeStorage encryption) ---
|
||||
|
||||
function readFromStore(providerName: string): string | null {
|
||||
const tokens = getStore().get('encryptedTokens');
|
||||
const stored = tokens[providerName];
|
||||
if (!stored) return null;
|
||||
|
||||
if (canUseSafeStorage()) {
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(stored, 'base64'));
|
||||
} catch {
|
||||
// Stored value might be plaintext from a previous fallback
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
// No encryption available — value is stored as plaintext
|
||||
return stored;
|
||||
}
|
||||
|
||||
function writeToStore(providerName: string, token: string): void {
|
||||
let value: string;
|
||||
if (canUseSafeStorage()) {
|
||||
value = safeStorage.encryptString(token).toString('base64');
|
||||
} else {
|
||||
// Last resort: store plaintext (WSL with no keyring)
|
||||
value = token;
|
||||
}
|
||||
const tokens = getStore().get('encryptedTokens');
|
||||
getStore().set('encryptedTokens', { ...tokens, [providerName]: value });
|
||||
}
|
||||
|
||||
function removeFromStore(providerName: string): void {
|
||||
const tokens = getStore().get('encryptedTokens');
|
||||
const { [providerName]: _, ...rest } = tokens;
|
||||
getStore().set('encryptedTokens', rest);
|
||||
}
|
||||
|
||||
// --- public API ---
|
||||
|
||||
/** Read a stored token for the given provider. */
|
||||
export async function getToken(providerName: string): Promise<string | null> {
|
||||
return readFromStore(providerName);
|
||||
}
|
||||
|
||||
/** Store a token for the given provider. */
|
||||
export async function setToken(providerName: string, token: string): Promise<void> {
|
||||
writeToStore(providerName, token);
|
||||
}
|
||||
|
||||
/** Delete a stored token for the given provider. */
|
||||
async function deleteToken(providerName: string): Promise<boolean> {
|
||||
removeFromStore(providerName);
|
||||
return true;
|
||||
}
|
||||
@@ -32,6 +32,8 @@ const MIGRATION_SQL = `
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
assignee TEXT,
|
||||
due_date INTEGER,
|
||||
is_ai_suggested INTEGER NOT NULL DEFAULT 0,
|
||||
is_approved INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -53,6 +55,14 @@ const MIGRATION_SQL = `
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
|
||||
@@ -71,6 +81,10 @@ export function initDb(): DbInstance {
|
||||
// Run non-destructive migrations on every start
|
||||
sqlite.exec(MIGRATION_SQL);
|
||||
|
||||
// Additive column migrations (SQLite has no ADD COLUMN IF NOT EXISTS)
|
||||
try { sqlite.exec('ALTER TABLE tasks ADD COLUMN is_ai_suggested INTEGER NOT NULL DEFAULT 0'); } catch { /* already exists */ }
|
||||
try { sqlite.exec('ALTER TABLE tasks ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 1'); } catch { /* already exists */ }
|
||||
|
||||
dbInstance = drizzle(sqlite, { schema });
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export const tasks = sqliteTable('tasks', {
|
||||
priority: text('priority').notNull().default('medium'),
|
||||
assignee: text('assignee'),
|
||||
dueDate: integer('due_date', { mode: 'number' }),
|
||||
isAiSuggested: integer('is_ai_suggested', { mode: 'number' }).notNull().default(0),
|
||||
isApproved: integer('is_approved', { mode: 'number' }).notNull().default(1),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
@@ -49,6 +51,14 @@ export const notes = sqliteTable('notes', {
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export const taskComments = sqliteTable('task_comments', {
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
author: text('author').notNull(),
|
||||
content: text('content').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
// Inferred TypeScript types — no manual duplication
|
||||
export type Client = InferSelectModel<typeof clients>;
|
||||
export type NewClient = InferInsertModel<typeof clients>;
|
||||
@@ -64,3 +74,6 @@ export type NewCheckpoint = InferInsertModel<typeof checkpoints>;
|
||||
|
||||
export type Note = InferSelectModel<typeof notes>;
|
||||
export type NewNote = InferInsertModel<typeof notes>;
|
||||
|
||||
export type TaskComment = InferSelectModel<typeof taskComments>;
|
||||
export type NewTaskComment = InferInsertModel<typeof taskComments>;
|
||||
|
||||
147
src/main/db/vectordb.ts
Normal file
147
src/main/db/vectordb.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import * as lancedb from 'vectordb';
|
||||
import { app } from 'electron';
|
||||
import path from 'node:path';
|
||||
import { getDb } from './index';
|
||||
import { notes } from './schema';
|
||||
import { embedText } from '../ai/embeddings';
|
||||
|
||||
interface NoteRecord {
|
||||
id: string;
|
||||
/** Empty string when the note has no project (Arrow string fields don't cleanly handle null) */
|
||||
projectId: string;
|
||||
content: string;
|
||||
vector: number[];
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
projectId: string;
|
||||
content: string;
|
||||
_distance: number;
|
||||
}
|
||||
|
||||
let conn: lancedb.Connection | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the LanceDB connection. Must be called before any other
|
||||
* function in this module. Vector data is stored at userData/vectors/.
|
||||
*/
|
||||
export async function initVectorDb(): Promise<void> {
|
||||
const vectorPath = path.join(app.getPath('userData'), 'vectors');
|
||||
conn = await lancedb.connect(vectorPath);
|
||||
console.log('[VectorDB] Connected at:', vectorPath);
|
||||
}
|
||||
|
||||
function getConn(): lancedb.Connection {
|
||||
if (!conn) throw new Error('[VectorDB] Not initialized. Call initVectorDb() first.');
|
||||
return conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed note content and upsert the record into the LanceDB 'notes' table.
|
||||
*
|
||||
* Upsert strategy: delete-then-add.
|
||||
* table.delete(where) is a no-op when no rows match, so this is safe for
|
||||
* both first-time inserts and subsequent updates.
|
||||
*
|
||||
* On the very first call when the table does not yet exist, createTable
|
||||
* infers the Arrow schema from the initial record.
|
||||
*
|
||||
* Throws on error — callers fire-and-forget via .catch().
|
||||
*/
|
||||
export async function upsertNoteEmbedding(
|
||||
noteId: string,
|
||||
projectId: string | null,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const c = getConn();
|
||||
const vector = await embedText(content);
|
||||
|
||||
const record: NoteRecord = {
|
||||
id: noteId,
|
||||
projectId: projectId ?? '',
|
||||
content,
|
||||
vector,
|
||||
};
|
||||
|
||||
const tableNames = await c.tableNames();
|
||||
|
||||
if (!tableNames.includes('notes')) {
|
||||
// First embedding: createTable infers the Arrow schema from this record.
|
||||
// The vector dimension (1536 for text-embedding-3-small) is baked in here.
|
||||
await c.createTable('notes', [record]);
|
||||
console.log('[VectorDB] Created notes table');
|
||||
return;
|
||||
}
|
||||
|
||||
const table = await c.openTable<NoteRecord>('notes');
|
||||
// Note IDs are UUID v4 — only [0-9a-f-] chars, no SQL injection risk.
|
||||
await table.delete(`id = '${noteId}'`);
|
||||
await table.add([record]);
|
||||
}
|
||||
|
||||
/**
|
||||
* On first startup, check if the LanceDB 'notes' table exists.
|
||||
* If not, embed all existing SQLite notes and populate LanceDB.
|
||||
*
|
||||
* Per-note errors are caught and logged; a single failure does not
|
||||
* abort the remaining notes.
|
||||
*/
|
||||
export async function migrateNotesIfNeeded(): Promise<void> {
|
||||
const c = getConn();
|
||||
const tableNames = await c.tableNames();
|
||||
|
||||
if (tableNames.includes('notes')) {
|
||||
console.log('[VectorDB] Notes table exists, skipping migration');
|
||||
return;
|
||||
}
|
||||
|
||||
const allNotes = getDb().select().from(notes).all();
|
||||
|
||||
if (allNotes.length === 0) {
|
||||
console.log('[VectorDB] No existing notes to migrate');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[VectorDB] Migrating ${allNotes.length} notes...`);
|
||||
let successCount = 0;
|
||||
|
||||
for (const note of allNotes) {
|
||||
try {
|
||||
const embeddingText = `${note.title}\n\n${note.content}`;
|
||||
await upsertNoteEmbedding(note.id, note.projectId ?? null, embeddingText);
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
console.error(`[VectorDB] Failed to embed note ${note.id} during migration:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[VectorDB] Migration complete: ${successCount}/${allNotes.length} notes embedded`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed the query string and perform a similarity search across all notes
|
||||
* in the LanceDB 'notes' table. Returns up to `limit` results sorted by
|
||||
* distance (closest first).
|
||||
*
|
||||
* Returns an empty array if the notes table does not exist yet.
|
||||
*/
|
||||
export async function searchNotes(query: string, limit = 5): Promise<SearchResult[]> {
|
||||
const c = getConn();
|
||||
const tableNames = await c.tableNames();
|
||||
|
||||
if (!tableNames.includes('notes')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryVector = await embedText(query);
|
||||
const table = await c.openTable('notes');
|
||||
const results = await table.search(queryVector).limit(limit).execute();
|
||||
|
||||
return results.map((r) => ({
|
||||
id: r.id as string,
|
||||
projectId: r.projectId as string,
|
||||
content: r.content as string,
|
||||
_distance: r._distance as number,
|
||||
}));
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import path from 'node:path';
|
||||
import started from 'electron-squirrel-startup';
|
||||
import { createIPCHandler } from 'electron-trpc/main';
|
||||
import { initDb } from './db';
|
||||
import { appRouter } from './router';
|
||||
import { createIPCHandler } from './ipc';
|
||||
import { initAI } from './ai/provider';
|
||||
import { initVectorDb, migrateNotesIfNeeded } from './db/vectordb';
|
||||
// Import to trigger provider registration before initAI() runs
|
||||
import './ai/copilot';
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
@@ -49,6 +53,12 @@ app.on('ready', () => {
|
||||
initDb();
|
||||
const win = createWindow();
|
||||
createIPCHandler({ router: appRouter, windows: [win] });
|
||||
// AI init is best-effort — never block window creation
|
||||
initAI().catch((err) => console.error('[AI] Init failed:', err));
|
||||
// Vector DB init + migration is best-effort — runs after window is shown
|
||||
initVectorDb()
|
||||
.then(() => migrateNotesIfNeeded())
|
||||
.catch((err) => console.error('[VectorDB] Init or migration failed:', err));
|
||||
});
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
|
||||
95
src/main/ipc.ts
Normal file
95
src/main/ipc.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Custom electron IPC ↔ tRPC v11 bridge.
|
||||
*
|
||||
* Replaces the incompatible electron-trpc package (v0.7.x bundles tRPC v10's
|
||||
* callProcedure which checks `procedure._def[type]`, while tRPC v11 uses
|
||||
* `procedure._def.type`).
|
||||
*/
|
||||
import { ipcMain, type BrowserWindow } from 'electron';
|
||||
import {
|
||||
callTRPCProcedure,
|
||||
getErrorShape,
|
||||
getTRPCErrorFromUnknown,
|
||||
transformTRPCResponse,
|
||||
type AnyRouter,
|
||||
} from '@trpc/server';
|
||||
|
||||
const IPC_CHANNEL = 'trpc';
|
||||
|
||||
/** Context passed to every tRPC procedure via the IPC bridge. */
|
||||
export type TRPCContext = {
|
||||
/** The IPC sender — available for streaming chunks back to the renderer. */
|
||||
sender?: Electron.WebContents;
|
||||
};
|
||||
|
||||
interface IPCRequest {
|
||||
method: 'request';
|
||||
operation: {
|
||||
id: number;
|
||||
type: 'query' | 'mutation' | 'subscription';
|
||||
path: string;
|
||||
input?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createIPCHandler<TRouter extends AnyRouter>({
|
||||
router,
|
||||
windows = [],
|
||||
}: {
|
||||
router: TRouter;
|
||||
windows?: BrowserWindow[];
|
||||
}) {
|
||||
const config = router._def._config;
|
||||
|
||||
ipcMain.on(IPC_CHANNEL, async (event, message: IPCRequest) => {
|
||||
if (message.method !== 'request') return;
|
||||
|
||||
const { id, type, path, input } = message.operation;
|
||||
|
||||
// Deserialize input through transformer (identity by default)
|
||||
const rawInput = input !== undefined
|
||||
? config.transformer.input.deserialize(input)
|
||||
: undefined;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const respond = (response: any) => {
|
||||
if (event.sender.isDestroyed()) return;
|
||||
const serialised = transformTRPCResponse(config, response);
|
||||
event.reply(IPC_CHANNEL, serialised);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await callTRPCProcedure({
|
||||
router,
|
||||
path,
|
||||
getRawInput: async () => rawInput,
|
||||
ctx: { sender: event.sender } satisfies TRPCContext,
|
||||
type,
|
||||
signal: undefined as unknown as AbortSignal,
|
||||
batchIndex: 0,
|
||||
});
|
||||
|
||||
respond({ id, result: { type: 'data', data: result } });
|
||||
} catch (cause) {
|
||||
const error = getTRPCErrorFromUnknown(cause);
|
||||
respond({
|
||||
id,
|
||||
error: getErrorShape({
|
||||
config,
|
||||
error,
|
||||
type,
|
||||
path,
|
||||
input: rawInput,
|
||||
ctx: {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Attach additional windows later if needed
|
||||
return {
|
||||
attachWindow(_win: BrowserWindow) {
|
||||
// No per-window setup needed — ipcMain.on is global
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,10 +3,14 @@ import { z } from 'zod';
|
||||
import { eq, asc, inArray, and, or, like, sql } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/sqlite-core';
|
||||
import { getDb } from '../db';
|
||||
import { clients, projects, tasks, checkpoints, notes } from '../db/schema';
|
||||
import { clients, projects, tasks, checkpoints, notes, taskComments } from '../db/schema';
|
||||
import { getStore } from '../store';
|
||||
import { saveTokenAndInit, hasActiveToken } from '../ai/provider';
|
||||
import { orchestrate, dailyBrief } from '../ai/orchestrator';
|
||||
import { upsertNoteEmbedding } from '../db/vectordb';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.create();
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
|
||||
const router = t.router;
|
||||
const publicProcedure = t.procedure;
|
||||
@@ -166,6 +170,13 @@ const projectsRouter = router({
|
||||
db.delete(projects).where(eq(projects.id, input.id)).run();
|
||||
return { success: true as const };
|
||||
}),
|
||||
|
||||
archiveByClient: publicProcedure
|
||||
.input(z.object({ clientId: z.string(), status: z.enum(['active', 'archived']) }))
|
||||
.mutation(({ input }) => {
|
||||
getDb().update(projects).set({ status: input.status }).where(eq(projects.clientId, input.clientId)).run();
|
||||
return { success: true as const };
|
||||
}),
|
||||
});
|
||||
|
||||
const tasksRouter = router({
|
||||
@@ -192,12 +203,13 @@ const tasksRouter = router({
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const orderByClause =
|
||||
const priorityExpr = sql`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
|
||||
const orderByClauses =
|
||||
input?.orderBy === 'dueDate'
|
||||
? asc(tasks.dueDate)
|
||||
? [asc(tasks.dueDate), asc(priorityExpr)]
|
||||
: input?.orderBy === 'priority'
|
||||
? asc(sql`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`)
|
||||
: asc(tasks.createdAt);
|
||||
? [asc(priorityExpr), asc(tasks.dueDate)]
|
||||
: [asc(tasks.dueDate), asc(priorityExpr)];
|
||||
|
||||
return db
|
||||
.select({
|
||||
@@ -209,6 +221,8 @@ const tasksRouter = router({
|
||||
priority: tasks.priority,
|
||||
assignee: tasks.assignee,
|
||||
dueDate: tasks.dueDate,
|
||||
isAiSuggested: tasks.isAiSuggested,
|
||||
isApproved: tasks.isApproved,
|
||||
createdAt: tasks.createdAt,
|
||||
projectName: projects.name,
|
||||
clientName: sql<string | null>`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${parentClients.name} ELSE ${clients.name} END`,
|
||||
@@ -219,19 +233,45 @@ const tasksRouter = router({
|
||||
.leftJoin(clients, eq(projects.clientId, clients.id))
|
||||
.leftJoin(parentClients, eq(clients.parentId, parentClients.id))
|
||||
.where(conditions)
|
||||
.orderBy(orderByClause)
|
||||
.orderBy(...orderByClauses)
|
||||
.all();
|
||||
}),
|
||||
|
||||
listAssignees: publicProcedure.query(() => {
|
||||
const rows = getDb()
|
||||
.select({ assignee: tasks.assignee })
|
||||
.from(tasks)
|
||||
.all();
|
||||
const names = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.assignee) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(row.assignee) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const n of parsed) {
|
||||
if (typeof n === 'string' && n) names.add(n);
|
||||
}
|
||||
} else {
|
||||
names.add(row.assignee);
|
||||
}
|
||||
} catch {
|
||||
names.add(row.assignee);
|
||||
}
|
||||
}
|
||||
return [...names].sort();
|
||||
}),
|
||||
|
||||
create: publicProcedure
|
||||
.input(z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
priority: z.string().optional(),
|
||||
assignee: z.string().optional(),
|
||||
assignees: z.array(z.string()).optional(),
|
||||
dueDate: z.number().optional(),
|
||||
projectId: z.string().optional(),
|
||||
isAiSuggested: z.number().optional(),
|
||||
isApproved: z.number().optional(),
|
||||
}))
|
||||
.mutation(({ input }) => {
|
||||
const id = crypto.randomUUID();
|
||||
@@ -242,9 +282,11 @@ const tasksRouter = router({
|
||||
description: input.description ?? null,
|
||||
status: input.status ?? 'todo',
|
||||
priority: input.priority ?? 'medium',
|
||||
assignee: input.assignee ?? null,
|
||||
assignee: input.assignees?.length ? JSON.stringify(input.assignees) : null,
|
||||
dueDate: input.dueDate ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
isAiSuggested: input.isAiSuggested ?? 0,
|
||||
isApproved: input.isApproved ?? 1,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
return { id };
|
||||
@@ -257,9 +299,10 @@ const tasksRouter = router({
|
||||
description: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
priority: z.string().optional(),
|
||||
assignee: z.string().optional(),
|
||||
assignees: z.array(z.string()).optional(),
|
||||
dueDate: z.number().optional(),
|
||||
projectId: z.string().optional(),
|
||||
isApproved: z.number().optional(),
|
||||
}))
|
||||
.mutation(({ input }) => {
|
||||
const set: Partial<{
|
||||
@@ -270,13 +313,15 @@ const tasksRouter = router({
|
||||
assignee: string | null;
|
||||
dueDate: number | null;
|
||||
projectId: string | null;
|
||||
isApproved: number;
|
||||
}> = {};
|
||||
if (input.title !== undefined) set.title = input.title;
|
||||
if (input.description !== undefined) set.description = input.description;
|
||||
if (input.status !== undefined) set.status = input.status;
|
||||
if (input.priority !== undefined) set.priority = input.priority;
|
||||
if (input.assignee !== undefined) set.assignee = input.assignee;
|
||||
if (input.assignees !== undefined) set.assignee = input.assignees.length ? JSON.stringify(input.assignees) : null;
|
||||
if (input.dueDate !== undefined) set.dueDate = input.dueDate;
|
||||
if (input.isApproved !== undefined) set.isApproved = input.isApproved;
|
||||
if (input.projectId !== undefined) set.projectId = input.projectId;
|
||||
if (Object.keys(set).length > 0) {
|
||||
getDb().update(tasks).set(set).where(eq(tasks.id, input.id)).run();
|
||||
@@ -290,6 +335,30 @@ const tasksRouter = router({
|
||||
getDb().delete(tasks).where(eq(tasks.id, input.id)).run();
|
||||
return { success: true as const };
|
||||
}),
|
||||
|
||||
dueToday: publicProcedure.query(() => {
|
||||
const now = new Date();
|
||||
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();
|
||||
|
||||
return getDb()
|
||||
.select({
|
||||
id: tasks.id,
|
||||
title: tasks.title,
|
||||
priority: tasks.priority,
|
||||
dueDate: tasks.dueDate,
|
||||
projectId: tasks.projectId,
|
||||
})
|
||||
.from(tasks)
|
||||
.where(
|
||||
and(
|
||||
sql`${tasks.dueDate} IS NOT NULL`,
|
||||
sql`${tasks.dueDate} <= ${endOfToday}`,
|
||||
sql`${tasks.status} != 'done'`,
|
||||
)
|
||||
)
|
||||
.orderBy(asc(tasks.dueDate))
|
||||
.all();
|
||||
}),
|
||||
});
|
||||
|
||||
const checkpointsRouter = router({
|
||||
@@ -371,7 +440,7 @@ const notesRouter = router({
|
||||
|
||||
create: publicProcedure
|
||||
.input(z.object({ title: z.string(), content: z.string(), projectId: z.string().optional() }))
|
||||
.mutation(({ input }) => {
|
||||
.mutation(async ({ input }) => {
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
getDb().insert(notes).values({
|
||||
@@ -382,18 +451,37 @@ const notesRouter = router({
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run();
|
||||
// Fire-and-forget: embed the note. Errors are logged, never thrown.
|
||||
upsertNoteEmbedding(id, input.projectId ?? null, `${input.title}\n\n${input.content}`)
|
||||
.catch((err) => console.error('[VectorDB] Failed to embed note on create:', err));
|
||||
return { id };
|
||||
}),
|
||||
|
||||
update: publicProcedure
|
||||
.input(z.object({ id: z.string(), title: z.string().optional(), content: z.string().optional() }))
|
||||
.mutation(({ input }) => {
|
||||
.mutation(async ({ input }) => {
|
||||
const set: Partial<{ title: string; content: string; updatedAt: number }> = {};
|
||||
if (input.title !== undefined) set.title = input.title;
|
||||
if (input.content !== undefined) set.content = input.content;
|
||||
// Always update updatedAt
|
||||
set.updatedAt = Date.now();
|
||||
getDb().update(notes).set(set).where(eq(notes.id, input.id)).run();
|
||||
|
||||
// Re-embed if searchable text fields changed.
|
||||
// Re-fetch from SQLite so the embedding reflects the full current note
|
||||
// (the update may have changed only one of title or content).
|
||||
if (input.title !== undefined || input.content !== undefined) {
|
||||
const updated = getDb()
|
||||
.select({ id: notes.id, projectId: notes.projectId, title: notes.title, content: notes.content })
|
||||
.from(notes)
|
||||
.where(eq(notes.id, input.id))
|
||||
.all()[0];
|
||||
if (updated) {
|
||||
upsertNoteEmbedding(updated.id, updated.projectId ?? null, `${updated.title}\n\n${updated.content}`)
|
||||
.catch((err) => console.error('[VectorDB] Failed to embed note on update:', err));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
|
||||
@@ -405,6 +493,41 @@ const notesRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
const taskCommentsRouter = router({
|
||||
list: publicProcedure
|
||||
.input(z.object({ taskId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(taskComments)
|
||||
.where(eq(taskComments.taskId, input.taskId))
|
||||
.orderBy(asc(taskComments.createdAt))
|
||||
.all();
|
||||
}),
|
||||
|
||||
create: publicProcedure
|
||||
.input(z.object({ taskId: z.string(), author: z.string(), content: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
getDb().insert(taskComments).values({
|
||||
id,
|
||||
taskId: input.taskId,
|
||||
author: input.author,
|
||||
content: input.content,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
return { id, taskId: input.taskId, author: input.author, content: input.content, createdAt: now };
|
||||
}),
|
||||
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
getDb().delete(taskComments).where(eq(taskComments.id, input.id)).run();
|
||||
return { success: true as const };
|
||||
}),
|
||||
});
|
||||
|
||||
const settingsRouter = router({
|
||||
getSidebarCollapsed: publicProcedure.query(() => getStore().get('sidebarCollapsed')),
|
||||
setSidebarCollapsed: publicProcedure
|
||||
@@ -413,6 +536,13 @@ const settingsRouter = router({
|
||||
getStore().set('sidebarCollapsed', input.collapsed);
|
||||
return null;
|
||||
}),
|
||||
getUserName: publicProcedure.query(() => getStore().get('userName')),
|
||||
setUserName: publicProcedure
|
||||
.input(z.object({ name: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
getStore().set('userName', input.name);
|
||||
return null;
|
||||
}),
|
||||
});
|
||||
|
||||
const aiRouter = router({
|
||||
@@ -422,13 +552,39 @@ const aiRouter = router({
|
||||
context: z.object({
|
||||
type: z.enum(['global', 'project']),
|
||||
projectId: z.string().optional(),
|
||||
uiContext: z.string().optional(),
|
||||
}),
|
||||
}))
|
||||
.mutation(() => ({ response: '' })),
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await orchestrate({
|
||||
message: input.message,
|
||||
context: input.context,
|
||||
sender: ctx.sender,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
return { response: '', error: msg };
|
||||
}
|
||||
}),
|
||||
setToken: publicProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.mutation(() => null),
|
||||
hasToken: publicProcedure.query(() => false),
|
||||
.mutation(async ({ input }) => {
|
||||
await saveTokenAndInit(input.token);
|
||||
return { success: true };
|
||||
}),
|
||||
dailyBrief: publicProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
try {
|
||||
return await dailyBrief(ctx.sender);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
return { response: '', error: msg };
|
||||
}
|
||||
}),
|
||||
hasToken: publicProcedure.query(async () => {
|
||||
return hasActiveToken();
|
||||
}),
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
@@ -439,6 +595,7 @@ export const appRouter = router({
|
||||
tasks: tasksRouter,
|
||||
checkpoints: checkpointsRouter,
|
||||
notes: notesRouter,
|
||||
taskComments: taskCommentsRouter,
|
||||
ai: aiRouter,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import Store from 'electron-store';
|
||||
|
||||
interface AppSettings {
|
||||
sidebarCollapsed: boolean;
|
||||
aiProvider: string;
|
||||
encryptedTokens: Record<string, string>;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
let _store: Store<AppSettings> | null = null;
|
||||
@@ -11,6 +14,9 @@ export function getStore(): Store<AppSettings> {
|
||||
_store = new Store<AppSettings>({
|
||||
defaults: {
|
||||
sidebarCollapsed: false,
|
||||
aiProvider: 'copilot',
|
||||
encryptedTokens: {},
|
||||
userName: 'there',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// See the Electron documentation for details on how to use preload scripts:
|
||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
|
||||
import { exposeElectronTRPC } from 'electron-trpc/main';
|
||||
|
||||
process.once('loaded', () => {
|
||||
exposeElectronTRPC();
|
||||
});
|
||||
import './trpc';
|
||||
|
||||
43
src/preload/trpc.ts
Normal file
43
src/preload/trpc.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Preload script — expose a minimal IPC transport to the renderer.
|
||||
*
|
||||
* Replaces electron-trpc's exposeElectronTRPC with a compatible shim
|
||||
* that works with our custom IPC handler + tRPC v11.
|
||||
*/
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
const IPC_CHANNEL = 'trpc';
|
||||
|
||||
contextBridge.exposeInMainWorld('electronTRPC', {
|
||||
sendMessage: (msg: unknown) => ipcRenderer.send(IPC_CHANNEL, msg),
|
||||
onMessage: (cb: (data: unknown) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: unknown) => cb(data);
|
||||
ipcRenderer.on(IPC_CHANNEL, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const AI_STREAM_CHANNEL = 'ai:stream';
|
||||
const AI_ACTION_CHANNEL = 'ai:action';
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAI', {
|
||||
/** Subscribe to AI streaming chunks. Returns an unsubscribe function. */
|
||||
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { token: string; done: boolean }) => cb(data);
|
||||
ipcRenderer.on(AI_STREAM_CHANNEL, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(AI_STREAM_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
|
||||
/** Subscribe to AI action events (task created, suggestions, etc.). Returns unsubscribe. */
|
||||
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: { type: string; taskId?: string; count?: number }) => cb(data);
|
||||
ipcRenderer.on(AI_ACTION_CHANNEL, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(AI_ACTION_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
});
|
||||
532
src/renderer/components/ai/AIChatPanel.tsx
Normal file
532
src/renderer/components/ai/AIChatPanel.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
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 = [
|
||||
{ icon: ListTodo, label: "What's on my plate today?" },
|
||||
{ icon: TrendingUp, label: 'Summarize this week' },
|
||||
{ icon: AlertCircle, label: 'Any overdue tasks?' },
|
||||
{ icon: Lightbulb, label: 'Suggest next actions' },
|
||||
] as const;
|
||||
|
||||
function getTimeGreeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Good morning,';
|
||||
if (hour < 17) return 'Good afternoon,';
|
||||
return 'Good evening,';
|
||||
}
|
||||
|
||||
/* Entrance animation: staggered fade-up */
|
||||
const stagger = {
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.08 } },
|
||||
};
|
||||
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.45, ease: [0.25, 0.1, 0.25, 1] as const },
|
||||
},
|
||||
};
|
||||
|
||||
interface AIChatPanelProps {
|
||||
onOpenSettings?: () => void;
|
||||
isHomePage?: boolean;
|
||||
}
|
||||
|
||||
export function AIChatPanel({
|
||||
onOpenSettings,
|
||||
isHomePage,
|
||||
}: AIChatPanelProps) {
|
||||
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
||||
|
||||
// Home-specific queries
|
||||
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
|
||||
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
|
||||
|
||||
const chatContext = useMemo<ChatContext>(
|
||||
() => ({ type: 'global' as const }),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
setInput,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
handleSend: chatHandleSend,
|
||||
} = useAIChat(chatContext);
|
||||
|
||||
// Daily brief state (home page only)
|
||||
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
|
||||
const [briefLoading, setBriefLoading] = useState(false);
|
||||
const briefContentRef = useRef('');
|
||||
const hasFiredBrief = useRef(false);
|
||||
|
||||
const [briefExpanded, setBriefExpanded] = useState(false);
|
||||
const [briefDismissed, setBriefDismissed] = useState(false);
|
||||
|
||||
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();
|
||||
|
||||
// When the user message appears in the list, set the placeholder and scroll it to the top
|
||||
useEffect(() => {
|
||||
if (!pendingScrollRef.current) return;
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!isHomePage || hasFiredBrief.current || hasTokenQuery.data !== true) return;
|
||||
hasFiredBrief.current = true;
|
||||
setBriefLoading(true);
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
|
||||
if (done) {
|
||||
setDailyBrief(briefContentRef.current);
|
||||
setBriefLoading(false);
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
briefContentRef.current += token;
|
||||
setDailyBrief(briefContentRef.current);
|
||||
});
|
||||
|
||||
briefMutation.mutate(undefined, {
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
unsubscribe();
|
||||
setDailyBrief(null);
|
||||
setBriefLoading(false);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
unsubscribe();
|
||||
setDailyBrief(null);
|
||||
setBriefLoading(false);
|
||||
},
|
||||
});
|
||||
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (briefLoading) return;
|
||||
pendingScrollRef.current = true;
|
||||
chatHandleSend();
|
||||
}, [briefLoading, chatHandleSend]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming;
|
||||
|
||||
// Derived values for home page
|
||||
const dueCount = dueTodayQuery.data?.length ?? 0;
|
||||
const userName = userNameQuery.data ?? 'there';
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-0 flex flex-col bg-background">
|
||||
{/* 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 */}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{/* Gradual blur at the bottom of messages */}
|
||||
{hasMessages && (
|
||||
<GradualBlur
|
||||
position="bottom"
|
||||
strength={0.6}
|
||||
height="4rem"
|
||||
divCount={10}
|
||||
curve="ease-out"
|
||||
opacity={0.8}
|
||||
zIndex={20}
|
||||
/>
|
||||
)}
|
||||
<ScrollArea
|
||||
className="h-full"
|
||||
viewportRef={messagesContainerRef}
|
||||
scrollbarClassName={hasMessages ? 'z-30' : undefined}
|
||||
viewportClassName={
|
||||
isHomePage && !hasMessages
|
||||
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
|
||||
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
|
||||
}
|
||||
>
|
||||
{/* Home page initial state: greeting + brief */}
|
||||
{isHomePage && !hasMessages && (
|
||||
<motion.div
|
||||
className="mx-auto w-full max-w-4xl px-8 pt-14 pb-8"
|
||||
variants={stagger}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
>
|
||||
<div className="flex flex-col" style={{ gap: 'clamp(2.5rem, 4vh, 4rem)' }}>
|
||||
{/* Greeting — editorial hero moment */}
|
||||
<motion.div variants={fadeUp} className="flex flex-col gap-1">
|
||||
<span
|
||||
className="font-light tracking-wide text-muted-foreground"
|
||||
style={{ fontSize: 'clamp(1rem, 1.6vw, 1.25rem)' }}
|
||||
>
|
||||
{getTimeGreeting()}
|
||||
</span>
|
||||
<h1
|
||||
className="font-bold leading-[1.05]"
|
||||
style={{ fontSize: 'clamp(3.25rem, 5.5vw, 5.5rem)', letterSpacing: '-0.035em' }}
|
||||
>
|
||||
{userName}
|
||||
<span className="text-primary ml-3 inline-block">✦</span>
|
||||
</h1>
|
||||
{dueCount > 0 && (
|
||||
<p
|
||||
className="text-muted-foreground mt-2"
|
||||
style={{ fontSize: 'clamp(0.875rem, 1.2vw, 1.125rem)' }}
|
||||
>
|
||||
<span className="text-foreground font-medium">{dueCount}</span>
|
||||
{' '}task{dueCount !== 1 ? 's' : ''} due today
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Daily brief */}
|
||||
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||
{hasTokenQuery.data === false ? (
|
||||
<div className="flex flex-col items-start gap-3 py-2">
|
||||
<KeyRound size={20} className="text-muted-foreground" />
|
||||
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
|
||||
Configure your AI provider in Settings to enable the daily brief.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
||||
Open Settings
|
||||
</Button>
|
||||
</div>
|
||||
) : briefLoading && !dailyBrief ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
</div>
|
||||
) : dailyBrief ? (
|
||||
<ChatMarkdown content={dailyBrief} size="lg" />
|
||||
) : (
|
||||
<p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
|
||||
Your daily brief will appear here.
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Input + suggestion links */}
|
||||
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||
<ChatInput
|
||||
input={input}
|
||||
isStreaming={isStreaming || briefLoading}
|
||||
onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5 mt-5">
|
||||
{SUGGESTION_CHIPS.map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
type="button"
|
||||
className="group flex items-center gap-3 py-1.5 text-muted-foreground transition-all duration-200 hover:text-foreground hover:translate-x-1 cursor-pointer text-left"
|
||||
style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}
|
||||
onClick={() => setInput(chip.label)}
|
||||
>
|
||||
<chip.icon
|
||||
size={16}
|
||||
className="shrink-0 transition-colors duration-200 group-hover:text-primary"
|
||||
/>
|
||||
<span>{chip.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Home page with messages: brief stays, then messages */}
|
||||
{isHomePage && hasMessages && (
|
||||
<div className="mx-auto w-full max-w-6xl px-6 pt-8 pb-32">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Chat messages */}
|
||||
{messages.map((msg, idx) => {
|
||||
const isLastMsg = idx === messages.length - 1;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
ref={isLastMsg ? lastUserMsgRef : undefined}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
|
||||
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.error) {
|
||||
return (
|
||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||
<p style={{ fontSize: CHAT_FONT }} className="text-destructive whitespace-pre-wrap">
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Sparkles size={16} className="text-foreground" />
|
||||
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
|
||||
</div>
|
||||
<div className="pl-[22px]">
|
||||
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Streaming AI response */}
|
||||
{isStreaming && (
|
||||
<div ref={setStreamingEl} className="mr-auto max-w-[75%]">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Sparkles size={16} className="text-foreground" />
|
||||
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
|
||||
</div>
|
||||
{streamingContent ? (
|
||||
<div className="pl-[22px]">
|
||||
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 pl-[22px]">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Non-home messages */}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Fixed input — pinned to the bottom, above the blur */}
|
||||
{hasMessages && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-30 px-6 pb-5 pt-4 pointer-events-none">
|
||||
<div className="relative pointer-events-auto mx-auto max-w-3xl">
|
||||
<ChatInput
|
||||
input={input}
|
||||
isStreaming={isStreaming || briefLoading}
|
||||
onInputChange={setInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSend={handleSend}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- ChatInput: Floating glass card ---------- */
|
||||
|
||||
interface ChatInputProps {
|
||||
input: string;
|
||||
isStreaming: boolean;
|
||||
onInputChange: (value: string) => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onSend: () => void;
|
||||
}
|
||||
|
||||
function ChatInput({
|
||||
input,
|
||||
isStreaming,
|
||||
onInputChange,
|
||||
onKeyDown,
|
||||
onSend,
|
||||
}: ChatInputProps) {
|
||||
return (
|
||||
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask me anything..."
|
||||
aria-label="Chat message"
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto"
|
||||
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||
/>
|
||||
<button
|
||||
onClick={onSend}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
aria-label="Send message"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
||||
|
||||
export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: string; size?: 'sm' | 'lg'; fontSize?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
|
||||
style={fontSize ? { fontSize } : undefined}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-muted rounded-lg p-3 overflow-x-auto text-xs">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
if (!className) {
|
||||
return (
|
||||
<code className="bg-muted rounded px-1.5 py-0.5 text-xs font-mono">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return <code className={className}>{children}</code>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
389
src/renderer/components/ai/FloatingChat.tsx
Normal file
389
src/renderer/components/ai/FloatingChat.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { X, ArrowUp } from 'lucide-react';
|
||||
import {
|
||||
useFloatingChat,
|
||||
computeDualAnchor,
|
||||
getChatWidth,
|
||||
CHAT_HEIGHT,
|
||||
PADDING,
|
||||
} from '@/context/FloatingChatContext';
|
||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
/** Map section IDs to their routes for cross-page navigation */
|
||||
const SECTION_ROUTES: Record<string, string> = {
|
||||
'project-summary': 'project',
|
||||
'project-timeline': 'project',
|
||||
'project-tasks': 'project',
|
||||
'project-notes': 'project',
|
||||
'tasks-overview': '/tasks',
|
||||
'tasks-list': '/tasks',
|
||||
'timeline-chart': '/timeline',
|
||||
'note-editor': 'note',
|
||||
};
|
||||
|
||||
function FloatingChatInner() {
|
||||
const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat();
|
||||
const utils = trpc.useUtils();
|
||||
const navigate = useNavigate();
|
||||
const routerState = useRouterState();
|
||||
const prevPathRef = useRef(routerState.location.pathname);
|
||||
|
||||
// Active section lookup
|
||||
const activeSection = sections.get(state.activeSectionId ?? '');
|
||||
|
||||
// Chat context derived from active section
|
||||
const chatContext = useMemo<ChatContext>(
|
||||
() => ({
|
||||
type: activeSection?.projectId ? 'project' : 'global',
|
||||
projectId: activeSection?.projectId,
|
||||
uiContext: activeSection?.label,
|
||||
}),
|
||||
[activeSection?.projectId, activeSection?.label],
|
||||
);
|
||||
|
||||
// Handle [SECTION:xxx] tags from AI responses
|
||||
const handleSectionTag = useCallback((sectionId: string) => {
|
||||
// Same-page: section is already registered
|
||||
const targetSection = sections.get(sectionId);
|
||||
if (targetSection) {
|
||||
moveToSection(sectionId);
|
||||
targetSection.ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Cross-page: section not registered, navigate to its route
|
||||
const route = SECTION_ROUTES[sectionId];
|
||||
if (!route) return;
|
||||
|
||||
setPendingSection({ sectionId });
|
||||
|
||||
if (route === 'project' && state.projectId) {
|
||||
// Navigate to the project page (stay on same project)
|
||||
// Project sections re-register on mount and pendingSection will auto-open
|
||||
void navigate({ to: '/projects', search: { projectId: state.projectId } });
|
||||
} else if (route.startsWith('/')) {
|
||||
void navigate({ to: route });
|
||||
}
|
||||
// 'note' type requires noteId — skip cross-page for now
|
||||
}, [sections, moveToSection, setPendingSection, state.projectId, navigate]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
setInput,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
handleSend,
|
||||
clearMessages,
|
||||
} = useAIChat(chatContext, { onSectionTag: handleSectionTag });
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ---- Close on Escape ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [state.isOpen, close]);
|
||||
|
||||
// ---- Close on route change (unless cross-page navigation pending) ----
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = routerState.location.pathname;
|
||||
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
|
||||
close();
|
||||
}
|
||||
prevPathRef.current = currentPath;
|
||||
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
|
||||
|
||||
// ---- Clear messages on close ----
|
||||
|
||||
const prevOpenRef = useRef(state.isOpen);
|
||||
useEffect(() => {
|
||||
if (prevOpenRef.current && !state.isOpen) {
|
||||
clearMessages();
|
||||
}
|
||||
prevOpenRef.current = state.isOpen;
|
||||
}, [state.isOpen, clearMessages]);
|
||||
|
||||
// ---- AI action: morph into newly-created task ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen) return;
|
||||
|
||||
const unsubscribe = window.electronAI.onAction((action) => {
|
||||
if (action.type === 'task_created' && action.taskId) {
|
||||
// Invalidate task queries so the new TaskRow renders
|
||||
void utils.tasks.list.invalidate();
|
||||
|
||||
// Set the morph target layoutId
|
||||
setMorphTarget(`task-morph-${action.taskId}`);
|
||||
|
||||
// Wait for the TaskRow to render, then close (triggering FLIP)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
close();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [state.isOpen, utils, setMorphTarget, close]);
|
||||
|
||||
// ---- Window resize: keep within bounds ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen) return;
|
||||
const handler = () => {
|
||||
// Re-anchor if the container would go offscreen
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
|
||||
el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - getChatWidth() - PADDING))}px`;
|
||||
el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handler);
|
||||
return () => window.removeEventListener('resize', handler);
|
||||
}, [state.isOpen, state.position.x, state.position.y]);
|
||||
|
||||
// ---- Scroll tracking: dual-anchor repositioning ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen || !state.activeSectionId) return;
|
||||
const section = sections.get(state.activeSectionId);
|
||||
if (!section || section.anchorMode === 'right-margin') return;
|
||||
|
||||
const el = section.ref.current;
|
||||
if (!el) return;
|
||||
|
||||
// Find scrollable ancestor
|
||||
let scrollParent: HTMLElement | null = el.parentElement;
|
||||
while (scrollParent) {
|
||||
const style = getComputedStyle(scrollParent);
|
||||
if (style.overflow === 'auto' || style.overflow === 'scroll' ||
|
||||
style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
||||
break;
|
||||
}
|
||||
// Also check for Radix ScrollArea viewport
|
||||
if (scrollParent.hasAttribute('data-radix-scroll-area-viewport')) break;
|
||||
scrollParent = scrollParent.parentElement;
|
||||
}
|
||||
|
||||
if (!scrollParent) return;
|
||||
|
||||
let rafId: number | null = null;
|
||||
const handleScroll = () => {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
const newPos = computeDualAnchor(section);
|
||||
if (newPos) {
|
||||
updatePosition(newPos);
|
||||
}
|
||||
// null = fully off-screen → freeze (do nothing)
|
||||
});
|
||||
};
|
||||
|
||||
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', handleScroll);
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [state.isOpen, state.activeSectionId, sections, updatePosition]);
|
||||
|
||||
// ---- Auto-scroll messages ----
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTo({ top: el.scrollHeight });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent, scrollToBottom]);
|
||||
|
||||
// ---- Auto-focus input on open ----
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
if (state.isOpen) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state.isOpen]);
|
||||
|
||||
// ---- Input handling ----
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const hasMessages = messages.length > 0 || isStreaming;
|
||||
|
||||
// Expand the messages panel upward if there's enough space above the input bar,
|
||||
// otherwise expand downward. 320px = 300px max-h + 8px gap + 12px buffer.
|
||||
const expandUp = state.position.y >= 320;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{state.isOpen && (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
key="floating-chat"
|
||||
layout
|
||||
layoutId={state.morphTargetId ?? undefined}
|
||||
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: state.position.x,
|
||||
top: state.position.y,
|
||||
width: state.position.width,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{/* ---- Messages panel — floats above or below the input bar ---- */}
|
||||
<AnimatePresence>
|
||||
{hasMessages && (
|
||||
<motion.div
|
||||
key="messages-panel"
|
||||
initial={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, y: expandUp ? 8 : -8 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
...(expandUp
|
||||
? { bottom: 'calc(100% + 8px)' }
|
||||
: { top: 'calc(100% + 8px)' }),
|
||||
}}
|
||||
className="rounded-2xl overflow-hidden"
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-[300px] overflow-y-auto rounded-2xl [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/40"
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 p-3">
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-end">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-br-md px-3.5 py-2">
|
||||
<p className="text-xs whitespace-pre-wrap leading-relaxed text-foreground">
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.error) {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-start">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2 !border-destructive/30">
|
||||
<p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
|
||||
{msg.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-start">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
|
||||
<div className="text-xs text-foreground">
|
||||
<ChatMarkdown content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Streaming */}
|
||||
{isStreaming && (
|
||||
<div className="flex justify-start">
|
||||
<div className="glass-surface-subtle max-w-[80%] rounded-2xl rounded-bl-md px-3.5 py-2">
|
||||
{streamingContent ? (
|
||||
<div className="text-xs text-foreground">
|
||||
<ChatMarkdown content={streamingContent} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 py-0.5">
|
||||
<Skeleton className="h-3 w-36" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ---- Floating input bar ---- */}
|
||||
<div className="glass-surface relative rounded-2xl transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.35)]">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={close}
|
||||
className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted/90 backdrop-blur-sm border border-border/50 shadow-sm hover:bg-muted transition-colors z-10"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto"
|
||||
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export function FloatingChatPortal() {
|
||||
return createPortal(<FloatingChatInner />, document.body);
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useRouterState } from '@tanstack/react-router';
|
||||
import { LayoutGroup } from 'framer-motion';
|
||||
import {
|
||||
House,
|
||||
ChartGantt,
|
||||
ClipboardCheck,
|
||||
FolderKanban,
|
||||
PanelLeft,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Check,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Palette
|
||||
} from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -21,8 +29,33 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
||||
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import { FloatingChatProvider } from '@/context/FloatingChatContext';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', icon: House, label: 'Home' },
|
||||
@@ -36,6 +69,16 @@ interface AppShellProps {
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<FloatingChatProvider>
|
||||
<AppShellInner>{children}</AppShellInner>
|
||||
</FloatingChatProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShellInner({ children }: AppShellProps) {
|
||||
useDoubleClickAI();
|
||||
|
||||
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
@@ -45,8 +88,9 @@ export function AppShell({ children }: AppShellProps) {
|
||||
const currentPath = routerState.location.pathname;
|
||||
|
||||
// Controlled open state (spec: "Controlled Sidebar" pattern)
|
||||
// Default to collapsed (false) until the persisted preference loads
|
||||
const [open, setOpen] = useState(() =>
|
||||
collapsedQuery.data === undefined ? true : !collapsedQuery.data
|
||||
collapsedQuery.data === undefined ? false : !collapsedQuery.data
|
||||
);
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
@@ -54,31 +98,105 @@ export function AppShell({ children }: AppShellProps) {
|
||||
setSidebarCollapsedMutation.mutate({ collapsed: !value });
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||
<AppSidebar currentPath={currentPath} />
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{children}
|
||||
// AI token dialog state (shared between sidebar gear menu and AIChatPanel prompt)
|
||||
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
const setTokenMutation = trpc.ai.setToken.useMutation({
|
||||
onSuccess: () => {
|
||||
setSaved(true);
|
||||
setTokenInput('');
|
||||
void utils.ai.hasToken.invalidate();
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
{/* Right-edge vertical 'keep scrolling for AI' affordance (non-interactive) */}
|
||||
<div className="absolute right-0 top-0 bottom-0 flex items-end justify-center pb-8 pointer-events-none select-none">
|
||||
<div className="flex flex-col items-center gap-1.5 pr-2">
|
||||
<span
|
||||
className="text-[9px] text-muted-foreground/30 tracking-widest uppercase font-medium"
|
||||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||||
>
|
||||
keep scrolling for AI
|
||||
</span>
|
||||
<ChevronDown size={10} className="text-muted-foreground/30" />
|
||||
const isHomePage = currentPath === '/';
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||
<AppSidebar
|
||||
currentPath={currentPath}
|
||||
setTokenDialogOpen={setTokenDialogOpen}
|
||||
/>
|
||||
<SidebarInset>
|
||||
{isHomePage ? (
|
||||
<AIChatPanel
|
||||
onOpenSettings={() => setTokenDialogOpen(true)}
|
||||
isHomePage
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex flex-col h-full">
|
||||
<header className="flex items-center gap-2 p-2 md:hidden">
|
||||
<SidebarTrigger />
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
{/* Floating AI Chat — portal to document.body */}
|
||||
<FloatingChatPortal />
|
||||
|
||||
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
|
||||
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
|
||||
setTokenDialogOpen(open);
|
||||
if (!open) { setTokenInput(''); setSaved(false); }
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your AI provider credentials for chat, summaries, and suggestions.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">GitHub Copilot Token</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Paste your token here"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your token is stored securely in the OS keychain.
|
||||
{hasTokenQuery.data === true && (
|
||||
<span className="text-green-600 dark:text-green-400 ml-1">A token is currently stored.</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400 mr-auto">
|
||||
<Check size={14} />
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
disabled={!tokenInput.trim() || setTokenMutation.isPending}
|
||||
onClick={() => setTokenMutation.mutate({ token: tokenInput.trim() })}
|
||||
>
|
||||
{setTokenMutation.isPending ? 'Saving...' : 'Save Token'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function AppSidebar({ currentPath }: { currentPath: string }) {
|
||||
interface AppSidebarProps {
|
||||
currentPath: string;
|
||||
setTokenDialogOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -142,9 +260,50 @@ function AppSidebar({ currentPath }: { currentPath: string }) {
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
{/* Collapse toggle — spec: useSidebar() + custom trigger */}
|
||||
{/* Settings gear + Collapse toggle */}
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton tooltip="Settings">
|
||||
<Settings />
|
||||
<span>Settings</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="end" className="w-56">
|
||||
<DropdownMenuItem onSelect={() => setTokenDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 size-4" />
|
||||
AI Provider
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Palette className="mr-2 size-4" />
|
||||
<span>Theme</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={() => setTheme('light')}>
|
||||
<Sun className="mr-2 size-4" />
|
||||
Light
|
||||
{theme === 'light' && <Check className="ml-auto size-4" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setTheme('dark')}>
|
||||
<Moon className="mr-2 size-4" />
|
||||
Dark
|
||||
{theme === 'dark' && <Check className="ml-auto size-4" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setTheme('system')}>
|
||||
<Monitor className="mr-2 size-4" />
|
||||
System
|
||||
{theme === 'system' && <Check className="ml-auto size-4" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton onClick={toggleSidebar} tooltip="Toggle Sidebar">
|
||||
<PanelLeft />
|
||||
@@ -153,6 +312,7 @@ function AppSidebar({ currentPath }: { currentPath: string }) {
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
85
src/renderer/components/notes/MilkdownEditor.tsx
Normal file
85
src/renderer/components/notes/MilkdownEditor.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Crepe, CrepeFeature } from '@milkdown/crepe';
|
||||
import { upload, uploadConfig } from '@milkdown/plugin-upload';
|
||||
|
||||
import '@milkdown/crepe/theme/common/style.css';
|
||||
import '@milkdown/crepe/theme/nord.css';
|
||||
|
||||
function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
interface MilkdownEditorProps {
|
||||
initialContent: string;
|
||||
onChange: (markdown: string) => void;
|
||||
}
|
||||
|
||||
export function MilkdownEditor({ initialContent, onChange }: MilkdownEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const crepeRef = useRef<Crepe | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const crepe = new Crepe({
|
||||
root: containerRef.current,
|
||||
defaultValue: initialContent,
|
||||
featureConfigs: {
|
||||
[CrepeFeature.Placeholder]: {
|
||||
text: 'Start writing...',
|
||||
},
|
||||
[CrepeFeature.ImageBlock]: {
|
||||
onUpload: fileToDataUrl,
|
||||
inlineOnUpload: fileToDataUrl,
|
||||
blockOnUpload: fileToDataUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Add upload plugin to handle Ctrl+V and drag-drop of image files
|
||||
crepe.editor
|
||||
.config((ctx) => {
|
||||
ctx.update(uploadConfig.key, (prev) => ({
|
||||
...prev,
|
||||
uploader: async (files: FileList, schema: import('@milkdown/prose/model').Schema) => {
|
||||
const results: import('@milkdown/prose/model').Node[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files.item(i);
|
||||
if (!file?.type.includes('image')) continue;
|
||||
const src = await fileToDataUrl(file);
|
||||
const node = schema.nodes.image?.createAndFill({ src, alt: file.name });
|
||||
if (node) results.push(node);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
enableHtmlFileUploader: true,
|
||||
}));
|
||||
})
|
||||
.use(upload);
|
||||
|
||||
crepe.on((listener) => {
|
||||
listener.markdownUpdated((_ctx, markdown, prevMarkdown) => {
|
||||
if (markdown !== prevMarkdown) {
|
||||
onChangeRef.current(markdown);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
crepe.create();
|
||||
crepeRef.current = crepe;
|
||||
|
||||
return () => {
|
||||
crepe.destroy();
|
||||
crepeRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={containerRef} className="milkdown-container" />;
|
||||
}
|
||||
168
src/renderer/components/projects/KanbanBoard.tsx
Normal file
168
src/renderer/components/projects/KanbanBoard.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
||||
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'todo', label: 'To Do' },
|
||||
{ id: 'in_progress', label: 'In Progress' },
|
||||
{ id: 'done', label: 'Completed' },
|
||||
] as const;
|
||||
|
||||
type ColumnId = (typeof COLUMNS)[number]['id'];
|
||||
|
||||
type KanbanBoardProps = {
|
||||
projectId: string;
|
||||
newTaskOpen: boolean;
|
||||
onNewTaskOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
|
||||
const { state: floatingState } = useFloatingChat();
|
||||
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => void utils.tasks.list.invalidate(),
|
||||
});
|
||||
|
||||
const deleteTask = trpc.tasks.delete.useMutation({
|
||||
onSuccess: () => void utils.tasks.list.invalidate(),
|
||||
});
|
||||
|
||||
// Edit / view task dialog state
|
||||
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
||||
|
||||
// Group tasks by status (exclude unapproved AI suggestions)
|
||||
const columns = useMemo(() => {
|
||||
const tasks = (tasksList ?? []).filter(
|
||||
(t) => !(t.isAiSuggested === 1 && t.isApproved === 0),
|
||||
);
|
||||
const grouped: Record<ColumnId, TaskItem[]> = {
|
||||
todo: [],
|
||||
in_progress: [],
|
||||
done: [],
|
||||
};
|
||||
for (const task of tasks) {
|
||||
const status = (task.status ?? 'todo') as ColumnId;
|
||||
if (status in grouped) {
|
||||
grouped[status].push(task);
|
||||
} else {
|
||||
grouped.todo.push(task);
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}, [tasksList]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
const { destination, source, draggableId } = result;
|
||||
if (!destination) return;
|
||||
if (destination.droppableId === source.droppableId) return;
|
||||
|
||||
updateTask.mutate({
|
||||
id: draggableId,
|
||||
status: destination.droppableId,
|
||||
});
|
||||
},
|
||||
[updateTask],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(taskId: string, currentStatus: string | null) => {
|
||||
const nextStatus =
|
||||
currentStatus === 'todo' ? 'in_progress' :
|
||||
currentStatus === 'in_progress' ? 'done' : 'todo';
|
||||
updateTask.mutate({ id: taskId, status: nextStatus });
|
||||
},
|
||||
[updateTask],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{COLUMNS.map((col) => (
|
||||
<div key={col.id} className="flex flex-col gap-3">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{columns[col.id].length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Droppable column */}
|
||||
<Droppable droppableId={col.id}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={`flex flex-col gap-2 min-h-[120px] rounded-md transition-colors ${
|
||||
snapshot.isDraggingOver ? 'bg-muted/50' : 'bg-muted/20'
|
||||
}`}
|
||||
>
|
||||
{columns[col.id].map((task, index) => (
|
||||
<Draggable
|
||||
key={task.id}
|
||||
draggableId={task.id}
|
||||
index={index}
|
||||
>
|
||||
{(dragProvided) => (
|
||||
<div
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
>
|
||||
<TaskRow
|
||||
task={task}
|
||||
onToggle={handleToggle}
|
||||
onEdit={setEditTask}
|
||||
onDelete={(id) => deleteTask.mutate({ id })}
|
||||
onClick={setViewTask}
|
||||
hideBreadcrumb
|
||||
layoutId={
|
||||
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||
? floatingState.morphTargetId
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
|
||||
<NewTaskDialog
|
||||
open={newTaskOpen}
|
||||
onOpenChange={onNewTaskOpenChange}
|
||||
defaultProjectId={projectId}
|
||||
/>
|
||||
<EditTaskDialog
|
||||
task={editTask}
|
||||
open={!!editTask}
|
||||
onOpenChange={(open) => { if (!open) setEditTask(null); }}
|
||||
/>
|
||||
<TaskDetailDialog
|
||||
task={viewTask}
|
||||
open={!!viewTask}
|
||||
onOpenChange={(open) => { if (!open) setViewTask(null); }}
|
||||
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
|
||||
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,184 @@
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { KanbanBoard } from './KanbanBoard';
|
||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
||||
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
|
||||
type ProjectDetailProps = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
||||
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
|
||||
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// AI section refs
|
||||
const summaryRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const tasksRef = useRef<HTMLDivElement>(null);
|
||||
const notesRef = useRef<HTMLDivElement>(null);
|
||||
const { registerSection, unregisterSection } = useFloatingChat();
|
||||
|
||||
useEffect(() => {
|
||||
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
|
||||
registerSection({ id: 'project-timeline', label: 'Project Timeline', ref: timelineRef, projectId });
|
||||
registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksRef, projectId });
|
||||
registerSection({ id: 'project-notes', label: 'Notes', ref: notesRef, projectId });
|
||||
return () => {
|
||||
unregisterSection('project-summary');
|
||||
unregisterSection('project-timeline');
|
||||
unregisterSection('project-tasks');
|
||||
unregisterSection('project-notes');
|
||||
};
|
||||
}, [projectId, registerSection, unregisterSection]);
|
||||
const utils = trpc.useUtils();
|
||||
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||
const { data: clientsList } = trpc.clients.list.useQuery();
|
||||
const { data: notesList } = trpc.notes.list.useQuery({ projectId });
|
||||
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
|
||||
const { data: checkpointsList } = trpc.checkpoints.list.useQuery({ projectId });
|
||||
|
||||
// Build breadcrumb path: Client > Sub-Client
|
||||
const breadcrumbPath = useMemo(() => {
|
||||
if (!project?.clientId || !clientsList) return [];
|
||||
|
||||
const clientMap = new Map(clientsList.map((c) => [c.id, c]));
|
||||
const client = clientMap.get(project.clientId);
|
||||
if (!client) return [];
|
||||
|
||||
// If client has a parent, show parent > client
|
||||
if (client.parentId) {
|
||||
const parent = clientMap.get(client.parentId);
|
||||
if (parent) return [parent.name, client.name];
|
||||
}
|
||||
return [client.name];
|
||||
}, [project?.clientId, clientsList]);
|
||||
|
||||
// Compute stats
|
||||
const notesCount = notesList?.length ?? 0;
|
||||
|
||||
const taskStats = useMemo(() => {
|
||||
const all = tasksList ?? [];
|
||||
const done = all.filter((t) => t.status === 'done').length;
|
||||
return { done, total: all.length };
|
||||
}, [tasksList]);
|
||||
|
||||
const checkpointStats = useMemo(() => {
|
||||
const all = checkpointsList ?? [];
|
||||
const approved = all.filter((c) => c.isApproved === 1).length;
|
||||
return { approved, total: all.length };
|
||||
}, [checkpointsList]);
|
||||
|
||||
const pendingCheckpoints = useMemo(() =>
|
||||
(checkpointsList ?? []).filter((c) => c.isAiSuggested === 1 && c.isApproved === 0),
|
||||
[checkpointsList],
|
||||
);
|
||||
|
||||
const pendingTasks = useMemo(() =>
|
||||
(tasksList ?? []).filter((t) => t.isAiSuggested === 1 && t.isApproved === 0),
|
||||
[tasksList],
|
||||
);
|
||||
|
||||
// Map checkpoints to GanttChart format
|
||||
const ganttCheckpoints: GanttCheckpoint[] = useMemo(() => {
|
||||
return (checkpointsList ?? []).map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
date: c.date,
|
||||
projectId,
|
||||
isAiSuggested: c.isAiSuggested,
|
||||
isApproved: c.isApproved,
|
||||
}));
|
||||
}, [checkpointsList, projectId]);
|
||||
|
||||
const { ganttStart, ganttEnd } = useMemo(() => {
|
||||
const now = new Date();
|
||||
if (ganttCheckpoints.length === 0) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 3, 0);
|
||||
return { ganttStart: start, ganttEnd: end };
|
||||
}
|
||||
const dates = ganttCheckpoints.map((c) => c.date);
|
||||
const minDate = new Date(Math.min(...dates));
|
||||
const maxDate = new Date(Math.max(...dates));
|
||||
const start = new Date(minDate.getFullYear(), minDate.getMonth() - 1, 1);
|
||||
const end = new Date(maxDate.getFullYear(), maxDate.getMonth() + 2, 0);
|
||||
return { ganttStart: start, ganttEnd: end };
|
||||
}, [ganttCheckpoints]);
|
||||
|
||||
const deleteCheckpoint = trpc.checkpoints.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.checkpoints.list.invalidate({ projectId });
|
||||
},
|
||||
});
|
||||
|
||||
const updateCheckpoint = trpc.checkpoints.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.checkpoints.list.invalidate({ projectId });
|
||||
},
|
||||
});
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.list.invalidate({ projectId });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTask = trpc.tasks.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.list.invalidate({ projectId });
|
||||
},
|
||||
});
|
||||
|
||||
const suggestCheckpoints = trpc.ai.chat.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.checkpoints.list.invalidate({ projectId });
|
||||
},
|
||||
});
|
||||
|
||||
const suggestTasks = trpc.ai.chat.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.list.invalidate({ projectId });
|
||||
},
|
||||
});
|
||||
|
||||
const createNote = trpc.notes.create.useMutation({
|
||||
onSuccess: (data) => {
|
||||
void utils.notes.list.invalidate({ projectId });
|
||||
void navigate({ to: '/notes/$noteId', params: { noteId: data.id } });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
Loading project...
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-56" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Skeleton className="h-20 rounded-lg" />
|
||||
<Skeleton className="h-20 rounded-lg" />
|
||||
<Skeleton className="h-20 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-16 rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,11 +192,262 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Project detail view will be implemented in US-013.
|
||||
</p>
|
||||
<div className="p-6 flex flex-col gap-6">
|
||||
{/* Breadcrumb + Project Name */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{breadcrumbPath.length > 0 && (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbPath.map((segment, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem>
|
||||
<span className="text-muted-foreground">{segment}</span>
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
|
||||
</div>
|
||||
|
||||
{/* Project Summary Section */}
|
||||
<div ref={summaryRef} data-ai-section="project-summary" className="flex flex-col gap-6">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<FileText />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{notesCount}</ItemTitle>
|
||||
<ItemDescription>Notes</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<CheckCircle2 />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
|
||||
<ItemDescription>Tasks Complete</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<Milestone />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle>
|
||||
<ItemDescription>Checkpoints</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
{/* AI Project Summary */}
|
||||
<Item variant="outline">
|
||||
<ItemMedia variant="icon">
|
||||
<Sparkles />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>AI Project Summary</ItemTitle>
|
||||
<ItemDescription>
|
||||
{project.aiSummary || 'AI summary will appear here'}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
{/* Project Timeline */}
|
||||
<div ref={timelineRef} data-ai-section="project-timeline" className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Project Timeline</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={suggestCheckpoints.isPending}
|
||||
onClick={() =>
|
||||
suggestCheckpoints.mutate({
|
||||
message: 'Suggest checkpoints for this project based on the notes.',
|
||||
context: { type: 'project', projectId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-1" />
|
||||
{suggestCheckpoints.isPending ? 'Suggesting…' : 'Suggest checkpoints'}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setAddCheckpointOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<GanttChart
|
||||
checkpoints={ganttCheckpoints}
|
||||
startDate={ganttStart}
|
||||
endDate={ganttEnd}
|
||||
onDelete={(id) => deleteCheckpoint.mutate({ id })}
|
||||
onEdit={(cp) => setEditingCheckpoint(cp)}
|
||||
onToggleApproval={(id, current) =>
|
||||
updateCheckpoint.mutate({ id, isApproved: current === 1 ? 0 : 1 })
|
||||
}
|
||||
/>
|
||||
{pendingCheckpoints.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{pendingCheckpoints.map((cp) => (
|
||||
<Card key={cp.id} className="border-dashed py-3">
|
||||
<CardContent className="flex items-center justify-between px-4 py-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{cp.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(cp.date), 'PPP')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => updateCheckpoint.mutate({ id: cp.id, isApproved: 1 })}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteCheckpoint.mutate({ id: cp.id })}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<AddCheckpointDialog
|
||||
open={addCheckpointOpen}
|
||||
onOpenChange={setAddCheckpointOpen}
|
||||
defaultProjectId={projectId}
|
||||
/>
|
||||
<EditCheckpointDialog
|
||||
checkpoint={editingCheckpoint}
|
||||
onOpenChange={(open) => { if (!open) setEditingCheckpoint(null); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tasks Kanban */}
|
||||
<div ref={tasksRef} data-ai-section="project-tasks" className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Tasks</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={suggestTasks.isPending}
|
||||
onClick={() =>
|
||||
suggestTasks.mutate({
|
||||
message: 'Suggest tasks for this project based on the notes.',
|
||||
context: { type: 'project', projectId },
|
||||
})
|
||||
}
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-1" />
|
||||
{suggestTasks.isPending ? 'Suggesting…' : 'Suggest tasks'}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setNewTaskOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{pendingTasks.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{pendingTasks.map((t) => (
|
||||
<Card key={t.id} className="border-dashed py-3">
|
||||
<CardContent className="flex items-center justify-between px-4 py-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{t.title}</span>
|
||||
{t.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{t.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => updateTask.mutate({ id: t.id, isApproved: 1 })}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteTask.mutate({ id: t.id })}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<KanbanBoard
|
||||
projectId={projectId}
|
||||
newTaskOpen={newTaskOpen}
|
||||
onNewTaskOpenChange={setNewTaskOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div ref={notesRef} data-ai-section="project-notes" className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Notes</h2>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={createNote.isPending}
|
||||
onClick={() =>
|
||||
createNote.mutate({ title: 'Untitled Note', content: '', projectId })
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{notesList && notesList.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-5">
|
||||
{notesList.map((note) => (
|
||||
<Item
|
||||
key={note.id}
|
||||
variant="muted"
|
||||
className="min-w-[280px] flex-1 cursor-pointer"
|
||||
onClick={() =>
|
||||
void navigate({ to: '/notes/$noteId', params: { noteId: note.id } })
|
||||
}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<FileText />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{note.title}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{format(new Date(note.createdAt), 'PPP')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No notes yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
384
src/renderer/components/tasks/EditTaskDialog.tsx
Normal file
384
src/renderer/components/tasks/EditTaskDialog.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { TZDate } from 'react-day-picker';
|
||||
import { Calendar as CalendarIcon, X, UserPlus, Check } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TaskItem } from './TaskRow';
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
|
||||
|
||||
function parseAssigneesLocal(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) return parsed.filter((n): n is string => typeof n === 'string');
|
||||
} catch { /* plain string fallback */ }
|
||||
return [raw];
|
||||
}
|
||||
|
||||
interface EditTaskDialogProps {
|
||||
task: TaskItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [priority, setPriority] = useState('medium');
|
||||
const [status, setStatus] = useState('todo');
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const [dueDate, setDueDate] = useState<TZDate | undefined>();
|
||||
const [dueHour, setDueHour] = useState('');
|
||||
const [dueMinute, setDueMinute] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [assignees, setAssignees] = useState<string[]>([]);
|
||||
const [assigneeInput, setAssigneeInput] = useState('');
|
||||
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false);
|
||||
|
||||
// Pre-fill fields whenever the task changes
|
||||
useEffect(() => {
|
||||
if (!task) return;
|
||||
setTitle(task.title);
|
||||
setDescription(task.description ?? '');
|
||||
setPriority(task.priority ?? 'medium');
|
||||
setStatus(task.status ?? 'todo');
|
||||
if (task.dueDate) {
|
||||
const d = new TZDate(task.dueDate, timezone);
|
||||
setDueDate(d);
|
||||
setDueHour(String(d.getHours()).padStart(2, '0'));
|
||||
setDueMinute(String(d.getMinutes()).padStart(2, '0'));
|
||||
} else {
|
||||
setDueDate(undefined);
|
||||
setDueHour('');
|
||||
setDueMinute('');
|
||||
}
|
||||
setProjectId(task.projectId ?? '');
|
||||
setAssignees(parseAssigneesLocal(task.assignee));
|
||||
setAssigneeInput('');
|
||||
setAssigneePopoverOpen(false);
|
||||
}, [task]);
|
||||
|
||||
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
||||
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.list.invalidate();
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
function addNewAssignee() {
|
||||
const name = assigneeInput.trim();
|
||||
if (!name || assignees.includes(name)) return;
|
||||
setAssignees((prev) => [...prev, name]);
|
||||
setAssigneeInput('');
|
||||
}
|
||||
|
||||
function toggleAssignee(name: string) {
|
||||
setAssignees((prev) =>
|
||||
prev.includes(name) ? prev.filter((a) => a !== name) : [...prev, name],
|
||||
);
|
||||
}
|
||||
|
||||
function removeAssignee(name: string) {
|
||||
setAssignees((prev) => prev.filter((a) => a !== name));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!task || !title.trim()) return;
|
||||
|
||||
let resolvedDueDate: number | undefined;
|
||||
if (dueDate) {
|
||||
const h = dueHour !== '' ? parseInt(dueHour, 10) : 0;
|
||||
const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0;
|
||||
const tzDate = new TZDate(
|
||||
dueDate.getFullYear(),
|
||||
dueDate.getMonth(),
|
||||
dueDate.getDate(),
|
||||
h, m, 0, 0,
|
||||
timezone,
|
||||
);
|
||||
resolvedDueDate = tzDate.getTime();
|
||||
}
|
||||
|
||||
updateTask.mutate({
|
||||
id: task.id,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
priority,
|
||||
status,
|
||||
dueDate: resolvedDueDate,
|
||||
projectId: projectId || undefined,
|
||||
assignees: assignees.length ? assignees : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[560px]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{/* Title */}
|
||||
<Input
|
||||
placeholder="Task title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<Textarea
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="min-h-20"
|
||||
/>
|
||||
|
||||
{/* Priority */}
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status */}
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Due Date + Time */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal',
|
||||
!dueDate && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dueDate
|
||||
? `${format(dueDate, 'PPP')}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}`
|
||||
: 'Pick a due date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={(d) => setDueDate(d as TZDate | undefined)}
|
||||
timeZone={timezone}
|
||||
/>
|
||||
<div className="border-t px-3 py-2 flex flex-col gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select value={dueHour} onValueChange={setDueHour}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="HH" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOURS.map((h) => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-sm">:</span>
|
||||
<Select value={dueMinute} onValueChange={setDueMinute}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="MM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(dueHour !== '' || dueMinute !== '') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => { setDueHour(''); setDueMinute(''); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{dueDate && dueHour !== '' && dueMinute !== '' && (
|
||||
<p className="text-xs text-muted-foreground pl-1">
|
||||
Due: {format(dueDate, 'PPP')} at {dueHour}:{dueMinute}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
<Select
|
||||
value={projectId || 'none'}
|
||||
onValueChange={(v) => setProjectId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Project (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No project</SelectItem>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Assignees */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{assignees.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{assignees.map((name) => (
|
||||
<Badge key={name} variant="secondary" className="gap-1 pr-1">
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAssignee(name)}
|
||||
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start font-normal',
|
||||
assignees.length === 0 && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{assignees.length > 0
|
||||
? `${assignees.length} assignee${assignees.length > 1 ? 's' : ''}`
|
||||
: 'Add assignees'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
{knownAssignees.length > 0 && (
|
||||
<ScrollArea className="max-h-36 mb-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{knownAssignees.map((name) => (
|
||||
<Button
|
||||
key={name}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start h-8 px-2"
|
||||
onClick={() => toggleAssignee(name)}
|
||||
>
|
||||
{assignees.includes(name) ? (
|
||||
<Check className="h-3 w-3 mr-2 text-primary shrink-0" />
|
||||
) : (
|
||||
<span className="w-5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{knownAssignees.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
|
||||
)}
|
||||
<Separator className="mb-2" />
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="New name…"
|
||||
value={assigneeInput}
|
||||
onChange={(e) => setAssigneeInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewAssignee();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={addNewAssignee}
|
||||
disabled={!assigneeInput.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!title.trim() || updateTask.isPending}>
|
||||
{updateTask.isPending ? 'Saving…' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
622
src/renderer/components/tasks/NewTaskDialog.tsx
Normal file
622
src/renderer/components/tasks/NewTaskDialog.tsx
Normal file
@@ -0,0 +1,622 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { TZDate } from 'react-day-picker';
|
||||
import { Calendar as CalendarIcon, X, UserPlus, Check, Plus } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
|
||||
|
||||
const NO_CLIENT = '__no_client__';
|
||||
|
||||
interface NewTaskDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultProjectId?: string;
|
||||
defaultStatus?: string;
|
||||
}
|
||||
|
||||
export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultStatus }: NewTaskDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [priority, setPriority] = useState('medium');
|
||||
const [status, setStatus] = useState(defaultStatus ?? 'todo');
|
||||
const [dueDate, setDueDate] = useState<TZDate | undefined>();
|
||||
const [dueHour, setDueHour] = useState('');
|
||||
const [dueMinute, setDueMinute] = useState('');
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||
|
||||
// Multi-assignee state
|
||||
const [assignees, setAssignees] = useState<string[]>([]);
|
||||
const [assigneeInput, setAssigneeInput] = useState('');
|
||||
const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false);
|
||||
|
||||
// Inline project creation state
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [newProjectClientId, setNewProjectClientId] = useState(NO_CLIENT);
|
||||
const [newProjectSubClientId, setNewProjectSubClientId] = useState(NO_CLIENT);
|
||||
const [creatingClient, setCreatingClient] = useState(false);
|
||||
const [newClientName, setNewClientName] = useState('');
|
||||
const [creatingSubClient, setCreatingSubClient] = useState(false);
|
||||
const [newSubClientName, setNewSubClientName] = useState('');
|
||||
|
||||
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
||||
const { data: clientList = [] } = trpc.clients.list.useQuery();
|
||||
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const topLevelClients = useMemo(() => clientList.filter((c) => !c.parentId), [clientList]);
|
||||
const subClientsByParent = useMemo(() => {
|
||||
const m = new Map<string, typeof clientList>();
|
||||
for (const c of clientList) {
|
||||
if (c.parentId) {
|
||||
const arr = m.get(c.parentId) ?? [];
|
||||
arr.push(c);
|
||||
m.set(c.parentId, arr);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [clientList]);
|
||||
|
||||
const createClientMutation = trpc.clients.create.useMutation({
|
||||
onSuccess: () => void utils.clients.list.invalidate(),
|
||||
});
|
||||
const createProjectMutation = trpc.projects.create.useMutation({
|
||||
onSuccess: () => void utils.projects.listAll.invalidate(),
|
||||
});
|
||||
const createTask = trpc.tasks.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.list.invalidate();
|
||||
resetAndClose();
|
||||
},
|
||||
});
|
||||
|
||||
function resetAndClose() {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setPriority('medium');
|
||||
setStatus(defaultStatus ?? 'todo');
|
||||
setDueDate(undefined);
|
||||
setDueHour('');
|
||||
setDueMinute('');
|
||||
setProjectId(defaultProjectId ?? '');
|
||||
setAssignees([]);
|
||||
setAssigneeInput('');
|
||||
setAssigneePopoverOpen(false);
|
||||
resetProjectCreation();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function resetProjectCreation() {
|
||||
setCreatingProject(false);
|
||||
setNewProjectName('');
|
||||
setNewProjectClientId(NO_CLIENT);
|
||||
setNewProjectSubClientId(NO_CLIENT);
|
||||
setCreatingClient(false);
|
||||
setNewClientName('');
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}
|
||||
|
||||
function addNewAssignee() {
|
||||
const name = assigneeInput.trim();
|
||||
if (!name || assignees.includes(name)) return;
|
||||
setAssignees((prev) => [...prev, name]);
|
||||
setAssigneeInput('');
|
||||
}
|
||||
|
||||
function toggleAssignee(name: string) {
|
||||
setAssignees((prev) =>
|
||||
prev.includes(name) ? prev.filter((a) => a !== name) : [...prev, name],
|
||||
);
|
||||
}
|
||||
|
||||
function removeAssignee(name: string) {
|
||||
setAssignees((prev) => prev.filter((a) => a !== name));
|
||||
}
|
||||
|
||||
async function handleCreateInlineProject(): Promise<string | undefined> {
|
||||
let resolvedClientId: string | undefined;
|
||||
|
||||
if (creatingClient && newClientName.trim()) {
|
||||
const r = await createClientMutation.mutateAsync({ name: newClientName.trim() });
|
||||
resolvedClientId = r.id;
|
||||
if (creatingSubClient && newSubClientName.trim()) {
|
||||
const sr = await createClientMutation.mutateAsync({
|
||||
name: newSubClientName.trim(),
|
||||
parentId: resolvedClientId,
|
||||
});
|
||||
resolvedClientId = sr.id;
|
||||
}
|
||||
} else if (newProjectClientId !== NO_CLIENT) {
|
||||
if (creatingSubClient && newSubClientName.trim()) {
|
||||
const sr = await createClientMutation.mutateAsync({
|
||||
name: newSubClientName.trim(),
|
||||
parentId: newProjectClientId,
|
||||
});
|
||||
resolvedClientId = sr.id;
|
||||
} else if (newProjectSubClientId !== NO_CLIENT) {
|
||||
resolvedClientId = newProjectSubClientId;
|
||||
} else {
|
||||
resolvedClientId = newProjectClientId;
|
||||
}
|
||||
}
|
||||
|
||||
const r = await createProjectMutation.mutateAsync({
|
||||
name: newProjectName.trim(),
|
||||
clientId: resolvedClientId,
|
||||
});
|
||||
return r.id;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
|
||||
// Resolve dueDate + optional time in the selected timezone
|
||||
let resolvedDueDate: number | undefined;
|
||||
if (dueDate) {
|
||||
const h = dueHour !== '' ? parseInt(dueHour, 10) : 0;
|
||||
const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0;
|
||||
const tzDate = new TZDate(
|
||||
dueDate.getFullYear(),
|
||||
dueDate.getMonth(),
|
||||
dueDate.getDate(),
|
||||
h, m, 0, 0,
|
||||
timezone,
|
||||
);
|
||||
resolvedDueDate = tzDate.getTime();
|
||||
}
|
||||
|
||||
// If creating a new project inline, do that first
|
||||
let resolvedProjectId = projectId || undefined;
|
||||
if (creatingProject && newProjectName.trim()) {
|
||||
resolvedProjectId = await handleCreateInlineProject();
|
||||
}
|
||||
|
||||
createTask.mutate({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
priority,
|
||||
status,
|
||||
dueDate: resolvedDueDate,
|
||||
projectId: resolvedProjectId,
|
||||
assignees: assignees.length ? assignees : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const isSubmitting =
|
||||
createTask.isPending ||
|
||||
createClientMutation.isPending ||
|
||||
createProjectMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[560px]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{/* Title */}
|
||||
<Input
|
||||
placeholder="Task title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<Textarea
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="min-h-20"
|
||||
/>
|
||||
|
||||
{/* Priority */}
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status */}
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todo">To Do</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="done">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Due Date + Time */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal',
|
||||
!dueDate && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dueDate
|
||||
? `${format(dueDate, 'PPP')}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}`
|
||||
: 'Pick a due date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dueDate}
|
||||
onSelect={(d) => setDueDate(d as TZDate | undefined)}
|
||||
timeZone={timezone}
|
||||
/>
|
||||
<div className="border-t px-3 py-2 flex flex-col gap-2">
|
||||
{/* Time row */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select value={dueHour} onValueChange={setDueHour}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="HH" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOURS.map((h) => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-sm">:</span>
|
||||
<Select value={dueMinute} onValueChange={setDueMinute}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="MM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(dueHour !== '' || dueMinute !== '') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => { setDueHour(''); setDueMinute(''); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{dueDate && dueHour !== '' && dueMinute !== '' && (
|
||||
<p className="text-xs text-muted-foreground pl-1">
|
||||
Due: {format(dueDate, 'PPP')} at {dueHour}:{dueMinute}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
{!creatingProject ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={projectId || 'none'}
|
||||
onValueChange={(v) => setProjectId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Project (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No project</SelectItem>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreatingProject(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">New Project</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={resetProjectCreation}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Project name */}
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Client selection */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Client <span className="opacity-60">(optional)</span>
|
||||
</label>
|
||||
{creatingClient ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="New client name"
|
||||
value={newClientName}
|
||||
onChange={(e) => setNewClientName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreatingClient(false);
|
||||
setNewClientName('');
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={newProjectClientId}
|
||||
onValueChange={(v) => {
|
||||
setNewProjectClientId(v);
|
||||
setNewProjectSubClientId(NO_CLIENT);
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_CLIENT}>None (Internal)</SelectItem>
|
||||
{topLevelClients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreatingClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sub-client selection — only when a client is selected or being created */}
|
||||
{(newProjectClientId !== NO_CLIENT || (creatingClient && newClientName.trim())) && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Sub-client <span className="opacity-60">(optional)</span>
|
||||
</label>
|
||||
{creatingSubClient ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="New sub-client name"
|
||||
value={newSubClientName}
|
||||
onChange={(e) => setNewSubClientName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreatingSubClient(false);
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : creatingClient ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => setCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New Sub-client
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={newProjectSubClientId}
|
||||
onValueChange={setNewProjectSubClientId}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a sub-client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_CLIENT}>None</SelectItem>
|
||||
{(subClientsByParent.get(newProjectClientId) ?? []).map((sc) => (
|
||||
<SelectItem key={sc.id} value={sc.id}>{sc.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignees */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Selected assignee badges */}
|
||||
{assignees.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{assignees.map((name) => (
|
||||
<Badge key={name} variant="secondary" className="gap-1 pr-1">
|
||||
{name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAssignee(name)}
|
||||
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignee picker popover */}
|
||||
<Popover open={assigneePopoverOpen} onOpenChange={setAssigneePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start font-normal',
|
||||
assignees.length === 0 && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{assignees.length > 0
|
||||
? `${assignees.length} assignee${assignees.length > 1 ? 's' : ''}`
|
||||
: 'Add assignees'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
{/* Known assignees list */}
|
||||
{knownAssignees.length > 0 && (
|
||||
<ScrollArea className="max-h-36 mb-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{knownAssignees.map((name) => (
|
||||
<Button
|
||||
key={name}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start h-8 px-2"
|
||||
onClick={() => toggleAssignee(name)}
|
||||
>
|
||||
{assignees.includes(name) ? (
|
||||
<Check className="h-3 w-3 mr-2 text-primary shrink-0" />
|
||||
) : (
|
||||
<span className="w-5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
{knownAssignees.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1 mb-2">No existing assignees</p>
|
||||
)}
|
||||
<Separator className="mb-2" />
|
||||
{/* Add new assignee */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="New name…"
|
||||
value={assigneeInput}
|
||||
onChange={(e) => setAssigneeInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewAssignee();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={addNewAssignee}
|
||||
disabled={!assigneeInput.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={resetAndClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!title.trim() || isSubmitting}>
|
||||
{isSubmitting ? 'Creating…' : 'Create Task'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
29
src/renderer/components/tasks/PriorityBadge.tsx
Normal file
29
src/renderer/components/tasks/PriorityBadge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ArrowUp, ArrowRight, ArrowDown } from 'lucide-react';
|
||||
|
||||
export function PriorityBadge({ priority }: { priority: string | null }) {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
High
|
||||
</span>
|
||||
);
|
||||
case 'medium':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
Medium
|
||||
</span>
|
||||
);
|
||||
case 'low':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
Low
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
281
src/renderer/components/tasks/TaskDetailDialog.tsx
Normal file
281
src/renderer/components/tasks/TaskDetailDialog.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
User,
|
||||
CircleDot,
|
||||
FolderOpen,
|
||||
Zap,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { PriorityBadge } from './PriorityBadge';
|
||||
import { parseAssignees, type TaskItem } from './TaskRow';
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
const d = new Date(timestamp);
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const date = `${months[d.getMonth()]} ${String(d.getDate()).padStart(2, '0')}, ${d.getFullYear()}`;
|
||||
if (d.getHours() === 0 && d.getMinutes() === 0) return date;
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${date} ${h}:${m}`;
|
||||
}
|
||||
|
||||
function relativeTime(timestamp: number): string {
|
||||
const diff = Date.now() - timestamp;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes} min ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hr ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
||||
todo: { label: 'To Do', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' },
|
||||
in_progress: { label: 'In Progress', className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
|
||||
done: { label: 'Done', className: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' },
|
||||
};
|
||||
|
||||
function AuthorAvatar({ name }: { name: string }) {
|
||||
const initials = name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
return (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskDetailDialogProps {
|
||||
task: TaskItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onEdit: (task: TaskItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TaskDetailDialog({ task, open, onOpenChange, onEdit, onDelete }: TaskDetailDialogProps) {
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('description');
|
||||
|
||||
const { data: comments } = trpc.taskComments.list.useQuery(
|
||||
{ taskId: task?.id ?? '' },
|
||||
{ enabled: !!task },
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const addComment = trpc.taskComments.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.taskComments.list.invalidate({ taskId: task?.id ?? '' });
|
||||
setCommentText('');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteComment = trpc.taskComments.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.taskComments.list.invalidate({ taskId: task?.id ?? '' });
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
const assignees = parseAssignees(task.assignee);
|
||||
const statusConf = STATUS_CONFIG[task.status ?? 'todo'] ?? { label: 'To Do', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' };
|
||||
const breadcrumb = [task.clientName, task.subClientName, task.projectName].filter(Boolean);
|
||||
|
||||
const handleAddComment = () => {
|
||||
const text = commentText.trim();
|
||||
if (!text) return;
|
||||
addComment.mutate({ taskId: task.id, author: 'Me', content: text });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[620px] gap-0 p-0" aria-describedby={undefined}>
|
||||
{/* Header */}
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle className="text-lg font-semibold leading-tight">{task.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Field rows */}
|
||||
<div className="grid grid-cols-[120px_1fr] gap-y-3 px-6 py-4 text-sm">
|
||||
{/* Assignee */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
Assignee
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{assignees.length > 0 ? (
|
||||
assignees.map((name) => (
|
||||
<Badge key={name} variant="secondary" className="text-xs">
|
||||
{name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CircleDot className="h-4 w-4" />
|
||||
Status
|
||||
</div>
|
||||
<div>
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusConf.className}`}>
|
||||
{statusConf.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Due date */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Due date
|
||||
</div>
|
||||
<div>
|
||||
{task.dueDate ? formatDate(task.dueDate) : <span className="text-muted-foreground">No due date</span>}
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Zap className="h-4 w-4" />
|
||||
Priority
|
||||
</div>
|
||||
<div>
|
||||
<PriorityBadge priority={task.priority} />
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
{breadcrumb.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Project
|
||||
</div>
|
||||
<div className="text-sm">{breadcrumb.join(' > ')}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tabs: Description / Comment */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col">
|
||||
<TabsList className="mx-6 mt-3 w-fit">
|
||||
<TabsTrigger value="description">Description</TabsTrigger>
|
||||
<TabsTrigger value="comment">Comment</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="description" className="px-6 py-4 min-h-[120px]">
|
||||
{task.description ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{task.description}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No description provided.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comment" className="px-6 py-4 min-h-[120px] flex flex-col gap-4">
|
||||
{/* Comment list */}
|
||||
<ScrollArea className="max-h-[260px]">
|
||||
<div className="flex flex-col gap-4">
|
||||
{(!comments || comments.length === 0) ? (
|
||||
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
|
||||
) : (
|
||||
comments.map((c) => (
|
||||
<div key={c.id} className="flex gap-3">
|
||||
<AuthorAvatar name={c.author} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium">{c.author}</span>
|
||||
<span className="text-xs text-muted-foreground">{relativeTime(c.createdAt)}</span>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted px-3 py-2 text-sm">
|
||||
{c.content}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteComment.mutate({ id: c.id })}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add comment input */}
|
||||
<form
|
||||
className="flex items-center gap-2 mt-auto"
|
||||
onSubmit={(e) => { e.preventDefault(); handleAddComment(); }}
|
||||
>
|
||||
<AuthorAvatar name="Me" />
|
||||
<Input
|
||||
placeholder="Add a comment..."
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={!commentText.trim() || addComment.isPending}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="px-6 py-4">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => { onDelete(task.id); onOpenChange(false); }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => { onEdit(task); onOpenChange(false); }}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
182
src/renderer/components/tasks/TaskRow.tsx
Normal file
182
src/renderer/components/tasks/TaskRow.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Fragment } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { PriorityBadge } from './PriorityBadge';
|
||||
|
||||
export type TaskItem = {
|
||||
id: string;
|
||||
projectId: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string | null;
|
||||
priority: string | null;
|
||||
assignee: string | null;
|
||||
dueDate: number | null;
|
||||
isAiSuggested: number;
|
||||
isApproved: number;
|
||||
projectName: string | null;
|
||||
clientName: string | null;
|
||||
subClientName: string | null;
|
||||
};
|
||||
|
||||
export function parseAssignees(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) return parsed.filter((n): n is string => typeof n === 'string');
|
||||
} catch { /* plain string fallback */ }
|
||||
return [raw];
|
||||
}
|
||||
|
||||
function formatDueDate(timestamp: number): string {
|
||||
const d = new Date(timestamp);
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const date = `Due ${months[d.getMonth()]} ${d.getDate()}`;
|
||||
if (d.getHours() === 0 && d.getMinutes() === 0) return date;
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${date}, ${h}:${m}`;
|
||||
}
|
||||
|
||||
export function TaskRow({
|
||||
task,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onClick,
|
||||
hideBreadcrumb,
|
||||
layoutId,
|
||||
}: {
|
||||
task: TaskItem;
|
||||
onToggle: (id: string, status: string | null) => void;
|
||||
onEdit?: (task: TaskItem) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onClick?: (task: TaskItem) => void;
|
||||
hideBreadcrumb?: boolean;
|
||||
layoutId?: string;
|
||||
}) {
|
||||
const isDone = task.status === 'done';
|
||||
|
||||
const checkboxState: boolean | 'indeterminate' =
|
||||
task.status === 'done' ? true :
|
||||
task.status === 'in_progress' ? 'indeterminate' : false;
|
||||
|
||||
const breadcrumb: string[] = [];
|
||||
if (!hideBreadcrumb) {
|
||||
if (task.clientName) breadcrumb.push(task.clientName);
|
||||
if (task.subClientName) breadcrumb.push(task.subClientName);
|
||||
if (task.projectName) breadcrumb.push(task.projectName);
|
||||
}
|
||||
|
||||
const hasMetadata =
|
||||
task.priority ||
|
||||
task.dueDate ||
|
||||
breadcrumb.length > 0 ||
|
||||
task.assignee;
|
||||
|
||||
const Wrapper = layoutId ? motion.div : 'div';
|
||||
const wrapperProps = layoutId ? { layoutId, layout: true as const } : {};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Wrapper
|
||||
{...wrapperProps}
|
||||
className={cn(
|
||||
'flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors',
|
||||
isDone
|
||||
? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900'
|
||||
: 'bg-card border-border',
|
||||
onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default',
|
||||
)}
|
||||
onClick={() => onClick?.(task)}
|
||||
>
|
||||
{/* Row 1: checkbox + title + description */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={checkboxState}
|
||||
onCheckedChange={() => onToggle(task.id, task.status)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-sm font-medium', isDone && 'line-through text-muted-foreground')}>
|
||||
{task.title}
|
||||
</div>
|
||||
{task.description && (
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: metadata, indented to align with title text */}
|
||||
{hasMetadata && (
|
||||
<div className="flex flex-wrap items-center gap-2 pl-7">
|
||||
<PriorityBadge priority={task.priority} />
|
||||
|
||||
{task.dueDate && (
|
||||
<Badge variant="outline" className="text-xs gap-1 shrink-0">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDueDate(task.dueDate)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{breadcrumb.length > 0 && (
|
||||
<Breadcrumb className="shrink-0">
|
||||
<BreadcrumbList>
|
||||
{breadcrumb.map((part, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem>
|
||||
<span className="text-xs">{part}</span>
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)}
|
||||
|
||||
{task.assignee && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||
<User className="h-3 w-3" />
|
||||
{parseAssignees(task.assignee).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Wrapper>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => onEdit?.(task)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit Task
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onDelete?.(task.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Task
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
72
src/renderer/components/theme-provider.tsx
Normal file
72
src/renderer/components/theme-provider.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "adiuva-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
|
||||
return context
|
||||
}
|
||||
162
src/renderer/components/timeline/AddCheckpointDialog.tsx
Normal file
162
src/renderer/components/timeline/AddCheckpointDialog.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Calendar as CalendarIcon, Check } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AddCheckpointDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultProjectId?: string;
|
||||
}
|
||||
|
||||
interface AddedEntry {
|
||||
title: string;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
export function AddCheckpointDialog({ open, onOpenChange, defaultProjectId }: AddCheckpointDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||
const [added, setAdded] = useState<AddedEntry[]>([]);
|
||||
|
||||
const showProjectSelect = !defaultProjectId;
|
||||
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
|
||||
enabled: showProjectSelect,
|
||||
});
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createCheckpoint = trpc.checkpoints.create.useMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
void utils.checkpoints.list.invalidate();
|
||||
setAdded((prev) => [...prev, { title: variables.title, date: new Date(variables.date) }]);
|
||||
setTitle('');
|
||||
setDate(undefined);
|
||||
},
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
setTitle('');
|
||||
setDate(undefined);
|
||||
setProjectId(defaultProjectId ?? '');
|
||||
setAdded([]);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const pid = defaultProjectId || projectId;
|
||||
if (!title.trim() || !date || !pid) return;
|
||||
|
||||
createCheckpoint.mutate({
|
||||
title: title.trim(),
|
||||
date: date.getTime(),
|
||||
projectId: pid,
|
||||
});
|
||||
}
|
||||
|
||||
const canSubmit = title.trim() && date && (defaultProjectId || projectId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Checkpoints</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Just-added list */}
|
||||
{added.length > 0 && (
|
||||
<ScrollArea className="max-h-32">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{added.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
|
||||
<span className="truncate">{entry.title}</span>
|
||||
<span className="ml-auto text-xs shrink-0">{format(entry.date, 'MMM d')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
placeholder="Checkpoint title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal',
|
||||
!date && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, 'PPP') : 'Pick a date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
numberOfMonths={2}
|
||||
onSelect={setDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{showProjectSelect && (
|
||||
<Select value={projectId} onValueChange={setProjectId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select project (required)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
{added.length > 0 ? 'Done' : 'Cancel'}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit || createCheckpoint.isPending}>
|
||||
{added.length > 0 ? 'Add Another' : 'Add'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
107
src/renderer/components/timeline/EditCheckpointDialog.tsx
Normal file
107
src/renderer/components/timeline/EditCheckpointDialog.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Calendar as CalendarIcon } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GanttCheckpoint } from './GanttChart';
|
||||
|
||||
interface EditCheckpointDialogProps {
|
||||
checkpoint: GanttCheckpoint | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditCheckpointDialog({ checkpoint, onOpenChange }: EditCheckpointDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
useEffect(() => {
|
||||
if (checkpoint) {
|
||||
setTitle(checkpoint.title);
|
||||
setDate(new Date(checkpoint.date));
|
||||
}
|
||||
}, [checkpoint]);
|
||||
|
||||
const updateCheckpoint = trpc.checkpoints.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.checkpoints.list.invalidate();
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!checkpoint || !title.trim() || !date) return;
|
||||
|
||||
updateCheckpoint.mutate({
|
||||
id: checkpoint.id,
|
||||
title: title.trim(),
|
||||
date: date.getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
const canSubmit = title.trim() && date;
|
||||
|
||||
return (
|
||||
<Dialog open={!!checkpoint} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Checkpoint</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
placeholder="Checkpoint title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal',
|
||||
!date && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, 'PPP') : 'Pick a date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit || updateCheckpoint.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
302
src/renderer/components/timeline/GanttChart.tsx
Normal file
302
src/renderer/components/timeline/GanttChart.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
|
||||
export interface GanttCheckpoint {
|
||||
id: string;
|
||||
title: string;
|
||||
date: number; // unix timestamp ms
|
||||
projectId: string;
|
||||
projectName?: string;
|
||||
isAiSuggested: number;
|
||||
isApproved: number;
|
||||
}
|
||||
|
||||
interface GanttChartProps {
|
||||
checkpoints: GanttCheckpoint[];
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onDelete?: (id: string) => void;
|
||||
onEdit?: (checkpoint: GanttCheckpoint) => void;
|
||||
onToggleApproval?: (id: string, currentApproved: number) => void;
|
||||
}
|
||||
|
||||
const HEADER_HEIGHT = 30;
|
||||
const BASELINE_Y = 70;
|
||||
const BASELINE_HEIGHT = 8;
|
||||
const SVG_HEIGHT = 110;
|
||||
const DOT_RADIUS = 10;
|
||||
const PADDING_X = 40;
|
||||
|
||||
function getMonthsBetween(start: Date, end: Date): Date[] {
|
||||
const months: Date[] = [];
|
||||
const current = new Date(start.getFullYear(), start.getMonth(), 1);
|
||||
while (current <= end) {
|
||||
months.push(new Date(current));
|
||||
current.setMonth(current.getMonth() + 1);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
function dateToX(date: Date, start: Date, end: Date, width: number): number {
|
||||
const totalMs = end.getTime() - start.getTime();
|
||||
if (totalMs <= 0) return PADDING_X;
|
||||
const ratio = (date.getTime() - start.getTime()) / totalMs;
|
||||
return PADDING_X + ratio * (width - PADDING_X * 2);
|
||||
}
|
||||
|
||||
export function GanttChart({
|
||||
checkpoints,
|
||||
startDate,
|
||||
endDate,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onToggleApproval,
|
||||
}: GanttChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(600);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const months = getMonthsBetween(startDate, endDate);
|
||||
const todayX = dateToX(new Date(), startDate, endDate, width);
|
||||
const todayVisible = todayX >= PADDING_X && todayX <= width - PADDING_X;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full overflow-hidden">
|
||||
<svg width={width} height={SVG_HEIGHT} className="select-none overflow-visible">
|
||||
{/* Month labels */}
|
||||
{months.map((month) => {
|
||||
const x = dateToX(month, startDate, endDate, width);
|
||||
return (
|
||||
<g key={month.toISOString()}>
|
||||
<text
|
||||
x={x}
|
||||
y={HEADER_HEIGHT - 8}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground"
|
||||
fontSize={12}
|
||||
fontFamily="Geist, sans-serif"
|
||||
>
|
||||
{format(month, 'MMM yyyy')}
|
||||
</text>
|
||||
<line
|
||||
x1={x}
|
||||
y1={HEADER_HEIGHT}
|
||||
x2={x}
|
||||
y2={BASELINE_Y + 14}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Baseline — thick rounded bar */}
|
||||
<rect
|
||||
x={PADDING_X}
|
||||
y={BASELINE_Y - BASELINE_HEIGHT / 2}
|
||||
width={Math.max(0, width - PADDING_X * 2)}
|
||||
height={BASELINE_HEIGHT}
|
||||
rx={BASELINE_HEIGHT / 2}
|
||||
fill="var(--border)"
|
||||
/>
|
||||
|
||||
{/* Today marker — dashed line */}
|
||||
{todayVisible && (
|
||||
<line
|
||||
x1={todayX}
|
||||
y1={HEADER_HEIGHT}
|
||||
x2={todayX}
|
||||
y2={BASELINE_Y + 14}
|
||||
stroke="var(--destructive)"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Today marker — Badge label via foreignObject */}
|
||||
{todayVisible && (
|
||||
<foreignObject
|
||||
x={todayX - 30}
|
||||
y={BASELINE_Y + 12}
|
||||
width={60}
|
||||
height={28}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-destructive text-destructive">
|
||||
Today
|
||||
</Badge>
|
||||
</div>
|
||||
</foreignObject>
|
||||
)}
|
||||
|
||||
{/* Checkpoint dots */}
|
||||
{checkpoints.map((cp) => {
|
||||
const cx = dateToX(new Date(cp.date), startDate, endDate, width);
|
||||
return (
|
||||
<CheckpointDot
|
||||
key={cp.id}
|
||||
checkpoint={cp}
|
||||
cx={cx}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onToggleApproval={onToggleApproval}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusLabel(checkpoint: GanttCheckpoint): { text: string; className: string } {
|
||||
if (checkpoint.isApproved === 0) {
|
||||
return { text: 'Pending', className: 'bg-muted text-muted-foreground' };
|
||||
}
|
||||
if (checkpoint.date < Date.now()) {
|
||||
return { text: 'Completed', className: 'bg-chart-2/15 text-chart-2' };
|
||||
}
|
||||
return { text: 'To Do', className: 'bg-primary/10 text-primary' };
|
||||
}
|
||||
|
||||
function CheckpointDot({
|
||||
checkpoint,
|
||||
cx,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onToggleApproval,
|
||||
}: {
|
||||
checkpoint: GanttCheckpoint;
|
||||
cx: number;
|
||||
onDelete?: (id: string) => void;
|
||||
onEdit?: (checkpoint: GanttCheckpoint) => void;
|
||||
onToggleApproval?: (id: string, currentApproved: number) => void;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
|
||||
hoverTimeout.current = setTimeout(() => setHovered(true), 200);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (hoverTimeout.current) clearTimeout(hoverTimeout.current);
|
||||
hoverTimeout.current = setTimeout(() => setHovered(false), 150);
|
||||
}, []);
|
||||
|
||||
const isPending = checkpoint.isApproved === 0;
|
||||
const isPast = checkpoint.date < Date.now();
|
||||
const fill = isPending ? 'none' : (isPast ? 'var(--chart-2)' : 'var(--primary)');
|
||||
const stroke = isPending ? 'var(--muted-foreground)' : 'none';
|
||||
const strokeDasharray = isPending ? '3 2' : undefined;
|
||||
const status = getStatusLabel(checkpoint);
|
||||
|
||||
const dotSize = DOT_RADIUS * 2 + 2;
|
||||
const hitArea = dotSize + 8;
|
||||
|
||||
const dotSvg = (
|
||||
<svg width={dotSize} height={dotSize} className="shrink-0">
|
||||
<circle
|
||||
cx={DOT_RADIUS + 1}
|
||||
cy={DOT_RADIUS + 1}
|
||||
r={DOT_RADIUS}
|
||||
fill={fill}
|
||||
stroke={stroke || 'var(--primary)'}
|
||||
strokeWidth={isPending ? 1.5 : 0}
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<foreignObject
|
||||
x={cx - hitArea / 2}
|
||||
y={BASELINE_Y - hitArea / 2}
|
||||
width={hitArea}
|
||||
height={hitArea}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<ContextMenu>
|
||||
<Popover open={hovered}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex items-center justify-center w-full h-full focus:outline-none cursor-pointer"
|
||||
type="button"
|
||||
onClick={() => onToggleApproval?.(checkpoint.id, checkpoint.isApproved)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{dotSvg}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-52 p-3 pointer-events-none"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-sm leading-snug truncate">
|
||||
{checkpoint.title}
|
||||
</span>
|
||||
<Badge variant="secondary" className={`text-[10px] px-1.5 py-0 shrink-0 ${status.className}`}>
|
||||
{status.text}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(checkpoint.date), 'PPP')}
|
||||
</span>
|
||||
{checkpoint.projectName && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{checkpoint.projectName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => onEdit?.(checkpoint)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit Checkpoint
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() => onDelete?.(checkpoint.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Checkpoint
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</foreignObject>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +1,194 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"bg-background 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 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
|
||||
48
src/renderer/components/ui/badge.tsx
Normal file
48
src/renderer/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
109
src/renderer/components/ui/breadcrumb.tsx
Normal file
109
src/renderer/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -1,30 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -34,24 +38,27 @@ const buttonVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
220
src/renderer/components/ui/calendar.tsx
Normal file
220
src/renderer/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
} from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
92
src/renderer/components/ui/card.tsx
Normal file
92
src/renderer/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
33
src/renderer/components/ui/checkbox.tsx
Normal file
33
src/renderer/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, MinusIcon } from "lucide-react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
const isIndeterminate = props.checked === 'indeterminate';
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
{isIndeterminate ? <MinusIcon className="size-3.5" /> : <CheckIcon className="size-3.5" />}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -1,11 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
||||
250
src/renderer/components/ui/context-menu.tsx
Normal file
250
src/renderer/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ContextMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
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 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
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 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:hover:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 dark:data-[variant=destructive]:hover:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:hover:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -1,120 +1,156 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogHeader = ({
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogFooter = ({
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
||||
@@ -1,199 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:hover:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 dark:data-[variant=destructive]:hover:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:hover:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
||||
109
src/renderer/components/ui/gradual-blur.tsx
Normal file
109
src/renderer/components/ui/gradual-blur.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type Position = 'top' | 'bottom';
|
||||
|
||||
interface GradualBlurProps {
|
||||
/** Edge to attach the blur overlay */
|
||||
position?: Position;
|
||||
/** Base blur strength multiplier */
|
||||
strength?: number;
|
||||
/** Overlay height (CSS value) */
|
||||
height?: string;
|
||||
/** Number of stacked blur layers (higher = smoother) */
|
||||
divCount?: number;
|
||||
/** Use exponential progression for stronger end blur */
|
||||
exponential?: boolean;
|
||||
/** Distribution curve: linear | ease-out */
|
||||
curve?: 'linear' | 'ease-out';
|
||||
/** Opacity applied to each blur layer */
|
||||
opacity?: number;
|
||||
/** z-index for the overlay */
|
||||
zIndex?: number;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getGradientDirection = (position: Position) =>
|
||||
position === 'top' ? 'to top' : 'to bottom';
|
||||
|
||||
export function GradualBlur({
|
||||
position = 'top',
|
||||
strength = 2,
|
||||
height = '6rem',
|
||||
divCount = 5,
|
||||
exponential = false,
|
||||
curve = 'linear',
|
||||
opacity = 1,
|
||||
zIndex = 10,
|
||||
className = '',
|
||||
}: GradualBlurProps) {
|
||||
const blurDivs = useMemo(() => {
|
||||
const divs: React.ReactNode[] = [];
|
||||
const increment = 100 / divCount;
|
||||
const direction = getGradientDirection(position);
|
||||
|
||||
const curveFunc = curve === 'ease-out'
|
||||
? (p: number) => 1 - Math.pow(1 - p, 2)
|
||||
: (p: number) => p;
|
||||
|
||||
for (let i = 1; i <= divCount; i++) {
|
||||
let progress = i / divCount;
|
||||
progress = curveFunc(progress);
|
||||
|
||||
let blurValue: number;
|
||||
if (exponential) {
|
||||
blurValue = Math.pow(2, progress * 4) * 0.0625 * strength;
|
||||
} else {
|
||||
blurValue = progress * strength;
|
||||
}
|
||||
|
||||
const p1 = Math.round((increment * i - increment) * 10) / 10;
|
||||
const p2 = Math.round(increment * i * 10) / 10;
|
||||
const p3 = Math.round((increment * i + increment) * 10) / 10;
|
||||
const p4 = Math.round((increment * i + increment * 2) * 10) / 10;
|
||||
|
||||
let gradient = `transparent ${p1}%, black ${p2}%`;
|
||||
if (p3 <= 100) gradient += `, black ${p3}%`;
|
||||
if (p4 <= 100) gradient += `, transparent ${p4}%`;
|
||||
|
||||
const maskImage = `linear-gradient(${direction}, ${gradient})`;
|
||||
|
||||
divs.push(
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
maskImage,
|
||||
WebkitMaskImage: maskImage,
|
||||
backdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
|
||||
WebkitBackdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
|
||||
opacity,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return divs;
|
||||
}, [position, strength, divCount, exponential, curve, opacity]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
[position]: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height,
|
||||
pointerEvents: 'none',
|
||||
zIndex,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
{blurDivs}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
src/renderer/components/ui/input-group.tsx
Normal file
168
src/renderer/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -2,21 +2,20 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
||||
193
src/renderer/components/ui/item.tsx
Normal file
193
src/renderer/components/ui/item.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn("group/item-group flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border-border",
|
||||
muted: "bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "p-4 gap-4 ",
|
||||
sm: "py-3 px-4 gap-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "size-8 [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
}
|
||||
87
src/renderer/components/ui/popover.tsx
Normal file
87
src/renderer/components/ui/popover.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-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-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
}
|
||||
67
src/renderer/components/ui/scroll-area.tsx
Normal file
67
src/renderer/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
viewportRef,
|
||||
viewportClassName,
|
||||
scrollbarClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||
viewportRef?: React.Ref<HTMLDivElement>;
|
||||
viewportClassName?: string;
|
||||
scrollbarClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1",
|
||||
viewportClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar className={scrollbarClassName} />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none z-50",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -1,159 +1,188 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"p-1",
|
||||
"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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
|
||||
@@ -1,135 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SheetHeader = ({
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SheetFooter = ({
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,10 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
|
||||
89
src/renderer/components/ui/tabs.tsx
Normal file
89
src/renderer/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
src/renderer/components/ui/textarea.tsx
Normal file
18
src/renderer/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,30 +1,55 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
||||
262
src/renderer/context/FloatingChatContext.tsx
Normal file
262
src/renderer/context/FloatingChatContext.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useState,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
interface AISection {
|
||||
id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart"
|
||||
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
|
||||
ref: RefObject<HTMLElement | null>;
|
||||
projectId?: string; // If section is project-scoped
|
||||
anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right'
|
||||
}
|
||||
|
||||
interface SectionOpenOpts {
|
||||
clickY?: number; // For right-margin mode: Y-coordinate of the double-click
|
||||
}
|
||||
|
||||
interface FloatingChatState {
|
||||
isOpen: boolean;
|
||||
activeSectionId: string | null;
|
||||
position: { x: number; y: number; width: number };
|
||||
morphTargetId: string | null;
|
||||
projectId?: string;
|
||||
pendingSection?: { sectionId: string; clickY?: number }; // For cross-page navigation
|
||||
}
|
||||
|
||||
interface FloatingChatContextValue {
|
||||
// State
|
||||
state: FloatingChatState;
|
||||
sections: Map<string, AISection>;
|
||||
|
||||
// Section registry
|
||||
registerSection: (section: AISection) => void;
|
||||
unregisterSection: (id: string) => void;
|
||||
|
||||
// Actions
|
||||
openAtSection: (sectionId: string, opts?: SectionOpenOpts) => void;
|
||||
moveToSection: (sectionId: string, opts?: SectionOpenOpts) => void;
|
||||
close: () => void;
|
||||
setMorphTarget: (id: string | null) => void;
|
||||
updatePosition: (pos: { x: number; y: number; width: number }) => void;
|
||||
setPendingSection: (pending: { sectionId: string; clickY?: number } | undefined) => void;
|
||||
}
|
||||
|
||||
// ---------- Constants ----------
|
||||
|
||||
/** Dynamic chat width: 35% of viewport, clamped between 320px and 520px. */
|
||||
export function getChatWidth(): number {
|
||||
return Math.min(630, Math.max(320, Math.round(window.innerWidth * 0.35)));
|
||||
}
|
||||
|
||||
export const CHAT_HEIGHT = 420;
|
||||
export const PADDING = 16;
|
||||
|
||||
// ---------- Position computation ----------
|
||||
|
||||
function clampPosition(x: number, y: number): { x: number; y: number } {
|
||||
const w = getChatWidth();
|
||||
return {
|
||||
x: Math.max(PADDING, Math.min(x, window.innerWidth - w - PADDING)),
|
||||
y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)),
|
||||
};
|
||||
}
|
||||
|
||||
function computeAnchorPosition(
|
||||
section: AISection,
|
||||
opts?: SectionOpenOpts,
|
||||
): { x: number; y: number; width: number } {
|
||||
const el = section.ref.current;
|
||||
const w = getChatWidth();
|
||||
if (!el) return { x: PADDING, y: PADDING, width: w };
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const mode = section.anchorMode ?? 'top-right';
|
||||
|
||||
if (mode === 'right-margin') {
|
||||
// Position to the right of the section at the click Y-coordinate
|
||||
const rawX = rect.right + PADDING;
|
||||
const rawY = opts?.clickY ?? rect.top + PADDING;
|
||||
const { x, y } = clampPosition(rawX, rawY);
|
||||
return { x, y, width: w };
|
||||
}
|
||||
|
||||
// Default: top-right of section
|
||||
const rawX = rect.right - w - PADDING;
|
||||
const rawY = rect.top + PADDING;
|
||||
const { x, y } = clampPosition(rawX, rawY);
|
||||
return { x, y, width: w };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual-anchor recomputation for scroll tracking.
|
||||
* Returns null when the section is fully off-screen (freeze at last position).
|
||||
*/
|
||||
export function computeDualAnchor(
|
||||
section: AISection,
|
||||
): { x: number; y: number; width: number } | null {
|
||||
const el = section.ref.current;
|
||||
if (!el) return null;
|
||||
|
||||
// Skip scroll tracking for right-margin mode (stays at fixed clickY)
|
||||
if (section.anchorMode === 'right-margin') return null;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const w = getChatWidth();
|
||||
|
||||
// Fully off-screen — freeze
|
||||
if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
|
||||
|
||||
// Primary anchor: top-right (when section top is visible)
|
||||
if (rect.top >= PADDING) {
|
||||
const { x, y } = clampPosition(
|
||||
rect.right - w - PADDING,
|
||||
rect.top + PADDING,
|
||||
);
|
||||
return { x, y, width: w };
|
||||
}
|
||||
|
||||
// Fallback anchor: bottom-right (when section top scrolled off)
|
||||
if (rect.bottom > CHAT_HEIGHT) {
|
||||
const { x, y } = clampPosition(
|
||||
rect.right - w - PADDING,
|
||||
rect.bottom - CHAT_HEIGHT - PADDING,
|
||||
);
|
||||
return { x, y, width: w };
|
||||
}
|
||||
|
||||
// Section visible but too small for fallback — clamp to top
|
||||
const { x, y } = clampPosition(
|
||||
rect.right - w - PADDING,
|
||||
PADDING,
|
||||
);
|
||||
return { x, y, width: w };
|
||||
}
|
||||
|
||||
// ---------- Context ----------
|
||||
|
||||
const FloatingChatCtx = createContext<FloatingChatContextValue | null>(null);
|
||||
|
||||
export function useFloatingChat(): FloatingChatContextValue {
|
||||
const ctx = useContext(FloatingChatCtx);
|
||||
if (!ctx)
|
||||
throw new Error('useFloatingChat must be used within FloatingChatProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
// ---------- Provider ----------
|
||||
|
||||
export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
const sectionsRef = useRef<Map<string, AISection>>(new Map());
|
||||
const [sections, setSections] = useState<Map<string, AISection>>(new Map());
|
||||
const [state, setState] = useState<FloatingChatState>({
|
||||
isOpen: false,
|
||||
activeSectionId: null,
|
||||
position: { x: 0, y: 0, width: getChatWidth() },
|
||||
morphTargetId: null,
|
||||
});
|
||||
|
||||
const registerSection = useCallback((section: AISection) => {
|
||||
sectionsRef.current.set(section.id, section);
|
||||
setSections(new Map(sectionsRef.current));
|
||||
|
||||
// Check if there's a pending section to open after cross-page navigation
|
||||
setState((prev) => {
|
||||
if (prev.pendingSection && prev.pendingSection.sectionId === section.id) {
|
||||
const position = computeAnchorPosition(section, { clickY: prev.pendingSection.clickY });
|
||||
return {
|
||||
...prev,
|
||||
isOpen: true,
|
||||
activeSectionId: section.id,
|
||||
position,
|
||||
morphTargetId: null,
|
||||
projectId: section.projectId,
|
||||
pendingSection: undefined,
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const unregisterSection = useCallback((id: string) => {
|
||||
sectionsRef.current.delete(id);
|
||||
setSections(new Map(sectionsRef.current));
|
||||
}, []);
|
||||
|
||||
const openAtSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
|
||||
const section = sectionsRef.current.get(sectionId);
|
||||
if (!section) return;
|
||||
|
||||
const position = computeAnchorPosition(section, opts);
|
||||
|
||||
setState({
|
||||
isOpen: true,
|
||||
activeSectionId: sectionId,
|
||||
position,
|
||||
morphTargetId: null,
|
||||
projectId: section.projectId,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const moveToSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
|
||||
const section = sectionsRef.current.get(sectionId);
|
||||
if (!section) return;
|
||||
|
||||
const position = computeAnchorPosition(section, opts);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
activeSectionId: sectionId,
|
||||
position,
|
||||
projectId: section.projectId,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isOpen: false,
|
||||
activeSectionId: null,
|
||||
morphTargetId: null,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setMorphTarget = useCallback((id: string | null) => {
|
||||
setState((prev) => ({ ...prev, morphTargetId: id }));
|
||||
}, []);
|
||||
|
||||
const updatePosition = useCallback((pos: { x: number; y: number; width: number }) => {
|
||||
setState((prev) => ({ ...prev, position: pos }));
|
||||
}, []);
|
||||
|
||||
const setPendingSection = useCallback((pending: { sectionId: string; clickY?: number } | undefined) => {
|
||||
setState((prev) => ({ ...prev, pendingSection: pending }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FloatingChatCtx.Provider
|
||||
value={{
|
||||
state,
|
||||
sections,
|
||||
registerSection,
|
||||
unregisterSection,
|
||||
openAtSection,
|
||||
moveToSection,
|
||||
close,
|
||||
setMorphTarget,
|
||||
updatePosition,
|
||||
setPendingSection,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FloatingChatCtx.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,302 @@
|
||||
@import '@fontsource/geist/300.css';
|
||||
@import '@fontsource/geist/400.css';
|
||||
@import '@fontsource/geist/500.css';
|
||||
@import '@fontsource/geist/600.css';
|
||||
@import '@fontsource/geist/700.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
|
||||
/* #f4edf3 - Light Pinkish-White Canvas */
|
||||
--background: oklch(0.945 0.012 328.5);
|
||||
/* #040404 - Almost Black Text */
|
||||
--foreground: oklch(0.145 0 0);
|
||||
|
||||
--card: oklch(0.945 0.012 328.5);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(0.945 0.012 328.5);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
|
||||
/* #fbc881 - Golden Yellow Accent */
|
||||
--primary: oklch(0.838 0.117 76.8);
|
||||
--primary-foreground: oklch(0.145 0 0);
|
||||
|
||||
/* #8a8ea9 - Slate Blue/Gray */
|
||||
--secondary: oklch(0.627 0.041 274.5);
|
||||
--secondary-foreground: oklch(0.945 0.012 328.5);
|
||||
|
||||
/* #c8c3cd - Light Gray/Purple */
|
||||
--muted: oklch(0.811 0.014 300.2);
|
||||
--muted-foreground: oklch(0.627 0.041 274.5);
|
||||
|
||||
--accent: oklch(0.811 0.014 300.2);
|
||||
--accent-foreground: oklch(0.145 0 0);
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
|
||||
--border: oklch(0.811 0.014 300.2);
|
||||
--input: oklch(0.811 0.014 300.2);
|
||||
--ring: oklch(0.838 0.117 76.8);
|
||||
|
||||
/* Kept your original chart colors */
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
|
||||
/* Sidebar uses the custom palette */
|
||||
--sidebar: oklch(0.945 0.012 328.5);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.838 0.117 76.8);
|
||||
--sidebar-primary-foreground: oklch(0.145 0 0);
|
||||
--sidebar-accent: oklch(0.811 0.014 300.2);
|
||||
--sidebar-accent-foreground: oklch(0.145 0 0);
|
||||
--sidebar-border: oklch(0.811 0.014 300.2);
|
||||
--sidebar-ring: oklch(0.838 0.117 76.8);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* #0c0c0c - Deepest black for the main canvas */
|
||||
--background: oklch(0.15 0 0);
|
||||
/* #fbfbfb - Crisp white for primary text */
|
||||
--foreground: oklch(0.985 0 0);
|
||||
|
||||
/* Cards use the main background but are defined by borders */
|
||||
--card: oklch(0.15 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.15 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
|
||||
/* #fbfbfb - Primary actions (like the active white circle menu item) */
|
||||
--primary: oklch(0.985 0 0);
|
||||
/* #0c0c0c - Dark text/icons inside primary buttons */
|
||||
--primary-foreground: oklch(0.15 0 0);
|
||||
|
||||
/* #323232 - Dark gray for secondary surfaces and button backgrounds */
|
||||
--secondary: oklch(0.335 0 0);
|
||||
/* #fbfbfb - White text on secondary surfaces */
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
|
||||
/* #323232 - Dark gray for muted backgrounds */
|
||||
--muted: oklch(0.335 0 0);
|
||||
/* #77797b - Mid gray for muted/secondary text (like "ELEVATE YOUR...") */
|
||||
--muted-foreground: oklch(0.555 0 0);
|
||||
|
||||
/* #323232 - Hover states */
|
||||
--accent: oklch(0.335 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
--destructive: oklch(0.704 0.191 22.216); /* Kept original dark red */
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
|
||||
/* #323232 - Distinct dark gray borders for the cards/panels */
|
||||
--border: oklch(0.335 0 0);
|
||||
--input: oklch(0.335 0 0);
|
||||
/* #bab7ba - Lighter gray for focus rings to stand out against dark borders */
|
||||
--ring: oklch(0.765 0 0);
|
||||
|
||||
/* Kept your original chart colors */
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
|
||||
/* Sidebar mapped to the new sleek dark palette */
|
||||
--sidebar: oklch(0.15 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.985 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.15 0 0);
|
||||
--sidebar-accent: oklch(0.335 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.335 0 0);
|
||||
--sidebar-ring: oklch(0.765 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Sidebar tokens — matching Figma exactly */
|
||||
--sidebar: 0 0% 98%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: 'Geist', 'Inter', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
margin: 0;
|
||||
overflow: hidden; /* Electron: no OS scrollbars */
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dark {
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Geist', 'Inter', system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
margin: 0;
|
||||
overflow: hidden; /* Electron: no OS scrollbars */
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Glass Surface (ReactBits-style) ---- */
|
||||
/*
|
||||
* Gradient border via padding-box/border-box background split —
|
||||
* most reliable technique in Chromium/Electron; no pseudo-element mask needed.
|
||||
*/
|
||||
.glass-surface {
|
||||
border: 1px solid transparent;
|
||||
background:
|
||||
/* glass fill — clips to padding-box (inside the border) */
|
||||
rgba(255, 255, 255, 0.55) padding-box,
|
||||
/* gradient border — clips to border-box (the 1px border strip) */
|
||||
linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.90) 0%,
|
||||
rgba(200, 195, 205, 0.40) 40%,
|
||||
rgba(200, 195, 205, 0.20) 100%
|
||||
) border-box;
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
box-shadow:
|
||||
0 4px 48px rgba(0, 0, 0, 0.10),
|
||||
0 1px 2px rgba(0, 0, 0, 0.06),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.80);
|
||||
}
|
||||
|
||||
.dark .glass-surface {
|
||||
background:
|
||||
rgba(255, 255, 255, 0.05) padding-box,
|
||||
linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.18) 0%,
|
||||
rgba(255, 255, 255, 0.04) 40%,
|
||||
rgba(255, 255, 255, 0.08) 100%
|
||||
) border-box;
|
||||
box-shadow:
|
||||
0 4px 48px rgba(0, 0, 0, 0.50),
|
||||
0 1px 2px rgba(0, 0, 0, 0.20),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
/* Subtle variant — same gradient border, much more transparent fill */
|
||||
.glass-surface-subtle {
|
||||
border: 1px solid transparent;
|
||||
background:
|
||||
rgba(255, 255, 255, 0.20) padding-box,
|
||||
linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.70) 0%,
|
||||
rgba(200, 195, 205, 0.25) 40%,
|
||||
rgba(200, 195, 205, 0.10) 100%
|
||||
) border-box;
|
||||
backdrop-filter: blur(16px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(160%);
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.06),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.60);
|
||||
}
|
||||
|
||||
.dark .glass-surface-subtle {
|
||||
background:
|
||||
rgba(255, 255, 255, 0.03) padding-box,
|
||||
linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.12) 0%,
|
||||
rgba(255, 255, 255, 0.02) 40%,
|
||||
rgba(255, 255, 255, 0.05) 100%
|
||||
) border-box;
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.30),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
/* Crepe editor layout */
|
||||
.milkdown-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.milkdown-container .milkdown {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
--crepe-color-background: var(--background);
|
||||
--crepe-font-default: 'Geist', 'Inter', system-ui, sans-serif;
|
||||
--crepe-font-title: 'Geist', 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Override Crepe's default 60px 120px padding for panel use.
|
||||
Left padding >=72px to leave room for the block handle (plus + drag buttons). */
|
||||
.milkdown-container .milkdown .ProseMirror {
|
||||
@apply pr-6 pl-18 py-0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Dark theme: scope nord-dark variables under .dark class */
|
||||
.dark .milkdown {
|
||||
--crepe-color-on-background: #f8f9ff;
|
||||
--crepe-color-surface: #111418;
|
||||
--crepe-color-surface-low: #191c20;
|
||||
--crepe-color-on-surface: #e1e2e8;
|
||||
--crepe-color-on-surface-variant: #c3c6cf;
|
||||
--crepe-color-outline: #8d9199;
|
||||
--crepe-color-primary: #a1c9fd;
|
||||
--crepe-color-secondary: #3c4858;
|
||||
--crepe-color-on-secondary: #d7e3f8;
|
||||
--crepe-color-inverse: #e1e2e8;
|
||||
--crepe-color-on-inverse: #2e3135;
|
||||
--crepe-color-inline-code: #ffb4ab;
|
||||
--crepe-color-error: #ffb4ab;
|
||||
--crepe-color-hover: #1d2024;
|
||||
--crepe-color-selected: #32353a;
|
||||
--crepe-color-inline-area: #111418;
|
||||
--crepe-shadow-1: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 1px 3px 1px rgba(255, 255, 255, 0.15);
|
||||
--crepe-shadow-2: 0px 1px 2px 0px rgba(255, 255, 255, 0.3), 0px 2px 6px 2px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
142
src/renderer/hooks/useAIChat.ts
Normal file
142
src/renderer/hooks/useAIChat.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatContext {
|
||||
type: 'global' | 'project';
|
||||
projectId?: string;
|
||||
uiContext?: string;
|
||||
}
|
||||
|
||||
interface UseAIChatReturn {
|
||||
messages: ChatMessage[];
|
||||
input: string;
|
||||
setInput: (v: string) => void;
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
handleSend: (overrideMessage?: string, overrideContext?: ChatContext) => void;
|
||||
clearMessages: () => void;
|
||||
}
|
||||
|
||||
interface UseAIChatOptions {
|
||||
onSectionTag?: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
export function useAIChat(defaultContext: ChatContext, options?: UseAIChatOptions): UseAIChatReturn {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
|
||||
const streamingContentRef = useRef('');
|
||||
const chatMutation = trpc.ai.chat.useMutation();
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
setStreamingContent('');
|
||||
streamingContentRef.current = '';
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(overrideMessage?: string, overrideContext?: ChatContext) => {
|
||||
const trimmed = (overrideMessage ?? input).trim();
|
||||
if (!trimmed || isStreaming) return;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
if (!overrideMessage) setInput('');
|
||||
setIsStreaming(true);
|
||||
setStreamingContent('');
|
||||
streamingContentRef.current = '';
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
|
||||
if (done) {
|
||||
let finalContent = streamingContentRef.current;
|
||||
|
||||
// Parse and strip [SECTION:xxx] tag from AI response
|
||||
const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/);
|
||||
if (sectionMatch) {
|
||||
finalContent = finalContent.slice(sectionMatch[0].length);
|
||||
options?.onSectionTag?.(sectionMatch[1]!);
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
|
||||
]);
|
||||
setStreamingContent('');
|
||||
streamingContentRef.current = '';
|
||||
setIsStreaming(false);
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
streamingContentRef.current += token;
|
||||
setStreamingContent(streamingContentRef.current);
|
||||
});
|
||||
|
||||
const ctx = overrideContext ?? defaultContext;
|
||||
|
||||
chatMutation.mutate(
|
||||
{
|
||||
message: trimmed,
|
||||
context: {
|
||||
type: ctx.type,
|
||||
...(ctx.type === 'project' && ctx.projectId ? { projectId: ctx.projectId } : {}),
|
||||
...(ctx.uiContext ? { uiContext: ctx.uiContext } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
unsubscribe();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true },
|
||||
]);
|
||||
setStreamingContent('');
|
||||
streamingContentRef.current = '';
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
unsubscribe();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: err.message || 'An unexpected error occurred.',
|
||||
error: true,
|
||||
},
|
||||
]);
|
||||
setStreamingContent('');
|
||||
streamingContentRef.current = '';
|
||||
setIsStreaming(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[input, isStreaming, defaultContext, chatMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
input,
|
||||
setInput,
|
||||
isStreaming,
|
||||
streamingContent,
|
||||
handleSend,
|
||||
clearMessages,
|
||||
};
|
||||
}
|
||||
54
src/renderer/hooks/useDoubleClickAI.ts
Normal file
54
src/renderer/hooks/useDoubleClickAI.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
|
||||
// Elements where double-click should NOT trigger the AI popup
|
||||
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
||||
|
||||
export function useDoubleClickAI(): void {
|
||||
const { openAtSection, moveToSection, sections, state } = useFloatingChat();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Skip interactive elements (preserve text selection behavior)
|
||||
if (INTERACTIVE_TAGS.has(target.tagName)) return;
|
||||
|
||||
// Skip contenteditable elements UNLESS they're inside Milkdown
|
||||
if (target.isContentEditable) {
|
||||
const inMilkdown =
|
||||
target.closest('.milkdown-container') ||
|
||||
target.closest('.crepe-editor');
|
||||
if (!inMilkdown) return;
|
||||
// For Milkdown: only trigger if no text was selected by the double-click
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().trim().length > 0) return;
|
||||
}
|
||||
|
||||
// Walk up DOM to find nearest [data-ai-section]
|
||||
const sectionEl = (target as Element).closest('[data-ai-section]');
|
||||
if (!sectionEl) return;
|
||||
|
||||
const sectionId = sectionEl.getAttribute('data-ai-section');
|
||||
if (!sectionId) return;
|
||||
|
||||
// If popup is already open at THIS section, do nothing
|
||||
if (state.isOpen && state.activeSectionId === sectionId) return;
|
||||
|
||||
// Build opts for right-margin sections
|
||||
const section = sections.get(sectionId);
|
||||
const opts = section?.anchorMode === 'right-margin' ? { clickY: e.clientY } : undefined;
|
||||
|
||||
// If chat is already open at a different section, move (keep conversation)
|
||||
if (state.isOpen) {
|
||||
moveToSection(sectionId, opts);
|
||||
return;
|
||||
}
|
||||
|
||||
openAtSection(sectionId, opts);
|
||||
};
|
||||
|
||||
document.addEventListener('dblclick', handler);
|
||||
return () => document.removeEventListener('dblclick', handler);
|
||||
}, [openAtSection, moveToSection, sections, state.isOpen, state.activeSectionId]);
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import { StrictMode, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ipcLink } from 'electron-trpc/renderer';
|
||||
import { ipcLink } from './lib/ipcLink';
|
||||
import { router } from './router';
|
||||
import { trpc } from './lib/trpc';
|
||||
import { ThemeProvider } from './components/theme-provider';
|
||||
import './globals.css';
|
||||
|
||||
function App() {
|
||||
@@ -16,11 +17,13 @@ function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
<ThemeProvider defaultTheme="system" storageKey="adiuva-theme">
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
82
src/renderer/lib/ipcLink.ts
Normal file
82
src/renderer/lib/ipcLink.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Renderer-side tRPC IPC link for Electron.
|
||||
*
|
||||
* Replaces electron-trpc's ipcLink with a custom implementation that
|
||||
* works with our custom IPC handler + tRPC v11.
|
||||
*/
|
||||
import { observable } from '@trpc/server/observable';
|
||||
import type { TRPCLink } from '@trpc/client';
|
||||
import type { AnyRouter } from '@trpc/server';
|
||||
|
||||
interface ElectronTRPC {
|
||||
sendMessage: (msg: unknown) => void;
|
||||
onMessage: (cb: (data: unknown) => void) => (() => void) | void;
|
||||
}
|
||||
|
||||
interface ElectronAI {
|
||||
onStreamChunk: (cb: (data: { token: string; done: boolean }) => void) => () => void;
|
||||
onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => () => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronTRPC: ElectronTRPC;
|
||||
electronAI: ElectronAI;
|
||||
}
|
||||
}
|
||||
|
||||
type TRPCResponse = {
|
||||
id: number | null;
|
||||
result?: { type: string; data?: unknown };
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export function ipcLink<TRouter extends AnyRouter>(): TRPCLink<TRouter> {
|
||||
return () =>
|
||||
({ op }) =>
|
||||
observable((observer) => {
|
||||
const id = ++nextId;
|
||||
const { electronTRPC } = window;
|
||||
|
||||
if (!electronTRPC) {
|
||||
observer.error(
|
||||
new Error(
|
||||
'Could not find `electronTRPC` global. ' +
|
||||
'Check that the preload script has been loaded.',
|
||||
) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = electronTRPC.onMessage((response: unknown) => {
|
||||
const msg = response as TRPCResponse;
|
||||
if (msg.id !== id) return;
|
||||
|
||||
if ('error' in msg) {
|
||||
observer.error(msg.error as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
return;
|
||||
}
|
||||
|
||||
observer.next({
|
||||
result: msg.result as { type: 'data'; data: unknown },
|
||||
});
|
||||
observer.complete();
|
||||
});
|
||||
|
||||
electronTRPC.sendMessage({
|
||||
method: 'request',
|
||||
operation: {
|
||||
id,
|
||||
type: op.type,
|
||||
path: op.path,
|
||||
input: op.input,
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Route as TimelineRouteImport } from './routes/timeline'
|
||||
import { Route as TasksRouteImport } from './routes/tasks'
|
||||
import { Route as ProjectsRouteImport } from './routes/projects'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as NotesNoteIdRouteImport } from './routes/notes.$noteId'
|
||||
|
||||
const TimelineRoute = TimelineRouteImport.update({
|
||||
id: '/timeline',
|
||||
@@ -34,18 +35,25 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const NotesNoteIdRoute = NotesNoteIdRouteImport.update({
|
||||
id: '/notes/$noteId',
|
||||
path: '/notes/$noteId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/projects': typeof ProjectsRoute
|
||||
'/tasks': typeof TasksRoute
|
||||
'/timeline': typeof TimelineRoute
|
||||
'/notes/$noteId': typeof NotesNoteIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/projects': typeof ProjectsRoute
|
||||
'/tasks': typeof TasksRoute
|
||||
'/timeline': typeof TimelineRoute
|
||||
'/notes/$noteId': typeof NotesNoteIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -53,13 +61,14 @@ export interface FileRoutesById {
|
||||
'/projects': typeof ProjectsRoute
|
||||
'/tasks': typeof TasksRoute
|
||||
'/timeline': typeof TimelineRoute
|
||||
'/notes/$noteId': typeof NotesNoteIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/projects' | '/tasks' | '/timeline'
|
||||
fullPaths: '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/projects' | '/tasks' | '/timeline'
|
||||
id: '__root__' | '/' | '/projects' | '/tasks' | '/timeline'
|
||||
to: '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
|
||||
id: '__root__' | '/' | '/projects' | '/tasks' | '/timeline' | '/notes/$noteId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -67,6 +76,7 @@ export interface RootRouteChildren {
|
||||
ProjectsRoute: typeof ProjectsRoute
|
||||
TasksRoute: typeof TasksRoute
|
||||
TimelineRoute: typeof TimelineRoute
|
||||
NotesNoteIdRoute: typeof NotesNoteIdRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -99,6 +109,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/notes/$noteId': {
|
||||
id: '/notes/$noteId'
|
||||
path: '/notes/$noteId'
|
||||
fullPath: '/notes/$noteId'
|
||||
preLoaderRoute: typeof NotesNoteIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +124,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ProjectsRoute: ProjectsRoute,
|
||||
TasksRoute: TasksRoute,
|
||||
TimelineRoute: TimelineRoute,
|
||||
NotesNoteIdRoute: NotesNoteIdRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,40 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: HomePage,
|
||||
component: () => null,
|
||||
});
|
||||
|
||||
function HomePage() {
|
||||
const pingQuery = trpc.health.ping.useQuery();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="text-foreground"
|
||||
>
|
||||
<path
|
||||
d="M12 2L13.5 8.5L20 10L13.5 11.5L12 18L10.5 11.5L4 10L10.5 8.5L12 2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
Hello, Roberto
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Adiuva is ready. Start building.
|
||||
</p>
|
||||
{pingQuery.data && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
tRPC IPC bridge: {pingQuery.data}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
218
src/renderer/routes/notes.$noteId.tsx
Normal file
218
src/renderer/routes/notes.$noteId.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { ArrowLeft, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
|
||||
export const Route = createFileRoute('/notes/$noteId')({
|
||||
component: NoteDetailPage,
|
||||
});
|
||||
|
||||
function NoteDetailPage() {
|
||||
const { noteId } = Route.useParams();
|
||||
const utils = trpc.useUtils();
|
||||
const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId });
|
||||
|
||||
// AI section — register with right-margin anchor mode
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const { registerSection, unregisterSection } = useFloatingChat();
|
||||
const noteProjectId = note?.projectId ?? undefined;
|
||||
useEffect(() => {
|
||||
registerSection({
|
||||
id: 'note-editor',
|
||||
label: 'Note Editor',
|
||||
ref: editorRef,
|
||||
projectId: noteProjectId,
|
||||
anchorMode: 'right-margin',
|
||||
});
|
||||
return () => unregisterSection('note-editor');
|
||||
}, [noteId, noteProjectId, registerSection, unregisterSection]);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Store the latest markdown so we can flush it on back navigation
|
||||
const pendingContentRef = useRef<string | null>(null);
|
||||
|
||||
const updateNote = trpc.notes.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.notes.get.invalidate({ id: noteId });
|
||||
void utils.notes.list.invalidate();
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSaving(false);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteNote = trpc.notes.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.notes.list.invalidate();
|
||||
window.history.back();
|
||||
},
|
||||
});
|
||||
|
||||
// Sync title from server data on initial load
|
||||
useEffect(() => {
|
||||
if (note) {
|
||||
setTitle(note.title);
|
||||
}
|
||||
}, [note]);
|
||||
|
||||
// Cleanup debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(markdown: string) => {
|
||||
setIsSaving(true);
|
||||
pendingContentRef.current = markdown;
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
pendingContentRef.current = null;
|
||||
updateNote.mutate({ id: noteId, content: markdown });
|
||||
}, 2000);
|
||||
},
|
||||
[noteId, updateNote]
|
||||
);
|
||||
|
||||
const handleTitleBlur = () => {
|
||||
const trimmed = title.trim();
|
||||
if (trimmed && note && trimmed !== note.title) {
|
||||
setIsSaving(true);
|
||||
updateNote.mutate({ id: noteId, title: trimmed });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
// Flush any pending debounced save immediately before navigating
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
if (pendingContentRef.current !== null) {
|
||||
updateNote.mutate({ id: noteId, content: pendingContentRef.current });
|
||||
pendingContentRef.current = null;
|
||||
}
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
// Cancel any pending save before deleting
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
pendingContentRef.current = null;
|
||||
deleteNote.mutate({ id: noteId });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">Loading note...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!note) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">Note not found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={handleTitleBlur}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
className="border-0 shadow-none text-2xl font-semibold focus-visible:ring-0 px-0 h-auto"
|
||||
placeholder="Untitled Note"
|
||||
/>
|
||||
{isSaving && <Badge variant="secondary">Saving...</Badge>}
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
Last edited {format(new Date(note.updatedAt), 'MMM d, yyyy · h:mm a')}
|
||||
</span>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteNote.isPending}
|
||||
>
|
||||
<Trash2 /> Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete note</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{title || 'Untitled Note'}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleDelete}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<ScrollArea ref={editorRef} data-ai-section="note-editor" className="flex-1 min-h-0">
|
||||
<div className="px-4 py-4">
|
||||
<MilkdownEditor
|
||||
key={noteId}
|
||||
initialContent={note.content}
|
||||
onChange={handleContentChange}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
import { FolderKanban } from 'lucide-react';
|
||||
import { ProjectSidebar } from '@/components/projects/ProjectSidebar';
|
||||
import { ProjectDetail } from '@/components/projects/ProjectDetail';
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
const searchSchema = z.object({
|
||||
projectId: z.string().optional(),
|
||||
@@ -21,20 +24,28 @@ function ProjectsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="flex h-full min-h-0">
|
||||
<ProjectSidebar
|
||||
selectedProjectId={projectId}
|
||||
onSelectProject={handleSelectProject}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ScrollArea className="flex-1">
|
||||
{projectId ? (
|
||||
<ProjectDetail projectId={projectId} />
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
Select a project to view details
|
||||
</div>
|
||||
<Empty className="h-full">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<FolderKanban />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No project selected</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Select a project from the sidebar to view its details.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,262 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ListTodo,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
|
||||
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
||||
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
|
||||
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
|
||||
|
||||
export const Route = createFileRoute('/tasks')({
|
||||
component: TasksPage,
|
||||
});
|
||||
|
||||
type StatusFilter = 'all' | 'todo' | 'in_progress' | 'done';
|
||||
type OrderBy = 'dueDate' | 'priority' | 'createdAt';
|
||||
|
||||
const ORDER_LABELS: Record<OrderBy, string> = {
|
||||
dueDate: 'Due Date',
|
||||
priority: 'Priority',
|
||||
createdAt: 'Created Date',
|
||||
};
|
||||
|
||||
function TasksPage() {
|
||||
// AI section refs
|
||||
const overviewRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const { state: floatingState, registerSection, unregisterSection } = useFloatingChat();
|
||||
useEffect(() => {
|
||||
registerSection({ id: 'tasks-overview', label: 'Tasks Overview', ref: overviewRef });
|
||||
registerSection({ id: 'tasks-list', label: 'Task List', ref: listRef });
|
||||
return () => {
|
||||
unregisterSection('tasks-overview');
|
||||
unregisterSection('tasks-list');
|
||||
};
|
||||
}, [registerSection, unregisterSection]);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [orderBy, setOrderBy] = useState<OrderBy>('dueDate');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
||||
|
||||
const debounceTimer = useMemo(() => ({ id: null as ReturnType<typeof setTimeout> | null }), []);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
if (debounceTimer.id) clearTimeout(debounceTimer.id);
|
||||
debounceTimer.id = setTimeout(() => setDebouncedSearch(value), 300);
|
||||
},
|
||||
[debounceTimer],
|
||||
);
|
||||
|
||||
const queryInput = useMemo(
|
||||
() => ({
|
||||
...(statusFilter !== 'all' ? { status: statusFilter as 'todo' | 'in_progress' | 'done' } : {}),
|
||||
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||
orderBy,
|
||||
}),
|
||||
[statusFilter, debouncedSearch, orderBy],
|
||||
);
|
||||
|
||||
const { data: allTasks } = trpc.tasks.list.useQuery({});
|
||||
const { data: filteredTasks } = trpc.tasks.list.useQuery(queryInput);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteTask = trpc.tasks.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const tasksList = (filteredTasks ?? []).filter(
|
||||
(t) => !(t.isAiSuggested === 1 && t.isApproved === 0),
|
||||
);
|
||||
|
||||
// Compute stats from all tasks (unfiltered)
|
||||
const stats = useMemo(() => {
|
||||
const all = allTasks ?? [];
|
||||
return {
|
||||
total: all.length,
|
||||
todo: all.filter((t) => t.status === 'todo').length,
|
||||
inProgress: all.filter((t) => t.status === 'in_progress').length,
|
||||
completed: all.filter((t) => t.status === 'done').length,
|
||||
};
|
||||
}, [allTasks]);
|
||||
|
||||
const handleCheckboxToggle = useCallback(
|
||||
(taskId: string, currentStatus: string | null) => {
|
||||
const nextStatus =
|
||||
currentStatus === 'todo' ? 'in_progress' :
|
||||
currentStatus === 'in_progress' ? 'done' : 'todo';
|
||||
updateTask.mutate({ id: taskId, status: nextStatus });
|
||||
},
|
||||
[updateTask],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Tasks — coming in US-007
|
||||
<div className="flex flex-col gap-6 p-6 w-full">
|
||||
{/* Stat Cards */}
|
||||
<div ref={overviewRef} data-ai-section="tasks-overview" className="grid grid-cols-4 gap-4">
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<ClipboardCheck />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{stats.total}</ItemTitle>
|
||||
<ItemDescription>Total Tasks</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<ListTodo />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{stats.todo}</ItemTitle>
|
||||
<ItemDescription>To Do</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item variant="muted" className="bg-sky-50 dark:bg-sky-950/30">
|
||||
<ItemMedia variant="icon">
|
||||
<Clock />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{stats.inProgress}</ItemTitle>
|
||||
<ItemDescription>In Progress</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item variant="muted" className="bg-green-50 dark:bg-green-950/30">
|
||||
<ItemMedia variant="icon">
|
||||
<CheckCircle2 />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{stats.completed}</ItemTitle>
|
||||
<ItemDescription>Completed</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
{/* Task List Section */}
|
||||
<div ref={listRef} data-ai-section="tasks-list" className="flex flex-col gap-6">
|
||||
{/* Search + Order By */}
|
||||
<div className="flex items-center gap-3">
|
||||
<InputGroup className="flex-1">
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search tasks or projects..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Order by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter Tabs + New Task Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="todo">To Do</TabsTrigger>
|
||||
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
|
||||
<TabsTrigger value="done">Completed</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{tasksList.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<ClipboardCheck />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No tasks found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Create a new task to get started or adjust your filters.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
tasksList.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onToggle={handleCheckboxToggle}
|
||||
onEdit={setEditTask}
|
||||
onDelete={(id) => deleteTask.mutate({ id })}
|
||||
onClick={setViewTask}
|
||||
layoutId={
|
||||
floatingState.morphTargetId === `task-morph-${task.id}`
|
||||
? floatingState.morphTargetId
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
<EditTaskDialog
|
||||
task={editTask}
|
||||
open={!!editTask}
|
||||
onOpenChange={(open: boolean) => { if (!open) setEditTask(null); }}
|
||||
/>
|
||||
<TaskDetailDialog
|
||||
task={viewTask}
|
||||
open={!!viewTask}
|
||||
onOpenChange={(open) => { if (!open) setViewTask(null); }}
|
||||
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
|
||||
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,144 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { Plus, ChartGantt } from 'lucide-react';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
||||
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty';
|
||||
|
||||
export const Route = createFileRoute('/timeline')({
|
||||
component: TimelinePage,
|
||||
});
|
||||
|
||||
function TimelinePage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
||||
|
||||
// AI section
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const { registerSection, unregisterSection } = useFloatingChat();
|
||||
useEffect(() => {
|
||||
registerSection({ id: 'timeline-chart', label: 'Timeline', ref: timelineRef });
|
||||
return () => unregisterSection('timeline-chart');
|
||||
}, [registerSection, unregisterSection]);
|
||||
|
||||
const { data: checkpoints } = trpc.checkpoints.list.useQuery({});
|
||||
const { data: projectsList } = trpc.projects.listAll.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const deleteCheckpoint = trpc.checkpoints.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.checkpoints.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const updateCheckpoint = trpc.checkpoints.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.checkpoints.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Build project name lookup
|
||||
const projectMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const p of projectsList ?? []) {
|
||||
map.set(p.id, p.name);
|
||||
}
|
||||
return map;
|
||||
}, [projectsList]);
|
||||
|
||||
// Map checkpoints to GanttChart format with project names
|
||||
const ganttCheckpoints: GanttCheckpoint[] = useMemo(() => {
|
||||
return (checkpoints ?? []).map((cp) => ({
|
||||
id: cp.id,
|
||||
title: cp.title,
|
||||
date: cp.date,
|
||||
projectId: cp.projectId,
|
||||
projectName: projectMap.get(cp.projectId),
|
||||
isAiSuggested: cp.isAiSuggested,
|
||||
isApproved: cp.isApproved,
|
||||
}));
|
||||
}, [checkpoints, projectMap]);
|
||||
|
||||
// Compute date range: 1 month before earliest checkpoint or today, 3 months after latest or today
|
||||
const { startDate, endDate } = useMemo(() => {
|
||||
const now = new Date();
|
||||
if (ganttCheckpoints.length === 0) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 4, 0);
|
||||
return { startDate: start, endDate: end };
|
||||
}
|
||||
const dates = ganttCheckpoints.map((cp) => cp.date);
|
||||
const min = Math.min(...dates, now.getTime());
|
||||
const max = Math.max(...dates, now.getTime());
|
||||
const start = new Date(new Date(min).getFullYear(), new Date(min).getMonth() - 1, 1);
|
||||
const end = new Date(new Date(max).getFullYear(), new Date(max).getMonth() + 2, 0);
|
||||
return { startDate: start, endDate: end };
|
||||
}, [ganttCheckpoints]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Timeline — coming in US-008
|
||||
<div ref={timelineRef} data-ai-section="timeline-chart" className="flex flex-col gap-6 p-6 w-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Timeline</h1>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="var(--primary)" /></svg>
|
||||
To Do
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="var(--chart-2)" /></svg>
|
||||
Completed
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg width={14} height={14}><circle cx={7} cy={7} r={5} fill="none" stroke="var(--muted-foreground)" strokeWidth={1.5} strokeDasharray="3 2" /></svg>
|
||||
AI Suggestion (Pending)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gantt Chart */}
|
||||
{ganttCheckpoints.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<ChartGantt />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No milestones yet</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Click "+ Add" to create your first project checkpoint.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="border rounded-md p-4 bg-card">
|
||||
<GanttChart
|
||||
checkpoints={ganttCheckpoints}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onDelete={(id) => deleteCheckpoint.mutate({ id })}
|
||||
onEdit={(cp) => setEditingCheckpoint(cp)}
|
||||
onToggleApproval={(id, current) =>
|
||||
updateCheckpoint.mutate({ id, isApproved: current === 1 ? 0 : 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddCheckpointDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
<EditCheckpointDialog
|
||||
checkpoint={editingCheckpoint}
|
||||
onOpenChange={(open) => { if (!open) setEditingCheckpoint(null); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: ['./index.html', './src/renderer/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'Geist',
|
||||
'Inter',
|
||||
'system-ui',
|
||||
'sans-serif'
|
||||
]
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
border: 'hsl(var(--sidebar-border))',
|
||||
accent: 'hsl(var(--sidebar-accent))',
|
||||
foreground: 'hsl(var(--sidebar-foreground))',
|
||||
primary: 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||
ring: 'hsl(var(--sidebar-ring))'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -5,7 +5,19 @@ export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// Externalize native Node modules — they're rebuilt by electron-forge
|
||||
external: ['better-sqlite3'],
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'@github/copilot-sdk',
|
||||
'@github/copilot',
|
||||
// LangChain — externalize to avoid bundling Node.js-specific code
|
||||
'@langchain/core',
|
||||
'@langchain/langgraph',
|
||||
'@langchain/openai',
|
||||
'@langchain/anthropic',
|
||||
'@langchain/langgraph-checkpoint',
|
||||
'@langchain/langgraph-sdk',
|
||||
'vectordb',
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'main.js',
|
||||
},
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { TanStackRouterVite } from '@tanstack/router-vite-plugin';
|
||||
import path from 'path';
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
react(),
|
||||
TanStackRouterVite({
|
||||
routesDirectory: './src/renderer/routes',
|
||||
|
||||
Reference in New Issue
Block a user