187 Commits

Author SHA1 Message Date
Roberto
81fe6d29e2 perf(DateTimeField): keep typing local, memoize Calendar + SegmentSpan
Typing in a segment no longer calls onChange — local state only.
onChange now fires only on commit (Enter, calendar pick), so the
parent TaskFormDialog stops re-rendering on every keystroke (and
the heavy Calendar grid + every pill / popover / query stops
re-rendering with it).

Inside DateTimeField:
- Calendar element memoized via useMemo keyed on the committed
  date's ms — only re-renders when a full valid date is reached
  or changes.
- SegmentSpan wrapped in React.memo.
- onSegKeyDown stabilized via useCallback + functional setSeg +
  refs for order / withTime / onChange / onCommit, so its
  identity never changes.
- Per-segment ref setters cached in a useRef map so they don't
  swap identity on each render.

TaskFormDialog:
- onChange / onCommit passed to DateTimeField wrapped in useCallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:46:47 +02:00
Roberto
b2d7fa1723 fix(DateTimeField): drop value-sync useEffect that wiped partial typing
Each onChange propagated to the parent caused a re-render with a fresh
Date instance, which retriggered the value-sync effect and overwrote
the in-progress segment state. Result: after picking a day in the
calendar, typing '14' in the hour field only kept the last digit.

Initial value still seeds segment state via the useState lazy
initializer, and Radix Popover unmounts the content on close so each
open starts from the current value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:09:21 +02:00
Roberto
4c641ab93a fix(DateTimeField): autocomplete missing segments on Enter, keep popover open on calendar pick
Enter inside any segment now commits even when the value is partial.
Missing segments default to today (day, month, year) or to 00
(hour, minute). Out-of-range values clamp to their segment max,
and an impossible day-in-month (e.g. Feb 31) clamps to the last
day of the resolved month.

Calendar click no longer fires onCommit, so the Due popover stays
open and the date segments update inline. The popover closes via
Enter inside a segment, Esc, or click outside.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:56:02 +02:00
Roberto
84720ff23c feat(TaskFormDialog): segment-based DateTimeField for Due, header padding, assignees kbd wrap
- New DateTimeField component: segment editor (DD/MM/YYYY HH:MM) honoring
  FormatPrefs.dateFormat. Each segment is keyboard-driven (digits to
  enter, arrows up/down to inc/dec, arrows left/right or separators to
  move, Enter commits and closes the Due popover). Calendar grid below
  stays in sync.
- TaskFormDialog: header gains px-5 pt-5 pb-2 so the title+description
  don't sit flush against the DialogContent edges.
- AssigneesList: ArrowDown on the last list item now focuses the
  "new name" Input; ArrowUp from the Input returns to the last list
  item; Esc on the Input closes the popover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:38:27 +02:00
Roberto
d7307e146a fix(TaskFormDialog): control Due popover open state for keyboard close
Add dueOpen state + onOpenChange so Esc and outside-click close the
popover consistently with the other pills; wire DateField.onCommit
to close the popover after Enter or calendar click.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:21:42 +02:00
Roberto
7d4059ca4b i18n: add tasks.newTaskDescription / editTaskDescription
Two new keys for the DialogDescription line under the TaskFormDialog
header. Added to all five supported languages (en, it, es, fr, de).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:16:56 +02:00
Roberto
9691842e79 feat(TaskFormDialog): new header, full keyboard nav, DateField-based due
Header: DialogTitle + DialogDescription, no separator border, matching
AddEventDialog.
Keyboard: pills row uses roving tabindex (←/→/↑/↓ + Home/End +
Enter-to-open). Each list popover (Project, Priority, Status,
Assignees) uses useListboxKeys for ↑/↓/Home/End/Enter/Space/Esc/Tab.
Due: replaced bespoke Calendar + hour/minute Selects with a
DateField (flat + withTime), which is keyboard-typeable and
format-prefs aware. derivePartsInTz / updateDueTime / HOURS /
MINUTES helpers removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:15:18 +02:00
Roberto
094840e671 refactor(PropertyPill): render as button with forwardRef and focus ring
Switch the trigger element from a presentational <span> to a real
<button type="button"> with forwardRef so the pill can receive keyboard
focus, be used directly as a PopoverTrigger asChild, and show a
focus-visible ring. Public API stays compatible: icon, label, value,
empty plus any standard button HTML attributes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:07:55 +02:00
Roberto
e8592b25a8 feat(date-field): add withTime and flat props
withTime renders hour/minute selects under the Calendar and appends
HH:MM to the display text when the value has a non-midnight time.
flat renders Input + Calendar (+ Time row) inline without the
internal Popover, so a caller can embed DateField inside its own
popover without nesting. Existing callers continue to work
unchanged (both props default to false).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:05:31 +02:00
Roberto
27b385df53 feat(parseDate): accept optional ' HH:MM' suffix
Existing callers unaffected: a bare date still parses to midnight as
before. New behavior: an optional trailing ' HH:MM' is stripped from
the input, the date portion goes through the existing resolution
order, and setHours/setMinutes is applied to the result. Out-of-range
times (e.g. 25:00, 12:99) return null.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:01:28 +02:00
Roberto
e170844f17 feat: add useListboxKeys hook for popover list keyboard
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:58:49 +02:00
Roberto
27c1194384 feat: add useRovingFocus hook for roving-tabindex groups
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:57:03 +02:00
Roberto
26ea095f60 ui(AddEventDialog): add DialogDescription to match project dialog header 2026-05-14 08:58:57 +02:00
Roberto
751d16a9f4 fix(AddEventDialog): clear focusedRowId on row blur + skip X icon in Tab cycle 2026-05-14 08:52:39 +02:00
Roberto
285214a2d2 ui(AddEventDialog): swap ToggleGroup for Tabs to match Gantt zoom selector 2026-05-14 08:38:10 +02:00
Roberto
89645f2abd polish(timeline): end-date validation + project-lock hint
- Surface invalidMessage on end DateField when end < start
- Auto-clear endDate when start changes past it
- Add title tooltip on SelectTrigger when project is locked

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:06:40 +02:00
Roberto
7dadeb88fe fix(AddEventDialog): reset edit mode when removing the row being edited 2026-05-13 19:05:27 +02:00
Roberto
13531fec40 feat(timeline): keyboard nav + edit mode for staged rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 19:01:37 +02:00
Roberto
e254efd420 a11y(AddEventDialog): i18n staged-list label + add title aria-label 2026-05-13 18:59:44 +02:00
Roberto
6d79911414 feat(timeline): batch-stage flow in AddEventDialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:55:53 +02:00
Roberto
69a859e19f i18n(timeline): add keys for batch-add dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:52:30 +02:00
Roberto
098ce86c76 fix(EditEventDialog): remove non-null assertion via inline expression 2026-05-13 18:50:17 +02:00
Roberto
9ef809ba02 refactor(timeline): migrate EditEventDialog to DateField
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:56:32 +02:00
Roberto
024d572ebb fix(DateField): wire aria-describedby + prevent calendar-icon focus steal
- Add useId() to generate stable fieldId/errorId; link <Input> to error
  <p> via aria-describedby so screen readers announce the message.
