Files
adiuva/progress.txt

565 lines
58 KiB
Plaintext

## Codebase Patterns
- `alias(table, 'alias_name')` from `drizzle-orm/sqlite-core` enables self-joins (e.g., clients → parentClients for hierarchy)
- `sql<T>\`CASE WHEN ... THEN ... ELSE ... END\`` for conditional SELECT fields (e.g., clientName vs subClientName based on parentId)
- `or(like(col1, pattern), like(col2, pattern))` for multi-column search; SQLite LIKE on NULL columns safely returns NULL (falsy) so OR is safe
- Vite configs use `.mts` extension (not `.ts`) to avoid ESM/CJS conflict with electron-forge's externalize-deps plugin
- electron-trpc uses `exposeElectronTRPC()` in preload and `createIPCHandler({ router, windows })` in main; renderer uses `ipcLink()` from `electron-trpc/renderer`
- appRouter lives at `src/main/router/index.ts`; renderer client at `src/renderer/lib/trpc.ts`
- `@/*` path alias maps to `src/renderer/*` (configured in tsconfig.json paths)
- Drizzle ORM with better-sqlite3 (sync driver): SELECT queries MUST end with `.all()` to execute; INSERT/UPDATE/DELETE MUST end with `.run()`
- `inArray(column, values)` works with nullable columns when values is `string[]` (TypeScript covariance allows string[] → (string | null)[])
- All DB tables use `CREATE TABLE IF NOT EXISTS` for non-destructive migrations
- All IDs are UUIDs generated via `crypto.randomUUID()`
- TypeScript strict mode + noUncheckedIndexedAccess enabled; always account for possible undefined on array access
- electron-store@8 (CJS) used for app settings; use lazy init pattern `getStore()` like `getDb()` to avoid calling before app ready
- ESLint uses `eslint-import-resolver-typescript` to resolve `@/*` aliases; configured in `.eslintrc.json` under `settings.import/resolver`
- App settings (sidebar state, etc.) exposed via `settings` tRPC sub-router for type-safe renderer access
- `z.string().nullable().optional()` in tRPC inputs enables three-state semantics: undefined = don't change, null = clear, string = set value
- NewTaskDialog component at `src/renderer/components/tasks/NewTaskDialog.tsx` accepts `defaultProjectId` and `defaultStatus` props for reuse in Kanban column "+ Add" buttons
- `date-fns` is available as a transitive dependency of `react-day-picker` (shadcn/ui calendar)
- GanttChart component at `src/renderer/components/timeline/GanttChart.tsx` is reusable: accepts `defaultProjectId` to scope to a project (for US-015 inline timeline)
- AddCheckpointDialog at `src/renderer/components/timeline/AddCheckpointDialog.tsx` accepts `defaultProjectId` — hides project select when provided
- TanStack Router `validateSearch` with Zod schema for passing selected-item IDs via URL search params (e.g., `?projectId=...`)
---
## 2026-02-19 - US-002
- Installed `better-sqlite3`, `drizzle-orm` (runtime) and `@types/better-sqlite3`, `drizzle-kit` (dev)
- Created `src/main/db/schema.ts`: 5 tables (clients, projects, tasks, checkpoints, notes) with exported InferSelectModel/InferInsertModel types
- Created `src/main/db/index.ts`: `initDb()` opens/creates `adiuva.db` at `app.getPath('userData')`, runs CREATE TABLE IF NOT EXISTS (non-destructive), enables WAL mode; `getDb()` singleton accessor
- Updated `src/main/index.ts`: call `initDb()` in `app.on('ready')`
- Updated `vite.main.config.mts`: externalized `better-sqlite3`
- Updated `forge.config.ts`: added `AutoUnpackNativesPlugin`
- Added `drizzle.config.ts` for drizzle-kit CLI
- Typecheck: passes with zero errors
- **Learnings for future iterations:**
- better-sqlite3 is CommonJS with native addon; Vite must NOT bundle it — always add to rollupOptions.external
- The CREATE TABLE IF NOT EXISTS approach satisfies "never destructive" and works perfectly in electron without needing migration file resolution
- electron-forge rebuilds native modules automatically on `electron-forge start`; no manual rebuild step needed
- `app.getPath('userData')` is only available after `app.on('ready')` fires — do not call earlier
---
## 2026-02-19 - US-008
- What was implemented:
- Full `checkpointsRouter` replacing stubs in `src/main/router/index.ts`
- Full `notesRouter` replacing stubs in `src/main/router/index.ts`
- Added `checkpoints` and `notes` to the schema import
- `checkpoints.list`: optional `projectId` filter, ordered by `asc(checkpoints.date)`
- `checkpoints.create`: inserts with UUID, createdAt=Date.now(), defaults isAiSuggested/isApproved to 0
- `checkpoints.update`: partial set for title/date/isApproved
- `checkpoints.delete`: deletes by id, returns `{ success: true }`
- `notes.list`: returns `{ id, projectId, title, createdAt, updatedAt }` only — no content (performance)
- `notes.get`: returns full record or null via `.all()[0] ?? null` pattern
- `notes.create`: inserts with UUID, createdAt=updatedAt=Date.now()
- `notes.update`: partial set, always sets updatedAt=Date.now() regardless of which fields changed
- `notes.delete`: deletes by id, returns `{ success: true }`
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `notes.update` must always set `updatedAt` — build the set object with updatedAt outside the conditional block
- `notes.list` intentionally excludes `content` column for performance; use `notes.get` for full record
- `checkpoints.projectId` is `.notNull()` in schema (unlike tasks.projectId which is nullable) — no null coalescing needed
---
## 2026-02-19 - US-003
- What was implemented:
- Installed: electron-trpc, @trpc/server, @trpc/client, @trpc/react-query, @tanstack/react-query, zod
- Created `src/main/router/index.ts` with full appRouter: stub routers for health, clients, projects, tasks, checkpoints, notes, ai
- Updated `src/preload/index.ts` to call `exposeElectronTRPC()`
- Updated `src/main/index.ts` to call `createIPCHandler({ router: appRouter, windows: [win] })`; `createWindow()` now returns `BrowserWindow`
- Created `src/renderer/lib/trpc.ts` with `createTRPCReact<AppRouter>()`
- Updated `src/renderer/index.tsx` to wrap app in `TRPCProvider` + `QueryClientProvider`
- Updated `src/renderer/routes/index.tsx` to call `trpc.health.ping.useQuery()` and display 'tRPC IPC bridge: pong'
- Files changed: package.json, package-lock.json, prd.json, src/main/index.ts, src/main/router/index.ts (new), src/preload/index.ts, src/renderer/index.tsx, src/renderer/lib/trpc.ts (new), src/renderer/routes/index.tsx
- **Learnings for future iterations:**
- electron-trpc `exposeElectronTRPC` is imported from `electron-trpc/main` (not a separate package)
- `ipcLink` is imported from `electron-trpc/renderer` in the renderer process
- `createTRPCReact<AppRouter>()` requires importing the AppRouter type from the main process router — this is a type-only import so it doesn't bundle main process code into renderer
- The TRPCProvider must wrap QueryClientProvider (or be a sibling); both need the same queryClient instance
- Stub routers return empty arrays or null — they will be replaced in US-005 through US-008
---
## 2026-02-19 - US-004
- What was implemented:
- Installed: electron-store@8 (CJS-compatible, for persistent app settings), @fontsource/geist (self-hosted Geist font), eslint-import-resolver-typescript (ESLint path alias fix)
- Created `src/main/store.ts` with lazy `getStore()` pattern using electron-store
- Added `settings` tRPC sub-router with `getSidebarCollapsed` query and `setSidebarCollapsed` mutation
- Updated `src/renderer/components/layout/AppShell.tsx` to: persist sidebar collapse via tRPC, add right-edge 'keep scrolling for AI' vertical label with ChevronDown icon
- Updated `src/renderer/globals.css`: replaced Google Fonts CDN with @fontsource/geist imports (weights 400/500/600)
- Updated `index.html`: removed Google Fonts CDN links
- Updated `.eslintrc.json`: added eslint-import-resolver-typescript to fix @/* alias resolution (fixed all 7 pre-existing lint errors)
- Files changed: .eslintrc.json, index.html, package.json, package-lock.json, src/main/router/index.ts, src/main/store.ts (new), src/renderer/components/layout/AppShell.tsx, src/renderer/globals.css
- **Learnings for future iterations:**
- Use electron-store@8 (not v9+) — v9+ is ESM-only and breaks with CommonJS main process
- electron-store must NOT be initialized at module import time (before app.ready); use lazy `getStore()` like `getDb()` pattern
- For sidebar/UI state loaded from IPC: use `localState ?? queryData ?? default` pattern to avoid flash while query resolves
- @fontsource packages are the npm equivalent of Google Fonts — import weight-specific CSS files (e.g., `@fontsource/geist/400.css`)
- ESLint `import/no-unresolved` requires `eslint-import-resolver-typescript` with `alwaysTryTypes: true` to resolve TypeScript path aliases
- The `writingMode: 'vertical-rl'` + `transform: 'rotate(180deg)'` CSS pattern creates bottom-to-top text for vertical affordance labels
---
## 2026-02-19 - US-006
- What was implemented:
- Full `projectsRouter` replacing stubs in `src/main/router/index.ts`
- Added `and` to drizzle-orm imports
- `projects.list`: uses `and()` with optional conditions for `clientId` filter and archived filter (defaults to active only)
- `projects.listAll`: returns only `{ id, name }` columns for dropdown use
- `projects.get`: `.all()` then `result[0] ?? null` pattern for nullable single-record lookup
- `projects.create`: inserts with UUID, status='active', createdAt=Date.now()
- `projects.update`: partial set object — only sets defined fields
- `projects.delete`: nulls `tasks.projectId` for all tasks in the project, then deletes the project
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `and(...conditions)` from drizzle-orm accepts `(SQL | undefined)[]` — pass `undefined` for optional conditions and drizzle filters them out automatically
- For nullable single-record queries: use `.all()` and `result[0] ?? null` (strict mode forbids `.get()` direct null return without this pattern)
- `and()` returns `SQL<unknown> | undefined` which `.where()` accepts directly (no extra wrapping needed)
---
## 2026-02-19 - US-005
- What was implemented:
- Full clients tRPC router replacing stubs in `src/main/router/index.ts`
- Added imports: `eq`, `asc`, `inArray` from `drizzle-orm`; `getDb` from `../db`; `clients`, `projects`, `tasks` from `../db/schema`
- `clients.list`: `db.select().from(clients).orderBy(asc(clients.name)).all()`
- `clients.create`: inserts with `crypto.randomUUID()` + `Date.now()` via `.run()`
- `clients.update`: partial update — only sets fields that are defined in input, skips if no-op
- `clients.delete`: checks for child clients and child projects; returns `{ error: string }` payload if any exist; otherwise deletes and returns `{ success: true }`
- `clients.deleteWithCascade`: BFS loop collects all descendant client IDs, finds their projects, nulls `projectId` on orphaned tasks, deletes projects, then deletes all clients
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- Drizzle ORM with better-sqlite3 sync driver: SELECT must call `.all()` to get an array; INSERT/UPDATE/DELETE must call `.run()` to execute — NOT calling these causes TypeScript errors (query builder ≠ result)
- `inArray(nullableColumn, string[])` is TypeScript-safe because `string[]` is assignable to `(string | null)[]` via covariance
- Guard against empty arrays before using `inArray` — while `allClientIds` is never empty (starts with input.id), `projectIds` could be empty; guarded with `if (projectIds.length > 0)` block
- `@typescript-eslint/no-non-null-assertion` is configured as a warning (not error) in this project — `queue.shift()!` is fine after a `length > 0` check
---
## 2026-02-19 - US-007
- What was implemented:
- Full `tasksRouter` replacing stubs in `src/main/router/index.ts`
- Added imports: `or`, `like`, `sql` from `drizzle-orm`; `alias` from `drizzle-orm/sqlite-core`
- `tasks.list`: LEFT JOINs projects → clients → parentClients (alias for self-join); CASE WHEN for clientName/subClientName breadcrumb fields; `and()` with optional conditions for projectId/status/search; `like()` OR search on title+description; CASE expression for priority ordering
- `tasks.create`: inserts with UUID, defaults (status='todo', priority='medium'), createdAt=Date.now()
- `tasks.update`: partial set object — only sets defined fields
- `tasks.delete`: deletes by id, returns `{ success: true }`
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `alias(table, 'alias_name')` is from `drizzle-orm/sqlite-core` (NOT `drizzle-orm`) for SQLite self-joins
- `sql<T>\`CASE WHEN ${col} IS NOT NULL THEN ${alias.col} ELSE ${col} END\`` for conditional field selection using drizzle template literals
- `or(like(col1, pattern), like(col2, pattern))` composes safely — null columns evaluate to NULL (falsy) in WHERE
- For priority ordering: `asc(sql\`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END\`)` puts high priority first
---
## 2026-02-19 - US-009
- What was implemented:
- Verified existing `ProjectSidebar` component at `src/renderer/components/projects/ProjectSidebar.tsx` satisfies all US-009 acceptance criteria
- New Project button at top using shadcn/ui Button + `clients.create` mutation with auto-rename on success
- Kebab context menu (DropdownMenu) with Rename, New Sub-Project, Delete actions
- Inline rename: Input replaces label, Enter saves via `clients.update`, Escape cancels, blur saves
- Delete: AlertDialog with two stages — initial confirm, then cascade-warn if children exist (uses `clients.delete` first, falls back to `clients.deleteWithCascade`)
- Hierarchical tree via `buildTree()` function (parent-child via `clients.parentId`)
- Empty state with EmptyMedia + call-to-action button
- All mutations invalidate `clients.list` query for immediate tree refresh
- Typecheck passes (zero errors), lint passes (1 non-null assertion warning, guarded)
- Files changed: `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`
- **Learnings for future iterations:**
- The ProjectSidebar was built as part of US-004 app shell work but the US-009 story wasn't marked as passing — always check existing code before implementing
- `useCallback` ref pattern (`ref={callbackRef}`) is used for auto-focus + select on mount without useEffect
- The two-stage delete flow (try simple delete first → if error, show cascade option) maps well to the backend's `clients.delete` (guards) + `clients.deleteWithCascade` (force) pattern
---
## 2026-02-19 - US-010
- What was implemented:
- Rewrote `ProjectSidebar` from a client-hierarchy tree to a project-centric sidebar grouped by client
- Projects grouped by `clientId` using Collapsible headers; projects without a client appear under "Internal / No Client"
- Search input filters projects by name in real-time (auto-expands all groups when searching)
- Show/hide archived projects via Switch toggle (queries `projects.list` with `includeArchived`)
- Context menu per project (DropdownMenu): Edit Client (Dialog + Select to assign/change/remove client), Archive/Unarchive, Delete (AlertDialog)
- Clicking a project sets `projectId` in search params → renders ProjectDetail placeholder in right pane
- Active project highlighted with `bg-sidebar-accent`
- Updated `projects.update` tRPC procedure to accept `clientId: z.string().nullable().optional()` (allows unlinking from client)
- Created placeholder `ProjectDetail` component (full implementation deferred to US-013)
- Installed shadcn/ui: dialog, select, switch
- Files changed: `src/renderer/components/projects/ProjectSidebar.tsx`, `src/renderer/routes/projects.tsx`, `src/renderer/components/projects/ProjectDetail.tsx` (new), `src/main/router/index.ts`, `src/renderer/components/ui/dialog.tsx` (new), `src/renderer/components/ui/select.tsx` (new), `src/renderer/components/ui/switch.tsx` (new), `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`
- **Learnings for future iterations:**
- TanStack Router `validateSearch` with Zod schema is the cleanest way to pass selected-item IDs via URL search params without creating nested routes
- `Route.useNavigate()` returns a typed navigate fn; use `void navigate({ search: { ... } })` to avoid unhandled promise warnings
- For project grouping, query both `projects.list` and `clients.list` separately then join in-memory via a Map — avoids complex SQL joins for display-only data
- `projects.update` with `clientId: z.string().nullable().optional()` allows three states: undefined (don't change), null (unlink), string (assign)
- Auto-expanding all groups during search (`effectiveExpanded` computed from grouped keys) gives a better UX than forcing users to manually expand
---
## 2026-02-20 - US-011
- What was implemented:
- Full Global Tasks view UI at `/tasks` route
- 4 stat cards (Total Tasks, To Do, In Progress, Completed) using shadcn/ui Card components with Lucide icons, reactively updated from unfiltered `tasks.list` query
- Search bar with 300ms debounce using shadcn/ui Input + Search icon
- Status filter tabs using shadcn/ui Tabs (All | To Do | In Progress | Completed)
- "Order by" dropdown using shadcn/ui DropdownMenu (Due Date | Priority | Created Date)
- Task rows with: shadcn/ui Checkbox (toggles todo↔done), title (bold 14px), description (muted truncated), priority Badge (HIGH=destructive, MEDIUM=secondary, LOW=outline green), due date chip (calendar icon + formatted date), breadcrumb (Client > Sub-Client > Project with ChevronRight separators), assignee (User icon + name)
- Completed task rows have green-tinted background (`bg-green-50 border-green-200`)
- NewTaskDialog component with: Input for title (required), Textarea for description, Select for priority/status, Popover+Calendar for due date, Select for project (from `projects.listAll`), Input for assignee
- Installed shadcn/ui components: card, tabs, checkbox, badge, textarea, popover, calendar
- Files changed: `src/renderer/routes/tasks.tsx`, `src/renderer/components/tasks/NewTaskDialog.tsx` (new), `src/renderer/components/ui/card.tsx` (new), `src/renderer/components/ui/tabs.tsx` (new), `src/renderer/components/ui/checkbox.tsx` (new), `src/renderer/components/ui/badge.tsx` (new), `src/renderer/components/ui/textarea.tsx` (new), `src/renderer/components/ui/popover.tsx` (new), `src/renderer/components/ui/calendar.tsx` (new), `scripts/ralph/prd.json`, `scripts/ralph/progress.txt`
- **Learnings for future iterations:**
- Use two separate `tasks.list` queries: one unfiltered `{}` for stat card counts, one with filters for the displayed list — ensures stats always reflect total counts
- `date-fns` `format(date, 'PPP')` produces "February 20th, 2026" style dates — already installed as a dependency of react-day-picker (shadcn/ui calendar)
- shadcn/ui Select with an empty string value (`<SelectItem value="">No project</SelectItem>`) works as a "none" option for optional fields
- The Popover+Calendar date picker pattern is standard shadcn/ui: Popover wraps a Button trigger showing the formatted date, PopoverContent contains the Calendar
- Electron app runs at `http://localhost:5173` in dev mode but only within the Electron BrowserWindow — Playwright browser testing requires the Electron-specific test harness, not direct URL navigation
---
## 2026-02-20 - US-012
- What was implemented:
- Reusable `GanttChart` SVG component at `src/renderer/components/timeline/GanttChart.tsx`
- Accepts `{ checkpoints: GanttCheckpoint[], startDate: Date, endDate: Date, onDelete? }` props
- Custom SVG rendering: month labels on X axis, horizontal baseline `<line>`, `<circle>` dots for checkpoints positioned by date
- Dot fill logic: dark (#171717) for future approved checkpoints, green (#16a34a) for past approved, dashed outline (#737373) for pending AI suggestions (isApproved=0)
- Vertical red "Today" marker line at current date
- ResizeObserver for responsive SVG width
- foreignObject + shadcn/ui Popover on each dot click: shows title, formatted date, project name, and destructive Delete button
- `AddCheckpointDialog` component at `src/renderer/components/timeline/AddCheckpointDialog.tsx`: title Input (required), Popover+Calendar date (required), Select project dropdown (required in global view, hidden when `defaultProjectId` provided)
- Global Timeline route (`/timeline`) renders GanttChart with all checkpoints, project name lookup via Map from `projects.listAll`
- Legend showing dot types, empty state message when no checkpoints
- Files changed: `src/renderer/components/timeline/GanttChart.tsx` (new), `src/renderer/components/timeline/AddCheckpointDialog.tsx` (new), `src/renderer/routes/timeline.tsx`
- **Learnings for future iterations:**
- `foreignObject` inside SVG is the cleanest way to embed React components (like Popover) on SVG elements — set `overflow-visible` class to prevent clipping
- Checkpoints don't have a `status` field; use `isApproved=1` + `date < now` heuristic for "completed" vs "todo" dot color
- Date range for the Gantt is computed dynamically: 1 month before earliest date, 2 months after latest date — ensures comfortable visual padding
- GanttChart is designed for reuse: the `defaultProjectId` prop on AddCheckpointDialog pre-selects the project and hides the dropdown (for per-project timeline in US-015)
- `trpc.projects.listAll.useQuery(undefined, { enabled: showProjectSelect })` prevents unnecessary queries when project is already known
---
## 2026-02-21 - US-014
- What was implemented:
- Installed `@hello-pangea/dnd` for drag-and-drop support
- Created `KanbanBoard` component at `src/renderer/components/projects/KanbanBoard.tsx`
- `DragDropContext` wraps 3 `Droppable` columns: To Do (`todo`), In Progress (`in_progress`), Completed (`done`)
- Each task is a `Draggable` wrapping the shared `TaskRow` component (same UI as global Tasks view)
- Drag-and-drop between columns calls `tasks.update({ id, status })` via tRPC mutation
- Each column header shows: status label, `Badge` (variant=secondary) with task count, `Button` (variant=ghost, size=sm) with "+ Add"
- "+ Add" opens `NewTaskDialog` with `defaultProjectId` and `defaultStatus` pre-set to the column's status
- `EditTaskDialog` integrated for right-click context menu editing
- `tasks.delete` integrated for right-click context menu deletion
- Added "Tasks" section with `KanbanBoard` to `ProjectDetail.tsx` below the AI summary card
- Tasks with unknown status values fall back to the "To Do" column
- Drop zones highlight with `bg-muted/50` when dragging over
- Files changed: `src/renderer/components/projects/KanbanBoard.tsx` (new), `src/renderer/components/projects/ProjectDetail.tsx`, `prd.json`, `progress.txt`, `package.json`, `package-lock.json`
- **Learnings for future iterations:**
- `@hello-pangea/dnd` ships its own TypeScript declarations — no `@types/` package needed
- `TaskRow` component from the global Tasks view is fully reusable inside Kanban `Draggable` wrappers — its `ContextMenu` (Edit/Delete) still works correctly inside drag-and-drop contexts
- `NewTaskDialog` accepts `defaultStatus` prop which resets correctly on close via `resetAndClose()` — ideal for column-specific "+ Add" buttons
- When grouping tasks by status for Kanban columns, always handle unknown/null status values with a fallback to prevent tasks from disappearing
- `DragDropContext.onDragEnd` provides `draggableId` which maps directly to `task.id` — no need to look up the task object for status updates
---
## 2026-02-22 - US-015
- What was implemented:
- Added Project Timeline section to `ProjectDetail.tsx` between AI Summary and Tasks Kanban
- Reused `GanttChart` component (from US-012) scoped to current project's checkpoints
- "+ Add" Button (variant=outline, size=sm) opens `AddCheckpointDialog` with `defaultProjectId={projectId}` (hides project selector)
- Wired `checkpoints.delete` mutation with `onDelete` prop for checkpoint dot deletion
- Computed `ganttStart`/`ganttEnd` dynamically from checkpoint dates with 1-month padding (fallback ±2 months if empty)
- Added Notes section below Tasks Kanban using `Item` component (variant=muted) in a flex-wrap grid layout matching Figma design
- Each note card shows `SquareDashed` icon + title + formatted createdAt date, clickable to navigate to `/notes/$noteId`
- "+ Add" Button calls `notes.create({ title: 'Untitled Note', content: '', projectId })` then navigates to the new note
- Created route stub at `src/renderer/routes/notes.$noteId.tsx` with back button + note title placeholder (full editor deferred to US-016)
- Files changed: `src/renderer/components/projects/ProjectDetail.tsx`, `src/renderer/routes/notes.$noteId.tsx` (new), `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- GanttChart + AddCheckpointDialog are designed for reuse: `defaultProjectId` prop scopes the dialog to a project and hides the project select dropdown
- Figma notes section uses a card grid layout (flex-wrap with Item cards), not a flat list with Separators — always cross-reference Figma when acceptance criteria text diverges
- `trpc.useUtils()` provides `invalidate()` for cache busting after mutations — use at the component level, not inside mutation callbacks
- `notes.create` returns `{ id }` which can be used directly for navigation in the `onSuccess` callback
- TanStack Router file-based routing: `notes.$noteId.tsx` generates `/notes/:noteId` route automatically — `Route.useParams()` provides typed `{ noteId }`
---
## 2026-02-22 - US-016
- What was implemented:
- Installed `@milkdown/kit`, `@milkdown/react`, `@milkdown/theme-nord` (following official Milkdown installation guide)
- Created `MilkdownEditor` wrapper component at `src/renderer/components/notes/MilkdownEditor.tsx`
- Uses official React recipe: `MilkdownProvider` + `useEditor` hook with `Editor.make()` configuring `commonmark`, `listener`, `history` plugins
- `listenerCtx.markdownUpdated()` fires onChange callback via stable `useRef` (avoids editor re-creation)
- `defaultValueCtx` sets initial markdown content from SQLite
- Rewrote `src/renderer/routes/notes.$noteId.tsx` with full editor page:
- Editable title: borderless shadcn/ui `Input` (border-0, shadow-none, focus-visible:ring-0), saves on blur via `notes.update({ id, title })`
- Auto-save: `onChange` from Milkdown triggers 500ms debounced `notes.update({ id, content })` via `useRef` + `setTimeout`/`clearTimeout`
- "Saving..." indicator: shadcn/ui `Badge` (variant=secondary) shown while debounce is pending, hidden on mutation `onSettled`
- Back button: shadcn/ui `Button` (variant=ghost, size=icon) with `ArrowLeft` Lucide icon, `window.history.back()`
- Loading/not-found states handled
- Added Milkdown/ProseMirror CSS overrides in `src/renderer/globals.css` using semantic color variables (`var(--foreground)`, `var(--muted)`, `var(--border)`, `var(--muted-foreground)`, `var(--primary)`)
- Typecheck passes (zero errors)
- Files changed: `src/renderer/components/notes/MilkdownEditor.tsx` (new), `src/renderer/routes/notes.$noteId.tsx`, `src/renderer/globals.css`, `package.json`, `package-lock.json`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `@milkdown/kit` is the recommended all-in-one package — it bundles core, preset-commonmark, plugin-listener, plugin-history, and utilities under sub-paths like `@milkdown/kit/core`, `@milkdown/kit/preset/commonmark`, etc.
- The `useEditor` hook from `@milkdown/react` takes `(root) => Editor.make()...` — the `root` param is the DOM element Milkdown manages, set via `ctx.set(rootCtx, root)`
- Use `useRef` for the onChange callback passed to `listenerCtx.markdownUpdated()` — this avoids re-creating the editor instance when the callback identity changes
- `listenerCtx.markdownUpdated((_ctx, markdown, prevMarkdown))` provides both current and previous markdown — compare them to avoid firing on no-op updates
- For debounced auto-save: `useRef<ReturnType<typeof setTimeout>>` + `clearTimeout`/`setTimeout` is simpler than external debounce libraries; cleanup in `useEffect` return prevents stale saves
- Nord theme (`@milkdown/theme-nord`) provides base ProseMirror structure; override with CSS using the app's semantic color variables for consistent theming
- Import both `@milkdown/theme-nord/style.css` and `@milkdown/kit/prose/view/style/prosemirror.css` for proper base styling
---
## 2026-02-22 - US-017
- What was implemented:
- Fluid Curtain pull-down animation in `AppShell.tsx`
- `framer-motion` `useMotionValue(0)` + `useSpring(y, { stiffness: 300, damping: 30 })` controls `y` CSS transform on a `motion.div` wrapping the content area inside `SidebarInset`
- Sidebar stays visible at all times — only content area slides down
- Trigger 1: `document` wheel event — `findScrollableAncestor()` walks DOM to detect nearest scrollable element; if `scrollTop === 0` and `deltaY < 0`, opens curtain (`y.set(window.innerHeight)`)
- Trigger 2: `Cmd/Ctrl+K` keyboard shortcut toggles curtain open/closed
- Closing: `deltaY > 0` while curtain is open, or `Cmd/Ctrl+K`
- `AIChatPanel` placeholder component at `src/renderer/components/ai/AIChatPanel.tsx` — absolute `z-0` layer behind the sliding content panel
- Right-edge label dynamically changes: `"scrolling up for Adiuva"` + `ChevronUp` when closed, `"back to app"` + `ChevronDown` when open
- App panel remains mounted during animation (no unmount/remount, no state loss)
- `curtainOpenRef` (useRef) keeps event handlers in sync without re-registering effects
- Files changed: `src/renderer/components/layout/AppShell.tsx`, `src/renderer/components/ai/AIChatPanel.tsx` (new), `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `SidebarInset` already has `relative` in its base classes — adding `overflow-hidden` via className prop is sufficient to clip the sliding `motion.div`
- `useMotionValue` + `useSpring` pattern: `y.set(target)` immediately sets the spring target; `springY` (from `useSpring`) animates toward it — apply `springY` to `style={{ y: springY }}`, not `y` directly
- `findScrollableAncestor()` DOM walk is needed because `body` and `#root` both have `overflow: hidden` — scroll detection must target inner route containers (e.g., `overflow-y-auto` divs in projects/tasks)
- `useRef` for curtain open state avoids stale closures in `useEffect` wheel/keyboard handlers — the boolean ref is updated synchronously alongside `useState` setter
- `{ passive: true }` on wheel listener is correct when not calling `preventDefault()` — avoids Chrome console warnings
---
## 2026-02-23 - US-018
- What was implemented:
- Installed `keytar` native module for OS keychain token storage; added to `vite.main.config.mts` externals
- Created provider-agnostic AI architecture under `src/main/ai/`:
- `token.ts` — keychain CRUD via keytar with safeStorage fallback (handles missing libsecret on WSL/Linux), keyed by provider name
- `provider.ts` — `AIProvider` interface with `name`, `displayName`, `initialize(token)`, `isReady()` methods; provider registry (`Map`), `initAI()` startup function, `saveTokenAndInit()`, `hasActiveToken()`
- `copilot.ts` — GitHub Copilot provider implementation (initial default); registers via `registerProvider()` on import
- Updated `src/main/store.ts` — added `aiProvider: string` and `encryptedTokens: Record<string, string>` to `AppSettings`
- Updated `src/main/index.ts` — made `app.on('ready')` async, calls `await initAI()` after `initDb()`
- Updated `src/main/router/index.ts` — `ai.setToken` mutation calls `saveTokenAndInit(input.token)`, `ai.hasToken` query calls `hasActiveToken()`
- Created `/settings` route at `src/renderer/routes/settings.tsx`:
- Full settings page with left nav sidebar (shadcn pattern) + content area
- "AI Provider" section with password Input for token, Save Button, green "Saved" feedback
- Shows "A token is currently stored" indicator when `ai.hasToken` returns true
- Invalidates `ai.hasToken` query cache on successful save
- Updated `src/renderer/components/layout/AppShell.tsx`:
- Settings `SidebarMenuButton` with gear icon in sidebar footer links to `/settings` route
- Removed Dialog-based settings in favor of full route page
- Clean separation: no settings state lifted to AppShell
- Updated `src/renderer/components/ai/AIChatPanel.tsx`:
- Calls `trpc.ai.hasToken.useQuery()`; if `false`, renders `Card` with `KeyRound` icon + "AI provider not configured" + Link to `/settings`
- If `true`, shows existing "AI Chat — coming soon" placeholder
- Typecheck passes (zero errors)
- Files changed: `package.json`, `package-lock.json`, `vite.main.config.mts`, `src/main/ai/token.ts` (new), `src/main/ai/provider.ts` (new), `src/main/ai/copilot.ts` (new), `src/main/store.ts`, `src/main/index.ts`, `src/main/router/index.ts`, `src/renderer/routes/settings.tsx` (new), `src/renderer/components/layout/AppShell.tsx`, `src/renderer/components/ai/AIChatPanel.tsx`, `src/renderer/routeTree.gen.ts` (auto-generated), `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `keytar` requires `libsecret` on Linux (system dependency) — on WSL it's often missing. Use try/catch lazy require + Electron `safeStorage` as fallback to keep the app functional everywhere
- `safeStorage.encryptString()` returns a Buffer; store as base64 in electron-store. `safeStorage.decryptString()` takes a Buffer back.
- Provider-agnostic pattern: `AIProvider` interface + `Map<string, AIProvider>` registry + `electron-store` for active provider name = swap providers by adding a new implementation + `registerProvider()` call
- `initAI()` uses dynamic `await import('./copilot')` to trigger side-effect registration before reading the provider from the registry
- Settings as a full route (not a Dialog) is better for extensibility — left nav allows adding sections (Appearance, Notifications, etc.) without cluttering the sidebar
- TanStack Router route tree must be regenerated after adding a new route file: `npx @tanstack/router-cli generate` or just `npm start` (Vite plugin does it)
- Token is stored per-provider so multiple providers can have tokens stored simultaneously; switching providers just changes which key is read
- `electron-store` dot-notation access (`store.get('a.b')`) works but loses type safety; prefer `store.get('encryptedTokens')` then access the nested key on the result object
---
## 2026-02-23 - US-019
- What was implemented:
- Installed `@github/copilot-sdk` (v0.1.25) — official GitHub Copilot SDK with CopilotClient for programmatic CLI control via JSON-RPC
- Updated `src/main/ai/copilot.ts` — CopilotClient singleton created via dynamic `import()` (SDK is ESM-only), `initialize()` starts the client with `githubToken`, `getCopilotClient()` exported, clean shutdown on `app.before-quit`
- Added `@github/copilot-sdk` and `@github/copilot` to `vite.main.config.mts` externals (native CLI binary + prebuilds must stay in node_modules)
- Created IPC streaming side-channel:
- `src/preload/trpc.ts` — exposed `window.electronAI.onStreamChunk(cb)` via contextBridge on `ai:stream` IPC channel
- `src/main/ipc.ts` — added `TRPCContext` type with optional `sender: Electron.WebContents`, passed `event.sender` into tRPC context
- `src/renderer/lib/ipcLink.ts` — added `Window.electronAI` type declaration
- Updated `src/main/router/index.ts` — `initTRPC.context<TRPCContext>().create()`, `ai.chat` mutation now calls `orchestrate()` with streaming + error handling
- Created `src/main/ai/orchestrator.ts` (new) — core Orchestrator agent:
- System prompt instructs model to use exactly one routing tool per message
- 3 routing tools: `route_to_project`, `route_to_knowledge`, `route_to_general` (each with JSON schema params + handler callback)
- `buildProjectContext(projectId)` — fetches project, tasks, checkpoints, notes from DB
- `buildGlobalContext()` — fetches active projects, task counts, upcoming tasks due this week
- Two-phase orchestration: (1) Orchestrator session classifies intent via tool call, (2) Specialist session generates streamed response
- Specialist agent prompts: @ProjectAgent (scoped project data), @KnowledgeAgent (stub — LanceDB pending US-023), @GeneralAgent (workspace summary)
- Streaming via `session.on('assistant.message_delta')` → `sender.send('ai:stream', { token, done })`
- Error classification: auth (401/403) → friendly message, timeout → retry prompt, generic → error message
- Typecheck passes (zero errors), no new lint errors
- Files changed: `package.json`, `package-lock.json`, `vite.main.config.mts`, `src/main/ai/copilot.ts`, `src/main/ai/orchestrator.ts` (new), `src/main/ipc.ts`, `src/preload/trpc.ts`, `src/main/router/index.ts`, `src/renderer/lib/ipcLink.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `@github/copilot-sdk` is ESM-only (`"type": "module"`) — cannot `require()` from CJS Electron main process. Use `await import('@github/copilot-sdk')` inside async functions
- The SDK depends on `@github/copilot` (the CLI binary, ~400MB) which includes native prebuilds, ripgrep, tree-sitter WASM, etc. Both must be externalized in Vite config
- `CopilotClient` manages a CLI subprocess via JSON-RPC. `autoStart: true` + `autoRestart: true` handles lifecycle. Call `client.stop()` on `app.before-quit`
- SDK tools use a `handler: async (args) => ToolResult` callback pattern — the SDK calls your handler when the model invokes the tool. This is different from OpenAI's function-calling (where you check tool_calls in the response)
- `session.sendAndWait()` blocks until `session.idle`, returns `AssistantMessageEvent | undefined`. Default timeout is 60s
- `session.on('assistant.message_delta', cb)` fires for each streaming chunk with `{ messageId, deltaContent }`. Returns an unsubscribe function
- `SessionConfig.availableTools` controls which tools are exposed to the model. Set to only your custom tool names to disable all built-in Copilot tools
- `SystemMessageConfig` has `mode: 'replace'` to fully replace the SDK's default system prompt — necessary for agent specialization
- For IPC streaming: extend the preload to expose a separate channel (`ai:stream`) via `contextBridge.exposeInMainWorld()`, and use `event.sender.send()` from the main process. This avoids touching the tRPC request-response infrastructure
- `TRPCContext` with optional `sender` field preserves backward compatibility — non-AI procedures get `sender` but don't use it
---
## 2026-02-23 - US-019 Refactor: LangGraph Provider-Independent Orchestration
- What was implemented:
- Installed `@langchain/langgraph`, `@langchain/core`, `@langchain/openai`, `@langchain/anthropic` for provider-independent agent orchestration
- Created `src/main/ai/llm.ts` (new) — LLM factory returning `BaseChatModel` for the active provider:
- `openai` → `ChatOpenAI` (gpt-4o-mini, streaming)
- `anthropic` → `ChatAnthropic` (claude-sonnet, streaming)
- `copilot` → `ChatCopilot` adapter wrapping the Copilot SDK
- Created `src/main/ai/chat-copilot.ts` (new) — LangChain-compatible `SimpleChatModel` adapter for GitHub Copilot SDK:
- `_call()` creates a Copilot session, sends messages, returns response text
- `_streamResponseChunks()` yields `ChatGenerationChunk` tokens via `assistant.message_delta` event listener + async generator pattern
- Refactored `src/main/ai/orchestrator.ts` — replaced Copilot SDK sessions with LangGraph `StateGraph`:
- `OrchestratorState` annotation: `userMessage`, `chatContext`, `route`, `messages`, `response`
- `classifyIntent` node: uses `llm.withStructuredOutput(RouteSchema)` for intent classification (project/knowledge/general)
- `projectAgent`, `knowledgeAgent`, `generalAgent` nodes: each gets context from DB + invokes LLM
- `addConditionalEdges` routes from classifier to specialist based on `state.route`
- Streaming via LangGraph `streamMode: 'messages'` — tokens from specialist nodes forwarded to renderer via IPC
- Graph is compiled once (singleton) and reused across calls
- Updated `vite.main.config.mts` — added all `@langchain/*` packages to externals
- Context assembly functions (`buildProjectContext`, `buildGlobalContext`) and system prompts preserved unchanged
- Typecheck passes (zero errors), no new lint errors
- Files changed: `package.json`, `package-lock.json`, `vite.main.config.mts`, `src/main/ai/llm.ts` (new), `src/main/ai/chat-copilot.ts` (new), `src/main/ai/orchestrator.ts`, `progress.txt`
- **Learnings for future iterations:**
- LangGraph packages ship dual ESM/CJS (`require` + `import` in exports map) — no dynamic import needed unlike `@github/copilot-sdk`
- `SimpleChatModel` from `@langchain/core` only requires implementing `_call()` and `_llmType()` — much simpler than full `BaseChatModel`
- `withStructuredOutput(zodSchema)` on a `BaseChatModel` forces the LLM to return JSON matching the schema — ideal for intent routing without custom tool handlers
- LangGraph `streamMode: 'messages'` yields `[chunk, metadata]` tuples; `metadata.langgraph_node` identifies which graph node produced each token — use this to filter out classifier tokens
- `Annotation.Root({})` with a `reducer` function enables append-only message arrays in state — matches LangGraph's immutable state update pattern
- The graph is compiled once via `buildGraph()` singleton — no per-request overhead for graph construction
- Architecture: agent logic (LangGraph) is now fully decoupled from the LLM provider. Adding a new provider only requires a new factory function in `llm.ts`
---
## 2026-02-23 - US-020
- What was implemented:
- Full context-scoped AI chat UI in `AIChatPanel` component, replacing the "coming soon" placeholder
- Two-mode layout: empty state (centered input) and chat state (messages + pinned bottom input)
- Context header: `Badge` (variant=outline) showing "Chatting about: [Project Name]" or "Global workspace"
- Context derived in AppShell from `currentPath` + `searchObj['projectId']`; project name fetched via `trpc.projects.get` query
- User messages: right-aligned `Card` components
- AI messages: left-aligned plain text (no Card) with `Sparkles` icon + bold "Adiuva" header line
- Streaming: subscribes to `window.electronAI.onStreamChunk` IPC channel before firing `trpc.ai.chat.mutate()`; tokens accumulate in `streamingContent` state via `useRef` pattern
- Loading indicator: `Skeleton` lines (w-48 + w-32) shown below Adiuva header while waiting for first token
- Error handling: mutation errors and `{ error }` responses display in `Card` with `border-destructive` styling
- Session-only history: `useEffect` on `curtainOpen` prop clears all messages, input, and streaming state when curtain closes
- Scroll behavior: after user sends, scrolls user message to top of visible area; does NOT auto-scroll during AI streaming
- Input: `Textarea` matching Figma (white bg, border #d4d4d4, shadow-lg, min-h 109px, "Ask me anything..."), Send `Button` (default variant, Send icon + label) absolute bottom-right
- Enter sends, Shift+Enter for newline
- Extracted `ChatInput` sub-component for reuse between empty and chat states
- Typecheck passes (zero errors), no new lint errors introduced
- Files changed: `src/renderer/components/ai/AIChatPanel.tsx`, `src/renderer/components/layout/AppShell.tsx`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `window.electronAI.onStreamChunk` returns an unsubscribe function — subscribe before firing the mutation, unsubscribe in error/completion handlers
- Use `useRef` for accumulating streaming content (`streamingContentRef.current += token`) to avoid stale closure issues in the stream callback, then sync to state with `setStreamingContent(streamingContentRef.current)`
- `trpc.ai.chat.mutate()` returns `{ response, error? }` — the `error` field is `string | undefined`, so must narrow before passing to typed state (assign to a `const` first)
- `trpc.projects.get` query with `enabled: !!projectId` + `id: projectId ?? ''` avoids both the non-null assertion lint warning and unnecessary queries
- For scroll-to-user-message UX: track the last user message with a ref and use `scrollIntoView({ behavior: 'smooth', block: 'start' })` — do NOT auto-scroll on AI streaming to let the user read from the top
---
## 2026-02-24 - US-021
- What was implemented:
- 4 project action tools in `src/main/ai/orchestrator.ts` using `@langchain/core/tools` `tool()` helper, via new `buildProjectTools(projectId)` factory function:
- `read_project_notes`: fetches full note content from SQLite (no 500-char truncation unlike buildProjectContext)
- `add_task`: inserts task via `db.insert(tasks).run()`, returns `'Task added: [title]'`
- `get_summary`: calls nested `getLLM().invoke()` to generate 2-3 sentence summary, persists via `db.update(projects).set({ aiSummary })`
- `suggest_checkpoints`: calls nested `getLLM().invoke()` with structured prompt, returns JSON array `[{ title, date }]` with regex extraction fallback
- `classifyIntent` short-circuits: `chatContext.type === 'project' && chatContext.projectId` → immediately returns `{ route: 'project' }` (saves one LLM round-trip, prevents misrouting)
- `projectAgent` rewritten with agent loop (max 5 iterations):
- `supportsTools` runtime guard: `'bindTools' in llm && typeof llm.bindTools === 'function'`
- Copilot path (no bindTools): direct `llm.invoke()` with full context prompt
- OpenAI/Anthropic path: `llm.bindTools!(projectTools)` → agent loop with `AIMessage.isInstance()` type guard for `tool_calls` access
- Tool dispatch via `matched.invoke({ ...toolCall, type: 'tool_call' as const })` — StructuredTool.invoke() detects ToolCall object and extracts args via internal `_isToolCall()` check
- `ToolMessage` appended per tool call with `tool_call_id`; `messageHistory` accumulated across iterations
- `makeProjectAgentPrompt` updated to describe all 4 available tools and usage guidance
- Streaming unaffected: tool-calling rounds produce empty `chunk.content` (falsy), filtered by existing guard; final text response streams normally
- Files changed: `src/main/ai/orchestrator.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `tool()` returns `DynamicStructuredTool<...>` — use `StructuredTool[]` as the function return type and cast with `as StructuredTool[]` to avoid generic type variance errors in strict mode
- `llm.bindTools` is typed as optional on `BaseChatModel` — even after a runtime `typeof === 'function'` guard, TypeScript still reports TS18048 ("possibly undefined"); use `llm.bindTools!()` with an eslint-disable comment after the guard
- `AIMessage.isInstance(response)` is the zero-unsafe-cast way to access `tool_calls` on a `BaseMessage` — avoids `as any`
- LangGraph `streamMode: 'messages'` naturally skips tool-calling rounds because `chunk.content` is `''` (falsy) for AIMessageChunks that have tool call deltas
- Nested `getLLM().invoke()` calls inside tool handlers (for `get_summary`, `suggest_checkpoints`) do NOT stream tokens to the IPC channel — they execute synchronously within the tool handler, outside LangGraph's stream interceptor
- Short-circuiting `classifyIntent` for project context saves cost and prevents misrouting when user asks general questions from within a project view
- Empty Zod schema `z.object({})` infers TypeScript type `{}` — use `Record<string, never>` as the handler parameter type to be explicit about intent in strict mode
---
## 2026-02-24 - US-021 bugfix
- Bug: `<tool_call>` XML appeared in chat and tasks weren't actually created
- Root cause: `ChatCopilot` extends `SimpleChatModel` which inherits `bindTools()` from `BaseChatModel` — so `'bindTools' in llm` returned TRUE. But `ChatCopilot._call()` ignores bound tools (no kwargs plumbing to the Copilot SDK). The model received tool descriptions in the system prompt but NOT via the API, so it hallucinated `<tool_call>{"name":"sql",...}` freeform text. `tool_calls` on the response was empty → tool not executed → fake success text streamed to UI
- Fix: Replaced runtime `'bindTools' in llm` check with provider-name whitelist (`TOOL_CALLING_PROVIDERS = new Set(['openai', 'anthropic'])`). Imported `getActiveProviderName` from `./provider`
- Fix: Copilot fallback path now uses `makeProjectAgentPrompt(contextData, false)` — no tool section in system prompt — preventing hallucinated tool calls text
- Files changed: `src/main/ai/orchestrator.ts`
- **Learnings for future iterations:**
- `BaseChatModel.bindTools()` exists as a default inherited method in modern LangChain — `'bindTools' in llm` is ALWAYS true. You CANNOT use this to detect actual tool calling support; must know the provider
- The safe check is provider-name based: only 'openai' and 'anthropic' have real bindTools support in this codebase
- When a model receives tool descriptions IN THE SYSTEM PROMPT but NOT via the API tool calling mechanism, it may hallucinate tool calls as freeform text (especially models trained on ReAct/ToolBench data)
- Remove tool mentions from system prompt when NOT using API-level tool calling
---
## 2026-02-24 - US-021 classifyIntent short-circuit bugfix
- Bug: After US-021, GitHub Copilot chat broke entirely with "Failed to list models" SDK error
- Root cause: The `classifyIntent` short-circuit (added in US-021 for project context) removed the FIRST LLM call from the graph. The Copilot SDK requires at least one prior `sendAndWait()` call to initialize its internal model list cache before a subsequent call succeeds. Without `classifyIntent`'s LLM invocation acting as warm-up, the cold `projectAgent` call triggered `runAgenticLoop → listModels` which failed
- Fix: Removed the short-circuit from `classifyIntent` entirely. The node always calls the LLM for routing (matching pre-US-021 behavior). The `metadata.langgraph_node !== 'classifyIntent'` check in the streaming loop already prevents the routing token from appearing in chat
- Files changed: `src/main/ai/orchestrator.ts`
- **Learnings for future iterations:**
- The Copilot SDK needs a "warm-up" LLM call before it can successfully process the main request. Never eliminate the first LLM call in the graph when Copilot is the provider
- Short-circuit optimizations that skip LLM nodes are only safe for providers where the SDK has no internal state to initialize (OpenAI, Anthropic)
- If you want to restore the short-circuit as an OpenAI/Anthropic optimization, gate it: `if (TOOL_CALLING_PROVIDERS.has(getActiveProviderName()) && state.chatContext.type === 'project')`
---
## [2026-02-24] - US-022
- What was implemented:
- Installed `vectordb` (LanceDB Node.js binding v0.21.2) as a project dependency
- Created `src/main/ai/embeddings.ts`: reads GitHub Copilot OAuth token from `~/.copilot/config.json` (via `copilot_tokens` map), falls back to stored OpenAI token via `getToken('openai')`. Uses `@langchain/openai` `OpenAIEmbeddings` with `baseURL: 'https://api.githubcopilot.com'` for Copilot path, or standard OpenAI API for fallback. Exposes `embedText(text): Promise<number[]>`
- Created `src/main/db/vectordb.ts`: LanceDB singleton (`initVectorDb()` / `getConn()`), `upsertNoteEmbedding(noteId, projectId, content)` with delete-then-add upsert strategy (first call auto-creates table with schema inferred from first record), `migrateNotesIfNeeded()` that checks table existence on startup and bulk-embeds all SQLite notes sequentially with per-note error isolation
- Modified `src/main/router/index.ts`: imported `upsertNoteEmbedding`, made `notes.create` and `notes.update` async, added fire-and-forget embedding calls with `.catch(console.error)` in both handlers; `notes.update` re-fetches the full note from SQLite after the write to embed current title+content
- Modified `src/main/index.ts`: imported `initVectorDb` + `migrateNotesIfNeeded`, added `initVectorDb().then(() => migrateNotesIfNeeded()).catch(...)` chain in `app.on('ready')`
- Modified `vite.main.config.mts`: added `'vectordb'` to the `external` array so ViteRollup doesn't try to bundle the NAPI-RS binary
- Files changed:
- `package.json` (vectordb dependency added)
- `vite.main.config.mts`
- `src/main/index.ts`
- `src/main/router/index.ts`
- `src/main/ai/embeddings.ts` (new)
- `src/main/db/vectordb.ts` (new)
- **Learnings for future iterations:**
- `@github/copilot-sdk` has **no embeddings API** — it is a pure chat/session SDK. The `CopilotClient` type definitions contain zero mention of "embedding". Do not assume any LLM provider SDK supports embeddings
- GitHub Copilot CLI stores OAuth tokens in `~/.copilot/config.json` under `copilot_tokens["{host}:{login}"]`. The token format is `gho_*` (GitHub OAuth). These tokens work with the GitHub Copilot REST API (`https://api.githubcopilot.com`) which is OpenAI-compatible — including embeddings
- `@langchain/openai`'s `OpenAIEmbeddings` accepts a `configuration.baseURL` option that makes it work against any OpenAI-compatible endpoint
- `vectordb` (v0.21.2): deprecated but functional. The new package name is `@lancedb/lancedb`. `vectordb` requires at least one data record for `createTable()` (cannot create an empty table — schema is inferred from the first record). Use delete-then-add for upsert since there's no native upsert API at this version
- When using dynamic `import('@langchain/openai')`, TypeScript cannot infer the exact return type of `embedDocuments()` — it resolves to `{}` instead of `number[][]`. Fix: cast explicitly `as number[][]`
- tRPC mutation handlers support both sync and async functions transparently — making a mutation `async` does not break the renderer-side interface
- `notes.update` allows partial field updates (title or content can be omitted). Always re-fetch the full note from SQLite after the update write to get the correct combined text for embedding
- `vectordb`'s `table.delete(where)` accepts a raw SQL WHERE clause string. UUID v4 IDs are safe to interpolate directly (only `[0-9a-f-]` characters)
---
## 2026-02-24 - US-023
- Implemented @KnowledgeAgent semantic search across all projects
- Added `searchNotes()` to `src/main/db/vectordb.ts`: embeds query via `embedText()`, performs `table.search(vector).limit(k).execute()` on LanceDB notes table, returns `SearchResult[]` with id, projectId, content, _distance
- Added `buildKnowledgeTools()` to `src/main/ai/orchestrator.ts`: defines `vector_search_all` tool that calls `searchNotes()`, joins SQLite for note title and project name, returns formatted results with `From: [Project Name] — [Note Title]` citation headers
- Rewrote `knowledgeAgent()` from simple LLM stub to full tool-calling agent loop (mirrors projectAgent/generalAgent pattern: TOOL_CALLING_PROVIDERS check, bindTools, 5-iteration MAX_ITERATIONS loop with ToolMessage accumulation, fallback for providers without tool support)
- Updated `makeKnowledgeAgentPrompt()` with `withTools` parameter, tool documentation for `vector_search_all`, and citation format instructions
- Files changed: `src/main/db/vectordb.ts`, `src/main/ai/orchestrator.ts`
- **Learnings for future iterations:**
- `openTable<T>('name')` in vectordb makes `search()` expect `T` as input type — omit the generic when using `search()` with a raw vector array
- The agent loop pattern (check TOOL_CALLING_PROVIDERS → buildTools → bindTools → iterate with ToolMessage) is now used consistently across all three agents (project, knowledge, general) — follow this pattern for any new agent
- LanceDB `table.search(vector).limit(k).execute()` returns objects with all stored fields plus `_distance` (L2 distance, lower = more similar)
- The `SearchResult` type is exported from `vectordb.ts` for reuse in the orchestrator — keep vector DB types in the DB module, not the AI module
---
## 2026-02-24 - US-024
- Implemented AI checkpoint suggestions UI with approve/reject flow
- Extended to also support AI task suggestions (user-requested scope expansion)
- Modified suggest_checkpoints tool in orchestrator to persist suggestions to DB (isAiSuggested=1, isApproved=0)
- Created new suggest_tasks tool with same pattern (analyzes notes, extracts actionable tasks, persists to DB)
- Added isAiSuggested/isApproved columns to tasks table schema + ALTER TABLE migration for existing databases
- Updated tasks.create/update router to accept new fields
- Added TaskItem type fields for isAiSuggested/isApproved
- ProjectDetail.tsx: "Suggest checkpoints" button (outline+Sparkles) in timeline header, "Suggest tasks" button in tasks header
- Pending suggestions render as border-dashed Card components below GanttChart / above KanbanBoard
- Approve (variant=default, size=sm) calls update with isApproved=1; Reject (variant=ghost, size=sm) calls delete
- KanbanBoard and workspace tasks route filter out unapproved AI suggestions
- Files changed:
- src/main/db/schema.ts (added isAiSuggested + isApproved to tasks)
- src/main/db/index.ts (ALTER TABLE migration + updated CREATE TABLE)
- src/main/router/index.ts (tasks.create/update/list updated)
- src/main/ai/orchestrator.ts (persist checkpoint suggestions + new suggest_tasks tool + updated system prompt)
- src/renderer/components/projects/ProjectDetail.tsx (suggest buttons + pending cards for both)
- src/renderer/components/projects/KanbanBoard.tsx (filter out pending AI suggestions)
- src/renderer/components/tasks/TaskRow.tsx (TaskItem type extended)
- src/renderer/routes/tasks.tsx (filter out pending AI suggestions)
- **Learnings for future iterations:**
- Tasks table defaults isApproved=1 (unlike checkpoints which default=0) so existing/manually-created tasks remain visible
- SQLite has no ADD COLUMN IF NOT EXISTS — use try/catch around ALTER TABLE statements
- The suggest_checkpoints/suggest_tasks tools persist directly via db.insert() in the tool handler, then query invalidation on the frontend picks up new records
- TaskItem type in TaskRow.tsx is manually defined (not auto-inferred from tRPC) — must be updated when adding columns to the tasks select
- The ai.chat mutation can be instantiated multiple times for independent suggest flows (suggestCheckpoints vs suggestTasks)
---