Compare commits
187 Commits
main
...
81fe6d29e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81fe6d29e2 | ||
|
|
b2d7fa1723 | ||
|
|
4c641ab93a | ||
|
|
84720ff23c | ||
|
|
d7307e146a | ||
|
|
7d4059ca4b | ||
|
|
9691842e79 | ||
|
|
094840e671 | ||
|
|
e8592b25a8 | ||
|
|
27b385df53 | ||
|
|
e170844f17 | ||
|
|
27c1194384 | ||
|
|
26ea095f60 | ||
|
|
751d16a9f4 | ||
|
|
285214a2d2 | ||
|
|
89645f2abd | ||
|
|
7dadeb88fe | ||
|
|
13531fec40 | ||
|
|
e254efd420 | ||
|
|
6d79911414 | ||
|
|
69a859e19f | ||
|
|
098ce86c76 | ||
|
|
9ef809ba02 | ||
|
|
024d572ebb | ||
|
|
d24f09bbea | ||
|
|
56fe6c0754 | ||
|
|
c76de207d7 | ||
|
|
4e89a7a96c | ||
|
|
0fc3aa421e | ||
|
|
c10fbe22d7 | ||
|
|
e3e0b06fb6 | ||
|
|
b3d85b93f1 | ||
|
|
659607a1e9 | ||
|
|
80a0d2c56f | ||
|
|
66448a25f4 | ||
|
|
93144b9de8 | ||
|
|
b0c415f90f | ||
|
|
8a2225da7c | ||
|
|
e0c5971d20 | ||
|
|
a499d55636 | ||
|
|
c36890cc8b | ||
|
|
b80ba0434b | ||
|
|
01d3735dd1 | ||
|
|
e0bcb2fe0a | ||
|
|
a1c83a6134 | ||
|
|
bd5e3076ed | ||
|
|
316b8fa66a | ||
|
|
6f907f6a96 | ||
|
|
93caf0116d | ||
|
|
15af8d54e6 | ||
|
|
c4ed7b3482 | ||
|
|
066d407a5f | ||
|
|
c2826ae4be | ||
|
|
adb1cc81ef | ||
|
|
a4fd10e640 | ||
|
|
efa3051c61 | ||
|
|
72e09501de | ||
|
|
875fe625b5 | ||
|
|
dac1d50b02 | ||
|
|
e104ffc3ab | ||
|
|
1cffb9bdbf | ||
|
|
bae84f1a48 | ||
|
|
938c8eef8a | ||
|
|
50d01c7aec | ||
|
|
ef04bec66f | ||
|
|
2e9ec31d83 | ||
|
|
ca290225b9 | ||
|
|
a5ec0647ec | ||
|
|
57f5470f0d | ||
|
|
33e5edc2ba | ||
|
|
fadda94135 | ||
|
|
5fa3df9c16 | ||
|
|
b48ceea0af | ||
|
|
9e31cfa78e | ||
|
|
c63c94b561 | ||
|
|
cbdb37f5a5 | ||
|
|
05de7405ba | ||
|
|
68286b61bd | ||
|
|
a7fbc4c7e3 | ||
|
|
1a5605569c | ||
|
|
ef71710244 | ||
|
|
ca78a4cbc0 | ||
|
|
b652248404 | ||
|
|
f5ac37867c | ||
|
|
37878df992 | ||
|
|
9e90791743 | ||
|
|
dd3f1442b0 | ||
|
|
a5556743f0 | ||
|
|
ca231e7b7c | ||
|
|
a5a6e25a89 | ||
|
|
df8cbb5c35 | ||
|
|
d0b344beec | ||
|
|
1f4adfca90 | ||
|
|
259ab50b25 | ||
|
|
a04c2434b6 | ||
|
|
c291fc689a | ||
|
|
b61a6de73a | ||
|
|
f2a68ee5f6 | ||
|
|
0c43f5633f | ||
|
|
4ebf0d4062 | ||
|
|
244d53f93d | ||
|
|
8dceacc2ce | ||
|
|
7244810fe1 | ||
|
|
e9c790e017 | ||
|
|
9b32d834b3 | ||
|
|
333b6cb769 | ||
|
|
87c444e78d | ||
|
|
811759dddb | ||
|
|
275edab4bf | ||
|
|
0371a46731 | ||
|
|
cd8f6a6751 | ||
|
|
dd98aaaf4d | ||
|
|
20bc28e59b | ||
|
|
5d112c8dfd | ||
|
|
27bc9d90af | ||
|
|
016c44c6f0 | ||
|
|
02a0f3635b | ||
|
|
109551f713 | ||
| f129b3ba43 | |||
| 7f0c6f45b0 | |||
|
|
2caea8e21d | ||
|
|
b23c4ef255 | ||
|
|
801ae43000 | ||
| bd9af5ddd6 | |||
| 3ae9e450be | |||
| 7616153345 | |||
| 0c21f47a59 | |||
| 7256f1ef4e | |||
| bf635d9c30 | |||
| 5add259348 | |||
| 198fd62ef2 | |||
| 34a771bee3 | |||
| 65a08838c9 | |||
| 8b5a05a16e | |||
| 6a87590176 | |||
| cd4644637b | |||
| 9fd441e7d7 | |||
| b7ddc95171 | |||
| 488dab7aa1 | |||
| a52e5362b3 | |||
| 582ad389e1 | |||
| 3283cc9ad5 | |||
|
|
396fd2faa4 | ||
| fc71ee6e02 | |||
| 96d49abd9a | |||
| 3bc08c6de7 | |||
| 8fe2b1c43e | |||
| 43cfb694e7 | |||
| e9347c5e5a | |||
| 3bc8ad32cd | |||
| 038cd48285 | |||
| 8830793105 | |||
| fa1cd36670 | |||
| c61d572023 | |||
| 7af6f0d9e0 | |||
| 7fd1e85adb | |||
| 34e725135d | |||
| 1caa930977 | |||
| d36ca43804 | |||
| b06f5f6022 | |||
| c3f298e384 | |||
| 733a3c16a8 | |||
| aec83c30d2 | |||
| 3a7a85c617 | |||
| 3051e6e0a9 | |||
| 4cd382b829 | |||
| 0d6c688015 | |||
| b804629f91 | |||
| b860e794a3 | |||
| 6f73824e7e | |||
| e132459fef | |||
| 43b031de5b | |||
| e769ff2806 | |||
| 0c8f0c429a | |||
| 35d7c3e710 | |||
| 89df7e48ad | |||
| 1f6e60d4a9 | |||
| 254424eec1 | |||
| 9892f21e59 | |||
| 8268881f41 | |||
| 0fcfa3e5bb | |||
| b77c6d1195 | |||
| 489e8e3bc9 | |||
| 1ba9c9eee2 | |||
|
|
0f3c63c4de | ||
|
|
aa089975df | ||
|
|
a6c04e52af |
@@ -1,136 +1,173 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
source ~/.nvm/nvm.sh && npm start # Start Electron app with hot-reload
|
||||
|
||||
# Build & Package
|
||||
source ~/.nvm/nvm.sh && npm run make # Build distributable packages
|
||||
source ~/.nvm/nvm.sh && npm run package # Package without making installers
|
||||
|
||||
# Lint
|
||||
source ~/.nvm/nvm.sh && npm run lint # ESLint over .ts/.tsx files
|
||||
|
||||
# Database migrations (Drizzle)
|
||||
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema changes
|
||||
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
|
||||
source ~/.nvm/nvm.sh && npm start # Dev with hot-reload
|
||||
source ~/.nvm/nvm.sh && npm run make # Build distributable packages
|
||||
source ~/.nvm/nvm.sh && npm run package # Package without installers
|
||||
source ~/.nvm/nvm.sh && npm run lint # ESLint (.ts/.tsx)
|
||||
source ~/.nvm/nvm.sh && npx drizzle-kit generate # Generate migration from schema
|
||||
source ~/.nvm/nvm.sh && npx drizzle-kit push # Push schema directly (dev only)
|
||||
```
|
||||
|
||||
There is no test suite currently.
|
||||
No test suite currently.
|
||||
|
||||
## Architecture Overview
|
||||
## Architecture
|
||||
|
||||
Adiuva is a local-first Electron desktop app. The three Electron processes communicate via a custom tRPC↔IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11, so a custom implementation is used).
|
||||
|
||||
### Process Boundaries
|
||||
AdiuvAI is a local-first Electron desktop app. The three processes communicate via a custom tRPC v11 ↔ IPC bridge (the public `electron-trpc` package is incompatible with tRPC v11).
|
||||
|
||||
```
|
||||
Renderer (React) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite)
|
||||
Renderer (React 19) ──ipcLink──► Preload (contextBridge) ──IPC──► Main (tRPC router + SQLite)
|
||||
```
|
||||
|
||||
1. **Main process** (`src/main/`) — Node.js, owns the database and all business logic
|
||||
- `index.ts` — Window creation, app lifecycle
|
||||
- `ipc.ts` — Custom handler that bridges `ipcMain` to tRPC procedures
|
||||
- `router/index.ts` — All tRPC routers (clients, projects, tasks, checkpoints, notes, settings, ai)
|
||||
- `db/index.ts` — Drizzle + better-sqlite3, WAL mode, singleton `getDb()`
|
||||
- `db/schema.ts` — All table definitions (clients, projects, tasks, checkpoints, notes)
|
||||
- `store.ts` — electron-store for persistent UI settings (e.g., `sidebarCollapsed`)
|
||||
### Main Process (`src/main/`)
|
||||
|
||||
2. **Preload** (`src/preload/trpc.ts`) — Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()`
|
||||
Owns the database and all business logic.
|
||||
|
||||
3. **Renderer** (`src/renderer/`) — React 19, never accesses Node APIs directly
|
||||
- `lib/ipcLink.ts` — Custom TRPCLink that routes calls through `window.electronTRPC`
|
||||
- `lib/trpc.ts` — `createTRPCReact<AppRouter>()` typed client
|
||||
- `index.tsx` — QueryClient + tRPC + Router providers
|
||||
- All data access is through `trpc.*.*useQuery()` / `trpc.*.*.useMutation()`
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `index.ts` | Window creation, app lifecycle |
|
||||
| `ipc.ts` | Bridges `ipcMain` to tRPC procedures |
|
||||
| `router/index.ts` | All tRPC sub-routers merged into `appRouter` |
|
||||
| `db/index.ts` | Drizzle + better-sqlite3, WAL mode, singleton `getDb()` |
|
||||
| `db/schema.ts` | Table definitions: clients, projects, tasks, checkpoints, notes, noteEdits, taskComments |
|
||||
| `db/notes-backfill.ts` | Startup backfill: generates aiSummary for notes with null summary |
|
||||
| `store.ts` | electron-store for persistent UI settings |
|
||||
|
||||
### Preload (`src/preload/trpc.ts`)
|
||||
|
||||
Exposes `window.electronTRPC` with `sendMessage()` / `onMessage()`.
|
||||
|
||||
### Renderer (`src/renderer/`)
|
||||
|
||||
React 19 — never accesses Node APIs directly. All data through `trpc.*.useQuery()` / `trpc.*.useMutation()`.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `lib/ipcLink.ts` | Custom TRPCLink routing through `window.electronTRPC` |
|
||||
| `lib/trpc.ts` | `createTRPCReact<AppRouter>()` typed client |
|
||||
| `index.tsx` | QueryClient + tRPC + Router providers |
|
||||
|
||||
### Routing
|
||||
|
||||
File-based routing via TanStack Router. Add a file to `src/renderer/routes/` and the route tree (`src/renderer/routeTree.gen.ts`) is auto-regenerated by the Vite plugin on next `npm start`. Routes:
|
||||
- `__root.tsx` — Root layout wrapping everything in `AppShell`
|
||||
- `index.tsx`, `tasks.tsx`, `timeline.tsx`, `projects.tsx`
|
||||
File-based via TanStack Router (`tsr.config.json` at root). Route tree auto-generated at `routeTree.gen.ts`.
|
||||
|
||||
Routes: `__root.tsx` (AppShell layout), `index`, `tasks`, `timeline`, `projects`, `notes.$noteId`
|
||||
|
||||
### tRPC Routers
|
||||
|
||||
`health`, `settings`, `clients`, `projects`, `tasks`, `checkpoints`, `notes`, `noteEdits`, `taskComments`, `ai`
|
||||
|
||||
### Database
|
||||
|
||||
Schema lives in `src/main/db/schema.ts`. Migrations are in `src/main/db/migrations/`. The DB is created in Electron's `userData` directory as `adiuva.db`. On startup, `initDb()` runs non-destructive migrations (CREATE TABLE IF NOT EXISTS).
|
||||
Schema in `src/main/db/schema.ts`, migrations in `src/main/db/migrations/`. DB created in Electron's `userData` as `adiuvai.db`. On startup, `initDb()` runs non-destructive migrations.
|
||||
|
||||
To add a new table or column: edit `schema.ts`, run `drizzle-kit generate`, then `drizzle-kit push` (dev) or commit the migration file.
|
||||
To add a table/column: edit `schema.ts` → `drizzle-kit generate` → `drizzle-kit push` (dev) or commit the migration.
|
||||
|
||||
### Adding a New Feature (end-to-end pattern)
|
||||
### Adding a Feature (end-to-end)
|
||||
|
||||
1. **Schema** — Add table/columns to `src/main/db/schema.ts`
|
||||
2. **Router** — Add a tRPC sub-router in `src/main/router/index.ts`, merge it into `appRouter`
|
||||
3. **Types** — `AppRouter` is exported from `src/main/router/index.ts` and imported in `src/renderer/lib/trpc.ts` — types flow automatically
|
||||
4. **UI** — Create components under `src/renderer/components/<feature>/`, use `trpc.*.*useQuery()` for data
|
||||
1. **Schema** — `src/main/db/schema.ts`
|
||||
2. **Router** — Add sub-router in `src/main/router/index.ts`, merge into `appRouter`
|
||||
3. **Types** — Flow automatically via `AppRouter` export
|
||||
4. **UI** — Components in `src/renderer/components/<feature>/`, data via `trpc.*.useQuery()`
|
||||
|
||||
### AI Subsystem (`src/main/ai/`)
|
||||
## AI Subsystem (`src/main/ai/`)
|
||||
|
||||
LangGraph-based agentic system with pluggable LLM providers (OpenAI, Anthropic, GitHub Copilot).
|
||||
LangGraph-based agentic system with pluggable LLM providers.
|
||||
|
||||
**Orchestrator** (`orchestrator.ts`): Classifies user intent → routes to one of three specialist agents:
|
||||
- **Project agent** — project-scoped Q&A with tools: `read_project_notes`, `add_task`, `get_summary`, `suggest_checkpoints`, `suggest_tasks`
|
||||
- **Knowledge agent** — cross-project semantic search via `vector_search_all`
|
||||
- **General agent** — workspace-wide `add_task`
|
||||
### Orchestrator (`orchestrator.ts`)
|
||||
|
||||
Tool-calling strategy differs by provider: OpenAI/Anthropic use LangChain `bindTools()` + ToolMessage loop (max 5 iterations); Copilot uses SDK-native tools (loop handled internally).
|
||||
Classifies user intent → routes to a specialist agent:
|
||||
|
||||
**Streaming**: Orchestrator calls `sendStreamChunk(sender, token, done)` over IPC channel `'ai:stream'`. Renderer subscribes via `window.electronAI.onStreamChunk()` in `AIChatPanel.tsx`. `<tool_call>` blocks are filtered before sending to renderer.
|
||||
| Agent | Scope | Tools |
|
||||
|---|---|---|
|
||||
| Project | Project-scoped Q&A | `read_project_notes`, `add_task`, `get_summary`, `suggest_checkpoints`, `suggest_tasks` |
|
||||
| Knowledge | Cross-project search | `list_notes` + `get_note` (aiSummary-based navigation) |
|
||||
| General | Workspace-wide | `add_task` |
|
||||
|
||||
**Provider factory** (`llm.ts`): `gpt-4o-mini` (OpenAI), `claude-sonnet-4-20250514` (Anthropic), or ChatCopilot wrapper — all with `temperature: 0.3` and streaming enabled.
|
||||
All providers use LangChain `bindTools()` + ToolMessage loop (max 5 iterations).
|
||||
|
||||
Also exports `dailyBrief()` for AI-generated daily summaries (`ai.dailyBrief` tRPC mutation).
|
||||
|
||||
### Streaming
|
||||
|
||||
`sendStreamChunk(sender, token, done)` over IPC `'ai:stream'`. Renderer subscribes via `window.electronAI.onStreamChunk()` in `AIChatPanel.tsx`. `<tool_call>` blocks are filtered before display.
|
||||
|
||||
### Providers (`llm.ts`)
|
||||
|
||||
| Provider | Model | Notes |
|
||||
|---|---|---|
|
||||
| OpenAI | `gpt-4o-mini` | Via LangChain |
|
||||
| Anthropic | `claude-sonnet-4-20250514` | Via LangChain |
|
||||
| Copilot | `ChatCopilot` wrapper | `copilot.ts` / `chat-copilot.ts` |
|
||||
|
||||
All use `temperature: 0.3`, streaming enabled. Provider management in `provider.ts`.
|
||||
|
||||
**Token storage** (`token.ts`) — 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.
|
||||
### Notes AI Navigation (aiSummary index)
|
||||
|
||||
### Vector Embeddings (`src/main/db/vectordb.ts`)
|
||||
Notes have `aiSummary` (≤250 char, nullable) and `aiSummaryUpdatedAt` columns. Generated by backend `POST /api/v1/agents/notes/summarize` (gpt-4o-mini, Langfuse `note_summary` prompt).
|
||||
|
||||
LanceDB stored in `{userData}/vectors/`. Table schema: `{ id, projectId, content, vector }`. Vectors are 1536-dimensional (`text-embedding-3-small`). Embeddings use a priority chain: Copilot CLI token → OpenAI token.
|
||||
- `list_notes` tool output includes the summary per note so AI can navigate without reading full content.
|
||||
- `notes-backfill.ts` generates missing summaries on startup (throttled 1 req/s, skipped when offline).
|
||||
- Summary is regenerated fire-and-forget on note create/update and on HITL approve.
|
||||
|
||||
- Note create/update fires `upsertNoteEmbedding()` (fire-and-forget, errors swallowed)
|
||||
- `migrateNotesIfNeeded()` backfills existing notes on first startup
|
||||
- `searchNotes(query, limit=5)` is called by the Knowledge agent tool
|
||||
### Notes HITL (`noteEdits` table)
|
||||
|
||||
### Key Config Notes
|
||||
AI-proposed note edits go to `noteEdits` instead of directly modifying `notes.content`:
|
||||
- `type: append | insert | replace` — append adds at end; insert after `anchorBefore` text; replace replaces `anchorText`.
|
||||
- `status: pending | approved | rejected` — pending shows in UI with dashed border + Approve/Reject.
|
||||
- On approve: content merged into `notes.content`; summary regenerated. If anchor not found (note edited since proposal), auto-rejects.
|
||||
- `propose_note_edit` backend tool → drizzle-executor `propose_note_edit` case → inserts `noteEdits` row.
|
||||
- `noteEditsRouter` in `router/index.ts`: `list`, `listPending`, `approve`, `reject`.
|
||||
|
||||
- Vite configs use `.mts` extension (not `.ts`) to avoid ESM/CJS conflicts with electron-forge's externalize-deps plugin
|
||||
- `@/*` path alias resolves to `src/renderer/*` (TypeScript + Vite + shadcn/ui all share this alias)
|
||||
- shadcn/ui style: **new-york**, base color: **neutral**
|
||||
- Icons: **lucide-react** throughout — do not introduce other icon libraries
|
||||
- Tailwind 4 (not 3) — use CSS variable theming via `globals.css`, not `tailwind.config.js`
|
||||
- Notes use Milkdown (`@milkdown/crepe`) as the markdown editor (`src/renderer/components/notes/MilkdownEditor.tsx`)
|
||||
- Routes: `index`, `tasks`, `timeline`, `projects`, `notes.$noteId` (note ID is a URL param)
|
||||
### 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
|
||||
|
||||
### Users
|
||||
Freelancers and solo professionals managing their own client work — projects, tasks, notes, and timelines. They work alone and need a single workspace that keeps everything organized without the overhead of enterprise tools. The AI assistant is a force multiplier, helping them stay on top of their workload.
|
||||
Freelancers and solo professionals managing client work (projects, tasks, notes, timelines). Single workspace, no enterprise overhead. AI as force multiplier. They open the app mid-workday — often stressed — so the interface must feel immediately grounding and in control.
|
||||
|
||||
### Brand Personality
|
||||
**Calm, intelligent, warm.** Adiuva is a thoughtful companion, not a flashy tool. It should feel like a well-organized desk — everything in its place, nothing competing for attention. The tone is confident and understated, never loud or gamified.
|
||||
**Calm. Intelligent. Warm.** A thoughtful companion, not a flashy tool. Confident and understated — never loud, gamified, or corporate. Fully original aesthetic (no external design system references; this look is intentional and owned).
|
||||
|
||||
### Emotional Goal
|
||||
When a user opens AdiuvAI, the first impression should communicate **"everything is under control"** — calm clarity over urgency. The design should lower cognitive load, not raise it.
|
||||
|
||||
### Aesthetic Direction
|
||||
- **Visual tone**: Editorial, premium, content-first. Inspired by Notion's clean typography and warm neutrals, but with a distinct identity through the warm pinkish-white canvas and golden yellow accent
|
||||
- **Light mode**: Soft and warm — pinkish-white (`#f4edf3`) canvas, golden yellow (`#fbc881`) primary, slate blue-gray (`#8a8ea9`) secondary, dusty lavender borders (`#c8c3cd`)
|
||||
- **Dark mode**: Stark monochrome — near-black canvas (`#0c0c0c`), crisp white text, dark gray surfaces (`#323232`). No color accent; primary is pure white
|
||||
- **Typography**: Geist (geometric sans-serif) at 400/500/600. Tight tracking on large headings (`-1px`). Body at `text-sm`, metadata at `text-xs`
|
||||
- **Corners**: 10px base radius, consistently rounded. Chat elements use `rounded-2xl`
|
||||
- **Signature effects**: Glassmorphism on AI inputs/floating chat (`backdrop-blur-xl`, transparency). Spring physics animations (stiffness 400, damping 30). Subtle scale-and-fade transitions
|
||||
- **Anti-references**: No gamification (badges, streaks, confetti). No corporate/enterprise density. Keep it mature and professional
|
||||
- Light mode: pinkish-white canvas `#f4edf3`, golden yellow primary `#fbc881`, slate blue-gray secondary `#8a8ea9`, dusty lavender borders `#c8c3cd`
|
||||
- Dark mode: near-black `#0c0c0c`, pure white primary, dark gray `#323232` surfaces
|
||||
- Geist sans-serif, weights 400/500/600. Tight tracking (`-1px`) on headings. Body `text-sm`, metadata `text-xs`
|
||||
- 10px border-radius (`rounded-lg`), `rounded-2xl` for chat/AI elements
|
||||
- Glassmorphism on AI inputs (`backdrop-blur-xl`, transparency, gradient border via padding-box/border-box technique)
|
||||
- Spring animations (stiffness 400, damping 30), scale-and-fade transitions
|
||||
- No gamification (badges, streaks, confetti). Mature and professional
|
||||
- Dashed borders + Sparkles icon = AI-pending state marker
|
||||
|
||||
### Accessibility
|
||||
Best-effort — not formally audited. Maintain reasonable contrast and keyboard operability without targeting a specific WCAG level.
|
||||
|
||||
### Current Design Focus
|
||||
**Polish and refinement.** The overall direction is solid; the priority is elevating specific areas that feel rough or inconsistent — tighter spacing, more intentional hierarchy, better empty/loading states, and smoother motion.
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Clarity over cleverness** — Every element should communicate its purpose instantly. Prefer clear hierarchy and whitespace over decorative flourish. Information density should feel comfortable, not cramped.
|
||||
|
||||
2. **AI as quiet partner** — The AI is deeply integrated (floating chat, suggestions) but never intrusive. AI-suggested items use dashed borders to signal "pending." The Sparkles icon is the consistent AI identity marker.
|
||||
|
||||
3. **Warmth in restraint** — The palette is deliberately warm (pinkish whites, golden yellows) to feel approachable without being playful. Dark mode trades warmth for focus. Let the content breathe.
|
||||
|
||||
4. **Motion with purpose** — Spring physics and glassmorphism create a sense of physicality and depth. Animations should feel natural and responsive, never decorative or slow. Every transition should reinforce spatial relationships.
|
||||
|
||||
5. **Confidence through consistency** — Use the established token system (CSS variables, shadcn/ui primitives, Geist font). The user should feel in control — predictable patterns, keyboard-first interactions, no surprises.
|
||||
1. **Clarity over cleverness** — Clear hierarchy, generous whitespace, comfortable density. Never sacrifice legibility for style.
|
||||
2. **AI as quiet partner** — Deeply integrated but never intrusive. Dashed borders for pending AI items, Sparkles icon as the sole AI marker. Surface AI capabilities without making them the hero.
|
||||
3. **Warmth in restraint** — The warm palette feels approachable without being playful. Dark mode trades warmth for focus. Neither mode should feel cold or aggressive.
|
||||
4. **Motion with purpose** — Spring animations reinforce spatial relationships and acknowledge state changes. Never purely decorative. Respect reduced-motion preferences where possible.
|
||||
5. **Polish over features** — Every surface should feel considered. Prefer refining what exists over introducing new complexity. The right amount of visual weight is the minimum needed.
|
||||
|
||||
14
.claude/settings.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add AI_REFACTOR_PLAN.md)",
|
||||
"Bash(git commit:*)",
|
||||
"Read(//home/rmusso/adiuvai-api/**)",
|
||||
"mcp__shadcn__get_item_examples_from_registries",
|
||||
"mcp__shadcn__view_items_in_registries",
|
||||
"Bash(npm run lint)",
|
||||
"Bash(npx eslint --ext .ts,.tsx src/renderer/components/ai/blocks/)",
|
||||
"WebFetch(domain:ui.shadcn.com)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/electron",
|
||||
"plugin:import/typescript"
|
||||
|
||||
6
.gitignore
vendored
@@ -91,6 +91,10 @@ typings/
|
||||
# Electron-Forge
|
||||
out/
|
||||
|
||||
# Web SPA build
|
||||
dist-web/
|
||||
|
||||
# local config files
|
||||
.vscode/
|
||||
|
||||
.agents/
|
||||
src/renderer/routeTree.gen.ts
|
||||
|
||||
11
.mcp.json
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
385
assets/logo/brand-showcase.html
Normal file
@@ -0,0 +1,385 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>adiuvAI — Brand Identity</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg-light: #f4edf3; --bg-dark: #0c0c0c;
|
||||
--text: #040404; --text-light: #fbfbfb;
|
||||
--primary: #fbc881; --secondary: #8a8ea9;
|
||||
--muted: #c8c3cd; --border: #c8c3cd;
|
||||
--radius: 10px;
|
||||
}
|
||||
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg-light); color: var(--text); -webkit-font-smoothing: antialiased; }
|
||||
.container { max-width: 1100px; margin: 0 auto; padding: 0 32px; }
|
||||
.section-label { font-size: 11px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--primary); margin-bottom: 12px; }
|
||||
|
||||
/* ── COMPASS ANIMATION ── */
|
||||
.compass-needle { animation: compass-settle 5s ease-in-out infinite; transform-origin: 32px 32px; }
|
||||
@keyframes compass-settle {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(4deg); }
|
||||
50% { transform: rotate(-3deg); }
|
||||
80% { transform: rotate(2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero { background: var(--bg-dark); padding: 96px 32px 80px; text-align: center; }
|
||||
.hero-mark { width: 96px; height: 96px; margin: 0 auto 32px; }
|
||||
.hero-label { font-size: 11px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--primary); margin-bottom: 16px; }
|
||||
.hero-name { font-size: clamp(40px,6vw,64px); font-weight: 700; color: var(--text-light); letter-spacing: -2px; line-height: 1; margin-bottom: 16px; }
|
||||
.hero-name span { color: var(--primary); }
|
||||
.hero-tagline { font-size: 16px; color: rgba(251,251,251,.45); max-width: 420px; margin: 0 auto; line-height: 1.6; }
|
||||
|
||||
/* ── SECTIONS ── */
|
||||
.section { padding: 72px 32px; border-bottom: 1px solid var(--border); }
|
||||
.section:last-of-type { border-bottom: none; }
|
||||
.section-dark { background: var(--bg-dark); border-bottom: 1px solid #1a1a1a; }
|
||||
.section h2 { font-size: 22px; font-weight: 600; color: var(--text); margin-bottom: 8px; letter-spacing: -.5px; }
|
||||
.section-dark h2 { color: var(--text-light); }
|
||||
.section > p { font-size: 14px; color: var(--secondary); line-height: 1.7; max-width: 560px; margin-bottom: 40px; }
|
||||
.section-dark > p { color: rgba(251,251,251,.4); }
|
||||
|
||||
/* ── CONCEPT GRID ── */
|
||||
.concept-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: start; }
|
||||
@media (max-width:700px) { .concept-grid { grid-template-columns: 1fr; } }
|
||||
.concept-mark-wrapper { background: var(--bg-dark); border-radius: var(--radius); padding: 56px 48px; display: flex; justify-content: center; align-items: center; }
|
||||
.concept-text .point { display: flex; gap: 12px; margin-bottom: 22px; align-items: flex-start; }
|
||||
.point-icon { width: 26px; height: 26px; background: rgba(251,200,129,.1); border: 1px solid rgba(251,200,129,.22); border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 1px; font-size: 12px; }
|
||||
.point-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
|
||||
.point-desc { font-size: 13px; color: var(--secondary); line-height: 1.65; margin: 0; }
|
||||
|
||||
/* ── VARIANTS ── */
|
||||
.variants-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.variant-full { grid-column: 1 / -1; }
|
||||
@media (max-width:700px) { .variants-grid { grid-template-columns: 1fr; } }
|
||||
.logo-card { border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border); transition: transform .18s ease, box-shadow .18s ease; }
|
||||
.logo-card:hover { transform: translateY(-3px); box-shadow: 0 8px 32px rgba(0,0,0,.10); }
|
||||
.logo-card-dark { border-color: #1e1e1e; }
|
||||
.logo-card-dark:hover { box-shadow: 0 8px 32px rgba(0,0,0,.5); }
|
||||
.card-inner { padding: 40px 32px; display: flex; align-items: center; justify-content: center; min-height: 120px; }
|
||||
.card-inner-light { background: var(--bg-light); }
|
||||
.card-inner-dark { background: var(--bg-dark); }
|
||||
.card-inner-white { background: #fff; }
|
||||
.card-inner img { max-width: 100%; max-height: 72px; object-fit: contain; }
|
||||
.card-meta { padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid var(--border); background: #fff; }
|
||||
.card-meta-dark { border-top-color: #1e1e1e; background: #0e0e0e; }
|
||||
.card-label { font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--secondary); }
|
||||
.card-meta-dark .card-label { color: rgba(251,251,251,.3); }
|
||||
.card-filename { font-size: 11px; font-family: monospace; color: var(--muted); }
|
||||
.card-meta-dark .card-filename { color: rgba(251,251,251,.2); }
|
||||
|
||||
/* ── PALETTE ── */
|
||||
.palette-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(140px,1fr)); gap: 12px; }
|
||||
.color-chip { border-radius: var(--radius); overflow: hidden; border: 1px solid rgba(0,0,0,.06); }
|
||||
.color-swatch { height: 80px; display: flex; align-items: flex-end; padding: 8px 10px; }
|
||||
.color-hex { font-size: 11px; font-family: monospace; font-weight: 500; opacity: .65; }
|
||||
.color-info { padding: 10px; background: #fff; }
|
||||
.color-name { font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 2px; }
|
||||
.color-role { font-size: 11px; color: var(--secondary); }
|
||||
|
||||
/* ── TYPE ── */
|
||||
.type-specimen { background: #fff; border: 1px solid var(--border); border-radius: var(--radius); padding: 40px; }
|
||||
.type-row { padding: 20px 0; border-bottom: 1px solid #f0eaef; }
|
||||
.type-row:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.type-meta { font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-bottom: 8px; }
|
||||
.type-xl { font-size: 48px; font-weight: 700; letter-spacing: -2px; line-height: 1; }
|
||||
.type-xl span { color: var(--primary); }
|
||||
.type-lg { font-size: 28px; font-weight: 600; letter-spacing: -.8px; }
|
||||
.type-md { font-size: 16px; font-weight: 400; line-height: 1.6; }
|
||||
.type-sm { font-size: 12px; font-weight: 500; letter-spacing: .06em; text-transform: uppercase; color: var(--secondary); }
|
||||
|
||||
/* ── DEV REF ── */
|
||||
.dev-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
@media (max-width:700px) { .dev-grid { grid-template-columns: 1fr; } }
|
||||
.code-block { background: #0e0e0e; border: 1px solid #1e1e1e; border-radius: var(--radius); padding: 24px; overflow-x: auto; }
|
||||
.code-block pre { font-family: monospace; font-size: 12px; line-height: 1.7; color: #e1e2e8; }
|
||||
.ck { color: #a1c9fd; } .cs { color: #fbc881; } .cc { color: rgba(225,226,232,.28); font-style: italic; }
|
||||
.file-tree { background: #0e0e0e; border: 1px solid #1e1e1e; border-radius: var(--radius); padding: 24px; }
|
||||
.file-tree-title { font-size: 11px; font-weight: 600; letter-spacing: .10em; text-transform: uppercase; color: rgba(251,251,251,.28); margin-bottom: 16px; }
|
||||
.fi { display: flex; gap: 10px; margin-bottom: 10px; align-items: flex-start; }
|
||||
.fi:last-child { margin-bottom: 0; }
|
||||
.fi-icon { font-size: 12px; flex-shrink: 0; margin-top: 1px; }
|
||||
.fi-name { font-family: monospace; font-size: 12px; color: #fbc881; flex-shrink: 0; }
|
||||
.fi-desc { font-size: 12px; color: rgba(225,226,232,.3); line-height: 1.5; }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
.footer { background: var(--bg-dark); padding: 48px 32px; text-align: center; }
|
||||
.footer-name { font-size: 20px; font-weight: 700; color: var(--text-light); letter-spacing: -.5px; margin-bottom: 4px; }
|
||||
.footer-name span { color: var(--primary); }
|
||||
.footer-sub { font-size: 12px; color: rgba(251,251,251,.28); letter-spacing: .06em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<div class="hero-mark">
|
||||
<svg viewBox="0 0 64 64" fill="none" width="96" height="96">
|
||||
<g class="compass-needle">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#FFFFFF" opacity="0.75"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#FFFFFF" opacity="0.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="hero-label">Brand Identity</p>
|
||||
<h1 class="hero-name">adiuv<span>AI</span></h1>
|
||||
<p class="hero-tagline">Il tuo compasso nel lavoro quotidiano — l'AI che ti indica sempre la direzione giusta.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DESIGN CONCEPT -->
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<p class="section-label">Design Concept</p>
|
||||
<h2>Il Compasso</h2>
|
||||
<p>Non il gesto dell'aiuto, ma il suo significato più profondo: qualcuno che ti indica la strada. Un ago di bussola che oscilla e si ferma sempre a nord.</p>
|
||||
|
||||
<div class="concept-grid">
|
||||
|
||||
<div class="concept-mark-wrapper">
|
||||
<svg viewBox="0 0 64 64" fill="none" width="140" height="140">
|
||||
<g class="compass-needle">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#FFFFFF" opacity="0.65"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32" stroke="#fff" stroke-width="0.5" opacity="0.15"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#fff" opacity="0.2"/>
|
||||
</g>
|
||||
<!-- Annotations -->
|
||||
<text x="34" y="14" font-size="4" fill="rgba(251,200,129,.6)" font-family="Inter,sans-serif" letter-spacing=".05em">NORD · AI</text>
|
||||
<line x1="32" y1="17" x2="32" y2="20" stroke="rgba(251,200,129,.4)" stroke-width=".6"/>
|
||||
<text x="34" y="52" font-size="4" fill="rgba(255,255,255,.35)" font-family="Inter,sans-serif" letter-spacing=".05em">SUD · YOU</text>
|
||||
<line x1="32" y1="47" x2="32" y2="50" stroke="rgba(255,255,255,.2)" stroke-width=".6"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="concept-text">
|
||||
<div class="point">
|
||||
<div class="point-icon">▲</div>
|
||||
<div>
|
||||
<div class="point-title">Nord dorato = l'AI</div>
|
||||
<p class="point-desc">La punta superiore (#fbc881) punta sempre verso l'alto — verso l'obiettivo. È l'AI: calda, orientata, che guida senza invadere.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="point">
|
||||
<div class="point-icon">▼</div>
|
||||
<div>
|
||||
<div class="point-title">Sud scuro = l'utente</div>
|
||||
<p class="point-desc">La punta inferiore (#040404) è ancorata alla realtà. L'utente con le sue attività, i suoi progetti, il suo lavoro concreto.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="point">
|
||||
<div class="point-icon">↺</div>
|
||||
<div>
|
||||
<div class="point-title">L'oscillazione (animazione)</div>
|
||||
<p class="point-desc">Il mark oscilla leggermente come un vero ago prima di fermarsi — trovare il nord. Un dettaglio quasi impercettibile, fedele alla brand personality "calma, mai appariscente".</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="point">
|
||||
<div class="point-icon">◇</div>
|
||||
<div>
|
||||
<div class="point-title">Il diamante (forma)</div>
|
||||
<p class="point-desc">Due triangoli che formano un rombo. Forma archetipica, funziona a 16px come a 512px. La divisione orizzontale racconta la relazione senza bisogno di parole.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LOGO VARIANTS -->
|
||||
<section class="section" style="background:#faf5f9;">
|
||||
<div class="container">
|
||||
<p class="section-label">Logo Variants</p>
|
||||
<h2>7 File Canonici</h2>
|
||||
<p>Ogni variante usa gli stessi due triangoli — cambiano solo colore e scala.</p>
|
||||
|
||||
<div class="variants-grid">
|
||||
|
||||
<div class="logo-card variant-full">
|
||||
<div class="card-inner card-inner-light" style="min-height:100px;">
|
||||
<img src="logo-full.svg" alt="adiuvAI full logo">
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-label">Full Logo</span>
|
||||
<span class="card-filename">logo-full.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card logo-card-dark variant-full">
|
||||
<div class="card-inner card-inner-dark" style="min-height:100px;">
|
||||
<img src="logo-white.svg" alt="adiuvAI white">
|
||||
</div>
|
||||
<div class="card-meta card-meta-dark">
|
||||
<span class="card-label">White Variant</span>
|
||||
<span class="card-filename">logo-white.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card logo-card-dark">
|
||||
<div class="card-inner card-inner-dark" style="min-height:140px;">
|
||||
<img src="logo-mark.svg" alt="mark" style="width:80px;height:80px;">
|
||||
</div>
|
||||
<div class="card-meta card-meta-dark">
|
||||
<span class="card-label">Mark</span>
|
||||
<span class="card-filename">logo-mark.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card">
|
||||
<div class="card-inner card-inner-white" style="min-height:140px;">
|
||||
<img src="logo-icon.svg" alt="icon" style="max-width:110px;max-height:110px;">
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-label">App Icon</span>
|
||||
<span class="card-filename">logo-icon.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card">
|
||||
<div class="card-inner card-inner-light">
|
||||
<img src="logo-wordmark.svg" alt="wordmark" style="max-height:40px;">
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-label">Wordmark</span>
|
||||
<span class="card-filename">logo-wordmark.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card logo-card-dark">
|
||||
<div class="card-inner card-inner-dark" style="flex-direction:column;gap:8px;">
|
||||
<img src="favicon.svg" alt="favicon" style="width:64px;height:64px;image-rendering:pixelated;">
|
||||
<span style="font-size:10px;color:rgba(251,251,251,.25);font-family:monospace;">16×16 px</span>
|
||||
</div>
|
||||
<div class="card-meta card-meta-dark">
|
||||
<span class="card-label">Favicon</span>
|
||||
<span class="card-filename">favicon.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logo-card variant-full">
|
||||
<div class="card-inner card-inner-white" style="min-height:100px;">
|
||||
<img src="logo-black.svg" alt="black">
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="card-label">Black Variant</span>
|
||||
<span class="card-filename">logo-black.svg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- COLOR PALETTE -->
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<p class="section-label">Color Palette</p>
|
||||
<h2>Colori Brand</h2>
|
||||
<p>Estratti direttamente da globals.css. Canvas rosato caldo in light; monocromo rigoroso in dark.</p>
|
||||
<div class="palette-grid">
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#fbc881;"><span class="color-hex" style="color:rgba(4,4,4,.5);">#fbc881</span></div>
|
||||
<div class="color-info"><div class="color-name">Golden</div><div class="color-role">Nord · AI · Accent</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#f4edf3;"><span class="color-hex" style="color:rgba(4,4,4,.35);">#f4edf3</span></div>
|
||||
<div class="color-info"><div class="color-name">Canvas Light</div><div class="color-role">Sfondo light mode</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#0c0c0c;"><span class="color-hex" style="color:rgba(251,251,251,.35);">#0c0c0c</span></div>
|
||||
<div class="color-info"><div class="color-name">Canvas Dark</div><div class="color-role">Sfondo dark mode</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#040404;"><span class="color-hex" style="color:rgba(251,251,251,.35);">#040404</span></div>
|
||||
<div class="color-info"><div class="color-name">Ink</div><div class="color-role">Sud · utente · testo</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#fbfbfb;border:1px solid #e8e0e7;"><span class="color-hex" style="color:rgba(4,4,4,.28);">#fbfbfb</span></div>
|
||||
<div class="color-info"><div class="color-name">Paper</div><div class="color-role">Testo dark mode</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#8a8ea9;"><span class="color-hex" style="color:rgba(251,251,251,.65);">#8a8ea9</span></div>
|
||||
<div class="color-info"><div class="color-name">Slate</div><div class="color-role">Secondario · muted</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#c8c3cd;"><span class="color-hex" style="color:rgba(4,4,4,.38);">#c8c3cd</span></div>
|
||||
<div class="color-info"><div class="color-name">Lavender</div><div class="color-role">Bordi · muted</div></div>
|
||||
</div>
|
||||
<div class="color-chip">
|
||||
<div class="color-swatch" style="background:#323232;"><span class="color-hex" style="color:rgba(251,251,251,.35);">#323232</span></div>
|
||||
<div class="color-info"><div class="color-name">Graphite</div><div class="color-role">Dark surfaces</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TYPOGRAPHY -->
|
||||
<section class="section" style="background:#faf5f9;">
|
||||
<div class="container">
|
||||
<p class="section-label">Typography</p>
|
||||
<h2>Geist · System Sans-Serif</h2>
|
||||
<p>Geometrico, pulito, sicuro. @fontsource/geist nell'app; system-ui come fallback.</p>
|
||||
<div class="type-specimen">
|
||||
<div class="type-row"><div class="type-meta">Display · 48px · 700</div><div class="type-xl">adiuv<span>AI</span></div></div>
|
||||
<div class="type-row"><div class="type-meta">Heading · 28px · 600</div><div class="type-lg">Workspace intelligente, locale, caldo.</div></div>
|
||||
<div class="type-row"><div class="type-meta">Body · 16px · 400</div><div class="type-md">adiuvAI organizza i tuoi progetti, task e note in un workspace calmo — con un AI che ti guida silenziosamente verso gli obiettivi.</div></div>
|
||||
<div class="type-row"><div class="type-meta">Label · 11px · 600 · Tracked</div><div class="type-sm">Brand Identity · Design System · 2026</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DEVELOPER REFERENCE -->
|
||||
<section class="section-dark section">
|
||||
<div class="container">
|
||||
<p class="section-label">Developer Reference</p>
|
||||
<h2>Tailwind Config · File Tree</h2>
|
||||
<p>Token da aggiungere al blocco @theme di globals.css (Tailwind 4).</p>
|
||||
<div class="dev-grid">
|
||||
<div class="code-block">
|
||||
<pre><span class="cc">// globals.css — @theme inline</span>
|
||||
<span class="ck">--brand-golden</span>: <span class="cs">#fbc881</span>; <span class="cc">/* nord · AI */</span>
|
||||
<span class="ck">--brand-canvas</span>: <span class="cs">#f4edf3</span>; <span class="cc">/* light bg */</span>
|
||||
<span class="ck">--brand-void</span>: <span class="cs">#0c0c0c</span>; <span class="cc">/* dark bg */</span>
|
||||
<span class="ck">--brand-ink</span>: <span class="cs">#040404</span>; <span class="cc">/* sud · user */</span>
|
||||
<span class="ck">--brand-slate</span>: <span class="cs">#8a8ea9</span>; <span class="cc">/* secondary */</span>
|
||||
<span class="ck">--brand-lavender</span>: <span class="cs">#c8c3cd</span>; <span class="cc">/* border */</span>
|
||||
<span class="ck">--brand-graphite</span>: <span class="cs">#323232</span>; <span class="cc">/* dark surface */</span>
|
||||
<span class="ck">--brand-paper</span>: <span class="cs">#fbfbfb</span>; <span class="cc">/* light text */</span></pre>
|
||||
</div>
|
||||
<div class="file-tree">
|
||||
<div class="file-tree-title">assets/logo/</div>
|
||||
<div class="fi"><span class="fi-icon">◇</span><span class="fi-name">logo-mark.svg</span><span class="fi-desc">Compasso canonico · 64×64 · animato</span></div>
|
||||
<div class="fi"><span class="fi-icon">◻</span><span class="fi-name">logo-full.svg</span><span class="fi-desc">Mark + wordmark · 320×72 · animato</span></div>
|
||||
<div class="fi"><span class="fi-icon">T</span><span class="fi-name">logo-wordmark.svg</span><span class="fi-desc">Solo testo · 200×40</span></div>
|
||||
<div class="fi"><span class="fi-icon">▣</span><span class="fi-name">logo-icon.svg</span><span class="fi-desc">App icon · 512×512</span></div>
|
||||
<div class="fi"><span class="fi-icon">·</span><span class="fi-name">favicon.svg</span><span class="fi-desc">Semplificato · 16×16</span></div>
|
||||
<div class="fi"><span class="fi-icon">◻</span><span class="fi-name">logo-white.svg</span><span class="fi-desc">Variante bianca · su sfondo scuro</span></div>
|
||||
<div class="fi"><span class="fi-icon">◻</span><span class="fi-name">logo-black.svg</span><span class="fi-desc">Variante nera · su sfondo chiaro</span></div>
|
||||
<div class="fi" style="margin-top:12px;padding-top:12px;border-top:1px solid #1e1e1e;">
|
||||
<span class="fi-icon">🌐</span><span class="fi-name">brand-showcase.html</span><span class="fi-desc">Questa pagina</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-name">adiuv<span>AI</span></div>
|
||||
<div class="footer-sub">Brand Identity · roberto · 2026</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
10
assets/logo/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||
<!--
|
||||
adiuvAI — Favicon 16×16
|
||||
Same compass needle, scaled to canvas.
|
||||
North: M8,1 L13,8 L3,8 Z
|
||||
South: M3,8 L13,8 L8,15 Z
|
||||
-->
|
||||
<path d="M8,1 L13,8 L3,8 Z" fill="#fbc881"/>
|
||||
<path d="M3,8 L13,8 L8,15 Z" fill="#040404"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 325 B |
13
assets/logo/logo-black.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none">
|
||||
<!-- adiuvAI — Black variant (light backgrounds, no color) -->
|
||||
<g transform="translate(2,2)">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#1A1A1A" opacity="0.55"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#1A1A1A"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#1A1A1A" opacity="0.2"/>
|
||||
</g>
|
||||
<text x="65" y="42"
|
||||
font-family="Geist, system-ui, -apple-system, sans-serif"
|
||||
font-size="30" letter-spacing="-0.5">
|
||||
<tspan font-weight="400" fill="#1A1A1A" opacity="0.7">adiuv</tspan><tspan font-weight="700" fill="#1A1A1A">AI</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 646 B |
35
assets/logo/logo-full.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none">
|
||||
<!--
|
||||
adiuvAI — Full logo (mark + wordmark)
|
||||
Mark: translate(4,4) — canonical paths from logo-mark.svg
|
||||
-->
|
||||
<style>
|
||||
.compass-needle {
|
||||
animation: compass-settle 5s ease-in-out infinite;
|
||||
transform-origin: 32px 32px;
|
||||
}
|
||||
@keyframes compass-settle {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(4deg); }
|
||||
50% { transform: rotate(-3deg); }
|
||||
80% { transform: rotate(2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<g transform="translate(2,2)">
|
||||
<g class="compass-needle">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32"
|
||||
stroke="#040404" stroke-width="0.5" opacity="0.12"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<text x="65" y="42"
|
||||
font-family="Geist, system-ui, -apple-system, sans-serif"
|
||||
font-size="30" letter-spacing="-0.5">
|
||||
<tspan font-weight="400" fill="#040404">adiuv</tspan><tspan font-weight="700" fill="#fbc881">AI</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/logo/logo-icon.ico
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/logo/logo-icon.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
14
assets/logo/logo-icon.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<!--
|
||||
adiuvAI — App icon 512×512
|
||||
Mark scaled 6.5× — translate(48,48) scale(6.5)
|
||||
-->
|
||||
<rect width="512" height="512" rx="112" fill="#f4edf3"/>
|
||||
<g transform="translate(48,48) scale(6.5)">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32"
|
||||
stroke="#040404" stroke-width="0.5" opacity="0.12"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 564 B |
41
assets/logo/logo-mark.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<!--
|
||||
adiuvAI — "Il Compasso" (The Compass Needle)
|
||||
|
||||
A compass needle split at its equator:
|
||||
North (top) → golden = the AI, always pointing toward your goal
|
||||
South (bottom) → dark = the user, grounded in reality
|
||||
|
||||
CANONICAL PATHS (derive all variants from these):
|
||||
North: M32,4 L48,32 L16,32 Z
|
||||
South: M16,32 L48,32 L32,60 Z
|
||||
Center: line x1=16 y1=32 x2=48 y2=32 (1px hairline separator)
|
||||
|
||||
The shape oscillates like a compass finding north — settles on upward guidance.
|
||||
-->
|
||||
<style>
|
||||
.compass-needle {
|
||||
animation: compass-settle 5s ease-in-out infinite;
|
||||
transform-origin: 32px 32px;
|
||||
}
|
||||
@keyframes compass-settle {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(4deg); }
|
||||
50% { transform: rotate(-3deg); }
|
||||
80% { transform: rotate(2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<g class="compass-needle">
|
||||
<!-- North — AI (golden) -->
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<!-- South — Human (dark) -->
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
||||
<!-- Hairline equator -->
|
||||
<line x1="16" y1="32" x2="48" y2="32"
|
||||
stroke="#040404" stroke-width="0.5" opacity="0.12"/>
|
||||
<!-- Center pivot -->
|
||||
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
13
assets/logo/logo-white.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none">
|
||||
<!-- adiuvAI — White variant (dark backgrounds) -->
|
||||
<g transform="translate(2,2)">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#FFFFFF" opacity="0.85"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#FFFFFF" opacity="0.25"/>
|
||||
</g>
|
||||
<text x="65" y="42"
|
||||
font-family="Geist, system-ui, -apple-system, sans-serif"
|
||||
font-size="30" letter-spacing="-0.5">
|
||||
<tspan font-weight="400" fill="#FFFFFF" opacity="0.85">adiuv</tspan><tspan font-weight="700" fill="#FFFFFF">AI</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 637 B |
7
assets/logo/logo-wordmark.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 105 30" fill="none">
|
||||
<text x="2" y="25"
|
||||
font-family="Geist, system-ui, -apple-system, sans-serif"
|
||||
font-size="30" letter-spacing="-0.5">
|
||||
<tspan font-weight="400" fill="#040404">adiuv</tspan><tspan font-weight="700" fill="#fbc881">AI</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 332 B |
BIN
assets/screenshot/home.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
assets/screenshot/home_chat.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
assets/screenshot/projects.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
assets/screenshot/task.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
@@ -15,12 +15,8 @@ import { execSync } from 'node:child_process';
|
||||
// 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',
|
||||
'@lancedb/lancedb',
|
||||
'ws',
|
||||
'electron-squirrel-startup',
|
||||
'electron-store',
|
||||
];
|
||||
@@ -30,7 +26,23 @@ const config: ForgeConfig = {
|
||||
asar: {
|
||||
unpack: '**/{*.node,*.dll,*.so,*.dylib}',
|
||||
},
|
||||
name: 'adiuva',
|
||||
name: 'adiuvAI',
|
||||
// icon path without extension — Forge picks .ico (Win), .icns (Mac), .png (Linux)
|
||||
icon: 'assets/logo/logo-icon',
|
||||
// Ship Drizzle's generated migrations as a sibling of the asar so the
|
||||
// runtime migrator (drizzle-orm/better-sqlite3/migrator) can read them at
|
||||
// `<resourcesPath>/migrations/` in packaged builds. See src/main/db/index.ts.
|
||||
extraResource: ['./src/main/db/migrations'],
|
||||
// Deep-link protocol for OAuth callback: adiuvai://oauth/callback?code=...
|
||||
// macOS: written into Info.plist by Forge automatically.
|
||||
// Windows: registered by the Squirrel installer via packagerConfig.protocols.
|
||||
// Dev: app.setAsDefaultProtocolClient() in index.ts handles both platforms.
|
||||
protocols: [
|
||||
{
|
||||
name: 'AdiuvAI',
|
||||
schemes: ['adiuvai'],
|
||||
},
|
||||
],
|
||||
},
|
||||
rebuildConfig: {},
|
||||
hooks: {
|
||||
@@ -70,20 +82,20 @@ const config: ForgeConfig = {
|
||||
|
||||
const targetKey = `${platform}-${arch}`;
|
||||
|
||||
// vectordb uses platform-specific optional deps (@lancedb/vectordb-<platform>-<arch>-*).
|
||||
// @lancedb/lancedb uses platform-specific optional deps (@lancedb/lancedb-<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': '',
|
||||
'@lancedb/lancedb-win32-x64-msvc': '',
|
||||
},
|
||||
'linux-x64': {
|
||||
'@lancedb/vectordb-linux-x64-gnu': '',
|
||||
'@lancedb/lancedb-linux-x64-gnu': '',
|
||||
},
|
||||
'darwin-x64': {
|
||||
'@lancedb/vectordb-darwin-x64': '',
|
||||
'@lancedb/lancedb-darwin-x64': '',
|
||||
},
|
||||
'darwin-arm64': {
|
||||
'@lancedb/vectordb-darwin-arm64': '',
|
||||
'@lancedb/lancedb-darwin-arm64': '',
|
||||
},
|
||||
};
|
||||
const nativePkgs = platformNativePackages[targetKey];
|
||||
@@ -92,7 +104,7 @@ const config: ForgeConfig = {
|
||||
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}`)) {
|
||||
if (entry.startsWith('lancedb-') && !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}`);
|
||||
}
|
||||
@@ -108,8 +120,7 @@ const config: ForgeConfig = {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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[] = [];
|
||||
@@ -137,26 +148,6 @@ const config: ForgeConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
// @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 ──────
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Adiuva</title>
|
||||
<title>adiuvAI</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
2080
package-lock.json
generated
29
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "adiuva",
|
||||
"productName": "Adiuva",
|
||||
"name": "adiuvai",
|
||||
"productName": "adiuvAI",
|
||||
"version": "0.1.0",
|
||||
"description": "Local-first intelligent desktop workspace",
|
||||
"main": ".vite/build/main.js",
|
||||
@@ -11,7 +11,10 @@
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "eslint --ext .ts,.tsx .",
|
||||
"knip": "knip"
|
||||
"knip": "knip",
|
||||
"dev:web": "vite --config vite.web.config.mts",
|
||||
"build:web": "vite build --config vite.web.config.mts",
|
||||
"preview:web": "vite preview --config vite.web.config.mts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "roberto",
|
||||
@@ -40,21 +43,17 @@
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"knip": "^5.85.0",
|
||||
"postcss": "^8.5.6",
|
||||
"shadcn": "^3.8.5",
|
||||
"shadcn": "^4.0.8",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@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",
|
||||
@@ -63,6 +62,7 @@
|
||||
"@trpc/client": "^11.10.0",
|
||||
"@trpc/react-query": "^11.10.0",
|
||||
"@trpc/server": "^11.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -71,16 +71,25 @@
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"framer-motion": "^12.34.2",
|
||||
"i18next": "^26.0.4",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.11.0",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vectordb": "^0.21.2",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
179
scripts/seed-fake-data.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Seed script: inserts fake clients, projects, tasks, timeline events, and notes
|
||||
into the local adiuvAI SQLite database.
|
||||
|
||||
Usage: python scripts/seed-fake-data.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
import random
|
||||
import time
|
||||
|
||||
# ── locate the database ──────────────────────────────────────────────────
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if not appdata:
|
||||
raise RuntimeError("APPDATA environment variable not found (Windows only)")
|
||||
db_path = os.path.join(appdata, "adiuvAI", "adiuvai.db")
|
||||
|
||||
if not os.path.isfile(db_path):
|
||||
raise FileNotFoundError(f"Database not found at {db_path}. Is the app installed / run at least once?")
|
||||
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cur = conn.cursor()
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────
|
||||
def uid():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def ts(days_ago=0):
|
||||
"""Timestamp in ms, optionally shifted into the past."""
|
||||
return int((time.time() - days_ago * 86400) * 1000)
|
||||
|
||||
# ── fake data definitions ────────────────────────────────────────────────
|
||||
CLIENTS = [
|
||||
{"name": "Acme Corp", "industry": "Manufacturing"},
|
||||
{"name": "Globex Inc", "industry": "Technology"},
|
||||
{"name": "Initech Solutions", "industry": "Finance"},
|
||||
{"name": "Umbrella Labs", "industry": "Healthcare"},
|
||||
{"name": "Wayne Enterprises", "industry": "Defense & Engineering"},
|
||||
]
|
||||
|
||||
PROJECTS_PER_CLIENT = [
|
||||
# (name, status)
|
||||
[("Website Redesign", "active"), ("ERP Migration", "active")],
|
||||
[("AI Chatbot MVP", "active"), ("Cloud Infrastructure", "archived")],
|
||||
[("Compliance Audit Tool", "active"),],
|
||||
[("Patient Portal v2", "active"), ("Lab Inventory System", "active"), ("R&D Dashboard", "archived")],
|
||||
[("Bat-Signal Network", "active"), ("Vehicle Fleet Tracker", "active")],
|
||||
]
|
||||
|
||||
TASK_TEMPLATES = [
|
||||
("Design homepage mockup", "Create wireframes and high-fidelity mockups for the landing page", "todo", "high"),
|
||||
("Set up CI/CD pipeline", "Configure GitHub Actions with build, test, deploy stages", "in-progress", "high"),
|
||||
("Write unit tests for auth", "Cover login, register, and token refresh flows", "todo", "medium"),
|
||||
("Database schema review", "Review ERD and optimize indexes for production workload", "done", "medium"),
|
||||
("Implement search feature", "Full-text search across projects and notes", "todo", "low"),
|
||||
("Fix timezone bug", "Date picker shows wrong day for UTC+offset users", "in-progress", "high"),
|
||||
("API rate limiting", "Add sliding-window rate limiter to public endpoints", "todo", "medium"),
|
||||
("Onboarding walkthrough", "Build step-by-step tour for new users", "todo", "low"),
|
||||
("Performance profiling", "Identify and fix top 3 slow queries", "done", "high"),
|
||||
("Accessibility audit", "Ensure WCAG 2.1 AA compliance across all pages", "todo", "medium"),
|
||||
("Mobile responsive layout", "Adapt dashboard for tablets and phones", "in-progress", "medium"),
|
||||
("Export to PDF", "Allow users to export reports and invoices to PDF", "todo", "low"),
|
||||
]
|
||||
|
||||
TIMELINE_TEMPLATES = [
|
||||
("Project Kickoff", 0, None),
|
||||
("Design Phase Complete", 14, None),
|
||||
("Alpha Release", 30, None),
|
||||
("Beta Testing", 45, 60),
|
||||
("User Acceptance Testing", 60, 75),
|
||||
("Production Launch", 90, None),
|
||||
("Post-Launch Review", 100, None),
|
||||
]
|
||||
|
||||
NOTE_TEMPLATES = [
|
||||
("Meeting Notes — Kickoff",
|
||||
"## Attendees\n- Product Owner\n- Dev Lead\n- Designer\n\n## Key Decisions\n1. Use React + TypeScript stack\n2. Two-week sprint cycles\n3. MVP scope: auth, dashboard, CRUD\n\n## Action Items\n- [ ] Set up repo\n- [ ] Create Figma workspace\n- [ ] Schedule daily standups"),
|
||||
|
||||
("Architecture Decision Record",
|
||||
"## ADR-001: Database Choice\n\n**Status:** Accepted\n\n**Context:** Need a lightweight, embedded database for local-first architecture.\n\n**Decision:** SQLite with WAL mode via better-sqlite3.\n\n**Consequences:**\n- Fast reads, good enough writes\n- No external service dependency\n- Limited concurrent write throughput (acceptable for single-user app)"),
|
||||
|
||||
("Sprint Retrospective",
|
||||
"## What went well\n- Shipped auth flow ahead of schedule\n- Good collaboration between design and dev\n\n## What could improve\n- Too many context switches mid-sprint\n- Need clearer acceptance criteria on tickets\n\n## Actions\n- Tech lead to review tickets before sprint start\n- Block Friday afternoons for deep work"),
|
||||
|
||||
("Research: API Integrations",
|
||||
"## Potential Integrations\n\n### Stripe\n- Webhooks for subscription events\n- Customer portal for self-service\n\n### SendGrid\n- Transactional emails (welcome, reset password)\n- Monthly digest newsletter\n\n### Sentry\n- Error tracking in production\n- Performance monitoring\n\n**Next step:** Create proof-of-concept for Stripe integration"),
|
||||
]
|
||||
|
||||
# ── insert data ───────────────────────────────────────────────────────────
|
||||
client_ids = []
|
||||
project_ids = []
|
||||
|
||||
print("\n── Creating clients ──")
|
||||
for i, c in enumerate(CLIENTS):
|
||||
cid = uid()
|
||||
client_ids.append(cid)
|
||||
cur.execute(
|
||||
"INSERT INTO clients (id, parent_id, name, industry, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(cid, None, c["name"], c["industry"], ts(random.randint(60, 180)))
|
||||
)
|
||||
print(f" ✓ {c['name']}")
|
||||
|
||||
print("\n── Creating projects ──")
|
||||
for i, proj_list in enumerate(PROJECTS_PER_CLIENT):
|
||||
for pname, pstatus in proj_list:
|
||||
pid = uid()
|
||||
project_ids.append((pid, client_ids[i]))
|
||||
cur.execute(
|
||||
"INSERT INTO projects (id, client_id, name, status, ai_summary, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(pid, client_ids[i], pname, pstatus, None, ts(random.randint(30, 90)))
|
||||
)
|
||||
print(f" ✓ {pname} → {CLIENTS[i]['name']}")
|
||||
|
||||
print("\n── Creating tasks ──")
|
||||
task_count = 0
|
||||
for pid, _cid in project_ids:
|
||||
# 3-5 random tasks per project
|
||||
selected = random.sample(TASK_TEMPLATES, k=random.randint(3, 5))
|
||||
for title, desc, status, priority in selected:
|
||||
tid = uid()
|
||||
due = ts(-random.randint(5, 45)) # future dates
|
||||
cur.execute(
|
||||
"INSERT INTO tasks (id, project_id, title, description, status, priority, assignee, due_date, is_ai_suggested, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(tid, pid, title, desc, status, priority, random.choice(["Alice", "Bob", "Carol", None]),
|
||||
due, 0, ts(random.randint(1, 30)))
|
||||
)
|
||||
task_count += 1
|
||||
print(f" ✓ {task_count} tasks created across {len(project_ids)} projects")
|
||||
|
||||
print("\n── Creating timeline events ──")
|
||||
event_count = 0
|
||||
for pid, _cid in project_ids:
|
||||
base_days_ago = random.randint(10, 60)
|
||||
for title, offset, end_offset in TIMELINE_TEMPLATES:
|
||||
eid = uid()
|
||||
event_date = ts(base_days_ago - offset) # spread into future
|
||||
end_date = ts(base_days_ago - end_offset) if end_offset else None
|
||||
is_completed = 1 if offset < 20 else 0
|
||||
cur.execute(
|
||||
"INSERT INTO timeline_events (id, project_id, title, date, end_date, is_completed, is_ai_suggested, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(eid, pid, title, event_date, end_date, is_completed, 0, ts(random.randint(10, 60)))
|
||||
)
|
||||
event_count += 1
|
||||
print(f" ✓ {event_count} timeline events created")
|
||||
|
||||
print("\n── Creating notes ──")
|
||||
note_count = 0
|
||||
for pid, _cid in project_ids:
|
||||
# 1-3 notes per project
|
||||
selected = random.sample(NOTE_TEMPLATES, k=random.randint(1, 3))
|
||||
for title, content in selected:
|
||||
nid = uid()
|
||||
created = ts(random.randint(1, 30))
|
||||
cur.execute(
|
||||
"INSERT INTO notes (id, project_id, title, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(nid, pid, title, content, created, created)
|
||||
)
|
||||
note_count += 1
|
||||
print(f" ✓ {note_count} notes created")
|
||||
|
||||
# ── commit & close ────────────────────────────────────────────────────────
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(f"""
|
||||
═══════════════════════════════════════
|
||||
Seed complete!
|
||||
{len(CLIENTS)} clients
|
||||
{len(project_ids)} projects
|
||||
{task_count} tasks
|
||||
{event_count} timeline events
|
||||
{note_count} notes
|
||||
═══════════════════════════════════════
|
||||
Restart adiuvAI to see the data.
|
||||
""")
|
||||
95
skills-lock.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"adapt": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "a884f9cc4adb0b3da02d0f8becb1c36245adec7dcc087cd44e6054113755ac6e"
|
||||
},
|
||||
"animate": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "ce0f9cc82930d5c3e674918d363aa095870d70951d136f0f72e252f5954bbc85"
|
||||
},
|
||||
"audit": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "85ff89a25110dd68ebb30b45c67b33b8f2d2bb123d407d957329a2931f0a6878"
|
||||
},
|
||||
"bolder": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "46e3a6a52b8bb694ca01dae4d98be4d85ab35e2ba95eee93bcb472ff6c98a70c"
|
||||
},
|
||||
"clarify": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "3eec88b6f38165fda2a091cdb46f78311347aa0af8d9fa40112124fdaae3bd43"
|
||||
},
|
||||
"colorize": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "da21ea34a9ba5aac8c87b6df23ad5b273bf60b708e5493e6bf4727fa172d2346"
|
||||
},
|
||||
"critique": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "033e4a42923fc97741626421c0873fe25b90674076d3f6a45a9dc3a307f1918f"
|
||||
},
|
||||
"delight": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "f46bb3c71cfe635a7742b94516ba53f0c5bfac65430513e99f1162d6d4e2e71d"
|
||||
},
|
||||
"distill": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "eb53dd6f18bbeb4d1b2986eaa858c9014b3c50b8ed9fcb68d841450c0b48bd12"
|
||||
},
|
||||
"extract": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "3c7ecd324b70ce07d525a2f8ecc0cda566b16612f1b413f121e82a65ccee38a2"
|
||||
},
|
||||
"frontend-design": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "70c1738e2ead9b1118bbf77ce6d72f3b9a6fef91b6ba42579066350fe7d1e745"
|
||||
},
|
||||
"harden": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "54072e299abb30b20ddca38dcbb8c585ccd3dcecc414586d6279db1fccae3578"
|
||||
},
|
||||
"normalize": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "82deb8f724b0188afee2bcc4f00a33b7446212ff831feda6d0b515e6d9ff0cea"
|
||||
},
|
||||
"onboard": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "1e90eb71e79b019c50c6e4ab01d45da4c093090e26f25ee4b2250fafe5274e8a"
|
||||
},
|
||||
"optimize": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "36de9c64e36c778a01502ca9c98a7a6d54d4fa5215c62c01a2e93dcc5912d869"
|
||||
},
|
||||
"polish": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "12a83281065df7cecc24c17fdf9a126a13f664140ed6939c8230eb3f447d1aa3"
|
||||
},
|
||||
"quieter": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "bdf6069485ed66c6da4ad6932319d56c06034198d7e8467bc7cdae8d3169759e"
|
||||
},
|
||||
"teach-impeccable": {
|
||||
"source": "pbakaus/impeccable",
|
||||
"sourceType": "github",
|
||||
"computedHash": "759bfe9a53d48b87d60352db3403b62a0663e5187b2a2bd61d43657ac48d1a11"
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/main/agents/agent-scheduler.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Agent scheduler — checks locally-stored agent configs on a periodic
|
||||
* interval and triggers BE-orchestrated runs when they are due.
|
||||
*
|
||||
* Follows the same pattern as the daily brief scheduler in orchestrator.ts:
|
||||
* a single `setInterval` tick that checks all enabled agents.
|
||||
*/
|
||||
|
||||
import { getLocalAgents, saveLocalAgent, getDeviceId } from '../store';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
import { getDb } from '../db';
|
||||
import { agentRuns } from '../db/schema';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** How often the scheduler checks for due agents (ms). */
|
||||
const TICK_INTERVAL_MS = 60_000; // 60 seconds
|
||||
|
||||
/**
|
||||
* Cron expression → minimum interval in ms.
|
||||
* We use a simple mapping for the supported presets; unknown cron values
|
||||
* are treated as manual-only.
|
||||
*/
|
||||
const CRON_INTERVAL_MS: Record<string, number> = {
|
||||
'*/15 * * * *': 15 * 60 * 1000,
|
||||
'0 * * * *': 60 * 60 * 1000,
|
||||
'0 */6 * * *': 6 * 60 * 60 * 1000,
|
||||
'0 0 * * *': 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let schedulerTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function startAgentScheduler(): void {
|
||||
if (schedulerTimer) return;
|
||||
|
||||
schedulerTimer = setInterval(() => {
|
||||
void tickAgentScheduler();
|
||||
}, TICK_INTERVAL_MS);
|
||||
|
||||
// Run once immediately on start
|
||||
void tickAgentScheduler();
|
||||
}
|
||||
|
||||
export function stopAgentScheduler(): void {
|
||||
if (schedulerTimer) {
|
||||
clearInterval(schedulerTimer);
|
||||
schedulerTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function tickAgentScheduler(): Promise<void> {
|
||||
const agents = getLocalAgents();
|
||||
const now = Date.now();
|
||||
|
||||
for (const agent of agents) {
|
||||
if (!agent.enabled) continue;
|
||||
|
||||
// Manual-only agents don't auto-trigger
|
||||
const intervalMs = CRON_INTERVAL_MS[agent.scheduleCron];
|
||||
if (!intervalMs) continue;
|
||||
|
||||
// Check if enough time has passed since lastRunAt
|
||||
if (agent.lastRunAt && now - agent.lastRunAt < intervalMs) continue;
|
||||
|
||||
try {
|
||||
const activeAgents = agents.length;
|
||||
console.log(
|
||||
`[AgentScheduler] Triggering agent "${agent.name}" (id=${agent.id}) with lastRunAt=${agent.lastRunAt} (${agent.lastRunAt ? new Date(agent.lastRunAt).toISOString() : 'null'})`,
|
||||
);
|
||||
const response = await getBackendClient().proxyPost<{ id: string }>(
|
||||
'/api/v1/agents/trigger',
|
||||
{
|
||||
directory: agent.directory,
|
||||
deviceId: getDeviceId(),
|
||||
agentId: agent.id,
|
||||
whatToExtract: agent.dataTypes,
|
||||
batchInterval: agent.scheduleCron,
|
||||
agentConfig: agent.agentConfig ?? undefined,
|
||||
activeAgents,
|
||||
lastRunAt: agent.lastRunAt ?? undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// Create the run row immediately so it appears in history even if
|
||||
// the agent finds nothing to create/update.
|
||||
if (response?.id) {
|
||||
try {
|
||||
await getDb().insert(agentRuns).values({
|
||||
id: response.id,
|
||||
agentId: agent.id,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
}).onConflictDoNothing();
|
||||
} catch { /* ignore — row may already exist */ }
|
||||
}
|
||||
|
||||
// Mark the run time so we don't re-trigger until the next interval
|
||||
saveLocalAgent({ ...agent, lastRunAt: now });
|
||||
console.log(`[AgentScheduler] Triggered agent "${agent.name}" (id=${agent.id}).`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[AgentScheduler] Failed to trigger agent "${agent.name}": ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* 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 */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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);
|
||||
@@ -1,73 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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}"`);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export async function setToken(providerName: string, token: string): Promise<voi
|
||||
}
|
||||
|
||||
/** Delete a stored token for the given provider. */
|
||||
async function deleteToken(providerName: string): Promise<boolean> {
|
||||
export async function deleteToken(providerName: string): Promise<boolean> {
|
||||
removeFromStore(providerName);
|
||||
return true;
|
||||
}
|
||||
|
||||
1159
src/main/api/backend-client.ts
Normal file
585
src/main/api/drizzle-executor.ts
Normal file
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* Drizzle executor — the "dumb" local data layer.
|
||||
*
|
||||
* Receives structured `WsToolCall` frames from the backend WebSocket and maps
|
||||
* them to Drizzle ORM calls against the local SQLite database.
|
||||
*
|
||||
* Security: table name and action are validated against an allowlist before
|
||||
* any database operation is performed. The backend never generates IDs —
|
||||
* the executor generates UUID v4 + timestamps for all inserts.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.4
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { eq, and, or, like, isNull, asc, desc, gte, lte, sql, SQL } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments, projectFolderFiles } from '../db/schema';
|
||||
import type { WsToolCall } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table registry — the only tables the backend may touch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TABLE_REGISTRY = {
|
||||
tasks,
|
||||
projects,
|
||||
clients,
|
||||
notes,
|
||||
taskComments,
|
||||
timelineEvents,
|
||||
// Alias: the backend sends "timelines" as the table name
|
||||
timelines: timelineEvents,
|
||||
projectFolderFiles,
|
||||
} as const;
|
||||
|
||||
type TableName = keyof typeof TABLE_REGISTRY;
|
||||
type AnyTable = (typeof TABLE_REGISTRY)[TableName];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filesystem constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum file content size returned by read_file_content (500 KB). */
|
||||
const MAX_READ_SIZE_BYTES = 500 * 1024;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ExecutorError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ExecutorError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Keys that are handled explicitly and should not be treated as direct column matchers. */
|
||||
const RESERVED_KEYS = new Set(['search', 'orderBy', 'orderDir', 'includeArchived', 'limit', 'offset']);
|
||||
|
||||
const RANGE_FROM_RE = /^(.+)From$/;
|
||||
const RANGE_TO_RE = /^(.+)To$/;
|
||||
|
||||
function buildConditions(
|
||||
table: AnyTable,
|
||||
filters: Record<string, unknown>,
|
||||
): SQL[] {
|
||||
const conditions: SQL[] = [];
|
||||
const tbl = table as unknown as Record<string, unknown>;
|
||||
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (RESERVED_KEYS.has(key)) continue;
|
||||
|
||||
// Generic *From / *To range filters — e.g. dueDateFrom, createdAtFrom, dateFrom, completedAtTo
|
||||
const fromMatch = RANGE_FROM_RE.exec(key);
|
||||
if (fromMatch) {
|
||||
const colName = fromMatch[1]!;
|
||||
const col = tbl[colName];
|
||||
if (col && value != null) {
|
||||
conditions.push(gte(col as Parameters<typeof gte>[0], Number(value)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const toMatch = RANGE_TO_RE.exec(key);
|
||||
if (toMatch) {
|
||||
const colName = toMatch[1]!;
|
||||
const col = tbl[colName];
|
||||
if (col && value != null) {
|
||||
conditions.push(lte(col as Parameters<typeof lte>[0], Number(value)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const col = tbl[key];
|
||||
if (!col) continue; // Unknown column — skip silently
|
||||
|
||||
if (value === null) {
|
||||
conditions.push(isNull(col as Parameters<typeof isNull>[0]));
|
||||
} else {
|
||||
conditions.push(eq(col as Parameters<typeof eq>[0], value as Parameters<typeof eq>[1]));
|
||||
}
|
||||
}
|
||||
|
||||
// Search across title and/or content
|
||||
if (filters['search'] != null) {
|
||||
const pattern = `%${String(filters['search'])}%`;
|
||||
const titleCol = tbl['title'];
|
||||
const contentCol = tbl['content'];
|
||||
if (titleCol && contentCol) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(titleCol as Parameters<typeof like>[0], pattern),
|
||||
like(contentCol as Parameters<typeof like>[0], pattern),
|
||||
)!,
|
||||
);
|
||||
} else if (titleCol) {
|
||||
conditions.push(like(titleCol as Parameters<typeof like>[0], pattern));
|
||||
} else if (contentCol) {
|
||||
conditions.push(like(contentCol as Parameters<typeof like>[0], pattern));
|
||||
}
|
||||
}
|
||||
|
||||
// includeArchived: false → restrict to active status
|
||||
if (filters['includeArchived'] === false) {
|
||||
const statusCol = tbl['status'];
|
||||
if (statusCol) {
|
||||
conditions.push(eq(statusCol as Parameters<typeof eq>[0], 'active'));
|
||||
}
|
||||
}
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
function buildOrderBy(
|
||||
table: AnyTable,
|
||||
filters: Record<string, unknown>,
|
||||
): SQL | undefined {
|
||||
const field = filters['orderBy'];
|
||||
if (!field || typeof field !== 'string') return undefined;
|
||||
|
||||
const tbl = table as unknown as Record<string, unknown>;
|
||||
const col = tbl[field];
|
||||
if (!col) return undefined;
|
||||
|
||||
const dir = filters['orderDir'];
|
||||
return dir === 'desc'
|
||||
? desc(col as Parameters<typeof desc>[0])
|
||||
: asc(col as Parameters<typeof asc>[0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Executor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class DrizzleExecutor {
|
||||
private getTable(name: string): AnyTable {
|
||||
if (!(name in TABLE_REGISTRY)) {
|
||||
throw new ExecutorError(`Unknown table: "${name}". Allowed: ${Object.keys(TABLE_REGISTRY).join(', ')}`);
|
||||
}
|
||||
return TABLE_REGISTRY[name as TableName];
|
||||
}
|
||||
|
||||
async execute(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const { action } = payload;
|
||||
|
||||
switch (action) {
|
||||
case 'select':
|
||||
return this.handleSelect(payload);
|
||||
case 'get':
|
||||
return this.handleGet(payload);
|
||||
case 'insert':
|
||||
return this.handleInsert(payload);
|
||||
case 'update':
|
||||
return this.handleUpdate(payload);
|
||||
case 'delete':
|
||||
return this.handleDelete(payload);
|
||||
case 'count':
|
||||
return this.handleCount(payload);
|
||||
case 'propose_note_edit':
|
||||
return this.handleProposeNoteEdit(payload);
|
||||
case 'list_directory':
|
||||
return this.handleListDirectory(payload);
|
||||
case 'read_file_content':
|
||||
return this.handleReadFileContent(payload);
|
||||
case 'get_file_metadata':
|
||||
return this.handleGetFileMetadata(payload);
|
||||
case 'read_project_folder_manifest':
|
||||
return this.handleReadProjectFolderManifest(payload);
|
||||
case 'read_project_folder_file':
|
||||
return this.handleReadProjectFolderFile(payload);
|
||||
case 'list_projects_with_folder_manifests':
|
||||
return this.handleListProjectsWithFolderManifests();
|
||||
default:
|
||||
throw new ExecutorError(`Unknown action: "${action as string}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Action handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private handleSelect(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const filters = (payload.filters ?? {}) as Record<string, unknown>;
|
||||
const conditions = buildConditions(table, filters);
|
||||
const orderBy = buildOrderBy(table, filters);
|
||||
|
||||
const query = getDb().select().from(table);
|
||||
const withWhere = conditions.length > 0
|
||||
? query.where(and(...conditions.filter(Boolean as unknown as (x: SQL) => x is SQL))!)
|
||||
: query;
|
||||
const withOrder = orderBy ? withWhere.orderBy(orderBy) : withWhere;
|
||||
|
||||
// Default limit of 50 prevents context flooding for AI tool calls
|
||||
const limit = filters['limit'] != null ? Number(filters['limit']) : 50;
|
||||
const offset = filters['offset'] != null ? Number(filters['offset']) : 0;
|
||||
const rows = withOrder.limit(limit).offset(offset).all();
|
||||
|
||||
return { rows };
|
||||
}
|
||||
|
||||
private handleCount(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const filters = (payload.filters ?? {}) as Record<string, unknown>;
|
||||
const conditions = buildConditions(table, filters);
|
||||
|
||||
const query = getDb().select({ count: sql<number>`count(*)` }).from(table);
|
||||
const withWhere = conditions.length > 0
|
||||
? query.where(and(...conditions.filter(Boolean as unknown as (x: SQL) => x is SQL))!)
|
||||
: query;
|
||||
|
||||
const result = withWhere.get();
|
||||
return { count: Number((result as { count: number } | undefined)?.count ?? 0) };
|
||||
}
|
||||
|
||||
private handleGet(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const id = data['id'];
|
||||
if (!id) throw new ExecutorError('"data.id" is required for get');
|
||||
|
||||
const tbl = table as unknown as Record<string, unknown>;
|
||||
const idCol = tbl['id'] as Parameters<typeof eq>[0];
|
||||
const row = getDb().select().from(table).where(eq(idCol, id as string)).get() ?? null;
|
||||
return { row };
|
||||
}
|
||||
|
||||
private handleInsert(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const now = Date.now();
|
||||
|
||||
// Auto-set completedAt for tables that have the column
|
||||
const completedAtPatch =
|
||||
('completedAt' in table && !('completedAt' in data) &&
|
||||
(data['status'] === 'done' || data['isCompleted'] === 1))
|
||||
? { completedAt: now }
|
||||
: {};
|
||||
|
||||
const values = {
|
||||
id: crypto.randomUUID(),
|
||||
...data,
|
||||
createdAt: now,
|
||||
...(('updatedAt' in table) ? { updatedAt: now } : {}),
|
||||
...completedAtPatch,
|
||||
};
|
||||
|
||||
const row = getDb().insert(table).values(values).returning().get() ?? null;
|
||||
return { row };
|
||||
}
|
||||
|
||||
private handleUpdate(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const id = data['id'];
|
||||
if (!id) throw new ExecutorError('"data.id" is required for update');
|
||||
|
||||
const updates = (data['updates'] ?? {}) as Record<string, unknown>;
|
||||
const now = Date.now();
|
||||
|
||||
const baseTimestamp = ('updatedAt' in table)
|
||||
? { ...updates, updatedAt: now }
|
||||
: updates;
|
||||
|
||||
// Auto-set completedAt when status/isCompleted changes, unless caller provided it explicitly
|
||||
const completedAtPatch: Record<string, unknown> = {};
|
||||
if ('completedAt' in table && !('completedAt' in updates)) {
|
||||
if (updates['status'] === 'done' || updates['isCompleted'] === 1) {
|
||||
completedAtPatch['completedAt'] = now;
|
||||
} else if (updates['status'] !== undefined || updates['isCompleted'] !== undefined) {
|
||||
completedAtPatch['completedAt'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
const withTimestamp = { ...baseTimestamp, ...completedAtPatch };
|
||||
|
||||
const tbl = table as unknown as Record<string, unknown>;
|
||||
const idCol = tbl['id'] as Parameters<typeof eq>[0];
|
||||
const row = getDb()
|
||||
.update(table)
|
||||
.set(withTimestamp)
|
||||
.where(eq(idCol, id as string))
|
||||
.returning()
|
||||
.get() ?? null;
|
||||
|
||||
return { row };
|
||||
}
|
||||
|
||||
private handleDelete(payload: WsToolCall): Record<string, unknown> {
|
||||
const table = this.getTable(payload.table!);
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const id = data['id'];
|
||||
if (!id) throw new ExecutorError('"data.id" is required for delete');
|
||||
|
||||
const tbl = table as unknown as Record<string, unknown>;
|
||||
const idCol = tbl['id'] as Parameters<typeof eq>[0];
|
||||
getDb().delete(table).where(eq(idCol, id as string)).run();
|
||||
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
private handleProposeNoteEdit(payload: WsToolCall): Record<string, unknown> {
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const noteId = data['noteId'] as string | undefined;
|
||||
const type = data['type'] as string | undefined;
|
||||
const proposedContent = data['proposedContent'] as string | undefined;
|
||||
|
||||
if (!noteId) throw new ExecutorError('"data.noteId" is required for propose_note_edit');
|
||||
if (!type) throw new ExecutorError('"data.type" is required for propose_note_edit');
|
||||
if (!proposedContent) throw new ExecutorError('"data.proposedContent" is required for propose_note_edit');
|
||||
|
||||
const values = {
|
||||
id: crypto.randomUUID(),
|
||||
noteId,
|
||||
type,
|
||||
proposedContent,
|
||||
anchorBefore: (data['anchorBefore'] as string | null) ?? null,
|
||||
anchorText: (data['anchorText'] as string | null) ?? null,
|
||||
reasoning: (data['reasoning'] as string | null) ?? null,
|
||||
agentId: (data['agentId'] as string | null) ?? null,
|
||||
runId: (data['runId'] as string | null) ?? null,
|
||||
status: 'pending' as const,
|
||||
createdAt: Date.now(),
|
||||
resolvedAt: null,
|
||||
};
|
||||
|
||||
const row = getDb().insert(noteEdits).values(values).returning().get() ?? null;
|
||||
return { id: values.id, row };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Filesystem handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async handleListDirectory(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const dirPath = data['path'] as string | undefined;
|
||||
if (!dirPath) throw new ExecutorError('"data.path" is required for list_directory');
|
||||
|
||||
const resolved = await fs.promises.realpath(path.resolve(dirPath));
|
||||
|
||||
let dirents: fs.Dirent[];
|
||||
try {
|
||||
dirents = await fs.promises.readdir(resolved, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
throw new ExecutorError(
|
||||
`Cannot read directory ${dirPath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const entries = dirents.map((d) => ({
|
||||
name: d.name,
|
||||
type: d.isDirectory() ? 'directory' : 'file',
|
||||
path: path.join(resolved, d.name),
|
||||
}));
|
||||
|
||||
return { entries };
|
||||
}
|
||||
|
||||
private async handleReadFileContent(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const filePath = data['path'] as string | undefined;
|
||||
if (!filePath) throw new ExecutorError('"data.path" is required for read_file_content');
|
||||
|
||||
const resolved = await fs.promises.realpath(path.resolve(filePath));
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = await fs.promises.stat(resolved);
|
||||
} catch (err) {
|
||||
throw new ExecutorError(
|
||||
`Cannot stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new ExecutorError(`Not a file: ${filePath}`);
|
||||
}
|
||||
|
||||
let content: string;
|
||||
if (stat.size > MAX_READ_SIZE_BYTES) {
|
||||
// Read only the first MAX_READ_SIZE_BYTES to prevent context saturation
|
||||
const buf = Buffer.alloc(MAX_READ_SIZE_BYTES);
|
||||
const fd = await fs.promises.open(resolved, 'r');
|
||||
try {
|
||||
await fd.read(buf, 0, MAX_READ_SIZE_BYTES, 0);
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
content = buf.toString('utf8') + '\n[…truncated]';
|
||||
} else {
|
||||
content = await fs.promises.readFile(resolved, 'utf8');
|
||||
}
|
||||
|
||||
return { content };
|
||||
}
|
||||
|
||||
private async handleGetFileMetadata(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const filePath = data['path'] as string | undefined;
|
||||
if (!filePath) throw new ExecutorError('"data.path" is required for get_file_metadata');
|
||||
|
||||
const resolved = await fs.promises.realpath(path.resolve(filePath));
|
||||
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = await fs.promises.stat(resolved);
|
||||
} catch (err) {
|
||||
throw new ExecutorError(
|
||||
`Cannot stat ${filePath}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: path.basename(resolved),
|
||||
extension: path.extname(resolved).toLowerCase(),
|
||||
size: stat.size,
|
||||
createdAt: stat.birthtime.toISOString(),
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Project folder handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private handleReadProjectFolderManifest(payload: WsToolCall): Record<string, unknown> {
|
||||
const { projectId } = (payload.data ?? {}) as { projectId: string };
|
||||
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj?.folderPath) return { folderPath: null, lastScannedAt: null, files: [] };
|
||||
|
||||
const files = getDb()
|
||||
.select({
|
||||
relPath: projectFolderFiles.relativePath,
|
||||
kind: projectFolderFiles.kind,
|
||||
summary: projectFolderFiles.summary,
|
||||
mtimeMs: projectFolderFiles.mtimeMs,
|
||||
})
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, projectId))
|
||||
.all();
|
||||
|
||||
// On-demand mtime check: if not currently scanning, fire-and-forget rescan when deltas exist.
|
||||
// Returns the current (possibly stale) manifest immediately; the rescan updates rows
|
||||
// for the next call.
|
||||
if (proj.folderLastScanStatus !== 'scanning') {
|
||||
void import('../files/scanner')
|
||||
.then(async ({ scanFolder }) => {
|
||||
const delta = await scanFolder(projectId, proj.folderPath!);
|
||||
if (
|
||||
delta.newFiles.length > 0 ||
|
||||
delta.changedFiles.length > 0 ||
|
||||
delta.deletedRelPaths.length > 0
|
||||
) {
|
||||
const { startIndexSession } = await import('../files/indexer');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
void startIndexSession(projectId, () => {});
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
folderPath: proj.folderPath,
|
||||
lastScannedAt: proj.folderLastScannedAt,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleReadProjectFolderFile(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const { projectId, relativePath, offset, length } = (payload.data ?? {}) as {
|
||||
projectId: string;
|
||||
relativePath: string;
|
||||
offset?: number;
|
||||
length?: number;
|
||||
};
|
||||
|
||||
if (!relativePath || relativePath.includes('..') || path.isAbsolute(relativePath)) {
|
||||
throw new ExecutorError('Access denied');
|
||||
}
|
||||
|
||||
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj?.folderPath) return { content: '', kind: 'missing', totalSize: 0 };
|
||||
|
||||
const abs = path.join(proj.folderPath, relativePath);
|
||||
if (!path.resolve(abs).startsWith(path.resolve(proj.folderPath))) {
|
||||
throw new ExecutorError('Access denied');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(abs);
|
||||
const ext = path.extname(relativePath).toLowerCase();
|
||||
|
||||
if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
return { content: buf.toString('base64'), kind: 'image', totalSize: stat.size };
|
||||
}
|
||||
|
||||
// PDF + DOCX: return full base64; backend extracts text + slices.
|
||||
if (ext === '.pdf' || ext === '.docx') {
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
return {
|
||||
content: buf.toString('base64'),
|
||||
kind: ext === '.pdf' ? 'pdf' : 'docx',
|
||||
totalSize: stat.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Text: slice at offset/length on Electron side to keep WS payload small.
|
||||
const start = Math.max(0, offset ?? 0);
|
||||
const want = Math.max(1, Math.min(length ?? MAX_READ_SIZE_BYTES, MAX_READ_SIZE_BYTES));
|
||||
const end = Math.min(start + want, stat.size);
|
||||
const len = Math.max(0, end - start);
|
||||
if (len === 0) {
|
||||
return { content: '', kind: 'text', totalSize: stat.size };
|
||||
}
|
||||
const buf = Buffer.alloc(len);
|
||||
const fd = await fs.promises.open(abs, 'r');
|
||||
try {
|
||||
await fd.read(buf, 0, len, start);
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
return { content: buf.toString('utf8'), kind: 'text', totalSize: stat.size };
|
||||
} catch {
|
||||
return { content: '', kind: 'error', totalSize: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
private handleListProjectsWithFolderManifests(): Record<string, unknown> {
|
||||
const projs = getDb()
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(sql`${projects.folderPath} IS NOT NULL`)
|
||||
.all();
|
||||
|
||||
const out: Array<unknown> = [];
|
||||
for (const p of projs) {
|
||||
const files = getDb()
|
||||
.select({
|
||||
relPath: projectFolderFiles.relativePath,
|
||||
kind: projectFolderFiles.kind,
|
||||
summary: projectFolderFiles.summary,
|
||||
mtimeMs: projectFolderFiles.mtimeMs,
|
||||
})
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, p.id))
|
||||
.all();
|
||||
out.push({
|
||||
projectId: p.id,
|
||||
projectName: p.name,
|
||||
folderPath: p.folderPath,
|
||||
lastScannedAt: p.folderLastScannedAt,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
return { projects: out };
|
||||
}
|
||||
}
|
||||
49
src/main/attachments/storage.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { app } from 'electron';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const FILENAME_MAX = 200;
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
const stripped = name
|
||||
.replace(/[\\/]/g, '_')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\x00-\x1f]/g, '')
|
||||
.replace(/^\.+/, '');
|
||||
return stripped.length > FILENAME_MAX ? stripped.slice(0, FILENAME_MAX) : stripped;
|
||||
}
|
||||
|
||||
export function attachmentsRoot(): string {
|
||||
return path.join(app.getPath('userData'), 'attachments');
|
||||
}
|
||||
|
||||
export function absolutePath(storedPath: string): string {
|
||||
return path.join(attachmentsRoot(), storedPath);
|
||||
}
|
||||
|
||||
export async function copyIntoTask(
|
||||
taskId: string,
|
||||
sourcePath: string,
|
||||
filename: string,
|
||||
): Promise<{ storedPath: string }> {
|
||||
const safeName = sanitizeFilename(filename);
|
||||
const dir = path.join(attachmentsRoot(), taskId);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const finalName = `${randomUUID()}-${safeName}`;
|
||||
const dest = path.join(dir, finalName);
|
||||
await fs.copyFile(sourcePath, dest);
|
||||
return { storedPath: path.join(taskId, finalName) };
|
||||
}
|
||||
|
||||
export async function deleteStored(storedPath: string): Promise<void> {
|
||||
const abs = absolutePath(storedPath);
|
||||
await fs.unlink(abs).catch((err) => {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTaskDir(taskId: string): Promise<void> {
|
||||
const dir = path.join(attachmentsRoot(), taskId);
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
604
src/main/auth/auth-manager.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* Auth manager — handles registration, login, token refresh, and profile
|
||||
* retrieval against the AdiuvAI backend API.
|
||||
*
|
||||
* Singleton. Tokens are persisted via the two-tier storage in `token.ts`
|
||||
* (safeStorage + electron-store fallback).
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.2
|
||||
*/
|
||||
|
||||
import { getStore } from '../store';
|
||||
import { getToken, setToken, deleteToken } from '../ai/token';
|
||||
import { toCamelCase, toSnakeCase } from '../../shared/casing';
|
||||
import { AuthTokensSchema, UserProfileSchema } from '../../shared/api-types';
|
||||
import type { AuthTokens, UserProfile } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Token key names in the encrypted store. */
|
||||
const TOKEN_KEYS = {
|
||||
access: 'auth_access',
|
||||
refresh: 'auth_refresh',
|
||||
/** Stored as string representation of Unix-epoch milliseconds. */
|
||||
expiresAt: 'auth_expires_at',
|
||||
} as const;
|
||||
|
||||
/** Refresh the access token when it expires within this window (seconds). */
|
||||
const REFRESH_WINDOW_SEC = 5 * 60; // 5 minutes
|
||||
|
||||
/** Maximum request timeout (ms). */
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RelationOut {
|
||||
id: string;
|
||||
subjectLabel: string;
|
||||
subjectType: string;
|
||||
predicate: string;
|
||||
objectLabel: string;
|
||||
objectType: string;
|
||||
confidence: number;
|
||||
lastConfirmedAt: number | null;
|
||||
}
|
||||
|
||||
export interface RelationPatch {
|
||||
subjectLabel?: string;
|
||||
objectLabel?: string;
|
||||
predicate?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AuthManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Tracks a pending OAuth login promise until the deep-link callback arrives. */
|
||||
interface PendingOAuth {
|
||||
provider: string;
|
||||
resolve: (tokens: AuthTokens) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
/** How long (ms) to wait for the user to complete the browser OAuth flow. */
|
||||
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export class AuthManager {
|
||||
private static instance: AuthManager | null = null;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Pending OAuth login promises keyed by state param.
|
||||
* One entry per in-flight OAuth flow (practically always ≤ 1).
|
||||
*/
|
||||
private _pendingOAuth = new Map<string, PendingOAuth>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): AuthManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new AuthManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Register a new account and store the returned tokens. */
|
||||
async register(email: string, password: string, name?: string, surname?: string): Promise<AuthTokens> {
|
||||
const body: Record<string, unknown> = { email, password };
|
||||
if (name) body.name = name;
|
||||
if (surname) body.surname = surname;
|
||||
const data = await this.post<AuthTokens>('/api/v1/auth/register', body);
|
||||
const tokens = AuthTokensSchema.parse(data);
|
||||
await this.storeTokens(tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Log in with email + password and store the returned tokens. */
|
||||
async login(email: string, password: string): Promise<AuthTokens> {
|
||||
const data = await this.post<AuthTokens>('/api/v1/auth/login', { email, password });
|
||||
const tokens = AuthTokensSchema.parse(data);
|
||||
await this.storeTokens(tokens);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** Clear all stored auth tokens. */
|
||||
async logout(): Promise<void> {
|
||||
await Promise.all([
|
||||
deleteToken(TOKEN_KEYS.access),
|
||||
deleteToken(TOKEN_KEYS.refresh),
|
||||
deleteToken(TOKEN_KEYS.expiresAt),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a valid access token, refreshing transparently if near expiry.
|
||||
* Returns `null` if not authenticated.
|
||||
*/
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
const token = await getToken(TOKEN_KEYS.access);
|
||||
if (!token) return null;
|
||||
|
||||
// Check expiry — refresh if within the window
|
||||
const expiresAtStr = await getToken(TOKEN_KEYS.expiresAt);
|
||||
if (expiresAtStr) {
|
||||
// Backend returns expires_at in milliseconds; convert to seconds.
|
||||
const expiresAtSec = Math.floor(Number(expiresAtStr) / 1000);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
if (expiresAtSec - nowSec < REFRESH_WINDOW_SEC) {
|
||||
const isExpired = nowSec >= expiresAtSec;
|
||||
// Coalesce concurrent refresh calls
|
||||
if (!this.refreshPromise) {
|
||||
this.refreshPromise = this.refreshTokens().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
await this.refreshPromise;
|
||||
return (await getToken(TOKEN_KEYS.access)) ?? token;
|
||||
} catch {
|
||||
// Refresh failed — if the token is already expired, don't
|
||||
// return a stale token that will certainly be rejected.
|
||||
if (isExpired) {
|
||||
console.warn('[Auth] Token expired and refresh failed — logging out.');
|
||||
await this.logout();
|
||||
return null;
|
||||
}
|
||||
// Token not yet expired — return it; it may still work.
|
||||
return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Whether we have stored auth tokens. */
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
const token = await getToken(TOKEN_KEYS.access);
|
||||
return token !== null;
|
||||
}
|
||||
|
||||
/** Fetch the user profile from the backend. */
|
||||
async getProfile(): Promise<UserProfile> {
|
||||
const data = await this.get<UserProfile>('/api/v1/auth/me');
|
||||
return UserProfileSchema.parse(data);
|
||||
}
|
||||
|
||||
/** Update the user's profile (name, surname) on the backend. */
|
||||
async updateProfile(fields: { name?: string; surname?: string }): Promise<UserProfile> {
|
||||
const data = await this.put<UserProfile>('/api/v1/auth/me', fields);
|
||||
return UserProfileSchema.parse(data);
|
||||
}
|
||||
|
||||
/** Update core memory key/value pairs and optionally mark onboarding complete. */
|
||||
async updateMemory(
|
||||
memory: Record<string, string>,
|
||||
markOnboarded = false,
|
||||
): Promise<UserProfile> {
|
||||
const data = await this.put<UserProfile>('/api/v1/auth/me/memory', {
|
||||
memory,
|
||||
markOnboarded,
|
||||
});
|
||||
return UserProfileSchema.parse(data);
|
||||
}
|
||||
|
||||
/** One-shot LLM normalization for free-text onboarding answers. */
|
||||
async normalizeOnboarding(
|
||||
inputs: Record<string, string>,
|
||||
): Promise<Record<string, string>> {
|
||||
const res = await this.post<{ normalized: Record<string, string> }>('/api/v1/auth/onboarding/normalize', { inputs });
|
||||
return res.normalized;
|
||||
}
|
||||
|
||||
/** Reset onboarding so the wizard runs again. */
|
||||
async resetOnboarding(): Promise<void> {
|
||||
await this.post('/api/v1/auth/me/onboarding/reset', {});
|
||||
}
|
||||
|
||||
/** Change password (email/password users only). */
|
||||
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
await this.put('/api/v1/auth/me/password', {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
/** List linked OAuth providers for the current user. */
|
||||
async listOAuthAccounts(): Promise<{ provider: string; providerEmail: string | null; createdAt: number }[]> {
|
||||
return this.get('/api/v1/auth/me/oauth-accounts');
|
||||
}
|
||||
|
||||
/** Unlink an OAuth provider from the current user. */
|
||||
async unlinkOAuthAccount(provider: string): Promise<void> {
|
||||
await this.httpDelete(`/api/v1/auth/me/oauth-accounts/${encodeURIComponent(provider)}`);
|
||||
}
|
||||
|
||||
/** Update the user's avatar URL. */
|
||||
async updateAvatar(avatarUrl: string): Promise<UserProfile> {
|
||||
const data = await this.put<UserProfile>('/api/v1/auth/me/avatar', { avatarUrl });
|
||||
return UserProfileSchema.parse(data);
|
||||
}
|
||||
|
||||
/** Permanently delete the user's account. */
|
||||
async deleteAccount(): Promise<void> {
|
||||
await this.httpDelete('/api/v1/auth/me');
|
||||
}
|
||||
|
||||
// ── Billing ────────────────────────────────────────────────────────
|
||||
|
||||
/** Get current subscription info. */
|
||||
async getSubscription(): Promise<Record<string, unknown>> {
|
||||
return this.get('/api/v1/billing/subscription');
|
||||
}
|
||||
|
||||
/** Create a Stripe checkout session for a tier upgrade. */
|
||||
async createCheckout(tier: string): Promise<{ checkoutUrl: string }> {
|
||||
return this.post('/api/v1/billing/checkout', { tier });
|
||||
}
|
||||
|
||||
/** Cancel the active subscription. */
|
||||
async cancelSubscription(): Promise<void> {
|
||||
await this.httpDelete('/api/v1/billing/subscription');
|
||||
}
|
||||
|
||||
/** List billing invoices from Stripe. */
|
||||
async listInvoices(): Promise<Record<string, unknown>[]> {
|
||||
return this.get('/api/v1/billing/invoices');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a Google (or other provider) OAuth login flow.
|
||||
*
|
||||
* 1. Calls GET /api/v1/auth/oauth/{provider}/authorize to obtain the
|
||||
* consent-screen URL and a PKCE state token from the backend.
|
||||
* 2. Opens the URL in the system browser via shell.openExternal().
|
||||
* 3. Returns a Promise that resolves with AuthTokens when the Electron app
|
||||
* receives the deep-link callback (adiuvai://oauth/callback?...) and
|
||||
* handleOAuthCallback() is called.
|
||||
*
|
||||
* Rejects after OAUTH_TIMEOUT_MS (5 min) if no callback arrives.
|
||||
*/
|
||||
async loginWithOAuth(provider: string): Promise<AuthTokens> {
|
||||
// Fetch the authorization URL from the backend (public endpoint — no auth header needed).
|
||||
const url = `${this.baseUrl}/api/v1/auth/oauth/${encodeURIComponent(provider)}/authorize`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new AuthError(`Failed to get OAuth authorize URL: ${res.status}${text ? ` ${text}` : ''}`);
|
||||
}
|
||||
const json = (await res.json()) as { url: string; state: string };
|
||||
|
||||
// Open the consent screen in the system browser.
|
||||
const { shell } = await import('electron');
|
||||
await shell.openExternal(json.url);
|
||||
|
||||
// Wait for the deep-link callback to arrive.
|
||||
return new Promise<AuthTokens>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this._pendingOAuth.delete(json.state);
|
||||
reject(new AuthError('OAuth login timed out — no callback received within 5 minutes'));
|
||||
}, OAUTH_TIMEOUT_MS);
|
||||
|
||||
this._pendingOAuth.set(json.state, { provider, resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the main process when the OS delivers an adiuvai:// deep link.
|
||||
*
|
||||
* Parses code + state from the URL, exchanges the code with the backend,
|
||||
* stores the resulting JWT tokens, and resolves the pending loginWithOAuth()
|
||||
* promise.
|
||||
*
|
||||
* If no pending flow matches the state (e.g. duplicate or stale callback),
|
||||
* the call is silently ignored.
|
||||
*/
|
||||
async handleOAuthCallback(deepLinkUrl: string): Promise<void> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(deepLinkUrl);
|
||||
} catch {
|
||||
console.warn('[Auth] Received malformed deep-link URL:', deepLinkUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = parsed.searchParams.get('code');
|
||||
const state = parsed.searchParams.get('state');
|
||||
const provider = parsed.searchParams.get('provider');
|
||||
|
||||
if (!code || !state || !provider) {
|
||||
console.warn('[Auth] Deep-link missing required params:', deepLinkUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this._pendingOAuth.get(state);
|
||||
if (!pending) {
|
||||
console.warn('[Auth] No pending OAuth flow for state:', state);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pending.timer);
|
||||
this._pendingOAuth.delete(state);
|
||||
|
||||
try {
|
||||
const data = await this.post<AuthTokens>(
|
||||
`/api/v1/auth/oauth/${encodeURIComponent(provider)}/callback`,
|
||||
{ code, state },
|
||||
);
|
||||
const tokens = AuthTokensSchema.parse(data);
|
||||
await this.storeTokens(tokens);
|
||||
pending.resolve(tokens);
|
||||
} catch (err) {
|
||||
pending.reject(err instanceof Error ? err : new AuthError('OAuth callback exchange failed'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Memory ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return all core memory k/v pairs (plaintext). */
|
||||
async getCoreMemory(): Promise<Record<string, string>> {
|
||||
return this.get<Record<string, string>>('/api/v1/memory/core');
|
||||
}
|
||||
|
||||
/** Add or overwrite a core memory key/value pair. */
|
||||
async addCoreKey(key: string, value: string): Promise<Record<string, string>> {
|
||||
return this.post<Record<string, string>>('/api/v1/memory/core', { key, value });
|
||||
}
|
||||
|
||||
/** Delete a core memory key (GDPR). */
|
||||
async deleteCoreKey(key: string): Promise<void> {
|
||||
await this.httpDelete(`/api/v1/memory/core/${encodeURIComponent(key)}`);
|
||||
}
|
||||
|
||||
/** Return relational memory rows. */
|
||||
async getRelationalMemory(): Promise<RelationOut[]> {
|
||||
return this.get<RelationOut[]>('/api/v1/memory/relational');
|
||||
}
|
||||
|
||||
/** Edit a relation row's labels, predicate, or confidence. */
|
||||
async patchRelation(id: string, patch: RelationPatch): Promise<RelationOut> {
|
||||
const url = `${this.baseUrl}/api/v1/memory/relational/${encodeURIComponent(id)}`;
|
||||
const accessToken = await this.getAccessToken();
|
||||
if (!accessToken) throw new AuthError('Not authenticated');
|
||||
const res = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
|
||||
body: JSON.stringify(patch),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new AuthError(`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`, res.status);
|
||||
}
|
||||
const json: unknown = await res.json();
|
||||
return toCamelCase<RelationOut>(json);
|
||||
}
|
||||
|
||||
/** Hard-delete a relation row (GDPR). */
|
||||
async deleteRelation(id: string): Promise<void> {
|
||||
await this.httpDelete(`/api/v1/memory/relational/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** Wipe all memory tiers for the current user (GDPR Art. 17). */
|
||||
async forgetAll(): Promise<void> {
|
||||
const url = `${this.baseUrl}/api/v1/memory/forget-all`;
|
||||
const accessToken = await this.getAccessToken();
|
||||
if (!accessToken) throw new AuthError('Not authenticated');
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}`, 'X-Confirm': 'true' },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new AuthError(`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`, res.status);
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicitly refresh the token pair. */
|
||||
async refreshTokens(): Promise<void> {
|
||||
const refreshToken = await getToken(TOKEN_KEYS.refresh);
|
||||
if (!refreshToken) {
|
||||
throw new AuthError('No refresh token available');
|
||||
}
|
||||
|
||||
// Use a direct fetch instead of this.post() to avoid sending the
|
||||
// (possibly expired) access token in the Authorization header.
|
||||
// The refresh endpoint only needs the refresh token in the body.
|
||||
const url = `${this.baseUrl}/api/v1/auth/refresh`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(toSnakeCase({ refreshToken })),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new AuthError(
|
||||
`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
const json: unknown = await res.json();
|
||||
const tokens = AuthTokensSchema.parse(toCamelCase<AuthTokens>(json));
|
||||
await this.storeTokens(tokens);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private get baseUrl(): string {
|
||||
return getStore().get('backendUrl');
|
||||
}
|
||||
|
||||
private async storeTokens(tokens: AuthTokens): Promise<void> {
|
||||
await Promise.all([
|
||||
setToken(TOKEN_KEYS.access, tokens.accessToken),
|
||||
setToken(TOKEN_KEYS.refresh, tokens.refreshToken),
|
||||
setToken(TOKEN_KEYS.expiresAt, String(tokens.expiresAt)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic POST request to the backend.
|
||||
* Outgoing body is snake_cased, incoming JSON is camelCased + Zod-parsed by caller.
|
||||
*/
|
||||
private async post<T>(path: string, body: Record<string, unknown>): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const accessToken = await getToken(TOKEN_KEYS.access);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(toSnakeCase(body)),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new AuthError(
|
||||
`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
const json: unknown = await res.json();
|
||||
return toCamelCase<T>(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic PUT request to the backend (authenticated).
|
||||
*/
|
||||
private async put<T>(path: string, body: Record<string, unknown>): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const accessToken = await this.getAccessToken();
|
||||
|
||||
if (!accessToken) {
|
||||
throw new AuthError('Not authenticated');
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(toSnakeCase(body)),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new AuthError(
|
||||
`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
const json: unknown = await res.json();
|
||||
return toCamelCase<T>(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic GET request to the backend (authenticated).
|
||||
*/
|
||||
private async get<T>(path: string): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const accessToken = await this.getAccessToken();
|
||||
|
||||
if (!accessToken) {
|
||||
throw new AuthError('Not authenticated');
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new AuthError(
|
||||
`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
const json: unknown = await res.json();
|
||||
return toCamelCase<T>(json);
|
||||
}
|
||||
|
||||
private async httpDelete<T = void>(path: string): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const accessToken = await this.getAccessToken();
|
||||
|
||||
if (!accessToken) {
|
||||
throw new AuthError('Not authenticated');
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new AuthError(
|
||||
`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
return toCamelCase<T>(JSON.parse(text));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience singleton accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getAuthManager(): AuthManager {
|
||||
return AuthManager.getInstance();
|
||||
}
|
||||
45
src/main/auth/backup-key.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Device-specific backup encryption key.
|
||||
*
|
||||
* Generated randomly (256-bit) on first call and persisted via the same
|
||||
* safeStorage + electron-store mechanism used for auth tokens (see token.ts).
|
||||
* This key is device-bound — it never leaves the machine and is not derived
|
||||
* from the user's password, so social-login users can use backups without issue.
|
||||
*
|
||||
* Usage:
|
||||
* const key = await getBackupKey(); // Buffer of 32 bytes
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getToken, setToken } from '../ai/token';
|
||||
|
||||
const BACKUP_KEY_STORE_NAME = 'backup_key';
|
||||
|
||||
/**
|
||||
* Return the device-specific backup encryption key (32 bytes).
|
||||
*
|
||||
* Generates a fresh key on first call and stores it via safeStorage so it
|
||||
* survives app restarts. Subsequent calls return the same key.
|
||||
*/
|
||||
export async function getBackupKey(): Promise<Buffer> {
|
||||
const stored = await getToken(BACKUP_KEY_STORE_NAME);
|
||||
|
||||
if (stored) {
|
||||
return Buffer.from(stored, 'base64');
|
||||
}
|
||||
|
||||
// First launch: generate a random 256-bit key and persist it.
|
||||
const key = randomBytes(32);
|
||||
await setToken(BACKUP_KEY_STORE_NAME, key.toString('base64'));
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored backup key (e.g. on device-wipe / factory reset).
|
||||
* After this call, the next `getBackupKey()` will generate a new key —
|
||||
* any backups encrypted with the old key will be unrecoverable.
|
||||
*/
|
||||
export async function deleteBackupKey(): Promise<void> {
|
||||
const { deleteToken } = await import('../ai/token');
|
||||
await deleteToken(BACKUP_KEY_STORE_NAME);
|
||||
}
|
||||
32
src/main/auth/locale-defaults.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { app } from 'electron';
|
||||
import type { FormatPrefs } from '../store';
|
||||
|
||||
export function detectFormatPrefs(): FormatPrefs {
|
||||
const locale = app.getLocale();
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const hour12 = Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12;
|
||||
const timeFormat = hour12 ? '12h' : '24h';
|
||||
const dateFormat = inferDateFormat(locale);
|
||||
return { timezone, timeFormat, dateFormat };
|
||||
}
|
||||
|
||||
export function detectLanguage(): string {
|
||||
const locale = app.getLocale(); // e.g. 'it-IT', 'en-US'
|
||||
try {
|
||||
const display = new Intl.DisplayNames([locale], { type: 'language' });
|
||||
return display.of(locale) ?? locale;
|
||||
} catch {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
|
||||
function inferDateFormat(locale: string): string {
|
||||
// MDY locales
|
||||
const mdyLocales = ['en-US', 'en-PH', 'en-BZ'];
|
||||
if (mdyLocales.some((l) => locale.startsWith(l))) return 'MM/dd/yyyy';
|
||||
// YMD locales (CJK, ISO-oriented)
|
||||
const ymdPrefixes = ['ja', 'zh', 'ko', 'hu', 'lt', 'sv', 'fi'];
|
||||
if (ymdPrefixes.some((p) => locale.startsWith(p))) return 'yyyy-MM-dd';
|
||||
// Default: DMY (most of the world)
|
||||
return 'dd/MM/yyyy';
|
||||
}
|
||||
@@ -1,91 +1,109 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { app } from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import * as schema from './schema';
|
||||
|
||||
// SQL to create all tables if they don't exist (non-destructive push strategy)
|
||||
const MIGRATION_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
industry TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
/** Resolved path to the SQLite database file. Set once in initDb(). */
|
||||
let _dbPath: string | null = null;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
client_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
ai_summary TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'todo',
|
||||
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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
date INTEGER NOT NULL,
|
||||
is_ai_suggested INTEGER NOT NULL DEFAULT 0,
|
||||
is_approved INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
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
|
||||
);
|
||||
`;
|
||||
/** Raw better-sqlite3 instance (needed for .backup() API). */
|
||||
let _rawSqlite: Database.Database | null = null;
|
||||
|
||||
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
let dbInstance: DbInstance | null = null;
|
||||
|
||||
/**
|
||||
* Resolve the migrations folder location.
|
||||
*
|
||||
* - Packaged: shipped via electron-forge `extraResource` → `<resourcesPath>/migrations`.
|
||||
* - Dev: lives in the source tree at `<appPath>/src/main/db/migrations`. We do NOT
|
||||
* resolve from `__dirname` because Vite bundles `src/main/**` into a single
|
||||
* `.vite/build/main.js` and the migrations folder is not copied next to it.
|
||||
*/
|
||||
function resolveMigrationsFolder(): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'migrations');
|
||||
}
|
||||
return path.join(app.getAppPath(), 'src', 'main', 'db', 'migrations');
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time bootstrap for DBs created by the legacy hand-rolled MIGRATION_SQL.
|
||||
*
|
||||
* Pre-Drizzle-migrator era, schema was managed by ad-hoc CREATE TABLE IF NOT EXISTS
|
||||
* + try/catch ALTER TABLE. Those DBs have all the tables from migrations 0000-0003
|
||||
* but no `__drizzle_migrations` ledger. If we just call migrate(), it will try to
|
||||
* re-run 0000 and crash on duplicate table.
|
||||
*
|
||||
* Strategy: if the DB looks pre-existing (has a `tasks` table) but no migrations
|
||||
* ledger, create the ledger and mark all migrations EXCEPT the latest as applied.
|
||||
* The migrator will then only run the latest one (0004 — adds `estimate` column +
|
||||
* `task_attachments` table — both genuinely missing from legacy DBs).
|
||||
*/
|
||||
function bootstrapMigrationsLedger(sqlite: Database.Database, migrationsFolder: string): void {
|
||||
const hasLedger = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
|
||||
.get();
|
||||
if (hasLedger) return;
|
||||
|
||||
const hasTasks = sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
|
||||
.get();
|
||||
if (!hasTasks) return; // fresh DB — let the migrator create everything from scratch
|
||||
|
||||
// Legacy DB detected. Build the ledger Drizzle expects.
|
||||
// Schema must match drizzle-orm/sqlite-core/dialect.js migrate():
|
||||
// id SERIAL PRIMARY KEY, hash text NOT NULL, created_at numeric
|
||||
sqlite.exec(`
|
||||
CREATE TABLE __drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
);
|
||||
`);
|
||||
|
||||
const journalPath = path.join(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(fs.readFileSync(journalPath, 'utf8')) as {
|
||||
entries: { idx: number; tag: string; when: number }[];
|
||||
};
|
||||
|
||||
// Mark everything except the latest entry as applied.
|
||||
// Drizzle's migrator filters by `lastDbMigration.created_at < migration.folderMillis`,
|
||||
// so seeding the second-to-last entry's `when` is sufficient.
|
||||
const toMark = journal.entries.slice(0, -1);
|
||||
if (toMark.length === 0) return;
|
||||
|
||||
const insert = sqlite.prepare(
|
||||
'INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)',
|
||||
);
|
||||
for (const entry of toMark) {
|
||||
// Hash value is opaque to the migrator — only created_at matters for the cutoff.
|
||||
// Use the tag for traceability.
|
||||
insert.run(entry.tag, entry.when);
|
||||
}
|
||||
}
|
||||
|
||||
export function initDb(): DbInstance {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const dbPath = path.join(userDataPath, 'adiuva.db');
|
||||
_dbPath = path.join(userDataPath, 'adiuvai.db');
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
const sqlite = new Database(_dbPath);
|
||||
_rawSqlite = sqlite;
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('synchronous = NORMAL');
|
||||
|
||||
// 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 */ }
|
||||
const migrationsFolder = resolveMigrationsFolder();
|
||||
bootstrapMigrationsLedger(sqlite, migrationsFolder);
|
||||
|
||||
dbInstance = drizzle(sqlite, { schema });
|
||||
migrate(dbInstance, { migrationsFolder });
|
||||
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
@@ -95,3 +113,31 @@ export function getDb(): DbInstance {
|
||||
}
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/** Returns the absolute path to the active SQLite database file. */
|
||||
export function getDbPath(): string {
|
||||
if (!_dbPath) throw new Error('Database not initialized.');
|
||||
return _dbPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw better-sqlite3 Database instance.
|
||||
* Used by BackupManager for the `.backup()` API.
|
||||
*/
|
||||
export function getRawSqlite(): Database.Database {
|
||||
if (!_rawSqlite) throw new Error('Database not initialized.');
|
||||
return _rawSqlite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the database connection and clears all module-level references.
|
||||
* Called by BackupManager before atomically replacing the DB file.
|
||||
* After calling this, you must call `initDb()` again to re-open.
|
||||
*/
|
||||
export function closeDb(): void {
|
||||
if (_rawSqlite) {
|
||||
try { _rawSqlite.close(); } catch { /* ignore */ }
|
||||
_rawSqlite = null;
|
||||
}
|
||||
dbInstance = null;
|
||||
}
|
||||
|
||||
86
src/main/db/migrations/0000_broad_dust.sql
Normal file
@@ -0,0 +1,86 @@
|
||||
CREATE TABLE `agent_run_actions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`run_id` text NOT NULL,
|
||||
`agent_id` text NOT NULL,
|
||||
`verb` text NOT NULL,
|
||||
`entity_type` text NOT NULL,
|
||||
`entity_id` text,
|
||||
`entity_title` text,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `agent_runs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`agent_id` text NOT NULL,
|
||||
`status` text DEFAULT 'running' NOT NULL,
|
||||
`started_at` integer NOT NULL,
|
||||
`completed_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `clients` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`parent_id` text,
|
||||
`name` text NOT NULL,
|
||||
`industry` text,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `notes` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text,
|
||||
`title` text NOT NULL,
|
||||
`content` text DEFAULT '' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `projects` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`client_id` text,
|
||||
`name` text NOT NULL,
|
||||
`status` text DEFAULT 'active' NOT NULL,
|
||||
`ai_summary` text,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_comments` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`task_id` text NOT NULL,
|
||||
`author` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `tasks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text,
|
||||
`title` text NOT NULL,
|
||||
`description` text,
|
||||
`status` text DEFAULT 'todo' NOT NULL,
|
||||
`priority` text DEFAULT 'medium' NOT NULL,
|
||||
`assignee` text,
|
||||
`due_date` integer,
|
||||
`is_ai_suggested` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`completed_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `timeline_event_dependencies` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`from_event_id` text NOT NULL,
|
||||
`to_event_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `timeline_events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text,
|
||||
`title` text NOT NULL,
|
||||
`date` integer NOT NULL,
|
||||
`end_date` integer,
|
||||
`type` text DEFAULT 'milestone' NOT NULL,
|
||||
`is_completed` integer DEFAULT 0 NOT NULL,
|
||||
`is_ai_suggested` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`completed_at` integer
|
||||
);
|
||||
17
src/main/db/migrations/0001_boring_the_leader.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `note_edits` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`note_id` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`anchor_before` text,
|
||||
`anchor_text` text,
|
||||
`proposed_content` text NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`agent_id` text,
|
||||
`run_id` text,
|
||||
`reasoning` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`resolved_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `notes` ADD `ai_summary` text;--> statement-breakpoint
|
||||
ALTER TABLE `notes` ADD `ai_summary_updated_at` integer;
|
||||
10
src/main/db/migrations/0002_giant_karnak.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE `task_briefings` (
|
||||
`task_id` text PRIMARY KEY NOT NULL,
|
||||
`briefing_markdown` text NOT NULL,
|
||||
`canvas_draft` text,
|
||||
`canvas_kind` text,
|
||||
`citations` text,
|
||||
`source_task_hash` text NOT NULL,
|
||||
`generated_at` integer NOT NULL,
|
||||
`model_version` text
|
||||
);
|
||||
8
src/main/db/migrations/0003_shiny_karma.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE `task_brief_chats` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`task_id` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`is_error` integer DEFAULT false NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
11
src/main/db/migrations/0004_right_alex_power.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE `task_attachments` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`task_id` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`mime_type` text,
|
||||
`size_bytes` integer NOT NULL,
|
||||
`stored_path` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `tasks` ADD `estimate` integer;
|
||||
16
src/main/db/migrations/0005_slim_baron_strucker.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `project_folder_files` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`relative_path` text NOT NULL,
|
||||
`ext` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`size_bytes` integer NOT NULL,
|
||||
`mtime_ms` integer NOT NULL,
|
||||
`summary` text,
|
||||
`summary_updated_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_path` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_last_scanned_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_last_scan_status` text DEFAULT 'idle';--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_total_files` integer DEFAULT 0 NOT NULL;
|
||||
537
src/main/db/migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,537 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "163d917f-37b9-44a6-8edc-222ebf3f7f74",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
646
src/main/db/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,646 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "a52096e8-17fe-493a-a24a-4305c2953b3d",
|
||||
"prevId": "163d917f-37b9-44a6-8edc-222ebf3f7f74",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
712
src/main/db/migrations/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,712 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "c2e44835-b24a-4410-babf-887a82a4568e",
|
||||
"prevId": "a52096e8-17fe-493a-a24a-4305c2953b3d",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
765
src/main/db/migrations/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,765 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "d42caef6-2cfa-48bf-a8b3-46de4af43f47",
|
||||
"prevId": "c2e44835-b24a-4410-babf-887a82a4568e",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_brief_chats": {
|
||||
"name": "task_brief_chats",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_error": {
|
||||
"name": "is_error",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
831
src/main/db/migrations/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,831 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "8127cd67-44d0-41e8-a146-12eb1311c6c1",
|
||||
"prevId": "d42caef6-2cfa-48bf-a8b3-46de4af43f47",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_attachments": {
|
||||
"name": "task_attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stored_path": {
|
||||
"name": "stored_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_brief_chats": {
|
||||
"name": "task_brief_chats",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_error": {
|
||||
"name": "is_error",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"estimate": {
|
||||
"name": "estimate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
934
src/main/db/migrations/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,934 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "db432653-ac1d-40f4-b7eb-216d054ae191",
|
||||
"prevId": "8127cd67-44d0-41e8-a146-12eb1311c6c1",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"project_folder_files": {
|
||||
"name": "project_folder_files",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relative_path": {
|
||||
"name": "relative_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ext": {
|
||||
"name": "ext",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mtime_ms": {
|
||||
"name": "mtime_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary": {
|
||||
"name": "summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary_updated_at": {
|
||||
"name": "summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_path": {
|
||||
"name": "folder_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_last_scanned_at": {
|
||||
"name": "folder_last_scanned_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_last_scan_status": {
|
||||
"name": "folder_last_scan_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'idle'"
|
||||
},
|
||||
"folder_total_files": {
|
||||
"name": "folder_total_files",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_attachments": {
|
||||
"name": "task_attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stored_path": {
|
||||
"name": "stored_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_brief_chats": {
|
||||
"name": "task_brief_chats",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_error": {
|
||||
"name": "is_error",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"estimate": {
|
||||
"name": "estimate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
48
src/main/db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1777233385010,
|
||||
"tag": "0000_broad_dust",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1777499571580,
|
||||
"tag": "0001_boring_the_leader",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1777882122765,
|
||||
"tag": "0002_giant_karnak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1777889091889,
|
||||
"tag": "0003_shiny_karma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1778238659431,
|
||||
"tag": "0004_right_alex_power",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1778579196669,
|
||||
"tag": "0005_slim_baron_strucker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
69
src/main/db/notes-backfill.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Notes AI summary backfill.
|
||||
*
|
||||
* On startup, scans notes with a null ai_summary and generates summaries
|
||||
* via the backend `POST /api/v1/agents/notes/summarize` endpoint.
|
||||
*
|
||||
* - Throttled to 1 request/second to avoid rate-limiting.
|
||||
* - Idempotent: notes that already have an aiSummary are skipped.
|
||||
* - Offline-safe: if the backend is unreachable the run is skipped entirely;
|
||||
* the next startup will retry.
|
||||
*/
|
||||
|
||||
import { eq, isNull } from 'drizzle-orm';
|
||||
import { getDb } from './index';
|
||||
import { notes } from './schema';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
|
||||
const THROTTLE_MS = 1_000;
|
||||
|
||||
export async function backfillNoteSummaries(): Promise<void> {
|
||||
const client = getBackendClient();
|
||||
|
||||
const isOnline = await client.isOnline().catch(() => false);
|
||||
if (!isOnline) {
|
||||
console.log('[NotesBackfill] Backend offline — skipping aiSummary backfill.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = getDb()
|
||||
.select({ id: notes.id, title: notes.title, content: notes.content })
|
||||
.from(notes)
|
||||
.where(isNull(notes.aiSummary))
|
||||
.all();
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log('[NotesBackfill] All notes have aiSummary — nothing to backfill.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[NotesBackfill] Generating aiSummary for ${pending.length} note(s)…`);
|
||||
let success = 0;
|
||||
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const note = pending[i]!;
|
||||
try {
|
||||
const result = await client.proxyPost<{ summary: string }>(
|
||||
'/api/v1/agents/notes/summarize',
|
||||
{ title: note.title, content: note.content },
|
||||
);
|
||||
const summary = result.summary?.trim() ?? '';
|
||||
if (summary) {
|
||||
getDb()
|
||||
.update(notes)
|
||||
.set({ aiSummary: summary, aiSummaryUpdatedAt: Date.now() })
|
||||
.where(eq(notes.id, note.id))
|
||||
.run();
|
||||
success++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[NotesBackfill] Failed for note ${note.id}:`, err);
|
||||
}
|
||||
|
||||
if (i < pending.length - 1) {
|
||||
await new Promise<void>((r) => setTimeout(r, THROTTLE_MS));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[NotesBackfill] Done: ${success}/${pending.length} summaries generated.`);
|
||||
}
|
||||
@@ -16,6 +16,12 @@ export const projects = sqliteTable('projects', {
|
||||
status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
|
||||
aiSummary: text('ai_summary'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
folderPath: text('folder_path'),
|
||||
folderLastScannedAt: integer('folder_last_scanned_at', { mode: 'number' }),
|
||||
folderLastScanStatus: text('folder_last_scan_status', {
|
||||
enum: ['idle', 'scanning', 'error'],
|
||||
}).default('idle'),
|
||||
folderTotalFiles: integer('folder_total_files', { mode: 'number' }).notNull().default(0),
|
||||
});
|
||||
|
||||
export const tasks = sqliteTable('tasks', {
|
||||
@@ -27,18 +33,29 @@ export const tasks = sqliteTable('tasks', {
|
||||
priority: text('priority').notNull().default('medium'),
|
||||
assignee: text('assignee'),
|
||||
dueDate: integer('due_date', { mode: 'number' }),
|
||||
estimate: integer('estimate', { 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(),
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export const checkpoints = sqliteTable('checkpoints', {
|
||||
export const timelineEvents = sqliteTable('timeline_events', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').notNull(),
|
||||
projectId: text('project_id'),
|
||||
title: text('title').notNull(),
|
||||
date: integer('date', { mode: 'number' }).notNull(),
|
||||
endDate: integer('end_date', { mode: 'number' }),
|
||||
type: text('type', { enum: ['milestone', 'checkpoint', 'activity'] }).notNull().default('milestone'),
|
||||
isCompleted: integer('is_completed', { mode: 'number' }).notNull().default(0),
|
||||
isAiSuggested: integer('is_ai_suggested', { mode: 'number' }).notNull().default(0),
|
||||
isApproved: integer('is_approved', { mode: 'number' }).notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export const timelineEventDependencies = sqliteTable('timeline_event_dependencies', {
|
||||
id: text('id').primaryKey(),
|
||||
fromEventId: text('from_event_id').notNull(),
|
||||
toEventId: text('to_event_id').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
@@ -47,10 +64,42 @@ export const notes = sqliteTable('notes', {
|
||||
projectId: text('project_id'),
|
||||
title: text('title').notNull(),
|
||||
content: text('content').notNull().default(''),
|
||||
aiSummary: text('ai_summary'),
|
||||
aiSummaryUpdatedAt: integer('ai_summary_updated_at', { mode: 'number' }),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export const projectFolderFiles = sqliteTable('project_folder_files', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').notNull(),
|
||||
relativePath: text('relative_path').notNull(),
|
||||
ext: text('ext').notNull(),
|
||||
kind: text('kind', { enum: ['text', 'image', 'pdf', 'docx', 'csv', 'skipped', 'error'] }).notNull(),
|
||||
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
|
||||
mtimeMs: integer('mtime_ms', { mode: 'number' }).notNull(),
|
||||
summary: text('summary'),
|
||||
summaryUpdatedAt: integer('summary_updated_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export type ProjectFolderFile = InferSelectModel<typeof projectFolderFiles>;
|
||||
export type NewProjectFolderFile = InferInsertModel<typeof projectFolderFiles>;
|
||||
|
||||
export const noteEdits = sqliteTable('note_edits', {
|
||||
id: text('id').primaryKey(),
|
||||
noteId: text('note_id').notNull(),
|
||||
type: text('type', { enum: ['append', 'insert', 'replace'] }).notNull(),
|
||||
anchorBefore: text('anchor_before'),
|
||||
anchorText: text('anchor_text'),
|
||||
proposedContent: text('proposed_content').notNull(),
|
||||
status: text('status', { enum: ['pending', 'approved', 'rejected'] }).notNull().default('pending'),
|
||||
agentId: text('agent_id'),
|
||||
runId: text('run_id'),
|
||||
reasoning: text('reasoning'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
resolvedAt: integer('resolved_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export const taskComments = sqliteTable('task_comments', {
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
@@ -59,6 +108,16 @@ export const taskComments = sqliteTable('task_comments', {
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export const taskAttachments = sqliteTable('task_attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
mimeType: text('mime_type'),
|
||||
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
|
||||
storedPath: text('stored_path').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>;
|
||||
@@ -69,11 +128,72 @@ export type NewProject = InferInsertModel<typeof projects>;
|
||||
export type Task = InferSelectModel<typeof tasks>;
|
||||
export type NewTask = InferInsertModel<typeof tasks>;
|
||||
|
||||
export type Checkpoint = InferSelectModel<typeof checkpoints>;
|
||||
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>;
|
||||
|
||||
export type TaskAttachment = InferSelectModel<typeof taskAttachments>;
|
||||
export type NewTaskAttachment = InferInsertModel<typeof taskAttachments>;
|
||||
|
||||
export type TimelineEvent = InferSelectModel<typeof timelineEvents>;
|
||||
export type NewTimelineEvent = InferInsertModel<typeof timelineEvents>;
|
||||
|
||||
export type TimelineEventDependency = InferSelectModel<typeof timelineEventDependencies>;
|
||||
export type NewTimelineEventDependency = InferInsertModel<typeof timelineEventDependencies>;
|
||||
|
||||
export const taskBriefings = sqliteTable('task_briefings', {
|
||||
taskId: text('task_id').primaryKey(),
|
||||
briefingMarkdown: text('briefing_markdown').notNull(),
|
||||
canvasDraft: text('canvas_draft'),
|
||||
canvasKind: text('canvas_kind'),
|
||||
citations: text('citations'),
|
||||
sourceTaskHash: text('source_task_hash').notNull(),
|
||||
generatedAt: integer('generated_at', { mode: 'number' }).notNull(),
|
||||
modelVersion: text('model_version'),
|
||||
});
|
||||
|
||||
export type TaskBriefing = InferSelectModel<typeof taskBriefings>;
|
||||
export type NewTaskBriefing = InferInsertModel<typeof taskBriefings>;
|
||||
|
||||
export const taskBriefChats = sqliteTable('task_brief_chats', {
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id').notNull(),
|
||||
role: text('role', { enum: ['user', 'assistant'] }).notNull(),
|
||||
content: text('content').notNull(),
|
||||
isError: integer('is_error', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export type TaskBriefChat = InferSelectModel<typeof taskBriefChats>;
|
||||
export type NewTaskBriefChat = InferInsertModel<typeof taskBriefChats>;
|
||||
|
||||
export const agentRuns = sqliteTable('agent_runs', {
|
||||
id: text('id').primaryKey(),
|
||||
agentId: text('agent_id').notNull(),
|
||||
status: text('status', { enum: ['running', 'completed', 'failed', 'partial'] }).notNull().default('running'),
|
||||
startedAt: integer('started_at', { mode: 'number' }).notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export const agentRunActions = sqliteTable('agent_run_actions', {
|
||||
id: text('id').primaryKey(),
|
||||
runId: text('run_id').notNull(),
|
||||
agentId: text('agent_id').notNull(),
|
||||
/** 'created' | 'updated' | 'deleted' | 'commented' */
|
||||
verb: text('verb').notNull(),
|
||||
/** 'task' | 'note' | 'project' | 'timeline' | 'comment' */
|
||||
entityType: text('entity_type').notNull(),
|
||||
entityId: text('entity_id'),
|
||||
entityTitle: text('entity_title'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export type AgentRun = InferSelectModel<typeof agentRuns>;
|
||||
export type NewAgentRun = InferInsertModel<typeof agentRuns>;
|
||||
export type AgentRunAction = InferSelectModel<typeof agentRunActions>;
|
||||
export type NewAgentRunAction = InferInsertModel<typeof agentRunActions>;
|
||||
|
||||
export type NoteEdit = InferSelectModel<typeof noteEdits>;
|
||||
export type NewNoteEdit = InferInsertModel<typeof noteEdits>;
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
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,
|
||||
}));
|
||||
}
|
||||
21
src/main/files/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** File-type whitelists & size caps for project folder indexing. */
|
||||
|
||||
export const TEXT_EXTS = new Set([
|
||||
'.md', '.txt', '.rst', '.adoc',
|
||||
'.json', '.yaml', '.yml', '.toml', '.ini', '.csv', '.tsv',
|
||||
'.html', '.htm', '.xml',
|
||||
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
|
||||
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
|
||||
'.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.sh', '.ps1',
|
||||
'.css', '.scss', '.sass',
|
||||
'.sql',
|
||||
]);
|
||||
|
||||
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
|
||||
export const PDF_EXTS = new Set(['.pdf']);
|
||||
export const DOCX_EXTS = new Set(['.docx']);
|
||||
|
||||
export const MAX_TEXT_FILE_BYTES = 1 * 1024 * 1024; // 1 MB
|
||||
export const MAX_IMAGE_FILE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
export const INDEX_BATCH_SIZE = 5;
|
||||
27
src/main/files/daily-rescan.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// adiuvAI/src/main/files/daily-rescan.ts
|
||||
import { getDb } from '../db';
|
||||
import { projects } from '../db/schema';
|
||||
import { sql, and, isNotNull } from 'drizzle-orm';
|
||||
import { startIndexSession } from './indexer';
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function runDailyRescan(): Promise<void> {
|
||||
const cutoff = Date.now() - ONE_DAY_MS;
|
||||
const stale = getDb()
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(
|
||||
and(
|
||||
isNotNull(projects.folderPath),
|
||||
sql`(${projects.folderLastScannedAt} IS NULL OR ${projects.folderLastScannedAt} < ${cutoff})`,
|
||||
),
|
||||
)
|
||||
.all();
|
||||
for (const p of stale) {
|
||||
if (p.folderLastScanStatus === 'scanning') continue;
|
||||
// Fire-and-forget; no UI listener.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
void startIndexSession(p.id, () => {});
|
||||
}
|
||||
}
|
||||
222
src/main/files/indexer.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Folder index session orchestrator.
|
||||
*
|
||||
* Walks a folder via scanner.ts, sends batches over WS to the backend, applies
|
||||
* returned summaries to projectFolderFiles, drives progress callbacks.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDb } from '../db';
|
||||
import { projects, projectFolderFiles } from '../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { scanFolder, type ScannedFile } from './scanner';
|
||||
import { INDEX_BATCH_SIZE } from './constants';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
|
||||
export interface IndexProgress {
|
||||
sessionId: string;
|
||||
processed: number;
|
||||
total: number;
|
||||
status: 'starting' | 'scanning' | 'cancelled' | 'completed' | 'quota_exceeded' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ProgressListener = (p: IndexProgress) => void;
|
||||
|
||||
async function readForIndex(
|
||||
folderPath: string,
|
||||
f: ScannedFile,
|
||||
): Promise<{ content: string; mime?: string }> {
|
||||
const abs = path.join(folderPath, f.relativePath);
|
||||
if (f.kind === 'image') {
|
||||
const buf = await readFile(abs);
|
||||
const ext = f.ext.toLowerCase();
|
||||
const mime =
|
||||
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
|
||||
return { content: buf.toString('base64'), mime };
|
||||
}
|
||||
if (f.kind === 'text') {
|
||||
return { content: await readFile(abs, 'utf-8') };
|
||||
}
|
||||
// pdf / docx: read as binary, base64. Server is responsible for extraction.
|
||||
const buf = await readFile(abs);
|
||||
return { content: buf.toString('base64') };
|
||||
}
|
||||
|
||||
export async function startIndexSession(
|
||||
projectId: string,
|
||||
onProgress: ProgressListener,
|
||||
): Promise<{ sessionId: string; cancel: () => void }> {
|
||||
const sessionId = randomUUID();
|
||||
const db = getDb();
|
||||
|
||||
const proj = db.select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj || !proj.folderPath) {
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'error', error: 'No folder linked' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
db.update(projects)
|
||||
.set({ folderLastScanStatus: 'scanning' })
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'scanning' });
|
||||
|
||||
const delta = await scanFolder(projectId, proj.folderPath);
|
||||
|
||||
// Filter out 'skipped' files — they are too large to index and must not be sent
|
||||
const toIndex = [
|
||||
...delta.newFiles.filter((f) => f.kind !== 'skipped'),
|
||||
...delta.changedFiles.filter((f) => f.kind !== 'skipped'),
|
||||
];
|
||||
const total = toIndex.length;
|
||||
|
||||
for (const rel of delta.deletedRelPaths) {
|
||||
db.delete(projectFolderFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(projectFolderFiles.projectId, projectId),
|
||||
eq(projectFolderFiles.relativePath, rel),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderLastScanStatus: 'idle',
|
||||
folderLastScannedAt: Date.now(),
|
||||
folderTotalFiles: delta.unchangedCount,
|
||||
})
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'completed' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
const backend = getBackendClient();
|
||||
|
||||
let processed = 0;
|
||||
let cancelled = false;
|
||||
|
||||
const finalize = (status: IndexProgress['status'], error?: string): void => {
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderLastScanStatus:
|
||||
status === 'completed' || status === 'cancelled' ? 'idle' : 'error',
|
||||
folderLastScannedAt: Date.now(),
|
||||
folderTotalFiles: delta.unchangedCount + processed,
|
||||
})
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed, total, status, error });
|
||||
};
|
||||
|
||||
backend.registerIndexSession(sessionId, {
|
||||
onFileResult: ({ relPath, summary, error }) => {
|
||||
if (error) return;
|
||||
const f = toIndex.find((x) => x.relativePath === relPath);
|
||||
if (!f) return;
|
||||
const now = Date.now();
|
||||
|
||||
// SELECT-then-INSERT-or-UPDATE: no unique index on (projectId, relativePath)
|
||||
const existing = db
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(projectFolderFiles.projectId, projectId),
|
||||
eq(projectFolderFiles.relativePath, f.relativePath),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
db.update(projectFolderFiles)
|
||||
.set({
|
||||
mtimeMs: f.mtimeMs,
|
||||
sizeBytes: f.sizeBytes,
|
||||
kind: f.kind,
|
||||
summary: summary ?? null,
|
||||
summaryUpdatedAt: now,
|
||||
})
|
||||
.where(eq(projectFolderFiles.id, existing.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(projectFolderFiles)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
projectId,
|
||||
relativePath: f.relativePath,
|
||||
ext: f.ext,
|
||||
kind: f.kind,
|
||||
sizeBytes: f.sizeBytes,
|
||||
mtimeMs: f.mtimeMs,
|
||||
summary: summary ?? null,
|
||||
summaryUpdatedAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
onProgress: ({ processed: p, total: t }) => {
|
||||
processed = p;
|
||||
onProgress({ sessionId, processed: p, total: t, status: 'scanning' });
|
||||
},
|
||||
onDone: (status) => {
|
||||
finalize(
|
||||
status === 'completed'
|
||||
? 'completed'
|
||||
: status === 'cancelled'
|
||||
? 'cancelled'
|
||||
: status === 'quota_exceeded'
|
||||
? 'quota_exceeded'
|
||||
: 'error',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
backend.sendIndexSessionStart(sessionId, projectId, total);
|
||||
} catch (err) {
|
||||
finalize('error', err instanceof Error ? err.message : 'WS send failed');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
// Send batches (skipped files already excluded from toIndex)
|
||||
for (let i = 0; i < toIndex.length; i += INDEX_BATCH_SIZE) {
|
||||
if (cancelled) break;
|
||||
const batch = toIndex.slice(i, i + INDEX_BATCH_SIZE);
|
||||
const payload = await Promise.all(
|
||||
batch.map(async (f) => {
|
||||
const { content, mime } = await readForIndex(proj.folderPath!, f);
|
||||
return {
|
||||
relPath: f.relativePath,
|
||||
kind: f.kind as 'text' | 'image' | 'pdf' | 'docx',
|
||||
content,
|
||||
ext: f.ext,
|
||||
mime,
|
||||
sizeBytes: f.sizeBytes,
|
||||
mtimeMs: f.mtimeMs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
try {
|
||||
backend.sendIndexFileBatch(sessionId, payload);
|
||||
} catch (err) {
|
||||
finalize('error', err instanceof Error ? err.message : 'WS send failed');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
cancelled = true;
|
||||
backend.sendIndexSessionCancel(sessionId);
|
||||
};
|
||||
return { sessionId, cancel };
|
||||
}
|
||||
95
src/main/files/scanner.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/** Filesystem scanner — walks a directory, filters by whitelist, computes delta vs DB manifest. */
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { getDb } from '../db';
|
||||
import { projectFolderFiles } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
TEXT_EXTS, IMAGE_EXTS, PDF_EXTS, DOCX_EXTS,
|
||||
MAX_TEXT_FILE_BYTES, MAX_IMAGE_FILE_BYTES,
|
||||
} from './constants';
|
||||
|
||||
export type FileKind = 'text' | 'image' | 'pdf' | 'docx' | 'skipped';
|
||||
|
||||
export interface ScannedFile {
|
||||
relativePath: string;
|
||||
ext: string;
|
||||
kind: FileKind;
|
||||
sizeBytes: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface ScanDelta {
|
||||
newFiles: ScannedFile[];
|
||||
changedFiles: ScannedFile[];
|
||||
unchangedCount: number;
|
||||
deletedRelPaths: string[];
|
||||
}
|
||||
|
||||
function classify(ext: string, sizeBytes: number): FileKind | null {
|
||||
const e = ext.toLowerCase();
|
||||
if (TEXT_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'text' : 'skipped';
|
||||
if (IMAGE_EXTS.has(e)) return sizeBytes <= MAX_IMAGE_FILE_BYTES ? 'image' : 'skipped';
|
||||
if (PDF_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'pdf' : 'skipped';
|
||||
if (DOCX_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'docx' : 'skipped';
|
||||
return null; // not indexable
|
||||
}
|
||||
|
||||
async function walk(root: string): Promise<ScannedFile[]> {
|
||||
const out: ScannedFile[] = [];
|
||||
async function recurse(dir: string) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // permission denied — skip silently
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith('.')) continue; // skip dot dirs / files
|
||||
if (e.name === 'node_modules') continue; // common noise
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
await recurse(full);
|
||||
} else if (e.isFile()) {
|
||||
let s;
|
||||
try { s = await stat(full); } catch { continue; }
|
||||
const ext = path.extname(e.name);
|
||||
const kind = classify(ext, s.size);
|
||||
if (kind === null) continue;
|
||||
out.push({
|
||||
relativePath: path.relative(root, full),
|
||||
ext,
|
||||
kind,
|
||||
sizeBytes: s.size,
|
||||
mtimeMs: Math.floor(s.mtimeMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await recurse(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function scanFolder(projectId: string, folderPath: string): Promise<ScanDelta> {
|
||||
const scanned = await walk(folderPath);
|
||||
const existing = getDb()
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, projectId))
|
||||
.all();
|
||||
|
||||
const existingMap = new Map(existing.map(r => [r.relativePath, r]));
|
||||
const newFiles: ScannedFile[] = [];
|
||||
const changedFiles: ScannedFile[] = [];
|
||||
let unchanged = 0;
|
||||
for (const f of scanned) {
|
||||
const prev = existingMap.get(f.relativePath);
|
||||
if (!prev) newFiles.push(f);
|
||||
else if (prev.mtimeMs !== f.mtimeMs || prev.sizeBytes !== f.sizeBytes) changedFiles.push(f);
|
||||
else unchanged++;
|
||||
existingMap.delete(f.relativePath);
|
||||
}
|
||||
const deletedRelPaths = Array.from(existingMap.keys());
|
||||
return { newFiles, changedFiles, unchangedCount: unchanged, deletedRelPaths };
|
||||
}
|
||||
@@ -1,27 +1,84 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron';
|
||||
import path from 'node:path';
|
||||
import started from 'electron-squirrel-startup';
|
||||
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';
|
||||
import { getAuthManager } from './auth/auth-manager';
|
||||
import { getBackendClient } from './api/backend-client';
|
||||
import { getStore } from './store';
|
||||
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
|
||||
import { startAgentScheduler, stopAgentScheduler } from './agents/agent-scheduler';
|
||||
import { backfillNoteSummaries } from './db/notes-backfill';
|
||||
import { runDailyRescan } from './files/daily-rescan';
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single-instance lock + deep link (OAuth callback via adiuvai://)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// In dev, Electron is launched as: `electron . ` (or via electron-forge).
|
||||
// setAsDefaultProtocolClient on Windows/Linux requires the path to the exe.
|
||||
if (process.defaultApp) {
|
||||
// Dev: electron.exe is the "app" — pass the script path as the second arg
|
||||
// so that OS-registered links include it and second-instance receives the URL.
|
||||
app.setAsDefaultProtocolClient('adiuvai', process.execPath, [path.resolve(process.argv[1] ?? '.')]);
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient('adiuvai');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and dispatch an adiuvai:// deep link URL.
|
||||
* Delegates to AuthManager so the pending OAuth promise is resolved.
|
||||
*/
|
||||
function handleDeepLink(url: string): void {
|
||||
if (url.startsWith('adiuvai://oauth/callback')) {
|
||||
void getAuthManager().handleOAuthCallback(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Windows / Linux: a second instance is launched with the deep link as an argv.
|
||||
// We prevent the second instance and redirect the URL to the first instance.
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!gotSingleInstanceLock) {
|
||||
// Another instance already running — hand off and exit.
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
// On Windows the URL is the last argument (e.g. adiuvai://oauth/callback?...)
|
||||
const url = argv.find((arg) => arg.startsWith('adiuvai://'));
|
||||
if (url) handleDeepLink(url);
|
||||
|
||||
// Bring the existing window to focus.
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
const win = windows[0]!;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// macOS: the OS delivers the URL via this event (no second instance spawned).
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
handleDeepLink(url);
|
||||
});
|
||||
|
||||
const createWindow = (): BrowserWindow => {
|
||||
// Create the browser window.
|
||||
const iconPath = path.join(__dirname, '../../assets/logo/logo-icon.png');
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@@ -46,6 +103,13 @@ const createWindow = (): BrowserWindow => {
|
||||
return mainWindow;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialog IPC — file/folder picker
|
||||
// ---------------------------------------------------------------------------
|
||||
ipcMain.handle('dialog:showOpenDialog', (_event, options: Electron.OpenDialogOptions) =>
|
||||
dialog.showOpenDialog(options),
|
||||
);
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
@@ -53,12 +117,33 @@ 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));
|
||||
// Persistent device WebSocket for agent triggers — best-effort on startup
|
||||
getAuthManager()
|
||||
.isAuthenticated()
|
||||
.then((authenticated) => {
|
||||
if (authenticated) {
|
||||
void getBackendClient().connectPersistent();
|
||||
// Best-effort notes backfill — runs after WS is likely connected
|
||||
setTimeout(() => {
|
||||
backfillNoteSummaries().catch((err) =>
|
||||
console.error('[NotesBackfill] Startup backfill failed:', err),
|
||||
);
|
||||
}, 5_000);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
|
||||
|
||||
startBriefScheduler();
|
||||
startAgentScheduler();
|
||||
// Delay so WS connection is likely up before triggering rescans
|
||||
setTimeout(() => { void runDailyRescan(); }, 10_000);
|
||||
});
|
||||
|
||||
// Clean up the persistent WS and backup timers before the app exits
|
||||
app.on('will-quit', () => {
|
||||
stopBriefScheduler();
|
||||
stopAgentScheduler();
|
||||
getBackendClient().disconnectPersistent();
|
||||
});
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
|
||||
128
src/main/router/projectFolders.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// adiuvAI/src/main/router/projectFolders.ts
|
||||
import { TRPCError, initTRPC } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { dialog } from 'electron';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { projects, projectFolderFiles } from '../db/schema';
|
||||
import { startIndexSession, type IndexProgress } from '../files/indexer';
|
||||
import { scanFolder } from '../files/scanner';
|
||||
import { getBackendClient, QuotaError } from '../api/backend-client';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
const router = t.router;
|
||||
const publicProcedure = t.procedure;
|
||||
|
||||
// In-memory map of active sessions per projectId so we can cancel
|
||||
const _active = new Map<string, { cancel: () => void; lastProgress: IndexProgress }>();
|
||||
|
||||
export const projectFoldersRouter = router({
|
||||
chooseFolder: publicProcedure.mutation(async () => {
|
||||
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
}),
|
||||
|
||||
link: publicProcedure
|
||||
.input(z.object({ projectId: z.string(), folderPath: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
db.update(projects)
|
||||
.set({ folderPath: input.folderPath, folderLastScanStatus: 'idle', folderTotalFiles: 0 })
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
unlink: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
db.delete(projectFolderFiles).where(eq(projectFolderFiles.projectId, input.projectId)).run();
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderPath: null,
|
||||
folderLastScannedAt: null,
|
||||
folderLastScanStatus: 'idle',
|
||||
folderTotalFiles: 0,
|
||||
})
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
startScan: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const proj = db.select().from(projects).where(eq(projects.id, input.projectId)).get();
|
||||
if (!proj?.folderPath) throw new Error('No folder linked');
|
||||
if (proj.folderLastScanStatus === 'scanning') throw new Error('Scan already in progress');
|
||||
|
||||
// Pre-flight: walk folder to estimate indexable file count, then ask the
|
||||
// backend whether the user's tier allows proceeding.
|
||||
const delta = await scanFolder(input.projectId, proj.folderPath);
|
||||
const estimated = delta.newFiles.length + delta.changedFiles.length + delta.unchangedCount;
|
||||
|
||||
try {
|
||||
await getBackendClient().checkFolderQuota(estimated);
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaError) {
|
||||
// Encode reason + backend message so the renderer can produce a
|
||||
// localised toast without an extra RPC call.
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: `QUOTA:${err.reason}:${err.message}`,
|
||||
});
|
||||
}
|
||||
// Network / auth errors: propagate as-is so the renderer shows a
|
||||
// generic error toast rather than silently swallowing the problem.
|
||||
throw err;
|
||||
}
|
||||
|
||||
const session = await startIndexSession(input.projectId, (p) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
if (entry) entry.lastProgress = p;
|
||||
if (
|
||||
p.status === 'completed' ||
|
||||
p.status === 'cancelled' ||
|
||||
p.status === 'quota_exceeded' ||
|
||||
p.status === 'error'
|
||||
) {
|
||||
_active.delete(input.projectId);
|
||||
}
|
||||
});
|
||||
_active.set(input.projectId, {
|
||||
cancel: session.cancel,
|
||||
lastProgress: { sessionId: session.sessionId, processed: 0, total: 0, status: 'starting' },
|
||||
});
|
||||
return { sessionId: session.sessionId };
|
||||
}),
|
||||
|
||||
cancelScan: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
if (entry) entry.cancel();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
getStatus: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
return entry?.lastProgress ?? null;
|
||||
}),
|
||||
|
||||
listFiles: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, input.projectId))
|
||||
.orderBy(projectFolderFiles.relativePath)
|
||||
.all();
|
||||
}),
|
||||
});
|
||||
@@ -1,12 +1,60 @@
|
||||
import Store from 'electron-store';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local agent config — stored entirely on the FE, never on the backend.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LocalAgentLocalConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
directory: string;
|
||||
dataTypes: string[];
|
||||
/** Structured extraction config produced by the Journey setup flow. */
|
||||
agentConfig: Record<string, unknown> | null;
|
||||
scheduleCron: string;
|
||||
enabled: boolean;
|
||||
lastRunAt: number | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format preferences — stored locally, never sent to LLM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FormatPrefs {
|
||||
timezone: string;
|
||||
dateFormat: string; // 'dd/MM/yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'
|
||||
timeFormat: '12h' | '24h';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App settings (electron-store shape)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AppSettings {
|
||||
sidebarCollapsed: boolean;
|
||||
aiProvider: string;
|
||||
encryptedTokens: Record<string, string>;
|
||||
userName: string;
|
||||
/** Base URL of the AdiuvAI backend API (e.g. 'http://localhost:8000'). */
|
||||
backendUrl: string;
|
||||
/**
|
||||
* Stable device identifier — UUID v4 generated once on first launch and
|
||||
* persisted forever. Used to bind local agents to the machine they were
|
||||
* configured on.
|
||||
*/
|
||||
deviceId: string;
|
||||
/** Cached daily brief — regenerated once per day or when relevant data changes. */
|
||||
dailyBriefCache: { content: string; date: string } | null;
|
||||
/** Locally-managed agent configurations. */
|
||||
localAgents: LocalAgentLocalConfig[];
|
||||
/** OS-detected display format preferences. */
|
||||
formatPrefs: FormatPrefs | null;
|
||||
/** UI language code (e.g. 'en', 'it', 'es', 'fr', 'de'). */
|
||||
uiLanguage: string;
|
||||
/** Timeline zoom level. */
|
||||
timelineZoom: ZoomLevel;
|
||||
}
|
||||
|
||||
export type ZoomLevel = 'day' | 'week' | 'month';
|
||||
|
||||
let _store: Store<AppSettings> | null = null;
|
||||
|
||||
export function getStore(): Store<AppSettings> {
|
||||
@@ -14,11 +62,91 @@ export function getStore(): Store<AppSettings> {
|
||||
_store = new Store<AppSettings>({
|
||||
defaults: {
|
||||
sidebarCollapsed: false,
|
||||
aiProvider: 'copilot',
|
||||
encryptedTokens: {},
|
||||
userName: 'there',
|
||||
backendUrl: 'http://localhost:8000',
|
||||
deviceId: '',
|
||||
dailyBriefCache: null,
|
||||
localAgents: [],
|
||||
formatPrefs: null,
|
||||
uiLanguage: 'en',
|
||||
timelineZoom: 'day',
|
||||
},
|
||||
});
|
||||
}
|
||||
return _store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stable device ID, generating and persisting a new UUID v4 on
|
||||
* first call. Subsequent calls always return the same value.
|
||||
*/
|
||||
export function getDeviceId(): string {
|
||||
const store = getStore();
|
||||
let id = store.get('deviceId');
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
store.set('deviceId', id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local agent helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getLocalAgents(): LocalAgentLocalConfig[] {
|
||||
return getStore().get('localAgents');
|
||||
}
|
||||
|
||||
export function getLocalAgent(id: string): LocalAgentLocalConfig | undefined {
|
||||
return getLocalAgents().find((a) => a.id === id);
|
||||
}
|
||||
|
||||
export function saveLocalAgent(agent: LocalAgentLocalConfig): void {
|
||||
const agents = getLocalAgents();
|
||||
const idx = agents.findIndex((a) => a.id === agent.id);
|
||||
if (idx >= 0) {
|
||||
agents[idx] = agent;
|
||||
} else {
|
||||
agents.push(agent);
|
||||
}
|
||||
getStore().set('localAgents', agents);
|
||||
}
|
||||
|
||||
export function deleteLocalAgent(id: string): void {
|
||||
const agents = getLocalAgents().filter((a) => a.id !== id);
|
||||
getStore().set('localAgents', agents);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format preference helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getFormatPrefs(): FormatPrefs | null {
|
||||
return getStore().get('formatPrefs', null);
|
||||
}
|
||||
|
||||
export function setFormatPrefs(prefs: FormatPrefs): void {
|
||||
getStore().set('formatPrefs', prefs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI language helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getUiLanguage(): string {
|
||||
return getStore().get('uiLanguage', 'en');
|
||||
}
|
||||
|
||||
export function setUiLanguage(lang: string): void {
|
||||
getStore().set('uiLanguage', lang);
|
||||
}
|
||||
|
||||
export function getTimelineZoom(): ZoomLevel {
|
||||
const v = getStore().get('timelineZoom', 'day');
|
||||
return v === 'day' || v === 'week' || v === 'month' ? v : 'day';
|
||||
}
|
||||
|
||||
export function setTimelineZoom(level: ZoomLevel): void {
|
||||
getStore().set('timelineZoom', level);
|
||||
}
|
||||
|
||||
@@ -20,24 +20,50 @@ contextBridge.exposeInMainWorld('electronTRPC', {
|
||||
});
|
||||
|
||||
const AI_STREAM_CHANNEL = 'ai:stream';
|
||||
const AI_ACTION_CHANNEL = 'ai:action';
|
||||
|
||||
// V3 stream event — discriminated union of all frame types the renderer can receive.
|
||||
type V3StreamEvent =
|
||||
| { type: 'stream_start'; requestId: string }
|
||||
| { type: 'stream_text'; requestId: string; chunk: string }
|
||||
| { type: 'stream_end'; requestId: string; mutations?: unknown[] }
|
||||
| {
|
||||
type: 'floating_domain';
|
||||
requestId: string;
|
||||
domain:
|
||||
| 'tasks'
|
||||
| 'notes'
|
||||
| 'timelines'
|
||||
| 'projects'
|
||||
| {
|
||||
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
|
||||
id?: string | null;
|
||||
section?: 'task' | 'timeline' | 'note' | null;
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
/** Subscribe to v3 AI stream events. Returns an unsubscribe function. */
|
||||
onStreamEvent: (cb: (data: V3StreamEvent) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, data: V3StreamEvent) => 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);
|
||||
/** Subscribe to background brief-updated push events. Returns an unsubscribe function. */
|
||||
onBriefUpdated: (cb: (content: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, content: string) => cb(content);
|
||||
ipcRenderer.on('ai:brief-updated', handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(AI_ACTION_CHANNEL, handler);
|
||||
ipcRenderer.removeListener('ai:brief-updated', handler);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dialog — native file/folder picker
|
||||
// ---------------------------------------------------------------------------
|
||||
contextBridge.exposeInMainWorld('electronDialog', {
|
||||
showOpenDialog: (options: Electron.OpenDialogOptions): Promise<Electron.OpenDialogReturnValue> =>
|
||||
ipcRenderer.invoke('dialog:showOpenDialog', options),
|
||||
});
|
||||
|
||||
149
src/renderer/components/agents/AgentRunLog.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileCheck,
|
||||
FilePlus,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useFormatPrefs, formatTs, formatDuration } from '@/lib/date';
|
||||
import type { AgentRunLog } from '../../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 text-emerald-600 dark:text-emerald-400 shrink-0">
|
||||
<CheckCircle2 className="size-3" /> Success
|
||||
</Badge>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1 shrink-0">
|
||||
<XCircle className="size-3" /> Error
|
||||
</Badge>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 shrink-0">
|
||||
<Loader2 className="size-3 animate-spin" /> Running
|
||||
</Badge>
|
||||
);
|
||||
case 'partial':
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-amber-600 shrink-0">
|
||||
<AlertCircle className="size-3" /> Partial
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge variant="outline" className="shrink-0">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-run row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RunRow({ run }: { run: AgentRunLog }) {
|
||||
const prefs = useFormatPrefs();
|
||||
const [errorsOpen, setErrorsOpen] = useState(false);
|
||||
const hasErrors = (run.errors ?? []).length > 0;
|
||||
const duration = formatDuration(run.startedAt, run.completedAt);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-xs">
|
||||
{statusBadge(run.status)}
|
||||
|
||||
<span className="text-muted-foreground shrink-0">{formatTs(run.startedAt, prefs)}</span>
|
||||
|
||||
{duration && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground shrink-0">
|
||||
<Clock className="size-3" />
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="flex items-center gap-1 text-muted-foreground shrink-0">
|
||||
<FileCheck className="size-3" />
|
||||
{run.itemsProcessed} processed
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-1 text-muted-foreground shrink-0">
|
||||
<FilePlus className="size-3" />
|
||||
{run.itemsCreated} created
|
||||
</span>
|
||||
|
||||
{hasErrors && (
|
||||
<button
|
||||
onClick={() => setErrorsOpen(v => !v)}
|
||||
className="ml-auto flex items-center gap-1 text-destructive hover:text-destructive/80 transition-colors"
|
||||
>
|
||||
{errorsOpen ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
{run.errors.length} {run.errors.length === 1 ? 'error' : 'errors'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasErrors && errorsOpen && (
|
||||
<div className="border-t px-3 py-2 flex flex-col gap-1">
|
||||
{run.errors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-destructive font-mono break-all">{err}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentRunLog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AgentRunLog({ agentId, expanded }: { agentId: string; expanded: boolean }) {
|
||||
const runsQuery = trpc.agent.runs.useQuery(
|
||||
{ agentId, limit: 10 },
|
||||
{ enabled: expanded },
|
||||
);
|
||||
|
||||
if (!expanded) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Run History
|
||||
</p>
|
||||
|
||||
{runsQuery.isPending && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{[0, 1, 2].map(i => (
|
||||
<Skeleton key={i} className="h-9 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!runsQuery.isPending && (runsQuery.data ?? []).length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No runs yet.</p>
|
||||
)}
|
||||
|
||||
{!runsQuery.isPending && (runsQuery.data ?? []).length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{(runsQuery.data as AgentRunLog[]).map(run => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
src/renderer/components/ai/ChatInputBox.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState, useRef, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { readInputDraft, writeInputDraft } from '@/hooks/useAIChat';
|
||||
|
||||
export interface ChatInputBoxHandle {
|
||||
getValue: () => string;
|
||||
setValue: (v: string) => void;
|
||||
clear: () => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
type ChatInputBoxVariant = 'panel' | 'floating' | 'comment';
|
||||
|
||||
interface ChatInputBoxProps {
|
||||
cacheKey: string;
|
||||
isStreaming: boolean;
|
||||
onSend: (message: string) => void;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
variant?: ChatInputBoxVariant;
|
||||
}
|
||||
|
||||
const VARIANT_STYLES = {
|
||||
panel: {
|
||||
container: 'flex items-center gap-2 px-4 py-2.5',
|
||||
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-[7.5rem] overflow-y-auto',
|
||||
button: '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',
|
||||
iconSize: 16,
|
||||
},
|
||||
floating: {
|
||||
container: 'flex items-center gap-2 px-3 py-2.5',
|
||||
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto',
|
||||
button: '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',
|
||||
iconSize: 14,
|
||||
},
|
||||
comment: {
|
||||
container: 'flex items-center gap-2 px-3 py-2',
|
||||
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-32 overflow-y-auto',
|
||||
button: 'flex h-7 w-7 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-30 disabled:cursor-not-allowed',
|
||||
iconSize: 14,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const ChatInputBox = forwardRef<ChatInputBoxHandle, ChatInputBoxProps>(
|
||||
({ cacheKey, isStreaming, onSend, placeholder, autoFocus, variant = 'panel' }, ref) => {
|
||||
const styles = VARIANT_STYLES[variant];
|
||||
const [value, setValue] = useState(() => readInputDraft(cacheKey));
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
// Re-init when the cache key changes (context switches in FloatingChat).
|
||||
const prevKeyRef = useRef(cacheKey);
|
||||
useEffect(() => {
|
||||
if (prevKeyRef.current !== cacheKey) {
|
||||
prevKeyRef.current = cacheKey;
|
||||
setValue(readInputDraft(cacheKey));
|
||||
}
|
||||
}, [cacheKey]);
|
||||
|
||||
// Debounced draft persistence — fires 250 ms after the last keystroke.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => writeInputDraft(cacheKey, value), 250);
|
||||
return () => clearTimeout(id);
|
||||
}, [cacheKey, value]);
|
||||
|
||||
// Flush on unmount so a fast close/reopen preserves the current draft.
|
||||
useEffect(() => {
|
||||
return () => writeInputDraft(cacheKey, valueRef.current);
|
||||
}, [cacheKey]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getValue: () => valueRef.current,
|
||||
setValue: (v: string) => {
|
||||
setValue(v);
|
||||
// Move caret to end + focus after React commits the new value.
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.setSelectionRange(v.length, v.length);
|
||||
}
|
||||
});
|
||||
},
|
||||
clear: () => setValue(''),
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
}));
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Guard IME composition — prevents spurious submit during Italian dead-key
|
||||
// input (e.g. ` + e → è) and CJK composition sequences.
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (isStreaming) return;
|
||||
const v = valueRef.current.trim();
|
||||
if (!v) return;
|
||||
setValue('');
|
||||
writeInputDraft(cacheKey, '');
|
||||
onSend(v);
|
||||
}
|
||||
},
|
||||
[isStreaming, onSend, cacheKey],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isStreaming) return;
|
||||
const v = valueRef.current.trim();
|
||||
if (!v) return;
|
||||
setValue('');
|
||||
writeInputDraft(cacheKey, '');
|
||||
onSend(v);
|
||||
}, [isStreaming, onSend, cacheKey]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
aria-label="Chat message"
|
||||
rows={1}
|
||||
autoFocus={autoFocus}
|
||||
className={styles.textarea}
|
||||
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={!value.trim() || isStreaming}
|
||||
aria-label="Send message"
|
||||
className={styles.button}
|
||||
>
|
||||
<ArrowUp size={styles.iconSize} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChatInputBox.displayName = 'ChatInputBox';
|
||||
@@ -2,7 +2,7 @@ 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 { X } from 'lucide-react';
|
||||
import {
|
||||
useFloatingChat,
|
||||
computeDualAnchor,
|
||||
@@ -10,78 +10,158 @@ import {
|
||||
CHAT_HEIGHT,
|
||||
PADDING,
|
||||
} from '@/context/FloatingChatContext';
|
||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||
import { useAIChat, type UIChatContext, type FloatingDomainSignal } from '@/hooks/useAIChat';
|
||||
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
||||
import { ChatInputBox, type ChatInputBoxHandle } from '@/components/ai/ChatInputBox';
|
||||
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',
|
||||
/** Map floating_domain signals to routes for background navigation */
|
||||
const DOMAIN_ROUTES: Record<string, string> = {
|
||||
tasks: '/tasks',
|
||||
notes: '/notes',
|
||||
timelines: '/timeline',
|
||||
projects: '/projects',
|
||||
};
|
||||
|
||||
const DOMAIN_SECTION_IDS: Partial<Record<'tasks' | 'notes' | 'timelines' | 'projects', string>> = {
|
||||
tasks: 'tasks-list',
|
||||
timelines: 'timeline-chart',
|
||||
};
|
||||
|
||||
interface DomainNavigationTarget {
|
||||
route: '/tasks' | '/timeline' | '/projects' | '/notes/$noteId';
|
||||
sectionId?: string;
|
||||
projectId?: string;
|
||||
noteId?: string;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
function normalizeDomainSignal(domain: FloatingDomainSignal): DomainNavigationTarget | null {
|
||||
if (typeof domain === 'string') {
|
||||
const route = DOMAIN_ROUTES[domain];
|
||||
if (!route) return null;
|
||||
return {
|
||||
route: route as DomainNavigationTarget['route'],
|
||||
sectionId: DOMAIN_SECTION_IDS[domain as keyof typeof DOMAIN_SECTION_IDS],
|
||||
};
|
||||
}
|
||||
|
||||
switch (domain.type) {
|
||||
case 'task':
|
||||
return { route: '/tasks', sectionId: 'tasks-list' };
|
||||
case 'timeline':
|
||||
return { route: '/timeline', sectionId: 'timeline-chart' };
|
||||
case 'note':
|
||||
if (!domain.id) return { route: '/projects' };
|
||||
return { route: '/notes/$noteId', noteId: domain.id };
|
||||
case 'project': {
|
||||
if (domain.section === 'task') {
|
||||
return { route: '/projects', sectionId: 'project-tasks', projectId: domain.id ?? undefined };
|
||||
}
|
||||
if (domain.section === 'timeline') {
|
||||
return { route: '/projects', sectionId: 'project-timeline', projectId: domain.id ?? undefined };
|
||||
}
|
||||
if (domain.section === 'note') {
|
||||
return { route: '/projects', sectionId: 'project-notes', projectId: domain.id ?? undefined };
|
||||
}
|
||||
return { route: '/projects', projectId: domain.id ?? undefined };
|
||||
}
|
||||
case 'node':
|
||||
if (!domain.id) return null;
|
||||
return { route: '/projects', sectionId: domain.id, nodeId: domain.id };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function FloatingChatInner() {
|
||||
const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat();
|
||||
const utils = trpc.useUtils();
|
||||
const { state, sections, close, updatePosition, setPendingSection, moveToSection } = useFloatingChat();
|
||||
const navigate = useNavigate();
|
||||
const routerState = useRouterState();
|
||||
const prevPathRef = useRef(routerState.location.pathname);
|
||||
const domainNavigationInFlightRef = useRef(false);
|
||||
|
||||
// Active section lookup
|
||||
const activeSection = sections.get(state.activeSectionId ?? '');
|
||||
|
||||
// Chat context derived from active section
|
||||
const chatContext = useMemo<ChatContext>(
|
||||
() => ({
|
||||
type: activeSection?.projectId ? 'project' : 'global',
|
||||
// Chat context — floating mode with scope derived from active section
|
||||
const chatContext = useMemo<UIChatContext>(() => {
|
||||
const scope = activeSection
|
||||
? {
|
||||
type: (activeSection.label?.toLowerCase().includes('task')
|
||||
? 'task'
|
||||
: activeSection.label?.toLowerCase().includes('note')
|
||||
? 'note'
|
||||
: activeSection.label?.toLowerCase().includes('timeline')
|
||||
? 'timeline'
|
||||
: 'project') as 'task' | 'project' | 'note' | 'timeline',
|
||||
id: activeSection.projectId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
type: 'floating' as const,
|
||||
projectId: activeSection?.projectId,
|
||||
uiContext: activeSection?.label,
|
||||
}),
|
||||
[activeSection?.projectId, activeSection?.label],
|
||||
scope,
|
||||
};
|
||||
}, [activeSection?.projectId, activeSection?.label]);
|
||||
|
||||
// Handle floating_domain signals — navigate in background
|
||||
const handleDomainSignal = useCallback(
|
||||
(domainSignal: FloatingDomainSignal) => {
|
||||
const target = normalizeDomainSignal(domainSignal);
|
||||
if (!target) return;
|
||||
|
||||
// If backend points to a currently registered node/section, move there immediately.
|
||||
if (target.sectionId && sections.has(target.sectionId)) {
|
||||
moveToSection(target.sectionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = routerState.location.pathname;
|
||||
const isCurrentRoute =
|
||||
(target.route === '/projects' && currentPath === '/projects') ||
|
||||
(target.route === '/tasks' && currentPath === '/tasks') ||
|
||||
(target.route === '/timeline' && currentPath === '/timeline') ||
|
||||
(target.route === '/notes/$noteId' && currentPath.startsWith('/notes/'));
|
||||
|
||||
if (isCurrentRoute && target.sectionId) {
|
||||
setPendingSection({ sectionId: target.sectionId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCurrentRoute) return;
|
||||
|
||||
domainNavigationInFlightRef.current = true;
|
||||
|
||||
const pendingSectionId = target.sectionId;
|
||||
if (pendingSectionId) {
|
||||
setPendingSection({ sectionId: pendingSectionId });
|
||||
} else {
|
||||
setPendingSection(undefined);
|
||||
}
|
||||
|
||||
if (target.route === '/projects') {
|
||||
void navigate({ to: '/projects', search: target.projectId ? { projectId: target.projectId } : {} });
|
||||
} else if (target.route === '/notes/$noteId' && target.noteId) {
|
||||
void navigate({ to: '/notes/$noteId', params: { noteId: target.noteId } });
|
||||
} else if (target.route === '/tasks') {
|
||||
void navigate({ to: '/tasks' });
|
||||
} else if (target.route === '/timeline') {
|
||||
void navigate({ to: '/timeline' });
|
||||
}
|
||||
},
|
||||
[routerState.location.pathname, navigate, setPendingSection, sections, moveToSection],
|
||||
);
|
||||
|
||||
// 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 });
|
||||
cacheKey,
|
||||
} = useAIChat(chatContext, { onDomainSignal: handleDomainSignal });
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -101,10 +181,20 @@ function FloatingChatInner() {
|
||||
|
||||
// ---- Close on route change (unless cross-page navigation pending) ----
|
||||
|
||||
// Tracks whether the most recent close was triggered by user navigation.
|
||||
// Used to decide whether to reset the session on close.
|
||||
const closeByNavigationRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = routerState.location.pathname;
|
||||
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
|
||||
close();
|
||||
if (prevPathRef.current !== currentPath && state.isOpen) {
|
||||
// Keep floating chat alive when navigation is AI-domain driven.
|
||||
if (domainNavigationInFlightRef.current) {
|
||||
domainNavigationInFlightRef.current = false;
|
||||
} else if (!state.pendingSection) {
|
||||
closeByNavigationRef.current = true;
|
||||
close();
|
||||
}
|
||||
}
|
||||
prevPathRef.current = currentPath;
|
||||
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
|
||||
@@ -114,36 +204,15 @@ function FloatingChatInner() {
|
||||
const prevOpenRef = useRef(state.isOpen);
|
||||
useEffect(() => {
|
||||
if (prevOpenRef.current && !state.isOpen) {
|
||||
clearMessages();
|
||||
const resetSession = closeByNavigationRef.current;
|
||||
closeByNavigationRef.current = false;
|
||||
// Clear input draft first so the unmount flush writes '' to the cache.
|
||||
inputRef.current?.clear();
|
||||
clearMessages(resetSession);
|
||||
}
|
||||
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(() => {
|
||||
@@ -222,7 +291,7 @@ function FloatingChatInner() {
|
||||
|
||||
// ---- Auto-focus input on open ----
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputRef = useRef<ChatInputBoxHandle>(null);
|
||||
useEffect(() => {
|
||||
if (state.isOpen) {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 100);
|
||||
@@ -230,15 +299,6 @@ function FloatingChatInner() {
|
||||
}
|
||||
}, [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,
|
||||
@@ -358,25 +418,14 @@ function FloatingChatInner() {
|
||||
<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>
|
||||
<ChatInputBox
|
||||
ref={inputRef}
|
||||
variant="floating"
|
||||
cacheKey={cacheKey}
|
||||
isStreaming={isStreaming}
|
||||
onSend={handleSend}
|
||||
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
184
src/renderer/components/ai/blocks/ChatChartBlock.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
Radar,
|
||||
RadarChart,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
XAxis,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import type { ChartBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatChartBlock({ data: blockData }: { data: ChartBlockData }) {
|
||||
const { chartType, title, data } = blockData;
|
||||
// config is optional — the AI sometimes omits it and embeds color in data items instead
|
||||
const config = blockData.config ?? {};
|
||||
|
||||
const chartConfig = useMemo(() => {
|
||||
const cfg: ChartConfig = {};
|
||||
const entries = Object.entries(config);
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const [key, val] = entries[i];
|
||||
// Normalize: guard against missing colors and the legacy hsl(var(--chart-N)) pattern
|
||||
// (chart vars are oklch values, so the hsl wrapper produces invalid CSS → black fills).
|
||||
const raw = val.color ?? '';
|
||||
const color =
|
||||
raw && !/^hsl\(var\(/.test(raw) ? raw : `var(--chart-${(i % 5) + 1})`;
|
||||
cfg[key] = { label: val.label, color };
|
||||
}
|
||||
return cfg;
|
||||
}, [config]);
|
||||
|
||||
const dataKeys = useMemo(() => {
|
||||
const keys = Object.keys(config);
|
||||
if (keys.length > 0) return keys;
|
||||
// Infer series keys from first data row when config is absent
|
||||
const first = data[0];
|
||||
if (!first) return ['value'];
|
||||
return Object.entries(first)
|
||||
.filter(([k, v]) => k !== 'name' && k !== 'color' && typeof v === 'number')
|
||||
.map(([k]) => k);
|
||||
}, [config, data]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
{title && (
|
||||
<p className="mb-3 text-sm font-medium">{title}</p>
|
||||
)}
|
||||
<ChartContainer config={chartConfig} className="max-h-[240px] w-full">
|
||||
{renderChart(chartType, data, dataKeys)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderChart(
|
||||
chartType: ChartBlockData['chartType'],
|
||||
data: Record<string, unknown>[],
|
||||
dataKeys: string[],
|
||||
) {
|
||||
switch (chartType) {
|
||||
case 'area':
|
||||
return (
|
||||
<AreaChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
fill={`var(--color-${key})`}
|
||||
stroke={`var(--color-${key})`}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
);
|
||||
case 'bar':
|
||||
return (
|
||||
<BarChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Bar key={key} dataKey={key} fill={`var(--color-${key})`} radius={4} />
|
||||
))}
|
||||
</BarChart>
|
||||
);
|
||||
case 'line':
|
||||
return (
|
||||
<LineChart accessibilityLayer data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={`var(--color-${key})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
);
|
||||
case 'pie':
|
||||
return (
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey={dataKeys[0] ?? 'value'}
|
||||
nameKey="name"
|
||||
innerRadius="40%"
|
||||
outerRadius="70%"
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={`var(--chart-${(i % 5) + 1})`} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
);
|
||||
case 'radar':
|
||||
return (
|
||||
<RadarChart data={data}>
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="name" />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<Radar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={`var(--color-${key})`}
|
||||
fillOpacity={0.3}
|
||||
stroke={`var(--color-${key})`}
|
||||
/>
|
||||
))}
|
||||
</RadarChart>
|
||||
);
|
||||
case 'radial':
|
||||
return (
|
||||
<RadialBarChart data={data} innerRadius="30%" outerRadius="90%">
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{dataKeys.map((key) => (
|
||||
<RadialBar key={key} dataKey={key} fill={`var(--color-${key})`} />
|
||||
))}
|
||||
</RadialBarChart>
|
||||
);
|
||||
}
|
||||
}
|
||||
267
src/renderer/components/ai/blocks/ChatEntityBlock.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { FileText, FolderOpen, Sparkles } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { TaskRow } from '@/components/tasks/TaskRow';
|
||||
import type { TaskItem } from '@/components/tasks/task-types';
|
||||
import { TaskDetailSheet } from '@/components/tasks/TaskDetailSheet';
|
||||
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
|
||||
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
|
||||
import { ChatTimelineBlock } from './ChatTimelineBlock';
|
||||
import { useFormatPrefs, formatDate } from '@/lib/date';
|
||||
import type { EntityRefBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatEntityBlock({ data }: { data: EntityRefBlockData }) {
|
||||
const { entity, ids } = data;
|
||||
|
||||
switch (entity) {
|
||||
case 'task':
|
||||
return <TaskEntityBlock ids={ids} />;
|
||||
case 'project':
|
||||
return <ProjectEntityBlock ids={ids} />;
|
||||
case 'note':
|
||||
return <NoteEntityBlock ids={ids} />;
|
||||
case 'timeline':
|
||||
return <TimelineEntityBlock ids={ids} />;
|
||||
case 'timelineEvent':
|
||||
return <TimelineEventEntityBlock ids={ids} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TaskEntityBlock({ ids }: { ids: string[] }) {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: tasksList } = trpc.tasks.byIds.useQuery({ ids }, { enabled: ids.length > 0 });
|
||||
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const updateTask = trpc.tasks.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.tasks.byIds.invalidate({ ids });
|
||||
void utils.tasks.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.task.updateError', err),
|
||||
});
|
||||
const deleteTask = trpc.tasks.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('warning', 'toast.task.deleted');
|
||||
void utils.tasks.byIds.invalidate({ ids });
|
||||
void utils.tasks.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.task.deleteError', err),
|
||||
});
|
||||
|
||||
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
||||
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
if (!tasksList?.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EntityWrapper label="Tasks">
|
||||
{tasksList.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onToggle={handleToggle}
|
||||
onClick={setViewTask}
|
||||
/>
|
||||
))}
|
||||
</EntityWrapper>
|
||||
|
||||
<TaskDetailSheet
|
||||
task={viewTask}
|
||||
open={!!viewTask}
|
||||
onOpenChange={(open) => { if (!open) setViewTask(null); }}
|
||||
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
|
||||
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
|
||||
/>
|
||||
<EditTaskDialog
|
||||
task={editTask}
|
||||
open={!!editTask}
|
||||
onOpenChange={(open: boolean) => { if (!open) setEditTask(null); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Projects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProjectEntityBlock({ ids }: { ids: string[] }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: allProjects } = trpc.projects.list.useQuery();
|
||||
|
||||
const filtered = useMemo(
|
||||
() => allProjects?.filter((p) => ids.includes(p.id)) ?? [],
|
||||
[allProjects, ids],
|
||||
);
|
||||
|
||||
if (!filtered.length) return null;
|
||||
|
||||
return (
|
||||
<EntityWrapper label="Projects">
|
||||
{filtered.map((p) => (
|
||||
<Item
|
||||
key={p.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer hover:bg-accent/50"
|
||||
onClick={() => void navigate({ to: '/projects', search: { projectId: p.id } })}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{p.name}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</EntityWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NoteEntityBlock({ ids }: { ids: string[] }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: allNotes } = trpc.notes.list.useQuery();
|
||||
|
||||
const filtered = useMemo(
|
||||
() => allNotes?.filter((n) => ids.includes(n.id)) ?? [],
|
||||
[allNotes, ids],
|
||||
);
|
||||
|
||||
if (!filtered.length) return null;
|
||||
|
||||
return (
|
||||
<EntityWrapper label="Notes">
|
||||
{filtered.map((n) => (
|
||||
<Item
|
||||
key={n.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="cursor-pointer hover:bg-accent/50"
|
||||
onClick={() => void navigate({ to: '/notes/$noteId', params: { noteId: n.id } })}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{n.title}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</EntityWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TimelineEntityBlock({ ids }: { ids: string[] }) {
|
||||
const { data: allEvents } = trpc.timelineEvents.list.useQuery();
|
||||
|
||||
const timelineData = useMemo(() => {
|
||||
const filtered = allEvents?.filter((e) => ids.includes(e.id)) ?? [];
|
||||
|
||||
return {
|
||||
events: filtered
|
||||
.map((e) => {
|
||||
const date = new Date(e.date).getTime();
|
||||
const endDate = e.endDate ? new Date(e.endDate).getTime() : undefined;
|
||||
return {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
date,
|
||||
endDate,
|
||||
projectId: e.projectId,
|
||||
isCompleted: e.isCompleted,
|
||||
isAiSuggested: e.isAiSuggested,
|
||||
};
|
||||
})
|
||||
.filter((e) => Number.isFinite(e.date)),
|
||||
};
|
||||
}, [allEvents, ids]);
|
||||
|
||||
if (!timelineData.events.length) return null;
|
||||
|
||||
return <ChatTimelineBlock data={timelineData} />;
|
||||
}
|
||||
|
||||
function TimelineEventEntityBlock({ ids }: { ids: string[] }) {
|
||||
const { data: allEvents } = trpc.timelineEvents.list.useQuery();
|
||||
const prefs = useFormatPrefs();
|
||||
|
||||
const filtered = useMemo(
|
||||
() => allEvents?.filter((e) => ids.includes(e.id)) ?? [],
|
||||
[allEvents, ids],
|
||||
);
|
||||
|
||||
if (!filtered.length) return null;
|
||||
|
||||
return (
|
||||
<EntityWrapper label="Timeline Events">
|
||||
{filtered.map((e) => (
|
||||
<Item
|
||||
key={e.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
{e.isAiSuggested ? (
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{e.title}</ItemTitle>
|
||||
{e.date && (
|
||||
<ItemDescription>
|
||||
{formatDate(e.date, prefs)}
|
||||
</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</EntityWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EntityWrapper({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-lg bg-card p-3 w-full">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{label}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/renderer/components/ai/blocks/ChatTableBlock.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/table';
|
||||
import type { TableBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatTableBlock({ data }: { data: TableBlockData }) {
|
||||
const { headers, rows } = data;
|
||||
|
||||
if (!headers.length && !rows.length) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<Table>
|
||||
{headers.length > 0 && (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{headers.map((h, i) => (
|
||||
<TableHead key={i}>{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)}
|
||||
<TableBody>
|
||||
{rows.map((row, ri) => (
|
||||
<TableRow key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<TableCell key={ci}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/renderer/components/ai/blocks/ChatTimelineBlock.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { ProjectTimelineBox, type ProjectGroup } from '@/components/timeline/ProjectTimelineBox';
|
||||
import type { TimelineEvent } from '@/components/timeline/ProjectTimeline';
|
||||
import type { TimelineBlockData } from '../../../../../shared/api-types';
|
||||
|
||||
export function ChatTimelineBlock({ data }: { data: TimelineBlockData }) {
|
||||
const { events: rawEvents } = data;
|
||||
|
||||
const { data: allProjects } = trpc.projects.list.useQuery({ includeArchived: true });
|
||||
|
||||
const events = useMemo<TimelineEvent[]>(() => {
|
||||
return rawEvents
|
||||
.map((event) => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
date: event.date,
|
||||
endDate: event.endDate ?? null,
|
||||
type: ((event as { type?: string }).type ?? 'milestone') as TimelineEvent['type'],
|
||||
projectId: event.projectId ?? null,
|
||||
isCompleted: event.isCompleted ?? 0,
|
||||
isAiSuggested: event.isAiSuggested ?? 0,
|
||||
}))
|
||||
.filter((event) => Number.isFinite(event.date));
|
||||
}, [rawEvents]);
|
||||
|
||||
const groups = useMemo<ProjectGroup[]>(() => {
|
||||
const PAD_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
const byProject = new Map<string, TimelineEvent[]>();
|
||||
for (const event of events) {
|
||||
const key = event.projectId ?? '__unassigned__';
|
||||
const current = byProject.get(key);
|
||||
if (current) {
|
||||
current.push(event);
|
||||
} else {
|
||||
byProject.set(key, [event]);
|
||||
}
|
||||
}
|
||||
|
||||
const builtGroups: ProjectGroup[] = [];
|
||||
for (const [key, projectEvents] of byProject.entries()) {
|
||||
const projectId = key === '__unassigned__' ? null : key;
|
||||
const project = projectId
|
||||
? allProjects?.find((p) => p.id === projectId)
|
||||
: undefined;
|
||||
|
||||
const dates = projectEvents.flatMap((event) => (event.endDate ? [event.date, event.endDate] : [event.date]));
|
||||
const minDate = Math.min(...dates, now);
|
||||
const maxDate = Math.max(...dates, now);
|
||||
|
||||
builtGroups.push({
|
||||
projectId,
|
||||
projectName: project?.name ?? 'Timeline',
|
||||
projectStatus: project?.status ?? 'active',
|
||||
breadcrumb: [],
|
||||
events: projectEvents,
|
||||
startDate: new Date(minDate - PAD_MS),
|
||||
endDate: new Date(maxDate + PAD_MS),
|
||||
});
|
||||
}
|
||||
|
||||
return builtGroups.sort((a, b) => a.projectName.localeCompare(b.projectName));
|
||||
}, [events, allProjects]);
|
||||
|
||||
if (!events.length) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-3">
|
||||
{groups.map((group) => (
|
||||
<ProjectTimelineBox
|
||||
key={group.projectId ?? 'unassigned'}
|
||||
group={group}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/renderer/components/ai/blocks/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Block components are now rendered inline by the MessageContent parser
|
||||
* in AIChatPanel.tsx. Import individual block components directly:
|
||||
* - ChatEntityBlock
|
||||
* - ChatChartBlock
|
||||
* - ChatTableBlock
|
||||
* - ChatTimelineBlock
|
||||
*/
|
||||
export { ChatEntityBlock } from './ChatEntityBlock';
|
||||
export { ChatChartBlock } from './ChatChartBlock';
|
||||
export { ChatTableBlock } from './ChatTableBlock';
|
||||
export { ChatTimelineBlock } from './ChatTimelineBlock';
|
||||
328
src/renderer/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||
|
||||
// Google 'G' logo — inline SVG to avoid importing a second icon library.
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sign-in form (login-03 layout)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SignInForm({
|
||||
className,
|
||||
onSwitchMode,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'div'> & { onSwitchMode: () => void }) {
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useTranslation();
|
||||
const loginMutation = trpc.auth.login.useMutation();
|
||||
const oauthMutation = trpc.auth.loginWithOAuth.useMutation();
|
||||
const { notifyError } = useNotify();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const isBusy = loginMutation.isPending || oauthMutation.isPending;
|
||||
|
||||
function handleSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!email || !password) return;
|
||||
setError('');
|
||||
loginMutation.mutate({ email, password }, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.success) setError(res.error ?? 'Authentication failed');
|
||||
else void utils.auth.status.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
notifyError('toast.auth.loginError', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleGoogleLogin() {
|
||||
setError('');
|
||||
oauthMutation.mutate({ provider: 'google' }, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.success) setError(res.error ?? 'Google sign-in failed');
|
||||
else void utils.auth.status.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
notifyError('toast.auth.oauthError', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{t('auth.welcomeBack')}</CardTitle>
|
||||
<CardDescription>{t('auth.signInDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6">
|
||||
{/* Email + password form */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">{t('auth.email')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
disabled={isBusy}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
disabled={isBusy}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive -mt-1">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={isBusy || !email || !password}>
|
||||
{loginMutation.isPending ? (
|
||||
<><Loader2 className="mr-2 size-4 animate-spin" /> {t('auth.signingIn')}</>
|
||||
) : t('auth.signIn')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
|
||||
<span className="relative z-10 bg-card px-2 text-muted-foreground">
|
||||
{t('auth.or')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Google OAuth button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{oauthMutation.isPending ? (
|
||||
<><Loader2 className="size-4 animate-spin" /> {t('auth.waitingForBrowser')}</>
|
||||
) : (
|
||||
<><GoogleIcon className="size-4" /> {t('auth.signInWithGoogle')}</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<button type="button" className="underline underline-offset-4 hover:text-primary" onClick={onSwitchMode} disabled={isBusy}>
|
||||
{t('auth.signUp')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sign-up form (signup-03 layout)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SignUpForm({
|
||||
className,
|
||||
onSwitchMode,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'div'> & { onSwitchMode: () => void }) {
|
||||
const utils = trpc.useUtils();
|
||||
const { t } = useTranslation();
|
||||
const registerMutation = trpc.auth.register.useMutation();
|
||||
const { notifyError } = useNotify();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [surname, setSurname] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function handleSubmit(e: React.SyntheticEvent) {
|
||||
e.preventDefault();
|
||||
if (!email || !password) return;
|
||||
setError('');
|
||||
registerMutation.mutate({
|
||||
email,
|
||||
password,
|
||||
...(name && { name }),
|
||||
...(surname && { surname }),
|
||||
}, {
|
||||
onSuccess: (res) => {
|
||||
if (!res.success) setError(res.error ?? 'Registration failed');
|
||||
else void utils.auth.status.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
notifyError('toast.auth.registerError', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-6', className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{t('auth.createAccount')}</CardTitle>
|
||||
<CardDescription>{t('auth.createAccountDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reg-name">{t('auth.name')}</FieldLabel>
|
||||
<Input
|
||||
id="reg-name"
|
||||
type="text"
|
||||
placeholder="John"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError(''); }}
|
||||
disabled={registerMutation.isPending}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reg-surname">{t('auth.surname')}</FieldLabel>
|
||||
<Input
|
||||
id="reg-surname"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
value={surname}
|
||||
onChange={(e) => { setSurname(e.target.value); setError(''); }}
|
||||
disabled={registerMutation.isPending}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reg-email">{t('auth.email')}</FieldLabel>
|
||||
<Input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
disabled={registerMutation.isPending}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="reg-password">{t('auth.password')}</FieldLabel>
|
||||
<Input
|
||||
id="reg-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
disabled={registerMutation.isPending}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<FieldDescription>Must be at least 8 characters long.</FieldDescription>
|
||||
</Field>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Field>
|
||||
<Button type="submit" className="w-full" disabled={registerMutation.isPending || !email || !password}>
|
||||
{t('auth.createAccountButton')}
|
||||
</Button>
|
||||
<FieldDescription className="text-center">
|
||||
{t('auth.haveAccount')}{' '}
|
||||
<button type="button" className="underline underline-offset-4 hover:text-primary" onClick={onSwitchMode}>
|
||||
{t('auth.signInLink')}
|
||||
</button>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FieldDescription className="px-6 text-center">
|
||||
By creating an account, you agree to our terms of service.
|
||||
</FieldDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shell — logo + mode switcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LoginForm() {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-full flex-col items-center justify-center gap-6 bg-muted p-6 md:p-10">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<div className="flex items-center self-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 70" fill="none" width="120" height="47">
|
||||
<style>{`
|
||||
.compass-needle-login {
|
||||
animation: compass-settle-login 5s ease-in-out infinite;
|
||||
transform-origin: 32px 32px;
|
||||
}
|
||||
@keyframes compass-settle-login {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(4deg); }
|
||||
50% { transform: rotate(-3deg); }
|
||||
80% { transform: rotate(2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
`}</style>
|
||||
<g transform="translate(2,2)">
|
||||
<g className="compass-needle-login">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="currentColor"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32" stroke="currentColor" strokeWidth="0.5" opacity="0.12"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="currentColor" opacity="0.18"/>
|
||||
</g>
|
||||
</g>
|
||||
<text x="65" y="42" fontFamily="Geist, system-ui, -apple-system, sans-serif" fontSize="30" letterSpacing="-0.5">
|
||||
<tspan fontWeight="400" fill="currentColor">adiuv</tspan><tspan fontWeight="700" fill="#fbc881">AI</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
{mode === 'login' ? (
|
||||
<SignInForm onSwitchMode={() => setMode('register')} />
|
||||
) : (
|
||||
<SignUpForm onSwitchMode={() => setMode('login')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/renderer/components/brief/BriefChatHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { AlertCircle, Clock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface BriefChatHeaderProps {
|
||||
title: string;
|
||||
projectName?: string | null;
|
||||
priority: string;
|
||||
dueDate?: number | null;
|
||||
}
|
||||
|
||||
function relativeDate(ts: number, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
||||
const diff = ts - Date.now();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
if (days < 0) return t('brief.overdue', { days: Math.abs(days) });
|
||||
if (days === 0) return t('brief.dueToday');
|
||||
if (days === 1) return t('brief.dueTomorrow');
|
||||
return t('brief.dueInDays', { days });
|
||||
}
|
||||
|
||||
const PRIORITY_STYLES: Record<string, string> = {
|
||||
high: 'bg-destructive/15 text-destructive',
|
||||
medium: 'bg-yellow-500/10 text-yellow-700 dark:text-yellow-400',
|
||||
low: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
export function BriefChatHeader({ title, projectName, priority, dueDate }: BriefChatHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="px-5 pt-5 pb-4 space-y-2">
|
||||
<h2 className="font-semibold text-base text-foreground leading-snug line-clamp-2">{title}</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{projectName && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-md bg-muted text-muted-foreground">
|
||||
{projectName}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-md capitalize',
|
||||
PRIORITY_STYLES[priority] ?? PRIORITY_STYLES.medium,
|
||||
)}
|
||||
>
|
||||
{priority}
|
||||
</span>
|
||||
{dueDate != null && (
|
||||
<span className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
dueDate < Date.now() ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}>
|
||||
{dueDate < Date.now() ? <AlertCircle size={11} /> : <Clock size={11} />}
|
||||
{relativeDate(dueDate, t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/renderer/components/brief/CanvasPlaceholder.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Mail, FileText, MessageSquare, ScrollText } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
interface CanvasPlaceholderProps {
|
||||
content: string | null;
|
||||
kind: string | null;
|
||||
onContentChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
const KIND_META: Record<string, { label: string; Icon: React.ElementType }> = {
|
||||
email: { label: 'Email Draft', Icon: Mail },
|
||||
document: { label: 'Document Draft', Icon: FileText },
|
||||
message: { label: 'Message Draft', Icon: MessageSquare },
|
||||
};
|
||||
|
||||
export function CanvasPlaceholder({ content, kind }: CanvasPlaceholderProps) {
|
||||
const { t } = useTranslation();
|
||||
const meta = kind ? (KIND_META[kind] ?? { label: kind.charAt(0).toUpperCase() + kind.slice(1), Icon: ScrollText }) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full px-4 py-4 gap-3">
|
||||
{/* Kind badge header */}
|
||||
{meta && (
|
||||
<div className="shrink-0 flex items-center gap-2 px-1">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-primary/15 text-primary border border-primary/20">
|
||||
<meta.Icon size={12} strokeWidth={2} />
|
||||
<span className="text-xs font-medium tracking-tight">{meta.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paper surface */}
|
||||
<div className="flex-1 min-h-0 rounded-2xl bg-background shadow-sm border border-border/30 overflow-hidden">
|
||||
{content ? (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="px-7 py-6">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-foreground leading-relaxed
|
||||
prose-p:text-foreground prose-p:leading-relaxed
|
||||
prose-headings:text-foreground prose-headings:font-semibold
|
||||
prose-strong:text-foreground prose-strong:font-semibold
|
||||
prose-li:text-foreground
|
||||
prose-a:text-primary prose-a:no-underline hover:prose-a:underline">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground/40 text-sm select-none">
|
||||
{t('brief.canvas.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/renderer/components/brief/CarouselControls.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CarouselControlsProps {
|
||||
count: number;
|
||||
activeIndex: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function CarouselControls({ count, activeIndex, onPrev, onNext }: CarouselControlsProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrev}
|
||||
disabled={activeIndex === 0}
|
||||
aria-label="Previous task"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all duration-200',
|
||||
i === activeIndex
|
||||
? 'w-5 bg-foreground'
|
||||
: 'w-1.5 bg-muted-foreground/40',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={activeIndex === count - 1}
|
||||
aria-label="Next task"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground hover:bg-accent disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
343
src/renderer/components/brief/TaskBriefChat.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||
import { Sparkles } 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 { ChatInputBox } from '@/components/ai/ChatInputBox';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'assistant' | 'user';
|
||||
content: string;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
interface BriefingResult {
|
||||
briefingMarkdown: string;
|
||||
canvasDraft: string | null;
|
||||
canvasKind: string | null;
|
||||
}
|
||||
|
||||
interface TaskBriefChatProps {
|
||||
taskId: string;
|
||||
projectId?: string | null;
|
||||
/** Pre-loaded briefing from DB/session cache. Null triggers research. */
|
||||
initialBriefing: BriefingResult | null;
|
||||
onBriefingReady: (result: BriefingResult) => void;
|
||||
}
|
||||
|
||||
export function TaskBriefChat({ taskId, projectId, initialBriefing, onBriefingReady }: TaskBriefChatProps) {
|
||||
const { t } = useTranslation();
|
||||
const sessionId = useRef(crypto.randomUUID()).current;
|
||||
const cacheKey = `brief-${taskId}`;
|
||||
|
||||
// Load persisted follow-up messages — only when briefing already exists
|
||||
const chatHistoryQuery = trpc.ai.getTaskBriefChats.useQuery(
|
||||
{ taskId },
|
||||
{ enabled: !!initialBriefing },
|
||||
);
|
||||
const saveChatMutation = trpc.ai.saveTaskBriefChat.useMutation();
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => {
|
||||
if (initialBriefing) {
|
||||
return [{ id: 'briefing', role: 'assistant', content: initialBriefing.briefingMarkdown }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
// True until DB history is applied (or skipped when no briefing)
|
||||
const [historyLoaded, setHistoryLoaded] = useState(!initialBriefing);
|
||||
const [isResearching, setIsResearching] = useState(!initialBriefing);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [briefingText, setBriefingText] = useState<string>(initialBriefing?.briefingMarkdown ?? '');
|
||||
|
||||
const streamingRef = useRef('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const researchMutation = trpc.ai.taskBriefResearch.useMutation();
|
||||
const chatMutation = trpc.ai.chat.useMutation();
|
||||
|
||||
// Merge DB history into messages once query settles
|
||||
useEffect(() => {
|
||||
if (historyLoaded) return;
|
||||
if (chatHistoryQuery.isLoading) return;
|
||||
setHistoryLoaded(true);
|
||||
if (!chatHistoryQuery.data || chatHistoryQuery.data.length === 0) return;
|
||||
setMessages([
|
||||
{ id: 'briefing', role: 'assistant', content: initialBriefing!.briefingMarkdown },
|
||||
...chatHistoryQuery.data.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
error: !!m.isError,
|
||||
})),
|
||||
]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatHistoryQuery.isLoading, chatHistoryQuery.data]);
|
||||
|
||||
// Auto-scroll on new content
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
// Research phase: fire on mount if no initial briefing
|
||||
useEffect(() => {
|
||||
if (initialBriefing) return;
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
let accumulated = '';
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamEvent((event) => {
|
||||
if (event.requestId !== requestId) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'stream_text':
|
||||
accumulated += event.chunk;
|
||||
streamingRef.current = accumulated;
|
||||
setStreamingContent(accumulated);
|
||||
break;
|
||||
|
||||
case 'stream_end': {
|
||||
const finalText = stripCanvas(streamingRef.current);
|
||||
const mutations = event.mutations as Array<Record<string, unknown>> | undefined;
|
||||
const canvasMut = mutations?.find((m) => m.type === 'canvas_draft');
|
||||
const canvasDraft = (canvasMut?.content as string) ?? null;
|
||||
const canvasKind = (canvasMut?.kind as string) ?? null;
|
||||
|
||||
setBriefingText(finalText);
|
||||
setMessages([{ id: 'briefing', role: 'assistant', content: finalText }]);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
setIsResearching(false);
|
||||
setHistoryLoaded(true);
|
||||
|
||||
onBriefingReady({ briefingMarkdown: finalText, canvasDraft, canvasKind });
|
||||
unsubscribe();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
researchMutation.mutate({ taskId, requestId });
|
||||
return () => unsubscribe();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback((message: string) => {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed || isStreaming || isResearching || !historyLoaded) return;
|
||||
|
||||
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: trimmed };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
saveChatMutation.mutate({ id: userMsg.id, taskId, role: 'user', content: trimmed, createdAt: Date.now() });
|
||||
|
||||
setIsStreaming(true);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamEvent((event) => {
|
||||
if (event.requestId !== requestId) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'stream_text':
|
||||
streamingRef.current += event.chunk;
|
||||
setStreamingContent(streamingRef.current);
|
||||
break;
|
||||
|
||||
case 'stream_end': {
|
||||
const finalContent = streamingRef.current;
|
||||
const assistantMsgId = crypto.randomUUID();
|
||||
setMessages((prev) => [...prev, { id: assistantMsgId, role: 'assistant', content: finalContent }]);
|
||||
if (finalContent) {
|
||||
saveChatMutation.mutate({ id: assistantMsgId, taskId, role: 'assistant', content: finalContent, createdAt: Date.now() });
|
||||
}
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
setIsStreaming(false);
|
||||
unsubscribe();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const conversationHistory = messages.slice(-20).map((m) => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
chatMutation.mutate(
|
||||
{
|
||||
requestId,
|
||||
message: trimmed,
|
||||
conversationHistory,
|
||||
sessionId,
|
||||
mode: 'floating',
|
||||
scope: { type: 'task', id: taskId },
|
||||
briefMode: true,
|
||||
briefingContext: briefingText || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (data.error) {
|
||||
unsubscribe();
|
||||
setMessages((prev) => [...prev, {
|
||||
id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true,
|
||||
}]);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
setIsStreaming(false);
|
||||
} else {
|
||||
unsubscribe();
|
||||
const content = streamingRef.current;
|
||||
if (content) {
|
||||
const assistantMsgId = crypto.randomUUID();
|
||||
setMessages((prev) => [...prev, { id: assistantMsgId, role: 'assistant', content }]);
|
||||
saveChatMutation.mutate({ id: assistantMsgId, taskId, role: 'assistant', content, createdAt: Date.now() });
|
||||
}
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
setIsStreaming(false);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
unsubscribe();
|
||||
setMessages((prev) => [...prev, {
|
||||
id: crypto.randomUUID(), role: 'assistant', content: err.message || 'An unexpected error occurred.', error: true,
|
||||
}]);
|
||||
setStreamingContent('');
|
||||
streamingRef.current = '';
|
||||
setIsStreaming(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [isStreaming, isResearching, historyLoaded, messages, taskId, sessionId, briefingText, chatMutation, saveChatMutation]);
|
||||
|
||||
const isInputBlocked = isStreaming || isResearching || !historyLoaded;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-5 py-5 space-y-5">
|
||||
{/* Researching state */}
|
||||
{isResearching && (
|
||||
<div>
|
||||
<div className="flex items-end gap-2.5 mb-1">
|
||||
<Sparkles size={24} className="text-foreground" />
|
||||
<span className="text-xl font-semibold leading-none">
|
||||
adiuv<span className="font-bold text-primary">AI</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-[32px] space-y-2">
|
||||
{streamingContent ? (
|
||||
<BriefMarkdown content={stripCanvas(streamingContent, true)} />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{t('brief.researching')}</p>
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message list */}
|
||||
<AnimatePresence initial={false}>
|
||||
{messages.map((msg) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
>
|
||||
{msg.role === 'assistant' ? (
|
||||
<div>
|
||||
<div className="flex items-end gap-2.5 mb-1">
|
||||
<Sparkles size={24} className="text-foreground" />
|
||||
<span className="text-xl font-semibold leading-none">
|
||||
adiuv<span className="font-bold text-primary">AI</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={`pl-[32px] ${msg.error ? 'text-destructive' : ''}`}>
|
||||
<BriefMarkdown content={msg.content} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-2xl bg-muted px-4 py-2.5 text-sm">
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Streaming follow-up */}
|
||||
{isStreaming && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||
>
|
||||
<div className="flex items-end gap-2.5 mb-1">
|
||||
<Sparkles size={24} className="text-foreground" />
|
||||
<span className="text-xl font-semibold leading-none">
|
||||
adiuv<span className="font-bold text-primary">AI</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-[32px]">
|
||||
{streamingContent ? (
|
||||
<BriefMarkdown content={streamingContent} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0 px-4 pb-4 pt-2">
|
||||
<div className="relative rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-md ring-1 ring-border/20 transition-shadow focus-within:shadow-lg focus-within:border-ring/50">
|
||||
<ChatInputBox
|
||||
cacheKey={cacheKey}
|
||||
isStreaming={isInputBlocked}
|
||||
onSend={handleSend}
|
||||
placeholder={t('brief.inputPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Strip <canvas> block — canvas goes to right panel, not chat text
|
||||
const CANVAS_COMPLETE_RE = /<canvas\b[^>]*>[\s\S]*?<\/canvas>/gi;
|
||||
const CANVAS_PARTIAL_RE = /<canvas\b[\s\S]*$/i;
|
||||
|
||||
function stripCanvas(text: string, partial = false): string {
|
||||
if (partial) return text.replace(CANVAS_PARTIAL_RE, '');
|
||||
return text.replace(CANVAS_COMPLETE_RE, '').trim();
|
||||
}
|
||||
|
||||
const BriefMarkdown = memo(function BriefMarkdown({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-foreground text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
20
src/renderer/components/brief/TaskBriefEmptyState.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function TaskBriefEmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-center px-8">
|
||||
<CheckCircle size={40} className="text-muted-foreground/50" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">{t('brief.empty.title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('brief.empty.description')}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/tasks">{t('brief.empty.cta')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/renderer/components/brief/TaskBriefingOverlay.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBriefTasks } from '@/hooks/useBriefTasks';
|
||||
import { useTaskBriefing } from '@/context/TaskBriefingContext';
|
||||
import { TaskCarousel, clearCarouselBriefingCache } from './TaskCarousel';
|
||||
import { TaskBriefEmptyState } from './TaskBriefEmptyState';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
/**
|
||||
* Inline task briefing section — renders inside the home page content area.
|
||||
* No overlay/backdrop/fixed positioning; the parent hides/shows this via AnimatePresence.
|
||||
*/
|
||||
export function TaskBriefingOverlay() {
|
||||
const { close, initialTaskId } = useTaskBriefing();
|
||||
const { t } = useTranslation();
|
||||
const { tasks, isLoading } = useBriefTasks();
|
||||
const backBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// ESC to close
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [close]);
|
||||
|
||||
// Focus back button on mount
|
||||
useEffect(() => {
|
||||
backBtnRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Clear session cache when unmounted (i.e. closed)
|
||||
useEffect(() => {
|
||||
return () => clearCarouselBriefingCache();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col h-full w-full"
|
||||
initial={{ opacity: 0, x: 24 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 24 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 38 }}
|
||||
>
|
||||
{/* Top bar — mirrors note page: SidebarTrigger | sep | back */}
|
||||
<div className="flex h-14 shrink-0 items-center gap-1 px-3">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4" />
|
||||
<Button
|
||||
ref={backBtnRef}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={close}
|
||||
aria-label={t('brief.controls.close')}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="ml-auto text-xs text-muted-foreground/60">
|
||||
{t('brief.overlayTitle')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="p-8 space-y-3">
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
<Skeleton className="h-5 w-1/3" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<TaskBriefEmptyState />
|
||||
) : (
|
||||
<TaskCarousel tasks={tasks} initialTaskId={initialTaskId} />
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
240
src/renderer/components/brief/TaskCarousel.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { BriefChatHeader } from './BriefChatHeader';
|
||||
import { TaskBriefChat } from './TaskBriefChat';
|
||||
import { CanvasPlaceholder } from './CanvasPlaceholder';
|
||||
import { CarouselControls } from './CarouselControls';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||
|
||||
interface BriefingResult {
|
||||
briefingMarkdown: string;
|
||||
canvasDraft: string | null;
|
||||
canvasKind: string | null;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: string;
|
||||
dueDate?: number | null;
|
||||
projectId?: string | null;
|
||||
projectName?: string | null;
|
||||
}
|
||||
|
||||
interface TaskCarouselProps {
|
||||
tasks: Task[];
|
||||
initialTaskId?: string;
|
||||
}
|
||||
|
||||
// Session-level briefing cache (survives carousel navigation, cleared on overlay close)
|
||||
const briefingSessionCache = new Map<string, BriefingResult>();
|
||||
|
||||
export function clearCarouselBriefingCache() {
|
||||
briefingSessionCache.clear();
|
||||
}
|
||||
|
||||
const SLIDE_VARIANTS = {
|
||||
enter: (dir: number) => ({ x: dir > 0 ? 60 : -60, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (dir: number) => ({ x: dir > 0 ? -60 : 60, opacity: 0 }),
|
||||
};
|
||||
|
||||
const SLIDE_TRANSITION = { type: 'spring' as const, stiffness: 400, damping: 40 };
|
||||
|
||||
export function TaskCarousel({ tasks, initialTaskId }: TaskCarouselProps) {
|
||||
const initialIndex = initialTaskId
|
||||
? Math.max(0, tasks.findIndex((t) => t.id === initialTaskId))
|
||||
: 0;
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
const [slideDir, setSlideDir] = useState(1);
|
||||
// Per-task canvas drafts derived from briefings
|
||||
const [canvasData, setCanvasData] = useState<Map<string, BriefingResult>>(new Map(briefingSessionCache));
|
||||
|
||||
const activeTask = tasks[activeIndex];
|
||||
|
||||
// Per-task DB briefing query
|
||||
const dbBriefingQuery = trpc.ai.getTaskBriefing.useQuery(
|
||||
{ taskId: activeTask?.id ?? '' },
|
||||
{
|
||||
enabled: !!activeTask && !briefingSessionCache.has(activeTask.id),
|
||||
staleTime: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
// Resolve initial briefing from session cache → DB → null (triggers research)
|
||||
const getCachedBriefing = (taskId: string): BriefingResult | null => {
|
||||
if (briefingSessionCache.has(taskId)) return briefingSessionCache.get(taskId)!;
|
||||
if (dbBriefingQuery.data && dbBriefingQuery.data.taskId === taskId) {
|
||||
return {
|
||||
briefingMarkdown: dbBriefingQuery.data.briefingMarkdown,
|
||||
canvasDraft: dbBriefingQuery.data.canvasDraft ?? null,
|
||||
canvasKind: dbBriefingQuery.data.canvasKind ?? null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Promote DB briefings to session cache so navigating back doesn't re-research
|
||||
useEffect(() => {
|
||||
if (!dbBriefingQuery.data || !activeTask) return;
|
||||
if (dbBriefingQuery.data.taskId !== activeTask.id) return;
|
||||
if (briefingSessionCache.has(activeTask.id)) return;
|
||||
const result: BriefingResult = {
|
||||
briefingMarkdown: dbBriefingQuery.data.briefingMarkdown,
|
||||
canvasDraft: dbBriefingQuery.data.canvasDraft ?? null,
|
||||
canvasKind: dbBriefingQuery.data.canvasKind ?? null,
|
||||
};
|
||||
briefingSessionCache.set(activeTask.id, result);
|
||||
setCanvasData((prev) => new Map(prev).set(activeTask.id, result));
|
||||
}, [dbBriefingQuery.data, activeTask]);
|
||||
|
||||
const handleBriefingReady = useCallback((result: BriefingResult) => {
|
||||
if (!activeTask) return;
|
||||
briefingSessionCache.set(activeTask.id, result);
|
||||
setCanvasData((prev) => new Map(prev).set(activeTask.id, result));
|
||||
}, [activeTask]);
|
||||
|
||||
const goTo = useCallback((index: number) => {
|
||||
if (index < 0 || index >= tasks.length) return;
|
||||
setSlideDir(index > activeIndex ? 1 : -1);
|
||||
setActiveIndex(index);
|
||||
}, [activeIndex, tasks.length]);
|
||||
|
||||
const lastWheelNavRef = useRef<number>(0);
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
||||
const now = Date.now();
|
||||
if (now - lastWheelNavRef.current < 600) return;
|
||||
if (e.deltaX > 30) { lastWheelNavRef.current = now; goTo(activeIndex + 1); }
|
||||
else if (e.deltaX < -30) { lastWheelNavRef.current = now; goTo(activeIndex - 1); }
|
||||
}, [activeIndex, goTo]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft') goTo(activeIndex - 1);
|
||||
if (e.key === 'ArrowRight') goTo(activeIndex + 1);
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [activeIndex, goTo]);
|
||||
|
||||
// Prefetch next slide's briefing from DB so it's warm
|
||||
const nextTaskId = tasks[activeIndex + 1]?.id;
|
||||
trpc.ai.getTaskBriefing.useQuery(
|
||||
{ taskId: nextTaskId ?? '' },
|
||||
{ enabled: !!nextTaskId && !briefingSessionCache.has(nextTaskId), staleTime: Infinity },
|
||||
);
|
||||
|
||||
if (!activeTask) return null;
|
||||
|
||||
// True while DB is still being checked — prevents TaskBriefChat mounting
|
||||
// with undefined initialBriefing and firing unnecessary research
|
||||
const isDbCheckPending = !briefingSessionCache.has(activeTask.id) && dbBriefingQuery.isFetching;
|
||||
|
||||
const initialBriefing = getCachedBriefing(activeTask.id);
|
||||
const activeCanvas = canvasData.get(activeTask.id) ?? initialBriefing;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" onWheel={handleWheel}>
|
||||
{/* Carousel slide area */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AnimatePresence initial={false} custom={slideDir} mode="wait">
|
||||
<motion.div
|
||||
key={activeTask.id}
|
||||
custom={slideDir}
|
||||
variants={SLIDE_VARIANTS}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={SLIDE_TRANSITION}
|
||||
className="h-full w-full"
|
||||
>
|
||||
{activeCanvas?.canvasDraft ? (
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full">
|
||||
{/* Left: Chat panel */}
|
||||
<ResizablePanel defaultSize={40} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
<BriefChatHeader
|
||||
title={activeTask.title}
|
||||
projectName={activeTask.projectName}
|
||||
priority={activeTask.priority}
|
||||
dueDate={activeTask.dueDate}
|
||||
/>
|
||||
<div className="flex-1 min-h-0">
|
||||
{isDbCheckPending ? (
|
||||
<div className="px-5 py-5 space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
) : (
|
||||
<TaskBriefChat
|
||||
key={activeTask.id}
|
||||
taskId={activeTask.id}
|
||||
projectId={activeTask.projectId}
|
||||
initialBriefing={initialBriefing}
|
||||
onBriefingReady={handleBriefingReady}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* Right: Canvas */}
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<CanvasPlaceholder
|
||||
content={activeCanvas.canvasDraft}
|
||||
kind={activeCanvas.canvasKind ?? null}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
/* No canvas: chat centered */
|
||||
<div className="flex flex-col h-full w-full max-w-2xl mx-auto">
|
||||
<BriefChatHeader
|
||||
title={activeTask.title}
|
||||
projectName={activeTask.projectName}
|
||||
priority={activeTask.priority}
|
||||
dueDate={activeTask.dueDate}
|
||||
/>
|
||||
<div className="flex-1 min-h-0">
|
||||
{isDbCheckPending ? (
|
||||
<div className="px-5 py-5 space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
) : (
|
||||
<TaskBriefChat
|
||||
key={activeTask.id}
|
||||
taskId={activeTask.id}
|
||||
projectId={activeTask.projectId}
|
||||
initialBriefing={initialBriefing}
|
||||
onBriefingReady={handleBriefingReady}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<div className="shrink-0">
|
||||
<CarouselControls
|
||||
count={tasks.length}
|
||||
activeIndex={activeIndex}
|
||||
onPrev={() => goTo(activeIndex - 1)}
|
||||
onNext={() => goTo(activeIndex + 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useRouterState } from '@tanstack/react-router';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { Link, useRouterState, useNavigate } from '@tanstack/react-router';
|
||||
import { LayoutGroup } from 'framer-motion';
|
||||
import {
|
||||
House,
|
||||
ChartGantt,
|
||||
ClipboardCheck,
|
||||
FolderKanban,
|
||||
PanelLeft,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Check,
|
||||
LogOut,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Palette
|
||||
ChevronsUpDown,
|
||||
SquarePen,
|
||||
Folder,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
||||
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import { FloatingChatProvider } from '@/context/FloatingChatContext';
|
||||
import { ExpandedClientsProvider, useExpandedClients } from '@/context/ExpandedClientsContext';
|
||||
import { TaskBriefingProvider, useTaskBriefing } from '@/context/TaskBriefingContext';
|
||||
import { LoginForm } from '@/components/auth/LoginForm';
|
||||
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: '/', icon: House, label: 'Home' },
|
||||
{ to: '/timeline', icon: ChartGantt, label: 'Timeline' },
|
||||
{ to: '/tasks', icon: ClipboardCheck, label: 'Tasks' },
|
||||
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
|
||||
{ to: '/', icon: House, labelKey: 'nav.home' },
|
||||
{ to: '/timeline', icon: ChartGantt, labelKey: 'nav.timeline' },
|
||||
{ to: '/tasks', icon: ClipboardCheck, labelKey: 'nav.tasks' },
|
||||
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||
] as const;
|
||||
|
||||
interface AppShellProps {
|
||||
@@ -71,13 +83,25 @@ interface AppShellProps {
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<FloatingChatProvider>
|
||||
<AppShellInner>{children}</AppShellInner>
|
||||
<ExpandedClientsProvider>
|
||||
<TaskBriefingProvider>
|
||||
<div className="flex w-full h-full">
|
||||
<AppShellInner>{children}</AppShellInner>
|
||||
</div>
|
||||
</TaskBriefingProvider>
|
||||
</ExpandedClientsProvider>
|
||||
</FloatingChatProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShellInner({ children }: AppShellProps) {
|
||||
useDoubleClickAI();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const authStatusQuery = trpc.auth.status.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
|
||||
staleTime: Infinity,
|
||||
@@ -87,8 +111,6 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
const routerState = useRouterState();
|
||||
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 ? false : !collapsedQuery.data
|
||||
);
|
||||
@@ -98,106 +120,102 @@ function AppShellInner({ children }: AppShellProps) {
|
||||
setSidebarCollapsedMutation.mutate({ collapsed: !value });
|
||||
};
|
||||
|
||||
// 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);
|
||||
},
|
||||
});
|
||||
const taskBriefing = useTaskBriefing();
|
||||
const chatActionsRef = useRef<{ clear: () => void } | null>(null);
|
||||
const [homeChatHasMessages, setHomeChatHasMessages] = useState(false);
|
||||
|
||||
const isHomePage = currentPath === '/';
|
||||
const isProjectsPage = currentPath.startsWith('/projects');
|
||||
const isNotesPage = currentPath.startsWith('/notes');
|
||||
const isSettingsPage = currentPath.startsWith('/settings');
|
||||
|
||||
// Derive the page label from the current path for the breadcrumb
|
||||
const matchedItem = NAV_ITEMS.find(
|
||||
(item) => item.to !== '/' && currentPath.startsWith(item.to),
|
||||
);
|
||||
const pageLabel = matchedItem ? t(matchedItem.labelKey) : (currentPath.startsWith('/settings') ? t('nav.settings') : '');
|
||||
|
||||
// Pages with their own header (SidebarTrigger integrated) hide the global one
|
||||
const showHeader = !isProjectsPage && !isNotesPage && !isSettingsPage && !isHomePage;
|
||||
|
||||
if (authStatusQuery.data?.authenticated === false) {
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
if (
|
||||
authStatusQuery.data?.profile &&
|
||||
authStatusQuery.data.profile.onboardingCompletedAt == null
|
||||
) {
|
||||
return <OnboardingFlow profile={authStatusQuery.data.profile} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
||||
<SidebarProvider open={open} onOpenChange={handleOpenChange} className="h-full">
|
||||
<AppSidebar
|
||||
currentPath={currentPath}
|
||||
setTokenDialogOpen={setTokenDialogOpen}
|
||||
profile={authStatusQuery.data?.profile ?? null}
|
||||
/>
|
||||
<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">
|
||||
<SidebarInset className="min-w-0 min-h-0 overflow-x-hidden">
|
||||
{showHeader && (
|
||||
<header className="flex h-14 shrink-0 items-center gap-2">
|
||||
<div className="flex flex-1 items-center gap-2 px-3">
|
||||
<SidebarTrigger />
|
||||
</header>
|
||||
{children}
|
||||
{!isHomePage && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px mr-2 data-[orientation=vertical]:h-4" />
|
||||
{/* <Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{pageLabel}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb> */}
|
||||
<h4 className="text-sm font-medium text-foreground flex-1">{pageLabel}</h4>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
{isHomePage ? (
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{!taskBriefing.isOpen && (
|
||||
<div className="absolute top-[10px] left-[8px] z-10 flex items-center gap-1 rounded-lg bg-background/60 backdrop-blur-md px-1 py-1">
|
||||
<SidebarTrigger />
|
||||
{homeChatHasMessages && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px mx-1" />
|
||||
<button
|
||||
onClick={() => chatActionsRef.current?.clear()}
|
||||
aria-label="New conversation"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-accent"
|
||||
>
|
||||
<SquarePen size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<AIChatPanel isHomePage actionsRef={chatActionsRef} onHasMessagesChange={setHomeChatHasMessages} />
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
currentPath: string;
|
||||
setTokenDialogOpen: (open: boolean) => void;
|
||||
profile: { email: string; name?: string | null; surname?: string | null; tier: string; avatarUrl?: string | null } | null;
|
||||
}
|
||||
|
||||
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
function AppSidebar({ currentPath, profile }: AppSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
{/* Logo */}
|
||||
@@ -207,21 +225,15 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<div className="cursor-default">
|
||||
<div className="size-7 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="text-primary-foreground"
|
||||
>
|
||||
<path
|
||||
d="M12 2L13.5 8.5L20 10L13.5 11.5L12 18L10.5 11.5L4 10L10.5 8.5L12 2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" width="18" height="18">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#040404" opacity="0.85"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32" stroke="#040404" strokeWidth="0.5" opacity="0.12"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-foreground">
|
||||
Adiuva
|
||||
adiuv<span className="font-bold text-primary">AI</span>
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
@@ -234,11 +246,12 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{NAV_ITEMS.map(({ to, icon: Icon, label }) => {
|
||||
{NAV_ITEMS.map(({ to, icon: Icon, labelKey }) => {
|
||||
const isActive =
|
||||
to === '/'
|
||||
? currentPath === '/'
|
||||
: currentPath.startsWith(to);
|
||||
const label = t(labelKey);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={to}>
|
||||
@@ -258,61 +271,323 @@ function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<NavProjects />
|
||||
</SidebarContent>
|
||||
|
||||
{/* Settings gear + Collapse toggle */}
|
||||
{/* User avatar + dropdown */}
|
||||
<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 />
|
||||
<span>Collapse</span>
|
||||
</SidebarMenuButton>
|
||||
<NavUser profile={profile} currentPath={currentPath} />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NavProjects — clients + projects tree in the sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NO_CLIENT_KEY = '__no_client__';
|
||||
|
||||
function NavProjects() {
|
||||
const { state } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const routerState = useRouterState();
|
||||
const currentPath = routerState.location.pathname;
|
||||
const currentProjectId = useMemo(() => {
|
||||
const params = new URLSearchParams(routerState.location.search);
|
||||
return params.get('projectId') ?? undefined;
|
||||
}, [routerState.location.search]);
|
||||
|
||||
const { expandedClients, toggleClient, expandClients } = useExpandedClients();
|
||||
|
||||
const { data: projectList = [] } = trpc.projects.list.useQuery({ includeArchived: false });
|
||||
const { data: clientList = [] } = trpc.clients.list.useQuery();
|
||||
|
||||
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);
|
||||
if (arr) arr.push(c);
|
||||
else m.set(c.parentId, [c]);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [clientList]);
|
||||
|
||||
const projectsByClient = useMemo(() => {
|
||||
const m = new Map<string, typeof projectList>();
|
||||
for (const p of projectList) {
|
||||
const key = p.clientId ?? NO_CLIENT_KEY;
|
||||
const arr = m.get(key);
|
||||
if (arr) arr.push(p);
|
||||
else m.set(key, [p]);
|
||||
}
|
||||
return m;
|
||||
}, [projectList]);
|
||||
|
||||
function handleSelectProject(projectId: string) {
|
||||
void navigate({ to: '/projects', search: { projectId } });
|
||||
}
|
||||
|
||||
if (state === 'collapsed') return null;
|
||||
if (currentPath.startsWith('/projects')) return null;
|
||||
if (projectList.length === 0 && clientList.length === 0) return null;
|
||||
|
||||
const isProjectsActive = currentPath.startsWith('/projects');
|
||||
const unassignedProjects = projectsByClient.get(NO_CLIENT_KEY) ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{t('projects.projects')}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{topLevelClients.map((client) => {
|
||||
const isExpanded = expandedClients.has(client.id);
|
||||
const directProjects = projectsByClient.get(client.id) ?? [];
|
||||
const subClients = subClientsByParent.get(client.id) ?? [];
|
||||
const hasChildren = directProjects.length > 0 || subClients.length > 0;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={client.id}
|
||||
open={isExpanded}
|
||||
onOpenChange={() => toggleClient(client.id)}
|
||||
asChild
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={client.name}>
|
||||
<Folder />
|
||||
<span>{client.name}</span>
|
||||
{hasChildren && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'ml-auto transition-transform duration-200',
|
||||
isExpanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{subClients.map((subClient) => {
|
||||
const subIsExpanded = expandedClients.has(subClient.id);
|
||||
const subProjects = projectsByClient.get(subClient.id) ?? [];
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={subClient.id}
|
||||
open={subIsExpanded}
|
||||
onOpenChange={() => toggleClient(subClient.id)}
|
||||
asChild
|
||||
>
|
||||
<SidebarMenuSubItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuSubButton>
|
||||
<Folder />
|
||||
<span>{subClient.name}</span>
|
||||
{subProjects.length > 0 && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'ml-auto size-3 transition-transform duration-200',
|
||||
subIsExpanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SidebarMenuSubButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{subProjects.map((p) => (
|
||||
<SidebarMenuSubItem key={p.id}>
|
||||
<SidebarMenuSubButton
|
||||
isActive={isProjectsActive && currentProjectId === p.id}
|
||||
onClick={() => handleSelectProject(p.id)}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuSubItem>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
{directProjects.map((p) => (
|
||||
<SidebarMenuSubItem key={p.id}>
|
||||
<SidebarMenuSubButton
|
||||
isActive={isProjectsActive && currentProjectId === p.id}
|
||||
onClick={() => handleSelectProject(p.id)}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
|
||||
{unassignedProjects.map((p) => (
|
||||
<SidebarMenuItem key={p.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={isProjectsActive && currentProjectId === p.id}
|
||||
onClick={() => handleSelectProject(p.id)}
|
||||
tooltip={p.name}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NavUser — avatar with dropdown (inspired by shadcn sidebar-07)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NavUser({
|
||||
profile,
|
||||
currentPath,
|
||||
}: {
|
||||
profile: { email: string; name?: string | null; surname?: string | null; tier: string; avatarUrl?: string | null } | null;
|
||||
currentPath: string;
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const logoutMutation = trpc.auth.logout.useMutation();
|
||||
const { notify } = useNotify();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const email = profile?.email ?? 'User';
|
||||
const displayName = [profile?.name, profile?.surname].filter(Boolean).join(' ') || email?.split('@')[0];
|
||||
const initials = profile?.name && profile?.surname
|
||||
? `${profile.name[0]}${profile.surname[0]}`.toUpperCase()
|
||||
: (email?.split('@')[0] ?? 'US').slice(0, 2).toUpperCase();
|
||||
|
||||
function handleLogout() {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
notify('info', 'toast.auth.loggedOut');
|
||||
void utils.auth.status.invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light' as const, label: 'Light', icon: Sun },
|
||||
{ value: 'dark' as const, label: 'Dark', icon: Moon },
|
||||
{ value: 'system' as const, label: 'System', icon: Monitor },
|
||||
];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
{profile?.avatarUrl && <AvatarImage src={profile.avatarUrl} alt={displayName} />}
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{displayName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
{profile?.avatarUrl && <AvatarImage src={profile.avatarUrl} alt={displayName} />}
|
||||
<AvatarFallback className="rounded-lg text-xs">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings">
|
||||
<Settings className="mr-2 size-4" />
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{theme === 'dark' ? (
|
||||
<Moon className="mr-2 size-4" />
|
||||
) : theme === 'light' ? (
|
||||
<Sun className="mr-2 size-4" />
|
||||
) : (
|
||||
<Monitor className="mr-2 size-4" />
|
||||
)}
|
||||
Theme
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{themeOptions.map(({ value, label, icon: Icon }) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => setTheme(value)}
|
||||
>
|
||||
<Icon className="mr-2 size-4" />
|
||||
{label}
|
||||
{theme === value && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} disabled={logoutMutation.isPending}>
|
||||
<LogOut className="mr-2 size-4" />
|
||||
{t('settings.signOut')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
82
src/renderer/components/notes/PendingEditBlock.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Sparkles, Check, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NoteEdit } from '../../../shared/api-types';
|
||||
|
||||
interface PendingEditBlockProps {
|
||||
edit: NoteEdit;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
const EDIT_TYPE_LABEL: Record<NoteEdit['type'], string> = {
|
||||
append: 'Add at end',
|
||||
insert: 'Insert',
|
||||
replace: 'Replace',
|
||||
};
|
||||
|
||||
export function PendingEditBlock({ edit, onApprove, onReject, isPending }: PendingEditBlockProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-dashed border-muted-foreground/40 bg-muted/30 p-4',
|
||||
'flex flex-col gap-3',
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<span className="font-medium uppercase tracking-wide">
|
||||
AI suggestion — {EDIT_TYPE_LABEL[edit.type]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Reasoning */}
|
||||
{edit.reasoning && (
|
||||
<p className="text-xs text-muted-foreground italic">{edit.reasoning}</p>
|
||||
)}
|
||||
|
||||
{/* Proposed content preview */}
|
||||
<pre className="whitespace-pre-wrap rounded-md bg-background/60 p-3 text-sm leading-relaxed text-foreground font-sans">
|
||||
{edit.proposedContent}
|
||||
</pre>
|
||||
|
||||
{/* Anchor hint for insert/replace */}
|
||||
{edit.type === 'replace' && edit.anchorText && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Replaces: <span className="font-mono">{edit.anchorText.slice(0, 80)}</span>
|
||||
</p>
|
||||
)}
|
||||
{edit.type === 'insert' && edit.anchorBefore && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
After: <span className="font-mono">{edit.anchorBefore.slice(0, 80)}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={onApprove}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={onReject}
|
||||
disabled={isPending}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
723
src/renderer/components/onboarding/OnboardingFlow.tsx
Normal file
@@ -0,0 +1,723 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronRight, ChevronLeft, Pencil, Check, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { JOB_ROLES, INDUSTRIES, USE_CASES, TONES } from './onboardingOptions';
|
||||
import type { UserProfile } from '../../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Step =
|
||||
| 'welcome'
|
||||
| 'jobRole'
|
||||
| 'industry'
|
||||
| 'useCase'
|
||||
| 'tone'
|
||||
| 'language'
|
||||
| 'reviewing'
|
||||
| 'done';
|
||||
|
||||
const STEP_ORDER: Step[] = [
|
||||
'welcome',
|
||||
'jobRole',
|
||||
'industry',
|
||||
'useCase',
|
||||
'tone',
|
||||
'language',
|
||||
'reviewing',
|
||||
];
|
||||
|
||||
interface OnboardingFlowProps {
|
||||
profile: UserProfile;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const spring = { type: 'spring' as const, stiffness: 400, damping: 32 };
|
||||
|
||||
function AIBubble({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={spring}
|
||||
className="flex items-start gap-3"
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" width="16" height="16">
|
||||
<path d="M32,4 L48,32 L16,32 Z" fill="#040404" opacity="0.85"/>
|
||||
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
||||
<line x1="16" y1="32" x2="48" y2="32" stroke="#040404" strokeWidth="0.5" opacity="0.12"/>
|
||||
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/60 backdrop-blur-md border border-border/30 px-5 py-3.5 max-w-[85%]">
|
||||
<div className="text-sm leading-relaxed">{children}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserBubble({ text }: { text: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={spring}
|
||||
className="flex justify-end"
|
||||
>
|
||||
<div className="rounded-2xl bg-primary/10 border border-primary/20 px-4 py-2.5 max-w-[70%]">
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Toggle-chip for multi-select. */
|
||||
function Chip({
|
||||
label,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant={selected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function OnboardingFlow({ profile }: OnboardingFlowProps) {
|
||||
const [step, setStep] = useState<Step>('welcome');
|
||||
// answers stores comma-joined values per field (supports multi-select)
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
// per-step selected chip sets (for multi-select toggle UI)
|
||||
const [selected, setSelected] = useState<Record<string, Set<string>>>({});
|
||||
const [freeTexts, setFreeTexts] = useState<Record<string, string>>({});
|
||||
const [customInput, setCustomInput] = useState('');
|
||||
const [reviewValues, setReviewValues] = useState<Record<string, string>>({});
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
const [editBuffer, setEditBuffer] = useState('');
|
||||
const [normalizeError, setNormalizeError] = useState(false);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const normalizeMutation = trpc.auth.normalizeOnboarding.useMutation();
|
||||
const updateMemoryMutation = trpc.auth.updateMemory.useMutation();
|
||||
|
||||
const displayName =
|
||||
[profile.name, profile.surname].filter(Boolean).join(' ') ||
|
||||
profile.email.split('@')[0];
|
||||
|
||||
// -- Chip toggle --
|
||||
|
||||
const toggleChip = useCallback((field: string, value: string) => {
|
||||
setSelected((prev) => {
|
||||
const set = new Set(prev[field] ?? []);
|
||||
if (set.has(value)) set.delete(value);
|
||||
else set.add(value);
|
||||
return { ...prev, [field]: set };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isChipSelected = useCallback(
|
||||
(field: string, value: string) => selected[field]?.has(value) ?? false,
|
||||
[selected],
|
||||
);
|
||||
|
||||
// -- Navigation helpers --
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
const idx = STEP_ORDER.indexOf(step);
|
||||
if (idx >= 0 && idx < STEP_ORDER.length - 1) {
|
||||
setCustomInput('');
|
||||
setStep(STEP_ORDER[idx + 1]);
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
const idx = STEP_ORDER.indexOf(step);
|
||||
if (idx > 0) {
|
||||
setCustomInput('');
|
||||
setStep(STEP_ORDER[idx - 1]);
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
/** Commit selections for a field and advance. */
|
||||
const commitAndNext = useCallback(
|
||||
(field: string) => {
|
||||
const chips = selected[field];
|
||||
const chipValues = chips ? [...chips] : [];
|
||||
const custom = customInput.trim();
|
||||
|
||||
// Merge chip selections + optional custom input
|
||||
const allValues = [...chipValues];
|
||||
if (custom && !allValues.includes(custom)) allValues.push(custom);
|
||||
|
||||
if (allValues.length > 0) {
|
||||
const joined = allValues.join(', ');
|
||||
setAnswers((prev) => ({ ...prev, [field]: joined }));
|
||||
// Track free text if custom input was used
|
||||
if (custom) {
|
||||
setFreeTexts((prev) => ({ ...prev, [field]: joined }));
|
||||
}
|
||||
}
|
||||
setCustomInput('');
|
||||
goNext();
|
||||
},
|
||||
[selected, customInput, goNext],
|
||||
);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
updateMemoryMutation.mutate(
|
||||
{ memory: {}, markOnboarded: true },
|
||||
{
|
||||
onSuccess: () => void utils.auth.status.invalidate(),
|
||||
},
|
||||
);
|
||||
}, [updateMemoryMutation, utils]);
|
||||
|
||||
/** Check if the current step has at least one selection. */
|
||||
const hasSelection = useCallback(
|
||||
(field: string) => {
|
||||
const chips = selected[field];
|
||||
return (chips && chips.size > 0) || customInput.trim().length > 0;
|
||||
},
|
||||
[selected, customInput],
|
||||
);
|
||||
|
||||
// -- Reviewing step --
|
||||
|
||||
const startReview = useCallback(async () => {
|
||||
setStep('reviewing');
|
||||
|
||||
const chipAnswers = { ...answers };
|
||||
const freeTextAnswers: Record<string, string> = {};
|
||||
|
||||
for (const [key, val] of Object.entries(freeTexts)) {
|
||||
if (val && answers[key] === val) {
|
||||
freeTextAnswers[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
setReviewValues({ ...chipAnswers });
|
||||
|
||||
if (Object.keys(freeTextAnswers).length > 0) {
|
||||
try {
|
||||
const normalized = await normalizeMutation.mutateAsync({
|
||||
inputs: freeTextAnswers,
|
||||
});
|
||||
setReviewValues((prev) => ({ ...prev, ...normalized }));
|
||||
setNormalizeError(false);
|
||||
} catch {
|
||||
setNormalizeError(true);
|
||||
}
|
||||
}
|
||||
}, [answers, freeTexts, normalizeMutation]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setSaveError(false);
|
||||
const memory = { ...reviewValues };
|
||||
const fullName = [profile.name, profile.surname].filter(Boolean).join(' ');
|
||||
if (fullName) memory.user_name = fullName;
|
||||
updateMemoryMutation.mutate(
|
||||
{ memory, markOnboarded: true },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.onboarding.completed', { descriptionKey: 'toast.onboarding.completedDescription' });
|
||||
void utils.auth.status.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
setSaveError(true);
|
||||
notifyError('toast.onboarding.error', err);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [reviewValues, profile, updateMemoryMutation, utils, notify, notifyError]);
|
||||
|
||||
const handleEditStart = useCallback(
|
||||
(key: string) => {
|
||||
setEditingField(key);
|
||||
setEditBuffer(reviewValues[key] ?? '');
|
||||
},
|
||||
[reviewValues],
|
||||
);
|
||||
|
||||
const handleEditConfirm = useCallback(() => {
|
||||
if (editingField) {
|
||||
setReviewValues((prev) => ({ ...prev, [editingField]: editBuffer }));
|
||||
setEditingField(null);
|
||||
setEditBuffer('');
|
||||
}
|
||||
}, [editingField, editBuffer]);
|
||||
|
||||
// -- Past answers --
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
job_role: 'Role',
|
||||
industry: 'Industry',
|
||||
primary_use_case: 'Use case',
|
||||
tone_preference: 'Tone',
|
||||
language: 'Language',
|
||||
};
|
||||
const fieldOrder = ['job_role', 'industry', 'primary_use_case', 'tone_preference', 'language'];
|
||||
|
||||
const pastAnswers: { label: string; value: string }[] = [];
|
||||
for (const key of fieldOrder) {
|
||||
if (answers[key]) {
|
||||
pastAnswers.push({ label: fieldLabels[key], value: answers[key] });
|
||||
}
|
||||
}
|
||||
|
||||
// -- Detected language (human-readable) --
|
||||
const detectedLang = useMemo(() => {
|
||||
const raw =
|
||||
profile.memory?.language ??
|
||||
(typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
// If it already looks like a display name (not a locale code), return as-is
|
||||
if (raw.length > 5 || !raw.includes('-')) {
|
||||
// Could be 'English', 'Italiano', or a bare code like 'en'
|
||||
try {
|
||||
const display = new Intl.DisplayNames([raw], { type: 'language' });
|
||||
return display.of(raw) ?? raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const display = new Intl.DisplayNames([raw], { type: 'language' });
|
||||
return display.of(raw) ?? raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}, [profile.memory?.language]);
|
||||
|
||||
// -- Step index for progress indicator --
|
||||
const stepIdx = STEP_ORDER.indexOf(step);
|
||||
const showBack = stepIdx > 1; // show back from jobRole onwards (not on welcome)
|
||||
|
||||
// -- Render --
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background">
|
||||
<div className="w-full max-w-xl px-6 py-10">
|
||||
{/* Progress dots */}
|
||||
{step !== 'welcome' && step !== 'done' && (
|
||||
<div className="flex justify-center gap-1.5 mb-6">
|
||||
{STEP_ORDER.slice(1, -1).map((s, i) => (
|
||||
<div
|
||||
key={s}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all duration-300',
|
||||
i < stepIdx - 1
|
||||
? 'w-6 bg-primary'
|
||||
: i === stepIdx - 1
|
||||
? 'w-6 bg-primary'
|
||||
: 'w-1.5 bg-muted-foreground/20',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={spring}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{/* Past answers (user bubbles) */}
|
||||
{step !== 'welcome' && step !== 'reviewing' && (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
{pastAnswers.map(({ label, value }) => (
|
||||
<UserBubble key={label} text={`${label}: ${value}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── WELCOME ── */}
|
||||
{step === 'welcome' && (
|
||||
<>
|
||||
<AIBubble>
|
||||
<p>
|
||||
Hi <span className="font-medium">{displayName}</span>! I'm
|
||||
your AI assistant. Let me learn a few things about you so I can
|
||||
help better.
|
||||
</p>
|
||||
</AIBubble>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button onClick={goNext} size="sm">
|
||||
Let's go <ChevronRight size={14} className="ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── JOB ROLE ── */}
|
||||
{step === 'jobRole' && (
|
||||
<>
|
||||
<AIBubble>What's your role? Pick all that apply.</AIBubble>
|
||||
<div className="flex flex-wrap gap-2 pl-11">
|
||||
{JOB_ROLES.map((role) => (
|
||||
<Chip
|
||||
key={role}
|
||||
label={role}
|
||||
selected={isChipSelected('job_role', role)}
|
||||
onClick={() => toggleChip('job_role', role)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 pl-11">
|
||||
<Input
|
||||
placeholder="Type your own…"
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && hasSelection('job_role')) {
|
||||
commitAndNext('job_role');
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<StepNav
|
||||
showBack={showBack}
|
||||
onBack={goBack}
|
||||
onNext={() => commitAndNext('job_role')}
|
||||
canNext={hasSelection('job_role')}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── INDUSTRY ── */}
|
||||
{step === 'industry' && (
|
||||
<>
|
||||
<AIBubble>What industry do you work in? Pick all that apply.</AIBubble>
|
||||
<div className="flex flex-wrap gap-2 pl-11">
|
||||
{INDUSTRIES.map((ind) => (
|
||||
<Chip
|
||||
key={ind}
|
||||
label={ind}
|
||||
selected={isChipSelected('industry', ind)}
|
||||
onClick={() => toggleChip('industry', ind)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2 pl-11">
|
||||
<Input
|
||||
placeholder="Type your own…"
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && hasSelection('industry')) {
|
||||
commitAndNext('industry');
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<StepNav
|
||||
showBack={showBack}
|
||||
onBack={goBack}
|
||||
onNext={() => commitAndNext('industry')}
|
||||
canNext={hasSelection('industry')}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── USE CASE ── */}
|
||||
{step === 'useCase' && (
|
||||
<>
|
||||
<AIBubble>How will you mainly use adiuvAI? Pick all that apply.</AIBubble>
|
||||
<div className="flex flex-wrap gap-2 pl-11">
|
||||
{USE_CASES.map((uc) => (
|
||||
<Chip
|
||||
key={uc}
|
||||
label={uc}
|
||||
selected={isChipSelected('primary_use_case', uc)}
|
||||
onClick={() => toggleChip('primary_use_case', uc)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<StepNav
|
||||
showBack={showBack}
|
||||
onBack={goBack}
|
||||
onNext={() => commitAndNext('primary_use_case')}
|
||||
canNext={hasSelection('primary_use_case')}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── TONE ── */}
|
||||
{step === 'tone' && (
|
||||
<>
|
||||
<AIBubble>How should I talk to you? Pick all that apply.</AIBubble>
|
||||
<div className="flex flex-wrap gap-2 pl-11">
|
||||
{TONES.map((t) => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={t}
|
||||
selected={isChipSelected('tone_preference', t)}
|
||||
onClick={() => toggleChip('tone_preference', t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<StepNav
|
||||
showBack={showBack}
|
||||
onBack={goBack}
|
||||
onNext={() => commitAndNext('tone_preference')}
|
||||
canNext={hasSelection('tone_preference')}
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── LANGUAGE ── */}
|
||||
{step === 'language' && (
|
||||
<>
|
||||
<AIBubble>
|
||||
I'll respond in <span className="font-medium">{detectedLang}</span>.
|
||||
Want to change it?
|
||||
</AIBubble>
|
||||
<div className="flex flex-wrap gap-2 pl-11">
|
||||
<Chip
|
||||
label={`Keep ${detectedLang}`}
|
||||
selected={isChipSelected('language', detectedLang)}
|
||||
onClick={() => toggleChip('language', detectedLang)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pl-11">
|
||||
<Input
|
||||
placeholder="Type a language…"
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && hasSelection('language')) {
|
||||
// Commit language, then go to review
|
||||
const chips = selected.language;
|
||||
const chipValues = chips ? [...chips] : [];
|
||||
const custom = customInput.trim();
|
||||
const allValues = [...chipValues];
|
||||
if (custom && !allValues.includes(custom)) allValues.push(custom);
|
||||
if (allValues.length > 0) {
|
||||
const joined = allValues.join(', ');
|
||||
setAnswers((prev) => ({ ...prev, language: joined }));
|
||||
if (custom) setFreeTexts((prev) => ({ ...prev, language: joined }));
|
||||
}
|
||||
setCustomInput('');
|
||||
void startReview();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<StepNav
|
||||
showBack={showBack}
|
||||
onBack={goBack}
|
||||
onNext={() => {
|
||||
// Commit language, then go to review
|
||||
const chips = selected.language;
|
||||
const chipValues = chips ? [...chips] : [];
|
||||
const custom = customInput.trim();
|
||||
const allValues = [...chipValues];
|
||||
if (custom && !allValues.includes(custom)) allValues.push(custom);
|
||||
if (allValues.length > 0) {
|
||||
const joined = allValues.join(', ');
|
||||
setAnswers((prev) => ({ ...prev, language: joined }));
|
||||
if (custom) setFreeTexts((prev) => ({ ...prev, language: joined }));
|
||||
}
|
||||
setCustomInput('');
|
||||
void startReview();
|
||||
}}
|
||||
canNext={hasSelection('language')}
|
||||
onSkip={handleSkip}
|
||||
nextLabel="Review"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── REVIEWING ── */}
|
||||
{step === 'reviewing' && (
|
||||
<>
|
||||
<AIBubble>Here's what I'll remember about you.</AIBubble>
|
||||
|
||||
{normalizeError && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="ml-11 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-2.5 text-xs text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
Couldn't auto-tidy — review and save as-is.
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<Card className="ml-11 rounded-xl">
|
||||
<CardContent className="px-5 py-4 flex flex-col gap-3">
|
||||
{fieldOrder.map((key) => {
|
||||
const value = reviewValues[key];
|
||||
if (!value) return null;
|
||||
const original = freeTexts[key];
|
||||
const wasTidied = original && original !== value;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{fieldLabels[key]}
|
||||
</p>
|
||||
{editingField === key ? (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Input
|
||||
value={editBuffer}
|
||||
onChange={(e) => setEditBuffer(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleEditConfirm();
|
||||
if (e.key === 'Escape') setEditingField(null);
|
||||
}}
|
||||
className="h-7 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleEditConfirm}
|
||||
>
|
||||
<Check size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium">{value}</p>
|
||||
{wasTidied && (
|
||||
<p className="text-xs text-muted-foreground/60 mt-0.5">
|
||||
auto-tidied from “{original}”
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{editingField !== key && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => handleEditStart(key)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{normalizeMutation.isPending && (
|
||||
<div className="ml-11 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Tidying up…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<div className="ml-11 text-xs text-red-500">
|
||||
Failed to save — please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 ml-11 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={goBack}
|
||||
>
|
||||
<ChevronLeft size={14} className="mr-1" /> Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
size="sm"
|
||||
disabled={updateMemoryMutation.isPending}
|
||||
>
|
||||
{updateMemoryMutation.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin mr-1.5" />
|
||||
) : null}
|
||||
Looks good — save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StepNav({
|
||||
showBack,
|
||||
onBack,
|
||||
onNext,
|
||||
canNext,
|
||||
onSkip,
|
||||
nextLabel = 'Next',
|
||||
}: {
|
||||
showBack: boolean;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
canNext: boolean;
|
||||
onSkip: () => void;
|
||||
nextLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 pl-11 mt-2">
|
||||
{showBack && (
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<ChevronLeft size={14} className="mr-1" /> Back
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={onNext} disabled={!canNext}>
|
||||
{nextLabel} <ChevronRight size={14} className="ml-1" />
|
||||
</Button>
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
|
||||
>
|
||||
Skip setup
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/renderer/components/onboarding/onboardingOptions.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const JOB_ROLES = [
|
||||
'Developer',
|
||||
'Designer',
|
||||
'Consultant',
|
||||
'Founder',
|
||||
'Project Manager',
|
||||
] as const;
|
||||
|
||||
export const INDUSTRIES = [
|
||||
'Tech',
|
||||
'Design',
|
||||
'Consulting',
|
||||
'Legal',
|
||||
'Marketing',
|
||||
'Education',
|
||||
] as const;
|
||||
|
||||
export const USE_CASES = [
|
||||
'Solo freelancer',
|
||||
'Client manager',
|
||||
'Team lead',
|
||||
'Personal productivity',
|
||||
] as const;
|
||||
|
||||
export const TONES = ['Casual', 'Formal', 'Concise', 'Detailed'] as const;
|
||||
@@ -1,168 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Folder,
|
||||
Circle,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
@@ -13,7 +12,9 @@ import {
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -54,7 +55,9 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { useExpandedClients } from '@/context/ExpandedClientsContext';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
@@ -76,7 +79,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const { expandedClients: expanded, toggleClient, expandClients } = useExpandedClients();
|
||||
const [deleteProjectId, setDeleteProjectId] = useState<{ id: string; name: string } | null>(null);
|
||||
const [renameProject, setRenameProject] = useState<{ id: string; name: string } | null>(null);
|
||||
const [renameProjectValue, setRenameProjectValue] = useState('');
|
||||
@@ -110,6 +113,19 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
);
|
||||
const { data: clientList = [] } = trpc.clients.list.useQuery();
|
||||
|
||||
// Auto-expand the client path for the selected project
|
||||
useEffect(() => {
|
||||
if (!selectedProjectId || projectList.length === 0) return;
|
||||
const project = projectList.find((p) => p.id === selectedProjectId);
|
||||
if (!project?.clientId) return;
|
||||
|
||||
const keysToExpand: string[] = [project.clientId];
|
||||
const client = clientList.find((c) => c.id === project.clientId);
|
||||
if (client?.parentId) keysToExpand.push(client.parentId);
|
||||
|
||||
expandClients(keysToExpand);
|
||||
}, [selectedProjectId, projectList, clientList]);
|
||||
|
||||
// Derived: top-level clients and sub-clients grouped by parentId
|
||||
const topLevelClients = useMemo(
|
||||
() => clientList.filter((c) => !c.parentId),
|
||||
@@ -127,50 +143,70 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
return m;
|
||||
}, [clientList]);
|
||||
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const createClientMutation = trpc.clients.create.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.client.created');
|
||||
void utils.clients.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.client.createError', err),
|
||||
});
|
||||
|
||||
const createMutation = trpc.projects.create.useMutation({
|
||||
onSuccess: (data, variables) => {
|
||||
notify('success', 'toast.project.created');
|
||||
// Auto-expand the matching client group
|
||||
const groupKey = variables.clientId ?? NO_CLIENT_KEY;
|
||||
setExpanded((prev) => new Set([...prev, groupKey]));
|
||||
expandClients([groupKey]);
|
||||
onSelectProject(data.id);
|
||||
void utils.projects.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.project.createError', err),
|
||||
});
|
||||
|
||||
const updateMutation = trpc.projects.update.useMutation({
|
||||
onSuccess: () => { void utils.projects.list.invalidate(); },
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.project.updated');
|
||||
void utils.projects.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.project.updateError', err),
|
||||
});
|
||||
|
||||
const deleteMutation = trpc.projects.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('warning', 'toast.project.deleted');
|
||||
setDeleteProjectId(null);
|
||||
void utils.projects.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.project.deleteError', err),
|
||||
});
|
||||
|
||||
const updateClientMutation = trpc.clients.update.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('success', 'toast.client.updated');
|
||||
setRenameClient(null);
|
||||
void utils.clients.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.client.updateError', err),
|
||||
});
|
||||
|
||||
const archiveByClientMutation = trpc.projects.archiveByClient.useMutation({
|
||||
onSuccess: () => { void utils.projects.list.invalidate(); },
|
||||
onSuccess: (_data, variables) => {
|
||||
notify('warning', variables.status === 'archived' ? 'toast.project.archivedAll' : 'toast.project.unarchivedAll');
|
||||
void utils.projects.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.project.updateError', err),
|
||||
});
|
||||
|
||||
const deleteClientMutation = trpc.clients.deleteWithCascade.useMutation({
|
||||
onSuccess: () => {
|
||||
notify('warning', 'toast.client.deleted');
|
||||
setDeleteClient(null);
|
||||
void utils.clients.list.invalidate();
|
||||
void utils.projects.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.client.deleteError', err),
|
||||
});
|
||||
|
||||
// Build a client lookup map
|
||||
@@ -225,11 +261,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}, [searchQuery, grouped, expanded]);
|
||||
|
||||
function toggleExpanded(key: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(key) ? next.delete(key) : next.add(key);
|
||||
return next;
|
||||
});
|
||||
toggleClient(key);
|
||||
}
|
||||
|
||||
function handleOpenNewProject() {
|
||||
@@ -374,17 +406,22 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
|
||||
const totalProjects = projectList.length;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border-r border-border w-60 shrink-0">
|
||||
<div className="flex flex-col h-full min-h-0 border-r border-border w-60 shrink-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 shrink-0">
|
||||
<h4 className="text-lg font-semibold text-foreground">Projects</h4>
|
||||
<div className="flex h-14 items-center gap-2 px-3 shrink-0">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px mr-2 data-[orientation=vertical]:h-4" />
|
||||
<h4 className="text-sm font-medium text-foreground flex-1">{t('projects.projects')}</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={handleOpenNewProject}
|
||||
disabled={createMutation.isPending}
|
||||
aria-label="New Project"
|
||||
aria-label={t('projects.newProject')}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
@@ -395,7 +432,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search projects..."
|
||||
placeholder={t('projects.searchPlaceholder')}
|
||||
className="h-7 text-sm pl-7"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
@@ -406,7 +443,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
{/* Show archived toggle */}
|
||||
<div className="flex items-center justify-between px-3 pb-2 shrink-0">
|
||||
<label htmlFor="show-archived" className="text-xs text-muted-foreground cursor-pointer">
|
||||
Show archived
|
||||
{t('projects.showArchived')}
|
||||
</label>
|
||||
<Switch
|
||||
id="show-archived"
|
||||
@@ -417,16 +454,16 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
</div>
|
||||
|
||||
{/* Project tree */}
|
||||
<ScrollArea className="flex-1 py-1 px-1">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto py-1 px-1">
|
||||
{totalProjects === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Folder />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No projects yet</EmptyTitle>
|
||||
<EmptyTitle>{t('projects.noProjectsYet')}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Get started by adding your first project.
|
||||
{t('projects.noProjectsDescription')}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
@@ -437,20 +474,20 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Project
|
||||
{t('projects.addProject')}
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : sortedGroupKeys.length === 0 && clientList.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground px-3 py-4 text-center">
|
||||
No projects match your search.
|
||||
{t('projects.noProjectsMatch')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Client groups */}
|
||||
{sortedGroupKeys.filter((k) => k !== NO_CLIENT_KEY).map((groupKey) => {
|
||||
const groupProjects = grouped.get(groupKey) ?? [];
|
||||
const groupName = clientMap.get(groupKey) ?? 'Unknown Client';
|
||||
const groupName = clientMap.get(groupKey) ?? t('projects.unknownClient');
|
||||
const isOpen = effectiveExpanded.has(groupKey);
|
||||
|
||||
return (
|
||||
@@ -486,7 +523,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
Rename
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -500,12 +537,12 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
{groupProjects.every((p) => p.status === 'archived') ? (
|
||||
<>
|
||||
<ArchiveRestore />
|
||||
Unarchive All
|
||||
{t('projects.unarchiveAll')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive />
|
||||
Archive All
|
||||
{t('projects.archiveAll')}
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
@@ -517,7 +554,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -565,7 +602,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
Rename
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -579,12 +616,12 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
{subProjects.every((p) => p.status === 'archived') ? (
|
||||
<>
|
||||
<ArchiveRestore />
|
||||
Unarchive All
|
||||
{t('projects.unarchiveAll')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive />
|
||||
Archive All
|
||||
{t('projects.archiveAll')}
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
@@ -596,7 +633,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -614,7 +651,6 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
style={{ paddingLeft: '40px', paddingRight: '4px' }}
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
>
|
||||
<Circle className="size-2.5 shrink-0 text-muted-foreground mr-1.5" />
|
||||
<span className="flex-1 min-w-0 truncate text-foreground">
|
||||
{project.name}
|
||||
</span>
|
||||
@@ -628,7 +664,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
Rename
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -640,7 +676,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Edit2 />
|
||||
Edit Client
|
||||
{t('projects.editClient')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -650,12 +686,12 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
{project.status === 'archived' ? (
|
||||
<>
|
||||
<ArchiveRestore />
|
||||
Unarchive
|
||||
{t('projects.unarchive')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive />
|
||||
Archive
|
||||
{t('projects.archive')}
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
@@ -667,7 +703,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -690,7 +726,6 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
style={{ paddingLeft: '28px', paddingRight: '4px' }}
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
>
|
||||
<Circle className="size-2.5 shrink-0 text-muted-foreground mr-1.5" />
|
||||
<span className="flex-1 min-w-0 truncate text-foreground">
|
||||
{project.name}
|
||||
</span>
|
||||
@@ -704,7 +739,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
Rename
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -716,7 +751,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Edit2 />
|
||||
Edit Client
|
||||
{t('projects.editClient')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -726,12 +761,12 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
{project.status === 'archived' ? (
|
||||
<>
|
||||
<ArchiveRestore />
|
||||
Unarchive
|
||||
{t('projects.unarchive')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive />
|
||||
Archive
|
||||
{t('projects.archive')}
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
@@ -743,7 +778,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -765,7 +800,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
)}
|
||||
onClick={() => onSelectProject(project.id)}
|
||||
>
|
||||
<Circle className="size-2.5 shrink-0 text-muted-foreground mr-1.5" />
|
||||
<div className="size-2.5 shrink-0 rounded-full bg-muted-foreground/40 mr-1.5" />
|
||||
<span className="flex-1 min-w-0 truncate text-foreground">
|
||||
{project.name}
|
||||
</span>
|
||||
@@ -779,7 +814,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
Rename
|
||||
{t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -791,7 +826,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Edit2 />
|
||||
Edit Client
|
||||
{t('projects.editClient')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
@@ -801,12 +836,12 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
{project.status === 'archived' ? (
|
||||
<>
|
||||
<ArchiveRestore />
|
||||
Unarchive
|
||||
{t('projects.unarchive')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive />
|
||||
Archive
|
||||
{t('projects.archive')}
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
@@ -818,14 +853,14 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Rename project dialog */}
|
||||
<Dialog
|
||||
@@ -836,20 +871,20 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Project</DialogTitle>
|
||||
<DialogTitle>{t('projects.renameProject')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for “{renameProject?.name}”.
|
||||
{t('common.renameDescription', { name: renameProject?.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameProjectValue}
|
||||
onChange={(e) => setRenameProjectValue(e.target.value)}
|
||||
placeholder="Project name"
|
||||
placeholder={t('projects.projectNamePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameProject(null)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -862,7 +897,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
disabled={!renameProjectValue.trim() || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? 'Saving\u2026' : 'Save'}
|
||||
{updateMutation.isPending ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -878,14 +913,14 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete “{deleteProjectId?.name}”?
|
||||
{t('common.deleteTitle', { name: deleteProjectId?.name })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete the project. Tasks assigned to this project will become unassigned. This action cannot be undone.
|
||||
{t('projects.deleteProjectDescription')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
@@ -893,7 +928,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting\u2026' : 'Delete'}
|
||||
{deleteMutation.isPending ? t('common.deleting') : t('common.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -903,19 +938,19 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
<Dialog open={newProjectOpen} onOpenChange={setNewProjectOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Project</DialogTitle>
|
||||
<DialogTitle>{t('projects.newProject')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Give your project a name and optionally assign it to a client.
|
||||
{t('projects.newProjectDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
{/* Project name */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="new-project-name" className="text-sm font-medium">Project Name</label>
|
||||
<label htmlFor="new-project-name" className="text-sm font-medium">{t('projects.projectName')}</label>
|
||||
<Input
|
||||
id="new-project-name"
|
||||
placeholder="e.g. Website Redesign"
|
||||
placeholder={t('projects.projectNameExample')}
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
autoFocus
|
||||
@@ -924,11 +959,11 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
|
||||
{/* Client selection */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">Client <span className="text-muted-foreground font-normal">(optional)</span></label>
|
||||
<label className="text-sm font-medium">{t('projects.clientOptional')} <span className="text-muted-foreground font-normal">{t('projects.clientOptionalHint')}</span></label>
|
||||
{creatingClient ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="New client name"
|
||||
placeholder={t('projects.newClientName')}
|
||||
value={newClientName}
|
||||
onChange={(e) => setNewClientName(e.target.value)}
|
||||
className="flex-1"
|
||||
@@ -943,7 +978,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -958,10 +993,10 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a client" />
|
||||
<SelectValue placeholder={t('projects.selectClient')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_CLIENT_KEY}>None (Internal)</SelectItem>
|
||||
<SelectItem value={NO_CLIENT_KEY}>{t('projects.noneInternal')}</SelectItem>
|
||||
{topLevelClients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||
))}
|
||||
@@ -972,7 +1007,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
size="sm"
|
||||
onClick={() => setCreatingClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
<Plus className="size-3.5 mr-1" />{t('projects.new')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -981,11 +1016,11 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
{/* Sub-client selection — only when a client is selected or being created */}
|
||||
{(newProjectClientId !== NO_CLIENT_KEY || (creatingClient && newClientName.trim())) && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">Sub-client <span className="text-muted-foreground font-normal">(optional)</span></label>
|
||||
<label className="text-sm font-medium">{t('projects.subClientOptional')} <span className="text-muted-foreground font-normal">{t('projects.clientOptionalHint')}</span></label>
|
||||
{creatingSubClient ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="New sub-client name"
|
||||
placeholder={t('projects.newSubClientName')}
|
||||
value={newSubClientName}
|
||||
onChange={(e) => setNewSubClientName(e.target.value)}
|
||||
className="flex-1"
|
||||
@@ -998,7 +1033,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
setNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : creatingClient ? (
|
||||
@@ -1009,7 +1044,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
className="w-fit"
|
||||
onClick={() => setCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New Sub-client
|
||||
<Plus className="size-3.5 mr-1" />{t('projects.newSubClient')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1032,7 +1067,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
size="sm"
|
||||
onClick={() => setCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
<Plus className="size-3.5 mr-1" />{t('projects.new')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1041,12 +1076,12 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNewProjectOpen(false)}>Cancel</Button>
|
||||
<Button variant="outline" onClick={() => setNewProjectOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button
|
||||
onClick={handleCreateProject}
|
||||
disabled={!newProjectName.trim() || createMutation.isPending || createClientMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending || createClientMutation.isPending ? 'Creating…' : 'Create Project'}
|
||||
{createMutation.isPending || createClientMutation.isPending ? t('common.creating') : t('projects.createProject')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -1062,14 +1097,14 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete “{deleteClient?.name}”?
|
||||
{t('common.deleteTitle', { name: deleteClient?.name })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete the client and all its projects. Tasks assigned to those projects will become unassigned. This action cannot be undone.
|
||||
{t('projects.deleteClientDescription')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteClientMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleteClientMutation.isPending}>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
@@ -1077,7 +1112,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
disabled={deleteClientMutation.isPending}
|
||||
>
|
||||
{deleteClientMutation.isPending ? 'Deleting\u2026' : 'Delete'}
|
||||
{deleteClientMutation.isPending ? t('common.deleting') : t('common.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -1092,20 +1127,20 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename Client</DialogTitle>
|
||||
<DialogTitle>{t('projects.renameClient')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a new name for “{renameClient?.name}”.
|
||||
{t('common.renameDescription', { name: renameClient?.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={renameClientValue}
|
||||
onChange={(e) => setRenameClientValue(e.target.value)}
|
||||
placeholder="Client name"
|
||||
placeholder={t('projects.clientNamePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameClient(null)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -1115,7 +1150,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
disabled={!renameClientValue.trim() || updateClientMutation.isPending}
|
||||
>
|
||||
{updateClientMutation.isPending ? 'Saving\u2026' : 'Save'}
|
||||
{updateClientMutation.isPending ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -1130,18 +1165,18 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Project Client</DialogTitle>
|
||||
<DialogTitle>{t('projects.editProjectClient')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign “{editDialog?.name}” to a client or leave as internal.
|
||||
{t('projects.editProjectClientDescription', { name: editDialog?.name })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">Client</label>
|
||||
<label className="text-sm font-medium">{t('projects.client')}</label>
|
||||
{editCreatingClient ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="New client name"
|
||||
placeholder={t('projects.newClientName')}
|
||||
value={editNewClientName}
|
||||
onChange={(e) => setEditNewClientName(e.target.value)}
|
||||
className="flex-1"
|
||||
@@ -1156,7 +1191,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
setEditNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1171,10 +1206,10 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a client" />
|
||||
<SelectValue placeholder={t('projects.selectClient')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_CLIENT_KEY}>No Client (Internal)</SelectItem>
|
||||
<SelectItem value={NO_CLIENT_KEY}>{t('projects.noClientInternal')}</SelectItem>
|
||||
{topLevelClients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
@@ -1187,7 +1222,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
size="sm"
|
||||
onClick={() => setEditCreatingClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
<Plus className="size-3.5 mr-1" />{t('projects.new')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1195,11 +1230,11 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
|
||||
{(editClientValue !== NO_CLIENT_KEY || (editCreatingClient && editNewClientName.trim())) && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">Sub-client <span className="text-muted-foreground font-normal">(optional)</span></label>
|
||||
<label className="text-sm font-medium">{t('projects.subClientOptional')} <span className="text-muted-foreground font-normal">{t('projects.clientOptionalHint')}</span></label>
|
||||
{editCreatingSubClient ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="New sub-client name"
|
||||
placeholder={t('projects.newSubClientName')}
|
||||
value={editNewSubClientName}
|
||||
onChange={(e) => setEditNewSubClientName(e.target.value)}
|
||||
className="flex-1"
|
||||
@@ -1212,7 +1247,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
setEditNewSubClientName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : editCreatingClient ? (
|
||||
@@ -1222,7 +1257,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
className="w-fit"
|
||||
onClick={() => setEditCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New Sub-client
|
||||
<Plus className="size-3.5 mr-1" />{t('projects.newSubClient')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1231,10 +1266,10 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
onValueChange={setEditSubClientValue}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Select a sub-client" />
|
||||
<SelectValue placeholder={t('projects.selectSubClient')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_CLIENT_KEY}>None</SelectItem>
|
||||
<SelectItem value={NO_CLIENT_KEY}>{t('projects.none')}</SelectItem>
|
||||
{(subClientsByParent.get(editClientValue) ?? []).map((sc) => (
|
||||
<SelectItem key={sc.id} value={sc.id}>
|
||||
{sc.name}
|
||||
@@ -1247,7 +1282,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
size="sm"
|
||||
onClick={() => setEditCreatingSubClient(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />New
|
||||
<Plus className="size-3.5 mr-1" />{t('projects.new')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1256,10 +1291,10 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialog(null)}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleEditSave} disabled={updateMutation.isPending || createClientMutation.isPending}>
|
||||
{updateMutation.isPending || createClientMutation.isPending ? 'Saving\u2026' : 'Save'}
|
||||
{updateMutation.isPending || createClientMutation.isPending ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
114
src/renderer/components/projects/ProjectTabBar.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState, useEffect, useCallback, type RefObject } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes', 'files'] as const;
|
||||
export type SectionId = typeof SECTIONS[number];
|
||||
|
||||
interface ProjectTabBarProps {
|
||||
sectionRefs: Record<SectionId, RefObject<HTMLDivElement | null>>;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
heroRef: RefObject<HTMLDivElement | null>;
|
||||
initialTab?: string;
|
||||
}
|
||||
|
||||
export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: ProjectTabBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(
|
||||
(SECTIONS.includes(initialTab as SectionId) ? initialTab : 'overview') as SectionId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
|
||||
const tabBarH = 41;
|
||||
const visible = new Map<SectionId, IntersectionObserverEntry>();
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
const id = e.target.getAttribute('data-section') as SectionId | null;
|
||||
if (!id) continue;
|
||||
if (e.isIntersecting) visible.set(id, e);
|
||||
else visible.delete(id);
|
||||
}
|
||||
if (visible.size === 0) return;
|
||||
let best: SectionId | null = null;
|
||||
let bestTop = Infinity;
|
||||
for (const [id, e] of visible) {
|
||||
const top = e.boundingClientRect.top;
|
||||
if (top >= 0 && top < bestTop) { bestTop = top; best = id; }
|
||||
}
|
||||
if (!best) {
|
||||
bestTop = -Infinity;
|
||||
for (const [id, e] of visible) {
|
||||
const top = e.boundingClientRect.top;
|
||||
if (top > bestTop) { bestTop = top; best = id; }
|
||||
}
|
||||
}
|
||||
if (best) setActiveSection(best);
|
||||
},
|
||||
{ root, rootMargin: `-${heroH + tabBarH}px 0px -50% 0px`, threshold: 0 },
|
||||
);
|
||||
for (const ref of Object.values(sectionRefs)) {
|
||||
if (ref.current) observer.observe(ref.current);
|
||||
}
|
||||
return () => observer.disconnect();
|
||||
}, [sectionRefs, scrollRef, heroRef]);
|
||||
|
||||
const scrollToSection = useCallback((id: SectionId) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (id === 'overview') {
|
||||
el.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
const ref = sectionRefs[id];
|
||||
if (!ref?.current) return;
|
||||
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
|
||||
const sectionTop = ref.current.getBoundingClientRect().top;
|
||||
const containerTop = el.getBoundingClientRect().top;
|
||||
const top = el.scrollTop + sectionTop - containerTop - heroH - 41;
|
||||
el.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||
}
|
||||
void navigate({
|
||||
search: (prev: Record<string, string | undefined>) => ({ ...prev, tab: id }),
|
||||
replace: true,
|
||||
});
|
||||
}, [sectionRefs, scrollRef, heroRef, navigate]);
|
||||
|
||||
const TAB_LABELS: Record<SectionId, string> = {
|
||||
overview: t('projects.overview'),
|
||||
timeline: t('projects.projectTimeline'),
|
||||
tasks: t('projects.tasks'),
|
||||
notes: t('projects.notes'),
|
||||
files: t('projects.folder.title'),
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="sticky z-20 backdrop-blur-md border-b border-border/40"
|
||||
style={{ top: 'var(--hero-h)' }}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl px-8 flex gap-0">
|
||||
{SECTIONS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => scrollToSection(id)}
|
||||
className={cn(
|
||||
'relative px-4 py-2.5 text-sm font-medium transition-colors',
|
||||
'border-b-2 -mb-px',
|
||||
activeSection === id
|
||||
? 'border-foreground text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{TAB_LABELS[id]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
127
src/renderer/components/projects/folder/FilesSection.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { addMonths, startOfMonth, format } from 'date-fns';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
} from '@/components/ui/empty';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { usePlatform } from '@/lib/platform';
|
||||
import { FolderLinkCard } from './FolderLinkCard';
|
||||
import { FolderFileList } from './FolderFileList';
|
||||
import { FolderUnlinkDialog } from './FolderUnlinkDialog';
|
||||
|
||||
interface FilesSectionProps {
|
||||
projectId: string;
|
||||
folderPath: string | null;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
}
|
||||
|
||||
export function FilesSection({
|
||||
projectId,
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
}: FilesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notify, notifyError } = useNotify();
|
||||
const platform = usePlatform();
|
||||
const [unlinkOpen, setUnlinkOpen] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const chooseFolder = trpc.projectFolders.chooseFolder.useMutation();
|
||||
const link = trpc.projectFolders.link.useMutation({
|
||||
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
const startScan = trpc.projectFolders.startScan.useMutation();
|
||||
|
||||
/** Parse a QUOTA error message from the tRPC FORBIDDEN payload. */
|
||||
function handleScanError(err: { message?: string }): void {
|
||||
const msg = err.message ?? '';
|
||||
if (msg.startsWith('QUOTA:max_files:')) {
|
||||
// Backend message format: "Folder has X files; tier 'free' allows max Y."
|
||||
// Extract tier and max-count to pass to the i18n key.
|
||||
const detail = msg.slice('QUOTA:max_files:'.length);
|
||||
const tierMatch = detail.match(/tier '([^']+)'/);
|
||||
const countMatch = detail.match(/allows max (\d+)/);
|
||||
const tier = tierMatch?.[1] ?? 'your';
|
||||
const count = countMatch ? parseInt(countMatch[1], 10) : 0;
|
||||
notify('error', 'projects.folder.errors.tooBig', { values: { tier, count } });
|
||||
return;
|
||||
}
|
||||
if (msg.startsWith('QUOTA:monthly_tokens:')) {
|
||||
// Compute first day of next month as the reset date.
|
||||
const resetDate = format(startOfMonth(addMonths(new Date(), 1)), 'PP');
|
||||
notify('error', 'projects.folder.errors.monthlyExhausted', { values: { date: resetDate } });
|
||||
return;
|
||||
}
|
||||
notifyError('errors.error', err);
|
||||
}
|
||||
|
||||
const handleChoose = async () => {
|
||||
const chosen = await chooseFolder.mutateAsync();
|
||||
if (chosen) {
|
||||
await link.mutateAsync({ projectId, folderPath: chosen });
|
||||
// Kick first scan (fire-and-forget — progress shown via getStatus polling).
|
||||
// Quota errors are caught here so we can show localised toasts.
|
||||
startScan.mutate({ projectId }, { onError: handleScanError });
|
||||
}
|
||||
};
|
||||
|
||||
if (!platform.isElectron) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground p-6 text-center">
|
||||
{t('projects.folder.webOnlyTooltip')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!folderPath) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia>
|
||||
<Sparkles />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t('projects.folder.empty.title')}</EmptyTitle>
|
||||
<EmptyDescription>{t('projects.folder.empty.description')}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button
|
||||
onClick={handleChoose}
|
||||
disabled={chooseFolder.isPending || link.isPending}
|
||||
>
|
||||
{t('projects.folder.empty.cta')}
|
||||
</Button>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FolderLinkCard
|
||||
projectId={projectId}
|
||||
folderPath={folderPath}
|
||||
totalFiles={totalFiles}
|
||||
lastScannedAt={lastScannedAt}
|
||||
scanStatus={scanStatus}
|
||||
onUnlinkRequested={() => setUnlinkOpen(true)}
|
||||
/>
|
||||
<FolderFileList projectId={projectId} />
|
||||
<FolderUnlinkDialog
|
||||
projectId={projectId}
|
||||
open={unlinkOpen}
|
||||
onOpenChange={setUnlinkOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/renderer/components/projects/folder/FolderChip.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface FolderChipProps {
|
||||
projectId: string;
|
||||
folderPath: string | null;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
scanProgress?: { processed: number; total: number } | null;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function FolderChip({
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
scanProgress,
|
||||
onClick,
|
||||
}: FolderChipProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!folderPath) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border border-dashed border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('projects.folder.linkCta')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (scanStatus === 'scanning' && scanProgress) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-100"
|
||||
>
|
||||
<Folder className="h-3 w-3 animate-pulse" />
|
||||
{t('projects.folder.scanning', {
|
||||
processed: scanProgress.processed,
|
||||
total: scanProgress.total,
|
||||
})}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (scanStatus === 'error') {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-200"
|
||||
>
|
||||
<Folder className="h-3 w-3" />
|
||||
{t('projects.folder.scanFailed')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const relative = lastScannedAt
|
||||
? formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true })
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium',
|
||||
'bg-[#fbc881]/20 hover:bg-[#fbc881]/30 transition-colors',
|
||||
)}
|
||||
>
|
||||
<Folder className="h-3 w-3" />
|
||||
<span>{t('projects.folder.filesCount', { count: totalFiles })}</span>
|
||||
<span className="opacity-60">·</span>
|
||||
<span className="opacity-70">{relative}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
70
src/renderer/components/projects/folder/FolderFileList.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FolderFileListProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
type Filter = 'all' | 'text' | 'image' | 'pdf' | 'docx';
|
||||
|
||||
const FILTERS: Filter[] = ['all', 'text', 'image', 'pdf', 'docx'];
|
||||
|
||||
export function FolderFileList({ projectId }: FolderFileListProps) {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const { data, isLoading } = trpc.projectFolders.listFiles.useQuery({ projectId });
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (filter === 'all') return data;
|
||||
return data.filter((f) => f.kind === filter);
|
||||
}, [data, filter]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-12" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-3 text-xs">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-full border border-border',
|
||||
filter === f
|
||||
? 'bg-foreground text-background'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 border border-border bg-background/50',
|
||||
f.kind === 'skipped' && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="font-mono text-xs">{f.relativePath}</div>
|
||||
{f.summary && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{f.summary}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
src/renderer/components/projects/folder/FolderLinkCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface FolderLinkCardProps {
|
||||
projectId: string;
|
||||
folderPath: string;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
onUnlinkRequested: () => void;
|
||||
}
|
||||
|
||||
export function FolderLinkCard({
|
||||
projectId,
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
onUnlinkRequested,
|
||||
}: FolderLinkCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notifyError } = useNotify();
|
||||
const utils = trpc.useUtils();
|
||||
const startScan = trpc.projectFolders.startScan.useMutation({
|
||||
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Folder className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-xs text-muted-foreground truncate">{folderPath}</div>
|
||||
<div className="text-xs text-muted-foreground/70 mt-0.5">
|
||||
{t('projects.folder.filesCount', { count: totalFiles })}
|
||||
{lastScannedAt && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('projects.folder.lastScanned', {
|
||||
relative: formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true }),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={scanStatus === 'scanning' || startScan.isPending}
|
||||
onClick={() => startScan.mutate({ projectId })}
|
||||
>
|
||||
{t('projects.folder.rescan')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onUnlinkRequested}>
|
||||
{t('projects.folder.unlink')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
|
||||
interface FolderUnlinkDialogProps {
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function FolderUnlinkDialog({
|
||||
projectId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: FolderUnlinkDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notifyError } = useNotify();
|
||||
const utils = trpc.useUtils();
|
||||
const unlink = trpc.projectFolders.unlink.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.projects.get.invalidate({ id: projectId });
|
||||
utils.projectFolders.listFiles.invalidate({ projectId });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('projects.folder.unlink')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('projects.deleteProjectDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => unlink.mutate({ projectId })}
|
||||
disabled={unlink.isPending}
|
||||
>
|
||||
{unlink.isPending ? t('common.deleting') : t('projects.folder.unlink')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
239
src/renderer/components/settings/AccountSection.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LogOut, Unlink, AlertTriangle } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { usePlatform } from '@/lib/platform';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { SettingsCard } from './SettingsCard';
|
||||
|
||||
export function AccountSection() {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const authStatusQuery = trpc.auth.status.useQuery(undefined, { staleTime: 5 * 60 * 1000 });
|
||||
const deviceIdQuery = trpc.settings.deviceId.useQuery(undefined, { enabled: platform.isElectron });
|
||||
const oauthAccountsQuery = trpc.auth.listOAuthAccounts.useQuery();
|
||||
const logoutMutation = trpc.auth.logout.useMutation();
|
||||
const changePasswordMutation = trpc.auth.changePassword.useMutation();
|
||||
const unlinkOAuthMutation = trpc.auth.unlinkOAuthAccount.useMutation();
|
||||
const deleteAccountMutation = trpc.auth.deleteAccount.useMutation();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { notify, notifyError } = useNotify();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const profile = authStatusQuery.data?.profile;
|
||||
const hasPassword = profile?.hasPassword ?? true;
|
||||
|
||||
function handleLogout() {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
notify('info', 'toast.auth.loggedOut');
|
||||
void utils.auth.status.invalidate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleChangePassword() {
|
||||
if (newPassword !== confirmPassword) {
|
||||
notify('error', 'settings.passwordMismatch');
|
||||
return;
|
||||
}
|
||||
changePasswordMutation.mutate(
|
||||
{ currentPassword, newPassword },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notify('success', 'settings.passwordChanged');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
},
|
||||
onError: (err) => notifyError('settings.passwordChangeError', err),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleUnlinkOAuth(provider: string) {
|
||||
unlinkOAuthMutation.mutate({ provider }, {
|
||||
onSuccess: () => {
|
||||
notify('success', 'settings.oauthUnlinked');
|
||||
void oauthAccountsQuery.refetch();
|
||||
},
|
||||
onError: (err) => notifyError('settings.oauthUnlinkError', err),
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteAccount() {
|
||||
deleteAccountMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
notify('info', 'settings.accountDeleted');
|
||||
void utils.auth.status.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('settings.accountDeleteError', err),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sign out */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{profile?.email}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{profile?.tier} {t('settings.plan')}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
disabled={logoutMutation.isPending}
|
||||
>
|
||||
<LogOut data-icon="inline-start" />
|
||||
{t('settings.signOut')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Password change — only for email/password users */}
|
||||
{hasPassword && (
|
||||
<SettingsCard
|
||||
title={t('settings.changePassword')}
|
||||
description={t('settings.changePasswordDescription')}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="currentPassword" className="text-xs">{t('settings.currentPassword')}</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="newPassword" className="text-xs">{t('settings.newPassword')}</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="confirmPassword" className="text-xs">{t('settings.confirmPassword')}</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={handleChangePassword}
|
||||
disabled={changePasswordMutation.isPending || !currentPassword || !newPassword || newPassword.length < 8}
|
||||
>
|
||||
{t('settings.updatePassword')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Connected accounts */}
|
||||
<SettingsCard
|
||||
title={t('settings.connectedAccounts')}
|
||||
description={t('settings.connectedAccountsDescription')}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{oauthAccountsQuery.data?.map((account) => (
|
||||
<div key={account.provider} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="capitalize">{account.provider}</Badge>
|
||||
{account.providerEmail && (
|
||||
<span className="text-sm text-muted-foreground">{account.providerEmail}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleUnlinkOAuth(account.provider)}
|
||||
disabled={unlinkOAuthMutation.isPending}
|
||||
>
|
||||
<Unlink data-icon="inline-start" />
|
||||
{t('settings.disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{(!oauthAccountsQuery.data || oauthAccountsQuery.data.length === 0) && (
|
||||
<p className="text-sm text-muted-foreground">{t('settings.noConnectedAccounts')}</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Device ID — Electron only */}
|
||||
{platform.isElectron && (
|
||||
<SettingsCard title={t('settings.deviceId')} description={t('settings.deviceIdDescription')}>
|
||||
<p className="font-mono text-xs text-muted-foreground select-all">
|
||||
{deviceIdQuery.data ?? '—'}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger zone — delete account */}
|
||||
<SettingsCard title={t('settings.dangerZone')}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive">{t('settings.deleteAccount')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('settings.deleteAccountDescription')}</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="text-destructive border-destructive/30 hover:bg-destructive/10">
|
||||
<AlertTriangle data-icon="inline-start" />
|
||||
{t('settings.deleteAccount')}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('settings.deleteAccountConfirmTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('settings.deleteAccountConfirmDescription')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteAccount}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t('settings.deleteAccount')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
src/renderer/components/settings/AgentRow.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
import { Play, Trash2, ChevronDown, ChevronUp, History } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { CloudAgentConfig } from '../../../../shared/api-types';
|
||||
import type { LocalAgentConfig } from './types';
|
||||
import { SCHEDULE_OPTIONS, formatTs } from './types';
|
||||
import { LocalAgentConfigPanel } from './LocalAgentConfigPanel';
|
||||
import { CloudAgentConfigPanel } from './CloudAgentConfigPanel';
|
||||
import { AgentRunHistorySheet } from './AgentRunHistorySheet';
|
||||
|
||||
export function AgentRow({
|
||||
agent,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
onRunNow,
|
||||
onOpenJourney,
|
||||
}: {
|
||||
agent: (LocalAgentConfig | CloudAgentConfig) & { agentType: 'local' | 'cloud' };
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onDelete: () => void;
|
||||
onRunNow: () => void;
|
||||
onOpenJourney: () => void;
|
||||
}) {
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const scheduleLabel = SCHEDULE_OPTIONS.find(s => s.value === agent.scheduleCron)?.label ?? agent.scheduleCron;
|
||||
const lastRunLabel = agent.lastRunAt ? formatTs(agent.lastRunAt) : 'Never';
|
||||
const kindLabel = agent.agentType === 'local' ? 'Local' : `Cloud · ${(agent as CloudAgentConfig).provider}`;
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl py-0 gap-0 overflow-hidden h-fit border-border/70 shadow-none">
|
||||
{/* Header row */}
|
||||
<div className="px-4 py-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{agent.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{kindLabel}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={agent.enabled}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-xs">
|
||||
<span className="text-muted-foreground">Schedule</span>
|
||||
<span className="text-foreground truncate">{scheduleLabel}</span>
|
||||
<span className="text-muted-foreground">Last run</span>
|
||||
<span className="text-foreground truncate">{lastRunLabel}</span>
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<span className="text-foreground">{agent.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={onRunNow} className="h-8">
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Run now
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setHistoryOpen(true)} className="h-8">
|
||||
<History className="size-3.5 mr-1.5" />
|
||||
History
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={onDelete} title="Delete agent" className="h-8 w-8 p-0">
|
||||
<Trash2 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onToggleExpand} title={expanded ? 'Collapse' : 'Configure'} className="h-8 w-8 p-0">
|
||||
{expanded ? <ChevronUp className="size-3.5" /> : <ChevronDown className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded config */}
|
||||
{expanded && (
|
||||
<div className="border-t px-4 py-4 bg-muted/20">
|
||||
{agent.agentType === 'local' ? (
|
||||
<LocalAgentConfigPanel agent={agent as LocalAgentConfig & { agentType: 'local' }} onOpenJourney={onOpenJourney} />
|
||||
) : (
|
||||
<CloudAgentConfigPanel agent={agent as CloudAgentConfig & { agentType: 'cloud' }} onOpenJourney={onOpenJourney} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentRunHistorySheet
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
225
src/renderer/components/settings/AgentRunHistorySheet.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CheckCircle2, XCircle, AlertCircle, Loader2, Clock,
|
||||
ChevronDown, ChevronRight, Plus, Pencil, Trash2, History,
|
||||
} from 'lucide-react';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '../ui/empty';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useFormatPrefs, formatTs, formatDuration } from '@/lib/date';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types inferred from router return
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RunSummary = {
|
||||
id: string;
|
||||
agentId: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'partial';
|
||||
startedAt: number;
|
||||
completedAt: number | null | undefined;
|
||||
actionCounts: { created: number; updated: number; deleted: number };
|
||||
};
|
||||
|
||||
type RunAction = {
|
||||
id: string;
|
||||
runId: string;
|
||||
verb: string;
|
||||
entityType: string;
|
||||
entityId: string | null | undefined;
|
||||
entityTitle: string | null | undefined;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 shrink-0 text-[10px] py-0">
|
||||
<CheckCircle2 className="size-2.5" /> Done
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1 shrink-0 text-[10px] py-0">
|
||||
<XCircle className="size-2.5" /> Failed
|
||||
</Badge>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 shrink-0 text-[10px] py-0">
|
||||
<Loader2 className="size-2.5 animate-spin" /> Running
|
||||
</Badge>
|
||||
);
|
||||
case 'partial':
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-amber-600 shrink-0 text-[10px] py-0">
|
||||
<AlertCircle className="size-2.5" /> Partial
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge variant="outline" className="shrink-0 text-[10px] py-0">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function actionSummary(counts: RunSummary['actionCounts']): string {
|
||||
const parts: string[] = [];
|
||||
if (counts.created > 0) parts.push(`${counts.created} created`);
|
||||
if (counts.updated > 0) parts.push(`${counts.updated} updated`);
|
||||
if (counts.deleted > 0) parts.push(`${counts.deleted} deleted`);
|
||||
return parts.join(' · ') || 'No changes';
|
||||
}
|
||||
|
||||
const VERB_ICON: Record<string, React.ReactNode> = {
|
||||
created: <Plus className="size-3 text-emerald-500 shrink-0" />,
|
||||
updated: <Pencil className="size-3 text-blue-500 shrink-0" />,
|
||||
deleted: <Trash2 className="size-3 text-destructive shrink-0" />,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expanded run actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RunActionList({ runId }: { runId: string }) {
|
||||
const query = trpc.agent.runActions.useQuery({ runId });
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<div className="px-3 pb-3 flex flex-col gap-1.5">
|
||||
{[0, 1, 2].map(i => <Skeleton key={i} className="h-5 w-full rounded" />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actions = (query.data ?? []) as RunAction[];
|
||||
if (actions.length === 0) {
|
||||
return <p className="px-3 pb-3 text-xs text-muted-foreground">No actions recorded.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3 flex flex-col gap-1">
|
||||
{actions.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-2 text-xs">
|
||||
{VERB_ICON[a.verb] ?? <span className="size-3 shrink-0" />}
|
||||
<span className="text-muted-foreground capitalize">{a.verb}</span>
|
||||
<span className="text-foreground/70 capitalize">{a.entityType}</span>
|
||||
{a.entityTitle && (
|
||||
<span className="text-foreground truncate max-w-[200px]">{a.entityTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single run row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RunRow({ run }: { run: RunSummary }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const prefs = useFormatPrefs();
|
||||
const duration = formatDuration(run.startedAt, run.completedAt);
|
||||
const hasActions = run.actionCounts.created + run.actionCounts.updated + run.actionCounts.deleted > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 overflow-hidden">
|
||||
<button
|
||||
className="w-full text-left px-3 py-2.5 flex items-start gap-2"
|
||||
onClick={() => hasActions && setExpanded(v => !v)}
|
||||
disabled={!hasActions}
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{statusBadge(run.status)}
|
||||
<span className="text-xs text-muted-foreground">{formatTs(run.startedAt, prefs)}</span>
|
||||
{duration && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="size-3" />{duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{actionSummary(run.actionCounts)}</p>
|
||||
</div>
|
||||
{hasActions && (
|
||||
<span className="mt-0.5 shrink-0">
|
||||
{expanded
|
||||
? <ChevronDown className="size-3.5 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3.5 text-muted-foreground" />}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && <RunActionList runId={run.id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sheet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AgentRunHistorySheet({
|
||||
agentId,
|
||||
agentName,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const runsQuery = trpc.agent.runs.useQuery(
|
||||
{ agentId, limit: 30 },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const runs = (runsQuery.data ?? []) as RunSummary[];
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-5 pt-5 pb-4">
|
||||
<SheetTitle className="text-base font-semibold">{agentName}</SheetTitle>
|
||||
<p className="text-xs text-muted-foreground -mt-1">Run history</p>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-5 py-4 flex flex-col gap-2">
|
||||
{runsQuery.isPending && (
|
||||
<>
|
||||
{[0, 1, 2, 4].map(i => <Skeleton key={i} className="h-16 w-full rounded-lg" />)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!runsQuery.isPending && runs.length === 0 && (
|
||||
<Empty className="border-none py-12">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<History />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className="text-sm">No runs yet</EmptyTitle>
|
||||
<EmptyDescription className="text-xs">
|
||||
Runs will appear here after the agent executes.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
|
||||
{!runsQuery.isPending && runs.map(run => (
|
||||
<RunRow key={run.id} run={run} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
165
src/renderer/components/settings/AgentsSection.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import { Bot, Plus } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { CloudAgentConfig } from '../../../../shared/api-types';
|
||||
import type { LocalAgentConfig } from './types';
|
||||
import { AgentRow } from './AgentRow';
|
||||
import { InlineAgentCreationStepper } from './InlineAgentCreationStepper';
|
||||
import { JourneyDialog } from './JourneyDialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function AgentsSection() {
|
||||
const { t } = useTranslation();
|
||||
const utils = trpc.useUtils();
|
||||
const localAgentsQuery = trpc.agent.local.list.useQuery();
|
||||
const cloudAgentsQuery = trpc.agent.cloud.list.useQuery();
|
||||
const deleteLocalMutation = trpc.agent.local.delete.useMutation();
|
||||
const deleteCloudMutation = trpc.agent.cloud.delete.useMutation();
|
||||
const updateLocalMutation = trpc.agent.local.update.useMutation();
|
||||
const updateCloudMutation = trpc.agent.cloud.update.useMutation();
|
||||
const runNowMutation = trpc.agent.runNow.useMutation();
|
||||
|
||||
const { notify, notifyError, notifyPromise } = useNotify();
|
||||
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
||||
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
|
||||
const [journeyAgent, setJourneyAgent] = useState<{ id: string; type: 'local' | 'cloud'; name: string; currentConfig: Record<string, unknown> | null; dataTypes: string[]; directory?: string } | null>(null);
|
||||
|
||||
const catalogQuery = trpc.agent.catalog.useQuery(undefined, {
|
||||
enabled: showTemplatePicker,
|
||||
});
|
||||
|
||||
const localAgents: LocalAgentConfig[] = localAgentsQuery.data ?? [];
|
||||
const cloudAgents: CloudAgentConfig[] = cloudAgentsQuery.data ?? [];
|
||||
const allAgents = [
|
||||
...localAgents.map(a => ({ ...a, agentType: 'local' as const })),
|
||||
...cloudAgents.map(a => ({ ...a, agentType: 'cloud' as const })),
|
||||
];
|
||||
const hasAgents = allAgents.length > 0;
|
||||
|
||||
function handleDelete(id: string, type: 'local' | 'cloud') {
|
||||
const mutation = type === 'local' ? deleteLocalMutation : deleteCloudMutation;
|
||||
mutation.mutate({ id }, {
|
||||
onSuccess: () => {
|
||||
notify('warning', 'toast.agent.deleted');
|
||||
void utils.agent.local.list.invalidate();
|
||||
void utils.agent.cloud.list.invalidate();
|
||||
},
|
||||
onError: (err) => notifyError('toast.agent.deleteError', err),
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggleEnabled(id: string, type: 'local' | 'cloud', enabled: boolean) {
|
||||
if (type === 'local') {
|
||||
updateLocalMutation.mutate({ id, enabled }, {
|
||||
onSuccess: () => void utils.agent.local.list.invalidate(),
|
||||
onError: (err) => notifyError('toast.agent.updateError', err),
|
||||
});
|
||||
} else {
|
||||
updateCloudMutation.mutate({ id, enabled }, {
|
||||
onSuccess: () => void utils.agent.cloud.list.invalidate(),
|
||||
onError: (err) => notifyError('toast.agent.updateError', err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleRunNow(id: string) {
|
||||
const promise = runNowMutation.mutateAsync({ id });
|
||||
notifyPromise(promise, { loading: 'toast.agent.runStarted', success: 'toast.agent.runStarted', error: 'toast.agent.runError' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Empty first-run state */}
|
||||
{!hasAgents && !showTemplatePicker && (
|
||||
<div className="py-4 text-center">
|
||||
<div className="size-11 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
||||
<Bot className="size-5 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold">{t('agents.noAgentsYet')}</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto mt-1.5">
|
||||
{t('agents.noAgentsDescription')}
|
||||
</p>
|
||||
<Button size="sm" className="mt-5" onClick={() => setShowTemplatePicker(true)}>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
{t('agents.createFirstAgent')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing configured agents */}
|
||||
{hasAgents && !showTemplatePicker && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{t('agents.yourAgents')}</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowTemplatePicker(prev => !prev)}>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
{t('agents.createAgent')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{allAgents.map((agent) => (
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
expanded={expandedAgent === agent.id}
|
||||
onToggleExpand={() => setExpandedAgent(prev => prev === agent.id ? null : agent.id)}
|
||||
onToggleEnabled={(enabled) => handleToggleEnabled(agent.id, agent.agentType, enabled)}
|
||||
onDelete={() => handleDelete(agent.id, agent.agentType)}
|
||||
onRunNow={() => handleRunNow(agent.id)}
|
||||
onOpenJourney={() => setJourneyAgent({
|
||||
id: agent.id,
|
||||
type: agent.agentType,
|
||||
name: agent.name,
|
||||
currentConfig: agent.agentType === 'local' ? (agent as LocalAgentConfig).agentConfig ?? null : null,
|
||||
dataTypes: agent.dataTypes,
|
||||
directory: agent.agentType === 'local' ? (agent as LocalAgentConfig).directory : undefined,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backend templates picker */}
|
||||
{showTemplatePicker && (
|
||||
<InlineAgentCreationStepper
|
||||
catalog={catalogQuery.data ?? []}
|
||||
isLoadingCatalog={catalogQuery.isPending}
|
||||
onCancel={() => setShowTemplatePicker(false)}
|
||||
onCreated={() => {
|
||||
setShowTemplatePicker(false);
|
||||
void utils.agent.local.list.invalidate();
|
||||
void utils.agent.cloud.list.invalidate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chatbot Journey dialog */}
|
||||
{journeyAgent && (
|
||||
<JourneyDialog
|
||||
agentType={journeyAgent.type}
|
||||
agentName={journeyAgent.name}
|
||||
currentConfig={journeyAgent.currentConfig}
|
||||
dataTypes={journeyAgent.dataTypes}
|
||||
directory={journeyAgent.directory}
|
||||
onClose={() => setJourneyAgent(null)}
|
||||
onSaved={(agentConfig) => {
|
||||
const local = localAgents.find(a => a.id === journeyAgent.id);
|
||||
if (local) {
|
||||
updateLocalMutation.mutate({ id: journeyAgent.id, agentConfig }, {
|
||||
onSuccess: () => {
|
||||
void utils.agent.local.list.invalidate();
|
||||
setJourneyAgent(null);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setJourneyAgent(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||