- Add onMouseDown={e => e.preventDefault()} to the calendar icon Button
  so clicking it does not trigger onBlur on the input mid-typing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:49:38 +02:00
Roberto
d24f09bbea feat(ui): add DateField with typed entry + calendar popover 2026-05-13 17:45:51 +02:00
Roberto
56fe6c0754 i18n(date): add date keyword arrays for parseDate 2026-05-13 17:25:41 +02:00
Roberto
c76de207d7 fix(date): re-validate leap-day roll-forward; tighten parseDateRange separator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:10:08 +02:00
Roberto
4e89a7a96c feat(date): add parseDate utility with locale-aware parsing 2026-05-13 16:04:44 +02:00
Roberto
0fc3aa421e fix(adiuvAI): WsStreamEndSchema accepts null mutations/error (backend emits null for zero-tool turns) 2026-05-13 09:20:45 +02:00
Roberto
c10fbe22d7 fix(adiuvAI): cap default DialogContent at sm:max-w-lg 2026-05-13 08:34:37 +02:00
Roberto
e3e0b06fb6 fix(adiuvAI): add 3 folder actions to ToolCallActionSchema enum (caused silent WS hang) 2026-05-12 17:57:52 +02:00
Roberto
b3d85b93f1 feat(adiuvAI): drizzle-executor read action returns kind+totalSize, supports offset/length 2026-05-12 17:34:19 +02:00
Roberto
659607a1e9 fix(adiuvAI): remove card border from FolderLinkCard, inline layout 2026-05-12 14:46:09 +02:00
Roberto
80a0d2c56f fix(adiuvAI): files section uses standard project section layout 2026-05-12 14:43:38 +02:00
Roberto
66448a25f4 fix(adiuvAI): collapse chip height in compact mode to preserve hero shrink 2026-05-12 14:32:51 +02:00
Roberto
93144b9de8 fix(adiuvAI): position FolderChip right of project title 2026-05-12 14:30:10 +02:00
Roberto
b0c415f90f feat(adiuvAI): pre-flight quota check + error toasts for folder integration
Before starting an index session, scanFolder is called to count
indexable files, then BackendClient.checkFolderQuota POSTs to
/api/v1/billing/quota/check.  A 402 response becomes a TRPCError
FORBIDDEN with a QUOTA:<reason>:<message> payload.  FilesSection
catches that payload and shows a localised sonner toast via
projects.folder.errors.tooBig or monthlyExhausted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 13:10:14 +02:00
Roberto
8a2225da7c feat(adiuvAI): wire FolderChip + FilesSection into ProjectDetail 2026-05-12 13:04:02 +02:00
Roberto
e0c5971d20 feat(adiuvAI): add 'files' tab to ProjectTabBar 2026-05-12 13:02:01 +02:00
Roberto
a499d55636 feat(adiuvAI): FilesSection orchestrator 2026-05-12 12:50:27 +02:00
Roberto
c36890cc8b feat(adiuvAI): FolderUnlinkDialog 2026-05-12 12:49:10 +02:00
Roberto
b80ba0434b feat(adiuvAI): FolderFileList with kind filter 2026-05-12 12:48:39 +02:00
Roberto
01d3735dd1 feat(adiuvAI): FolderLinkCard component 2026-05-12 12:47:57 +02:00
Roberto
e0bcb2fe0a feat(adiuvAI): FolderChip component 2026-05-12 12:47:13 +02:00
Roberto
a1c83a6134 i18n: projects.folder keys in all 5 locales 2026-05-12 12:41:04 +02:00
Roberto
bd5e3076ed feat(adiuvAI): daily auto-rescan of stale folder links 2026-05-12 12:23:27 +02:00
Roberto
316b8fa66a feat(adiuvAI): drizzle-executor folder manifest + scoped read actions 2026-05-12 12:21:36 +02:00
Roberto
6f907f6a96 feat(adiuvAI): projectFolders tRPC router (link, unlink, scan, list) 2026-05-12 12:18:50 +02:00
Roberto
93caf0116d feat(adiuvAI): WS index session orchestrator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:09:36 +02:00
Roberto
15af8d54e6 feat(adiuvAI): WS index session frame senders + dispatcher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:06:48 +02:00
Roberto
c4ed7b3482 feat(adiuvAI): folder scanner with mtime delta 2026-05-12 12:04:04 +02:00
Roberto
066d407a5f feat(adiuvAI): folder index constants 2026-05-12 12:03:20 +02:00
Roberto
c2826ae4be feat(adiuvAI): schema for project folder integration
Add folderPath, folderLastScannedAt, folderLastScanStatus, folderTotalFiles
columns to projects table; add project_folder_files table with kind/summary
columns. Migration: 0005_slim_baron_strucker.sql.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:00:36 +02:00
Roberto
adb1cc81ef chore: remove kbd hint from TaskFormDialog header 2026-05-08 16:11:49 +02:00
Roberto
a4fd10e640 fix: TaskDetailSheet priority/status popovers auto-close on selection 2026-05-08 16:08:44 +02:00
Roberto
efa3051c61 fix: task UX polish — card menu, sheet live render, composer align, project link, no comment toast
- TaskCard: replace checkbox toggle with right-click ContextMenu (Edit / Change Status submenu / Delete), matching TaskTableRow flow; status now visible via shared StatusBadge in card footer
- TaskTableRow + TaskCard: add RefreshCw icon to Change Status submenu trigger
- TaskDetailSheet: subscribe to fresh row via tasks.byIds and render liveTask so priority/status chip popovers reflect mutations immediately; invalidate byIds alongside tasks.list on update
- ChatInputBox 'comment' variant: items-end -> items-center so single-line placeholder aligns with send button
- TaskTableRow: remove project-cell click handler and underline; remove onProjectClick prop chain from TaskTable
- TaskDetailSheet header breadcrumb: now a button navigating to /projects?projectId=... (closes sheet first)
- TaskDetailSheet addComment: drop success toast on create, keep error toast and cache invalidation
2026-05-08 16:00:55 +02:00
Roberto
72e09501de fix: TaskDetailSheet X close + overflow menu aligned in same row 2026-05-08 15:37:55 +02:00
Roberto
875fe625b5 fix: TaskFormDialog due-date time picker; TaskDetailSheet header X/menu overlap 2026-05-08 15:24:04 +02:00
Roberto
dac1d50b02 refactor: replace hand-rolled DB migrations with Drizzle migrator
Drop the MIGRATION_SQL string + try/catch ALTER TABLE block from initDb()
in favor of drizzle-orm/better-sqlite3/migrator, which reads
src/main/db/migrations/ (the canonical drizzle-kit output) and applies
each *.sql in order, tracked via __drizzle_migrations.

This fixes a class of bugs where schema.ts + a generated migration ship
correctly but db/index.ts is forgotten — most recently 0004
(estimate column + task_attachments table), which silently broke
tasks.list on existing DBs.

Migration folder resolution:
- Packaged: <resourcesPath>/migrations (declared as extraResource in
  forge.config.ts so it lands next to the asar)
- Dev: <appPath>/src/main/db/migrations (Vite bundles main into a
  single main.js, so __dirname is not next to the migrations folder)

Bootstrap for legacy DBs: pre-existing DBs created by the old
hand-rolled MIGRATION_SQL have all tables from 0000-0003 but no
__drizzle_migrations ledger. We detect this on startup (tasks table
present, ledger missing), seed the ledger marking all but the latest
migration as applied, then let the migrator run only the new one.
This preserves existing data and the migrator's hash check on
subsequent runs.

Verified locally: real user DB (51 tasks) migrated cleanly — estimate
column added, task_attachments table created, all rows preserved.

Future schema changes: edit schema.ts → npx drizzle-kit generate → commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:11:52 +02:00
Roberto
e104ffc3ab feat(i18n): add attachment toast keys for all 5 languages 2026-05-08 14:44:39 +02:00
Roberto
1cffb9bdbf feat(i18n): add task list/sheet/dialog keys for all 5 languages 2026-05-08 14:42:27 +02:00
Roberto
bae84f1a48 refactor: project page tasks tab uses TaskListView with hideProjectColumn 2026-05-08 14:36:24 +02:00
Roberto
938c8eef8a refactor: tasks route uses TaskListView; extract TaskItem to task-types 2026-05-08 14:34:42 +02:00
Roberto
50d01c7aec feat: TaskListView orchestrator (toolbar + table/grid + pager) 2026-05-08 14:30:29 +02:00
Roberto
ef04bec66f feat: TaskPager with numbered buttons and ResizeObserver-aware width 2026-05-08 14:28:38 +02:00
Roberto
2e9ec31d83 feat: TaskTable + TaskTableRow with context menu and status submenu 2026-05-08 14:27:02 +02:00
Roberto
ca290225b9 feat: TaskFormDialog edit-mode 📎 attach pill 2026-05-08 14:19:08 +02:00
Roberto
a5ec0647ec refactor: NewTaskDialog/EditTaskDialog become wrappers around TaskFormDialog 2026-05-08 14:14:09 +02:00
Roberto
57f5470f0d feat: inline project/client/assignee creation in TaskFormDialog pills 2026-05-08 14:12:23 +02:00
Roberto
33e5edc2ba feat: TaskFormDialog property pills with popover editors 2026-05-08 14:08:47 +02:00
Roberto
fadda94135 feat: TaskFormDialog shell with title/description 2026-05-08 14:06:44 +02:00
Roberto
5fa3df9c16 feat: TaskDetailSheet — clickable priority/status chips 2026-05-08 14:04:49 +02:00
Roberto
b48ceea0af refactor: replace TaskDetailDialog with TaskDetailSheet 2026-05-08 14:03:30 +02:00
Roberto
9e31cfa78e feat: TaskDetailSheet description, comments, and ChatInputBox composer 2026-05-08 14:01:34 +02:00
Roberto
c63c94b561 feat: TaskDetailSheet attachments inline strip with add-file flow 2026-05-08 13:59:25 +02:00
Roberto
cbdb37f5a5 feat: TaskDetailSheet properties card (assignee/due/estimate/created) 2026-05-08 13:57:16 +02:00
Roberto
05de7405ba feat: TaskDetailSheet header — breadcrumb, title, chips, overflow menu 2026-05-08 13:48:30 +02:00
Roberto
68286b61bd feat: add TaskDetailSheet skeleton (sticky header/body/composer) 2026-05-08 13:47:23 +02:00
Roberto
a7fbc4c7e3 feat: add 'comment' variant to ChatInputBox 2026-05-08 13:46:20 +02:00
Roberto
1a5605569c feat: add TaskAttachmentChip + useTaskAttachments hook 2026-05-08 13:45:17 +02:00
Roberto
ef71710244 feat: add StatusBadge component
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:41:38 +02:00
Roberto
ca78a4cbc0 feat: add AssigneeStack component 2026-05-08 13:40:16 +02:00
Roberto
b652248404 feat: tasks.update accepts estimate; tasks.delete cascades attachments 2026-05-08 13:39:15 +02:00
Roberto
f5ac37867c feat: add taskAttachments tRPC sub-router (list/pick/create/delete/open) 2026-05-08 13:28:04 +02:00
Roberto
37878df992 feat: add attachments storage helper module 2026-05-08 13:20:52 +02:00
Roberto
9e90791743 feat: add tasks.estimate column and task_attachments table 2026-05-08 13:19:16 +02:00
Roberto
dd3f1442b0 Improve timeline axis labels and gantt grid rendering
- Add year row in month-zoom axis header
- Center secondary tick labels within their column
- Tighter day column (46px → 32px) and normalized day boundaries
- Render explicit grid lines per zoom level (day/week/month)
- Switch sticky label background from blur to solid bg-background

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:11:32 +02:00
Roberto
a5556743f0 Align sidebar trigger position across home, note, brief
Match standard h-14 px-3 header from timeline/tasks pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 18:11:24 +02:00
Roberto
ca231e7b7c Add task briefing carousel with per-task AI research and canvas panel
- New brief/ components: TaskBriefingOverlay, TaskCarousel, TaskBriefChat,
  BriefChatHeader, CanvasPlaceholder, CarouselControls, TaskBriefEmptyState
- ResizablePanelGroup splits chat/canvas when draft present; pill handle in primary color
- taskBriefings SQLite table + tRPC endpoints: taskBriefResearch, getTaskBriefing,
  invalidateTaskBriefing; briefings cached, invalidated on task update
- Stage 1 deep-research agent streams briefing + optional canvas draft via IPC
- Stage 2 follow-up chat injects briefing context into floating mode
- Trackpad horizontal scroll navigation (deltaX threshold + 600ms throttle)
- canvas block stripped from chat panel, rendered only in canvas pane
- i18n keys added across all 5 locales (en/it/es/fr/de)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 15:09:36 +02:00
Roberto
a5a6e25a89 Update the background 2026-05-03 22:52:10 +02:00
Roberto
df8cbb5c35 Update note management from db vector to index 2026-04-30 00:11:25 +02:00
Roberto
d0b344beec timeline resize view 2026-04-29 23:13:29 +02:00
Roberto
1f4adfca90 Update project circle 2026-04-29 14:42:21 +02:00
Roberto
259ab50b25 Update projects page view 2026-04-29 09:31:15 +02:00
Roberto
a04c2434b6 fix tools calls 2026-04-27 09:15:04 +02:00
Roberto
c291fc689a timeevent and date formt fix 2026-04-26 21:06:20 +02:00
Roberto
b61a6de73a Step 1: Improve timeline event view 2026-04-23 00:07:18 +02:00
Roberto
f2a68ee5f6 update tasks visualization 2026-04-22 00:13:22 +02:00
Roberto
0c43f5633f flex-wrap to filter section in the task page 2026-04-21 23:23:59 +02:00
Roberto
4ebf0d4062 Keep project client open 2026-04-21 23:17:27 +02:00
Roberto
244d53f93d Update note view and sidebar where now it visible the project 2026-04-21 23:02:01 +02:00
Roberto
8dceacc2ce Review project page 2026-04-21 21:42:49 +02:00
Roberto
7244810fe1 add daily brief agent 2026-04-19 14:47:47 +02:00
Roberto Musso
e9c790e017 PHASE 4 — Settings > Memory UI (Electron renderer) 2026-04-17 17:04:50 +02:00
Roberto Musso
9b32d834b3 Update setting page 2026-04-15 11:44:40 +02:00
Roberto Musso
333b6cb769 feat(notifications): add sonner toasts to auth and onboarding flows 2026-04-12 18:17:18 +02:00
Roberto Musso
87c444e78d feat(notifications): add sonner toasts to all CRUD operations 2026-04-12 18:13:52 +02:00
Roberto Musso
811759dddb feat(notifications): replace settings saved-state patterns with sonner toasts 2026-04-12 18:06:50 +02:00
Roberto Musso
275edab4bf feat(notifications): add sonner toast foundation with useNotify hook and i18n keys 2026-04-12 18:04:29 +02:00
Roberto Musso
0371a46731 feat(i18n): add multilanguage support (EN/IT/ES/FR/DE) with optimized shared keys
- Add i18next + react-i18next with bundled JSON translations
- Translate all pages: Home, Tasks, Timeline, Projects, Settings, Auth, Agents
- Language selector in Settings > General syncs to electron-store + backend memory
- AI daily brief and agent responses respect selected language
- Optimize translation files: consolidate 16 duplicate keys into common.* namespace
  (add, rename, save, edit, delete, saving, deleting, creating, renameDescription, deleteTitle)
- LanguageSync component in root restores persisted language on startup
2026-04-12 00:33:14 +02:00
Roberto Musso
cd8f6a6751 feat: onboarding wizard - multi-step flow, locale detection, profile settings, user_name in core memory 2026-04-11 23:40:12 +02:00
Roberto Musso
dd98aaaf4d feat: add seed script for populating database with fake data and logging for agent triggers 2026-04-11 02:13:56 +02:00
Roberto Musso
20bc28e59b feat: replace _cachedPassword with device-specific backup key
Add backup-key.ts that generates a random 256-bit key on first use and
persists it via safeStorage + electron-store (same pattern as token.ts).
Remove _cachedPassword and getCachedPassword() from AuthManager — they
were unused since BackupManager does not exist yet. Social-login users
can now use backup features without being tied to a password.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:41:48 +02:00
Roberto Musso
5d112c8dfd feat: implement Google OAuth flow — deep link, auth-manager, login UI, avatar
Step 3 (deep link + auth manager):
- forge.config.ts: register adiuvai:// protocol for packaged app
- index.ts: single-instance lock, setAsDefaultProtocolClient (dev + prod),
  second-instance (Windows/Linux) and open-url (macOS) handlers
- auth-manager.ts: loginWithOAuth() opens browser + awaits deep-link promise;
  handleOAuthCallback() parses adiuvai://oauth/callback, exchanges code via
  POST /auth/oauth/{provider}/callback, resolves pending promise
- router/index.ts: auth.loginWithOAuth tRPC mutation

Step 4 (UI + avatar):
- LoginForm.tsx: Google button with inline SVG icon, divider, "Waiting for
  browser..." pending state; isBusy guards both mutations
- AppShell.tsx: AvatarImage added to NavUser (sidebar trigger + dropdown);
  avatarUrl propagated through AppSidebarProps and NavUser types
- AccountSection.tsx: avatar with photo/initials fallback, display name, email
- api-types.ts: avatarUrl added to UserProfileSchema (camelCase, nullable)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:04:02 +02:00
Roberto Musso
27bc9d90af feat: enhance agent scheduling and prompt builder functionality 2026-04-10 08:45:45 +02:00
Roberto Musso
016c44c6f0 remove unecessary indications 2026-04-09 00:41:02 +02:00
Roberto Musso
02a0f3635b update app name 2026-04-08 23:27:03 +02:00
Roberto
109551f713 Merge branch 'develop' of https://git.muticolturano.com/Adiuva/adiuva into develop 2026-04-08 23:14:31 +02:00
f129b3ba43 Merge branch 'develop' of https://git.muticolturano.com/Adiuva/adiuva into develop 2026-04-08 22:04:43 +02:00
7f0c6f45b0 feat(local-agent-v2): step 5 — migrate promptTemplate → agentConfig in FE
- store.ts: LocalAgentLocalConfig.promptTemplate (string) → agentConfig (Record | null)
- agent-scheduler.ts + router runNow: send agentConfig object to trigger, drop customAgentPrompt
- api-types.ts: WsJourneyReplySchema + LocalAgentConfigSchema + JourneyMessageSchema use agentConfig
- WsJourneyStartSchema: existingTemplate → existingConfig (aligns with backend existing_config field)
- backend-client.ts: JourneyListener + sendJourneyStart + journey_reply handler use agentConfig
- router/index.ts: local agent create/update accept agentConfig; journey router returns agentConfig
- types.ts + AgentsSection + JourneyDialog: promptTemplate → agentConfig throughout
- JourneyDialog: parses JSON agentConfig string → object; shows AgentConfigSummary preview
- PromptBuilderChat: adds onConfigUpdate callback for local agent path (cloud keeps onPromptUpdate)
- InlineAgentCreationStepper: local path uses agentConfig state; cloud path keeps promptTemplate

Cloud agents are intentionally NOT migrated — they retain promptTemplate string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:03:26 +02:00
Roberto
2caea8e21d rebrand: Adiuva → adiuvAI with new compass logo
Replace generic star icon and "Adiuva" text with new compass mark and
"adiuvAI" wordmark across sidebar, login form, and AI chat header.
Add app icon (PNG/ICO) and configure Forge packager and BrowserWindow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:26:44 +02:00
Roberto
b23c4ef255 Merge branch 'develop' of https://git.muticolturano.com/Adiuva/adiuva into develop 2026-04-08 09:54:12 +02:00
Roberto
801ae43000 Add logo brand 2026-04-08 09:53:48 +02:00
bd9af5ddd6 refactor: remove backup, storage, and plugin types from Electron app
- Delete src/main/backup/ (backup-manager, e2e-crypto, sync-queue)
- Remove backup lifecycle from index.ts and router
- Remove syncQueue table from db/schema.ts
- Remove backupEnabled/backupIntervalHours/lastBackupAt from store
- Remove uploadBackup/downloadBackup from backend-client
- Update embed URL to /api/v1/chat/embed
- Remove PluginListing, InstalledPlugin from batch-types
- Remove PermissionGrant, BackupMetadata from api-types
2026-04-08 00:48:00 +02:00
3ae9e450be Fix ProjectSidebar scroll and style native scrollbar
- Constrain SidebarProvider to h-full to close height chain
- Replace Radix ScrollArea in ProjectSidebar with overflow-y-auto div
  (Radix needs explicit pixel height; flex-1 alone is unreliable)
- Add min-h-0 to ProjectSidebar root to allow flex shrink
- Style native webkit scrollbar to match shadcn ScrollBar component
  (w-2.5, bg-border thumb, rounded-full, transparent track/corner)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 23:44:43 +01:00
7616153345 Remove isApproved from tasks, rework agent runner, fix layout overflow
- Remove isApproved column from tasks DB schema and migration; drop column on startup
- Remove isApproved from tRPC router (list, create, update queries)
- Remove isApproved filter from KanbanBoard and ProjectDetail approve/reject UI
- AI-generated tasks now auto-approved; show Sparkles icon via isAiSuggested flag
- Fix tasks page width overflow: add min-w-0 to SidebarInset in AppShell
- Fix task title overflow: truncate with ellipsis inside TaskRow
- Fix tasks toolbar layout: shrink-0 on right side, fixed w-56 on search input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 22:20:55 +01:00
0c21f47a59 removed unused files 2026-03-20 12:47:33 +01:00
7256f1ef4e Fix runNow: pass agentId and create run row in local SQLite
The manual 'Run now' path was missing both agentId in the trigger
request (so BE couldn't echo it in run_context) and the agentRuns
insert after the trigger responded, so manually-triggered runs never
appeared in the history sheet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 11:54:54 +01:00
bf635d9c30 Always record agent run even when no actions are taken
Create the agentRuns row immediately after the trigger POST responds,
before any tool calls arrive. This ensures runs with zero mutations
(agent found nothing to create/update) still appear in the history sheet.

Removed the redundant onConflictDoNothing guard from recordRunAction
since the row is guaranteed to exist by trigger time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:46:42 +01:00
5add259348 Fix await in sync WS message handler for run_complete
Wrap the async db.update in void (async () => {})() like the tool_call
case does — the ws.on('message') callback is synchronous.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:16:18 +01:00
198fd62ef2 Add agent run history sheet with action breakdown
- agent.runs tRPC procedure now queries local SQLite agentRuns table
  (previously fetched from backend) and joins action counts per run
- agent.runActions procedure added for lazy-loading individual actions
  when a run is expanded in the sheet
- AgentRunHistorySheet: slide-in sheet opened via History button on the
  agent card; shows runs with status/duration/action summary; each run
  is expandable to list individual actions (created/updated/deleted)
  with entity type and title
- AgentRow: adds History button, removes embedded AgentRunLog from
  expanded config section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 10:11:40 +01:00
34a771bee3 Implement agent run logging in local SQLite
Protocol:
- RunContextSchema added to api-types — attached to WsToolCall frames
  originating from batch runs; type/runId/agentId identify the run
- WsRunCompleteSchema added — server sends this when a batch run ends

Database:
- agent_runs table: one row per run (id, agentId, status, startedAt, completedAt)
- agent_run_actions table: one row per mutating tool call
  (verb: created/updated/deleted, entityType, entityId, entityTitle)

Logging logic (backend-client.ts):
- On tool_call with runContext: ensure agentRuns row exists, insert
  agentRunActions for insert/update/delete actions
- On run_complete: update agentRuns status and completedAt

Scheduler passes agentId in the trigger POST so the backend echoes it
back in run_context for correct attribution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 09:46:48 +01:00
65a08838c9 Truncate WS log output to 200 chars
Prevents large tool_result payloads from flooding the dev console.
Both send and receive logs now append … when the serialised frame
exceeds 200 characters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:04:16 +01:00
8b5a05a16e Refactor settings page into pluggable components
- Split monolithic settings.tsx (~1500 lines) into focused components under
  src/renderer/components/settings/: GeneralSection, AccountSection,
  AgentsSection, AgentRow, LocalAgentConfigPanel, CloudAgentConfigPanel,
  TemplateSelectCard, PromptBuilderChat, InlineAgentCreationStepper,
  JourneyDialog, SettingsCard, and shared types/constants
- Hide agents list while creation stepper is open
- Use ScrollArea (app scroll primitive) in PromptBuilderChat
- Fix done-state handling: filter empty AI messages, show hardcoded
  confirmation bubble only once, move saved badge below chat, keep
  input enabled after prompt is saved so user can keep refining
- Wrap LocalAgentConfigPanel footer buttons with flex-wrap for narrow cards
- Update Agents section title/subtitle copy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 23:52:39 +01:00
6a87590176 Update shadcn to v4, fix sendHomeRequest call signature, refresh skills lock
- Upgrade shadcn from 3.8.5 to 4.0.8
- Add missing session_id parameter to sendHomeRequest calls in orchestrator
- Update skills-lock.json computed hashes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:34:07 +01:00
cd4644637b Wire journey chat to WS backend and handle end-of-conversation
- Rewrite PromptBuilderChat to use real WS journey mutations with
  button-to-start pattern, loading states, and markdown rendering
- Add isDone state to both PromptBuilderChat and JourneyDialog so
  input is disabled and a confirmation banner shown after prompt generation
- Extract and save promptTemplate via onPromptUpdate when BE sends done=true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:26:21 +01:00
9fd441e7d7 Refactor Local Directory Monitor Agent to two-phase BE-orchestrated architecture
Replace the old single-pass FE file-reader flow (agent_run → agent_data →
agent_complete) with a BE-orchestrated two-phase execution where the BE's LLM
calls filesystem tools on the FE via tool_call/tool_result WS round-trips.

Key changes:
- Remove deprecated file-reader.ts and agent_run/agent_data/agent_complete frames
- Add list_directory, read_file_content, get_file_metadata handlers to DrizzleExecutor
- Migrate journey setup from REST to WebSocket (journey_start/message/reply frames)
- Store agent configs locally in electron-store (no longer on BE)
- Add agent scheduler for periodic auto-trigger via POST /agents/trigger
- Update device_hello to use local agent configs
- Remove fileExtensions from agent config, switch to single directory path
- Add agent.canCreate quota check mutation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 11:05:08 +01:00
b7ddc95171 Udpate task page 2026-03-16 08:53:08 +01:00
488dab7aa1 Refine floating chat session lifecycle and home page glass effects
- Floating chat: reset session_id only on user-initiated page navigation,
  not when closed via X/Escape (session persists for reopening same context)
- Home buttons (sidebar trigger + new chat): add frosted glass background
  so they remain legible when chat messages scroll behind them
- Daily brief toast: match frosted glass opacity/blur to button treatment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:53:39 +01:00
a52e5362b3 make visible buttons to create new chat 2026-03-16 00:38:16 +01:00
582ad389e1 migrate LanceDB package and include pending UI updates 2026-03-16 00:33:48 +01:00
3283cc9ad5 Add new conversation button and session_id to AI chat
- Add "New conversation" button in home page header, next to SidebarTrigger,
  separated by a vertical divider (visible only after first message)
- Generate and persist session_id per chat context in useAIChat; reset to a
  new UUID on clearMessages so each new conversation gets a fresh session
- Floating chat auto-resets session_id on close (clearMessages already fires)
- Thread session_id through tRPC router → orchestrator → backend-client WS
  payloads (home_request and floating_request) as snake_cased session_id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 00:32:54 +01:00
Roberto Musso
396fd2faa4 fix autoscroll aichat 2026-03-15 23:46:01 +01:00
fc71ee6e02 Refactor AI chat rendering and layout stability
- Extract shared AIMessage component to eliminate duplicated markup between
  completed and streaming turns; fix pl-[22px]/pl-[32px] indent inconsistency
- Cache stable CSS layout values (spacing, font-size) in a ref, recomputed
  only on mount and window resize instead of on every message
- Filter whitespace-only text segments in mergeTimelineSegments to prevent
  empty prose divs after timeline tags are stripped
- Group ChatTimelineBlock events by project, rendering one ProjectTimelineBox
  per project instead of collapsing all events into a single timeline
- Apply --ui-scale zoom to body and compensate #root dimensions accordingly
- Fix sidebar svh units to use full height for correct Electron layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 22:46:38 +01:00
96d49abd9a removed unused button 2026-03-13 17:07:08 +01:00
3bc08c6de7 Support structured floating_domain routing and stabilize floating chat stream 2026-03-13 16:10:13 +01:00
8fe2b1c43e Add time-slot and event-driven auto-refresh for daily brief 2026-03-13 12:07:43 +01:00
43cfb694e7 Persist AI chat state across navigation 2026-03-13 10:55:51 +01:00
e9347c5e5a Unify timeline tags into bottom ProjectTimeline block 2026-03-13 00:38:51 +01:00
3bc8ad32cd remove stream_block — parse entity and chart tags inline from text
- Remove WsStreamBlock schema, type, and server frame entry
- Remove stream_block from V3StreamEvent unions (preload, ipcLink)
- Remove onBlock from StreamListener and all WS/IPC wiring
- Remove StreamBlock type, streamingBlocks state from useAIChat
- Convert parseMutationsToBlocks → parseMutationsToEntityTags (inline tags)
- Add inline tag parser: <type>[ids]</type> for entities, <chart>{JSON}</chart>
- Add MessageContent component to render mixed text + entity/chart segments
- Replace BlockRenderer with direct ChatEntityBlock/ChatChartBlock rendering
- Simplify blocks/index.tsx to re-exports only
2026-03-12 00:40:53 +01:00
038cd48285 update daily 2026-03-11 01:12:28 +01:00
8830793105 Update auth token refresh 2026-03-11 01:08:50 +01:00
fa1cd36670 update project timeline view 2026-03-11 00:56:41 +01:00
c61d572023 Update task filter 2026-03-11 00:30:07 +01:00
7af6f0d9e0 bug fix the collapse button 2026-03-11 00:24:29 +01:00
7fd1e85adb update timeline visualization 2026-03-11 00:16:56 +01:00
34e725135d update the vertical in header 2026-03-10 16:32:14 +01:00
1caa930977 update sidebar 2026-03-10 16:13:40 +01:00
d36ca43804 Bug fix send button 2026-03-10 09:10:57 +01:00
b06f5f6022 step 6.1 complete: auth gate in AppShell + LoginForm
- LoginForm.tsx: centered login/register screen with spring animations
- AppShell: queries auth.status on startup; renders LoginForm full-screen when authenticated === false; passes through while loading to avoid flicker
- Settings AccountSection: removed inline login form (AppShell now gates auth); always shows account info + sign out

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 20:54:44 +01:00
c3f298e384 steps 5-8: block renderers, chat integration, floating domain nav, v2 cleanup
- Add block renderer components (chart, entity, table, timeline) with
  shadcn chart/table and spring entrance animations
- Integrate BlockRenderer into AIChatPanel for inline block display
- Refactor FloatingChat to use floating_domain signals for background
  navigation, remove v2 section tag mechanism and dead onAction handler
- Remove v2 chat schemas from api-types.ts (ChatContext, ChatRequest,
  ChatResponse, WsChatRequest, WsTextChunk, WsFinal)
- Fix daily brief onStreamChunk → onStreamEvent migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:56:22 +01:00
733a3c16a8 steps 1-4: v3 ws streaming pipeline
- Step 1: add v3 frame types to api-types.ts (WsHomeRequest,
  WsFloatingRequest, WsStreamStart/Text/Block/End, WsFloatingDomain,
  block data interfaces)
- Step 2: unify chat onto persistent device WS (backend-client.ts) —
  sendHomeRequest/sendFloatingRequest with StreamListener map;
  remove chatStream/openChatWebSocket
- Step 3: refactor orchestrator to v3 (orchestrator.ts) — remove
  buildChatContext/sendStreamChunk, add orchestrateFloating;
  update preload onStreamChunk→onStreamEvent, remove onAction;
  update aiRouter.chat input for mode/scope/conversationHistory
- Step 4: update useAIChat for v3 structured streaming — StreamBlock
  type, onStreamEvent handler, streamingBlocks state, onDomainSignal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 09:00:27 +01:00
aec83c30d2 Update plan 2026-03-09 08:09:45 +01:00
3a7a85c617 Create v3 arch plan 2026-03-08 22:53:01 +01:00
3051e6e0a9 update plan 2026-03-05 23:54:30 +01:00
4cd382b829 step 4.1+4.2 complete: E2E encrypted backup + offline sync queue
- e2e-crypto.ts: Argon2id key derivation (time=3, mem=64MB) + AES-256-GCM
  encrypt/decrypt + SHA-256 checksum + ADV1 blob packing (salt+IV+authTag+ciphertext)
- backup-manager.ts: createBackup (WAL snapshot → encrypt → upload), restoreBackup
  (download → verify → decrypt → atomic file swap → reinit DB → notify renderer),
  getHistory, deleteBackup, schedulePeriodicBackup (setInterval, default 24h)
- sync-queue.ts: enqueues failed backup intents in sync_queue table; processQueue
  retries up to 5×; triggered automatically on WS reconnect via onConnected()
- backend-client.ts: uploadBackup (raw PUT /api/v1/backup with custom headers),
  downloadBackup (If-Modified-Since / 304 support), onConnected() event hook
- auth-manager.ts: password cached in memory at login/register, cleared at logout,
  getCachedPassword() for BackupManager — never persisted to disk
- store.ts: backupEnabled, backupIntervalHours, lastBackupAt settings
- db/schema.ts: sync_queue table (id, action, payload, status, retries, timestamps)
- db/index.ts: getRawSqlite() for .backup() API, getDbPath(), closeDb() for restore
- router/index.ts: backupRouter (create/restore/history/delete/settings/updateSettings);
  login starts periodic backup; logout stops it
- index.ts: backup scheduler wired to app lifecycle; will-quit cleans up timer
- package.json: argon2 added

Backend integration: PUT/GET /api/v1/backup already fully implemented; no
backend changes needed. Tier gating (free=0, pro=5GB, power=25GB) enforced
server-side. Backend only verifies SHA-256 checksum — never decrypts.
2026-03-05 22:56:10 +01:00
0d6c688015 step 3.9 complete: agent run logs UI
- AgentRunLog component with status badges, duration, processed/created counts
- Per-run expandable error list (click to reveal all errors)
- Skeleton loading state + empty state
- Lazy-loaded (only fetches when agent row is expanded), limit 10 runs
- Replaces inline Recent Runs block in AgentRow
2026-03-05 17:46:59 +01:00
b804629f91 feat: add settings page with sections for general, account, agents, and appearance 2026-03-05 17:40:43 +01:00
b860e794a3 step 3.5 complete: persistent WS for agent triggers
- BackendClient.connectPersistent(): opens always-on WS to /api/v1/ws/device
  - sends device_hello frame with deviceId + active local agent IDs on connect
  - handles agent_run: reads files → sends agent_data + agent_complete frames
  - handles tool_call: DrizzleExecutor → tool_result (same as chat WS)
  - client-side heartbeat ping every 30s with 10s pong timeout
  - auto-reconnect with exponential backoff (1s→2s→4s→8s→16s→30s cap)
- BackendClient.disconnectPersistent(): clean close, disables reconnect
- handleAgentRunAndSend(): validates device ID (Step 3.3 final checkbox),
  sends agent_data + agent_complete frames over persistent WS; removes TODO
- index.ts: connectPersistent() on startup (if authenticated), will-quit handler
- authRouter.login: connectPersistent() on success
- authRouter.logout: disconnectPersistent()
- Completes Step 3.3 final checkbox (device-ID validation on agent_run)
2026-03-05 16:08:38 +01:00
6f73824e7e step 3.4 complete: agent tRPC router
- Add AgentCatalogItem, LocalAgentConfig, CloudAgentConfig, AgentRunLog,
  JourneyMessage Zod schemas + types to src/shared/api-types.ts

- Add proxyGet/proxyPost/proxyPut/proxyDelete methods to BackendClient
  (authenticated, casing-converted HTTP proxies with retry + auth bypass)

- Add agentRouter to src/main/router/index.ts (14 procedures):
    agent.catalog                     GET /api/v1/agents/catalog
    agent.local.{list,create,update,delete}  with deviceId injected on create
    agent.cloud.{list,create,update,delete}
    agent.runs                        GET /api/v1/agents/runs (paginated)
    agent.runNow                      POST /api/v1/agents/{id}/run
    agent.journey.{start,message}     chatbot journey endpoints

- Merge agent router into appRouter
- Mark Step 3.3 deviceId checkbox done (satisfied by local.create injection)
2026-03-05 15:51:27 +01:00
e132459fef step 3.3 complete: device ID management
- Add deviceId: string to AppSettings (electron-store) with default ''
- Add getDeviceId() helper — lazy UUID v4 generation, persisted on first call
- Add settings.deviceId tRPC query so renderer + agent router can read it
- Local agents will be device-bound (config injection in step 3.4)
2026-03-05 15:33:50 +01:00
43b031de5b step 3.2 complete: local file reader for directory agent
- Create src/main/agents/file-reader.ts:
  - readAgentFiles() — recursive directory walker with extension allowlist
  - extractContent() — dispatches by ext: text/md/eml/csv/json (readFile),
    pdf (PDFParse v2), docx (mammoth.extractRawText), unknown → error entry
  - chunkContent() — splits >50KB content on newline boundaries with chunk
    metadata; 10MB per-file size cap
  - Security: all paths resolved via realpath() before I/O; every path
    checked against allowedRoots to block symlink escapes and .. traversal
- Update BackendClient.handleAgentRun() to call readAgentFiles() and return
  { files, errors, filesRead }; WS transmission deferred to Step 3.5
- Add pdf-parse@^2.4.5 and mammoth@^1.11.0 (pure JS, no packaging changes)
2026-03-05 15:24:29 +01:00
e769ff2806 step 3.1 complete: WS agent frame types + handleAgentRun stub
- Add WsAgentRunSchema (server→client): agent_run with runId, agentId, config
- Add WsAgentDataSchema (client→server): agent_data with files array
- Add WsAgentCompleteSchema (client→server): agent_complete with filesRead + errors
- Add WsDeviceHelloSchema (client→server): device_hello with deviceId + agentIds
- Extend WsServerFrameSchema union to include agent_run
- Extend WsClientFrameSchema union to include agent_data, agent_complete, device_hello
- Add BackendClient.handleAgentRun() stub (full impl in Steps 3.2 + 3.5)
2026-03-05 15:01:46 +01:00
0c8f0c429a step 2.1 complete: no LangChain, no Copilot SDK, no local LLM 2026-03-05 11:22:08 +01:00
35d7c3e710 step 1.6 complete: migrate embeddings to backend
- upsertNoteEmbedding() calls BackendClient.embedText() instead of local LangChain
- Offline graceful degradation: skip embedding with warning, retry on next save
- searchNotes() embeds queries via backend client
- Add searchNotesByVector() for pre-computed vector search (used by DrizzleExecutor)
- drizzle-executor: vector_search now uses searchNotesByVector with backend-provided vector
- Delete src/main/ai/embeddings.ts (LangChain OpenAIEmbeddings removed)
2026-03-05 00:27:49 +01:00
89df7e48ad step 1.5 complete: refactor orchestrator to delegate to backend
- Replace 996-line LangGraph orchestrator with ~190-line backend-delegation layer
- orchestrate() checks online/auth → builds ChatContext from SQLite → delegates to BackendClient.chatStream()
- Remove setToken, hasToken from aiRouter (replaced by auth.status)
- AIChatPanel: trpc.ai.hasToken → trpc.auth.status, update auth-gate messaging
- AppShell: remove Copilot token dialog, replace with auth-status placeholder
2026-03-05 00:23:46 +01:00
1f6e60d4a9 steps 1.3 + 1.4 complete: backend WebSocket client + Drizzle executor
- Add ws package (WebSocket client for backend streaming)
- src/main/api/backend-client.ts: BackendClient singleton
  - chatStream() bidirectional WS loop with tool_call handling
  - isOnline() health check, embedText() embedding endpoint
  - Error types: OfflineError, AuthExpiredError, RateLimitError, ServerError
  - Exponential backoff retry (max 3 attempts, auth errors not retried)
- src/main/api/drizzle-executor.ts: DrizzleExecutor
  - Table registry allowlist (tasks, projects, clients, checkpoints, notes, taskComments)
  - execute() dispatches select/get/insert/update/delete/vector_upsert/vector_search
  - Filter builder: eq, isNull, like (search), status (includeArchived), date range
  - Inserts auto-generate UUID v4 id + createdAt/updatedAt timestamps
- src/main/db/vectordb.ts: add upsertWithVector() (pre-computed vector, no embed call)
- vite.main.config.mts: externalize ws
2026-03-05 00:07:22 +01:00
254424eec1 step 1.2 2026-03-04 23:57:52 +01:00
9892f21e59 remove old file 2026-03-04 10:16:53 +01:00
8268881f41 step 0.1 complete: Type-safe contracts for all backend communication and the batch/storage subsystem 2026-03-04 10:16:05 +01:00
0fcfa3e5bb Merge branch 'feature/agents-plugin' into develop 2026-03-04 09:21:39 +01:00
b77c6d1195 updated plan 2026-03-02 17:57:02 +01:00
489e8e3bc9 update refactor plan 2026-03-02 14:06:38 +01:00
1ba9c9eee2 remove unused file 2026-03-02 00:07:32 +01:00
Roberto Musso
0f3c63c4de feat(settings): add permissions for git commands in settings.json 2026-03-01 23:31:57 +01:00
Roberto Musso
aa089975df docs: split plan into Electron app + separate backend repo
- AI_REFACTOR_PLAN.md: Electron-only, 7 phases, 18 steps
- BACKEND_PLAN.md: standalone FastAPI backend guide for separate repo

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:20:18 +01:00
195 changed files with 31820 additions and 7817 deletions

View File

@@ -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 tRPCIPC 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 understatednever 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
View 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)"
]
}
}

View File

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

@@ -91,6 +91,10 @@ typings/
# Electron-Forge
out/
# Web SPA build
dist-web/
# local config files
.vscode/
.agents/
src/renderer/routeTree.gen.ts

View File

@@ -1,11 +0,0 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
}
}
}

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/logo/logo-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

14
assets/logo/logo-icon.svg Normal file
View 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
View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

BIN
assets/screenshot/task.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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"
}
}
}

View 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}`);
}
}
}

View File

@@ -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 */ });
}
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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}"`);
}
}

View File

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

File diff suppressed because it is too large Load Diff

View 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 };
}
}

View 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 });
}

View 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();
}

View 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);
}

View 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';
}

View File

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

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

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

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

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

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

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

View 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": {}
}
}

View 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": {}
}
}

View 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": {}
}
}

View 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": {}
}
}

View 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": {}
}
}

View 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": {}
}
}

View 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
}
]
}

View 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.`);
}

View File

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

View File

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

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

View 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
View 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
View 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 };
}

View File

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

File diff suppressed because it is too large Load Diff

View 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();
}),
});

View File

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

View File

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

View 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>
);
}

File diff suppressed because it is too large Load Diff

View 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';

View File

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

View 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>
);
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View 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>
);
}

View 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&apos;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&apos;s go <ChevronRight size={14} className="ml-1" />
</Button>
</div>
</>
)}
{/* ── JOB ROLE ── */}
{step === 'jobRole' && (
<>
<AIBubble>What&apos;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&apos;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&apos;s what I&apos;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&apos;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 &ldquo;{original}&rdquo;
</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>
);
}

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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 &ldquo;{renameProject?.name}&rdquo;.
{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 &ldquo;{deleteProjectId?.name}&rdquo;?
{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 &ldquo;{deleteClient?.name}&rdquo;?
{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 &ldquo;{renameClient?.name}&rdquo;.
{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 &ldquo;{editDialog?.name}&rdquo; 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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

Some files were not shown because too many files have changed in this diff Show More