Compare commits

53 Commits

Author SHA1 Message Date
Roberto
e35542741c refactoring plan 2026-06-12 17:16:22 +02:00
Roberto
315c5d0112 chore: bump submodules — cloud scout fixes
- adiuvAI: static handleScoutProposal import (fixes IPC double-register crash)
- adiuvAI: cloud scout UX (Real-time label, no Run now)
- api: deliver_pending diagnostics + Gmail scout debug scripts
- adiuvAI: drizzle migration journal when timestamps corrected (was blocking 0007/0008 apply)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 00:27:33 +02:00
Roberto
e76e67920e chore: bump submodules — cloud scout fixes
- DB migration 009 applied note (gmail_address)
- Fix: read camelCased authorizeUrl in startGmailOAuth (shell.openExternal undefined)
- Fix: delete cloud scout via Core delete (varchar=uuid cascade 500)
- Fix: delete toast branches on result.success
- Feature: create-at-end — pending-session Gmail OAuth, scout created only at finalize (no orphan rows on abandon)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:28:27 +02:00
Roberto
2cfe0cfb36 chore: bump submodules — cloud scout creation flow (Gmail)
Builds foundational cloud-scout CRUD routes (never existed), gmail_address
column, GmailConnector list_labels/stop_watch, label-list + disconnect
routes. Electron: stepper split into Local/Cloud flows, Gmail slim creation
(name+focus+auto-trash+OAuth, then label/sender filter), config panel
parity, Teams/Outlook gated, i18n 5 langs. Plus header-hide-on-create +
TemplateSelectCard import fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:29:33 +02:00
Roberto
93353c7867 docs: add cloud scout creation flow implementation plan
15 tasks across BE cloud CRUD (foundational — routes never existed),
gmail_address + connector list_labels/stop_watch, tRPC + shared types,
Electron stepper split (Local/Cloud flows) + config panel parity +
catalog gating + i18n.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:10:52 +02:00
Roberto
d85feed325 docs: add cloud scout creation flow design spec
Branches scout creation: Gmail gets slim flow (name + focus + auto-trash
+ label/sender filter, OAuth during creation) fitting two-stage HITL
pipeline. Local-directory flow untouched. Config panel rewritten for
edit parity. Adds gmail_address column, label-list + disconnect routes,
serializer oauthConnected/filterConfig fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:51:41 +02:00
Roberto
bff26c07db chore: bump submodules — post-review fixes
Fixes from final code review:
- Cloud scout CRUD URLs corrected (/api/v1/scouts/cloud)
- Note summarization URL corrected (/api/v1/scouts/notes/summarize)
- GmailConnector.fetch_content now uses single-message Gmail get instead of bulk fetch
- scout_proposal ack now sent only after successful local persist

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 05:40:22 +02:00
Roberto
f5fc25ebce chore: bump submodules — Phase 3 Gmail scout end-to-end
Phase 3 ships:
- GmailConnector implementation (list_new, fetch_metadata, fetch_content, archive, setup_watch, renew_watch)
- Connector registration at app startup
- Real triage LLM via scout-triage-system Langfuse prompt
- Pub/Sub webhook with JWT verification (dev-mode skip when GMAIL_PUBSUB_AUDIENCE empty)
- Cron-fallback poll + Gmail watch renewal in APScheduler lifespan
- Settings UI: Connect Gmail OAuth flow with separate gmail.readonly+modify scopes
- Deep-link callback handler adiuvai://scout/oauth/gmail/callback
- i18n keys scouts.connectGmail + toast.scout.gmailConnected in all 5 languages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 04:55:48 +02:00
Roberto
6eadd61f62 chore: bump submodules — Phase 2 connector skeleton
Phase 2 ships:
- scout_triage_queue Postgres table + cloud_scout_configs gmail fields
- ScoutTriageQueue SQLAlchemy model
- SourceConnector Protocol + connector registry
- ScoutEngine: trigger_scout, _process_item (stub _triage_llm), deliver_pending, ack_proposal
- WS frame contract: scout_proposal + scout_proposal_ack
- Electron scout_suggestions SQLite table
- Electron scout-suggestion-handler

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 03:48:54 +02:00
Roberto
596e5551f8 chore: bump submodules — Phase 1 scouts rename complete
api: agent_ids → scout_ids in device_hello WS frame + tests
adiuvAI: CloudAgentConfig → CloudScoutConfig, agentIds → scoutIds
.claude/CLAUDE.md: update all scout-subsystem doc references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 01:50:48 +02:00
Roberto
445a4cbbf9 docs: add scouts refactor + gmail scout implementation plan
Covers Phases 1-3 (rename, connector skeleton, Gmail end-to-end) as
28 TDD tasks. Phase 4 (Stage 2 categorization + brief HITL) deferred
to separate spec.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:32:40 +02:00
Roberto
732a4c42f8 docs: add scouts refactor + gmail scout design spec
Phases 1-3 in scope: rename agents → scouts (UI/code/Postgres/SQLite/
Langfuse), Gmail cloud scout w/ two-stage pipeline, SourceConnector
abstraction. Phase 4 (Stage 2 categorization + HITL surface in brief)
deferred to task-brief rework.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:15:46 +02:00
Roberto
1cd7a59dfc Add Contextual chat 2026-05-15 22:30:34 +02:00
Roberto
fd700c29be chore: bump adiuvAI submodule — contextual M7 (empty-state copy)
Empty-state hint on contextual sidebar when no messages.
Per-page copy with entity-name interpolation for project / note.
Notes hint mentions note editing deferred to a later release.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:17:09 +02:00
Roberto
750fada8f6 chore: bump submodules — contextual M6 (deprecation sweep)
M6 deletes legacy floating chat entirely:
- Frontend: FloatingChat / FloatingChatContext / useDoubleClickAI / all
  data-ai-section attrs / 'floating' from useAIChat / ChatInputBox /
  sendFloatingRequest / orchestrateFloating / ai.chat mode='floating'
- TaskBriefChat migrated from mode='floating' to mode='contextual'
- Backend: _handle_floating_request / run_floating_stream /
  _FLOATING_SYSTEM_PROMPT / floating tool helpers / WsFloatingRequest /
  WsFloatingDomain / StreamFormatter floating_domain branch /
  LLM_MODEL_FLOATING_AGENT / 'floating-agent' llm.py entry
- Langfuse: 'floating_system' prompt deleted (4 versions, all labels)
- Tests: floating-specific test functions removed from
  test_deep_agent.py + test_ws_unified.py + test_output_formatter.py +
  test_schemas_v3.py

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 18:58:08 +02:00
Roberto
6b857302e3 chore: bump api submodule — narrow contextual tool palette
Smoke trace 0b46841484ba7d024ed9f8d5ac8b1df0 showed agent picking
legacy list_projects + get_project (shallow snapshot, no aiSummary
or tasks) for a 'summarize Nexus' query. _contextual_tools now
exposes ONLY get_page_details for reads. Prompt rule 2 explicitly
forbids list_*/get_* legacy tools. Langfuse contextual_system v2
mirrors the new rule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 18:24:39 +02:00
Roberto
9e28c35725 chore: bump adiuvAI submodule — contextual M5 (get_page_details executor)
M5 ships client-side dispatch for the contextual agent's
get_page_details tool. Handler in drizzle-executor returns
project/task/note entity snapshots plus tasks_all/projects_all/
timeline_all list variants. ToolCallActionSchema widened to
admit the new action.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:47:05 +02:00
Roberto
1662dcd7c0 chore: bump adiuvAI submodule — notes page header into shared AppShell header
HeaderContext exposes leftExtras + rightExtras slots. Notes route
publishes [back arrow] (left) + [Saving? + 3-dot menu] (right) to
the shared header. No more inline toolbar div on notes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 14:42:36 +02:00
Roberto
c1beffb713 chore: bump adiuvAI submodule — trigger button restyle + scroll fixes 2026-05-15 07:54:51 +02:00
Roberto
a9169708ce chore: bump adiuvAI submodule — ProjectTabBar sticky offset = heroH
Two bugs after showHeader: tab bar was sticky top-0 competing with
hero (also sticky top-0); and heroH was measured once at mount,
stale on cold loads. Fix: ResizeObserver tracks heroH in state,
tab bar sticky offset = heroH, observer re-creates on heroH change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 07:31:54 +02:00
Roberto
f6ccde92a7 chore: bump adiuvAI submodule — projects-list header hoist + HeaderContext
ProjectSidebar internal header strip removed. Create-project button
renders in AppShell header next to the page label, only on /projects.
New HeaderContext + useHeaderSlot hook for publishing header label
and trailing extras from route components.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 07:22:52 +02:00
Roberto
c7bf8ce87b chore: bump adiuvAI submodule — move trigger to shared header, shrink to sm
AdiuvaTriggerButton now lives in AppShell header (next to sidebar
toggle + page label), hidden on home. Default size 32px (sm
variant). Routes keep their useContextualScope calls; per-route
button renders removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:33:45 +02:00
Roberto
3b2110d530 chore: bump adiuvAI submodule — contextual sidebar M4 frontend complete
M4 ships full contextual chat UI:
- ContextualChatProvider (mounted once in AppShell)
- useContextualScope hook + ContextualScope type
- AdiuvaTriggerButton + AdiuvaIcon (compass needle, elevated, hover halo)
- ContextualSidebar shell (top-right elevated controls, fade-under-input)
- AppShell ResizablePanelGroup mount (non-home routes only, resize persists)
- Trigger + scope hook on Timeline / Tasks / Projects / ProjectDetail / Notes
- Main process bridge: ai.chat mode='contextual', orchestrateContextual,
  sendContextualRequest/sendContextualScopeUpdate, ai:contextual-scope-update IPC
- Stream listener cleanup on unmount (prevents IPC leaks mid-stream)
- ProjectsListScope sub-component prevents parent-effect clobbering child scope

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:10:03 +02:00
Roberto
69feea03da chore: bump api submodule — contextual frames + run_contextual_stream
M3 complete (code-side):
- ContextualScope Pydantic schema (camelCase alias_generator)
- render_scope_block (no id leakage)
- _CONTEXTUAL_SYSTEM_PROMPT fallback + {date_context} + {language_instruction} slots
- run_contextual_stream (Langfuse 'contextual_system' + fallback)
- _handle_contextual_request + _handle_contextual_scope_update WS handlers
- ContextualBufferProxy + append_system_message on session buffer

NOTE: Langfuse 'contextual_system' prompt must be created manually
before deploy. See M3.6 in docs/2026-05-14-contextual-sidebar-agent-plan.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 21:18:21 +02:00
Roberto
4df5769e8f chore: bump adiuvAI submodule — fix multi-value filter splitting in drizzle-executor
Agent tools sending status="todo,in_progress" et sim now match rows
via inArray instead of literal-string equality.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 20:54:38 +02:00
Roberto
11097b4b5d chore: bump adiuvAI submodule — ChatSurface refactor + home persistence
M2 complete:
- useChatStream hook (shared streaming engine)
- ChatSurface presentational component (variant: home | contextual)
- AIChatPanel thin wrapper over ChatSurface
- Home chat history persisted to SQLite aiChatSessions/aiChatMessages
- 'new chat' rotates session id

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 20:13:59 +02:00
Roberto
2e2150498f chore: bump adiuvAI submodule — aiChat persistence tables + router
M1 complete: aiChatSessions + aiChatMessages tables, migration 0006,
indexes, and aiChat tRPC sub-router. No UI consumer yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:54:31 +02:00
Roberto
9d00c5d06d docs: contextual sidebar implementation plan
Step-by-step plan across M1-M7 with bite-sized TDD-style tasks,
exact code, commit boundaries, and submodule pointer bumps.
Source: docs/2026-05-14-contextual-sidebar-agent-design.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:39:26 +02:00
Roberto
782004916e docs: contextual sidebar agent design
Replace floating-chat (double-click) with adiuva trigger button + right-side
resizable sidebar that persists across navigation and shares the chat surface
with home chat. Includes deprecation sweep of floating_* code paths,
Langfuse prompt swap, and SQLite-backed chat history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:15:36 +02:00
Roberto
1a8acd08c0 chore: bump api submodule + untrack settings.local.json
- api: folder agent pagination/search + PDF/DOCX extract, WS frame casing compat, Langfuse traces
- .gitignore: add .claude/settings.local.json
- graphify-out: refresh after 166-file incremental update
2026-05-14 14:27:41 +02:00
Roberto
82a7a8dc27 chore: bump adiuvAI submodule — DateTimeField typing perf
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:46:47 +02:00
Roberto
5a90dbc832 chore: bump adiuvAI submodule — DateTimeField typing fix after calendar pick
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 13:09:21 +02:00
Roberto
f72aaa8424 chore: bump adiuvAI submodule — DateTimeField autocomplete + calendar stay-open
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:56:02 +02:00
Roberto
fa09ed2156 chore: bump adiuvAI submodule — DateTimeField + assignees kbd wrap + header padding
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 12:38:33 +02:00
Roberto
1341fb3144 chore: bump adiuvAI submodule — Due popover keyboard close fix
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:21:45 +02:00
Roberto
3705316a25 feat: bump adiuvAI submodule — task form keyboard polish
Brings the TaskFormDialog UX in line with the timeline AddEventDialog:
new header (DialogTitle + DialogDescription, no separator), roving
focus across property pills, listbox keyboard inside each popover,
and a typeable DateField (with optional HH:MM suffix) for the Due
field. Two shared hooks (useRovingFocus, useListboxKeys) underpin
the keyboard model.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 11:18:30 +02:00
Roberto
72d7cc2f6e docs: add task form dialog keyboard polish implementation plan
Step-by-step plan to port AddEventDialog UX (header, full keyboard nav,
date+time via DateField) into TaskFormDialog. Two new shared hooks
(useRovingFocus, useListboxKeys), parseDate time-suffix, DateField
withTime + flat props, and i18n updates across all 5 languages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:51:57 +02:00
Roberto
e1d15b3edd docs: add task form dialog keyboard polish design
Spec for porting AddEventDialog UX patterns into TaskFormDialog: new
header (title + description, no separator), full keyboard navigation
(roving focus on pills, arrow nav, Enter to open popover, arrow nav
inside list popovers and calendar), and date+time keyboard entry via
extended DateField (withTime + flat props) and parseDate time suffix.

Includes interactive HTML mockup demonstrating the keyboard flow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 10:47:57 +02:00
Roberto
faea5f0448 docs: add timeline batch-add implementation plan
9 tasks, manual verification per task (no automated test suite).
Covers parseDate utility, DateField primitive, EditEventDialog
migration, AddEventDialog rewrite with keyboard nav, edit-row mode,
batch submit with allSettled error handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 15:59:43 +02:00
Roberto
c68e23b713 docs(spec): defer TaskFormDialog migration from timeline batch-add
TaskFormDialog uses TZDate + H/M selectors; DateField primitive does
not cover timezone or time-of-day. Track as follow-up after DateField
gains timezone/time support.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 15:56:35 +02:00
Roberto
aba0f38816 docs: add timeline batch-add design spec
Stage-then-commit batch flow for AddEventDialog. One project per
batch, typed date entry with smart parse, full keyboard operation.
Extracts DateField + parseDate as shared primitives, migrates
EditEventDialog and TaskFormDialog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 15:53:07 +02:00
Roberto
8dffbc714c feat: bump api + adiuvAI submodules for project folder integration
api/ (15 commits): migration + ORM model + tier matrix + quota helpers +
quota endpoint + folder_indexer (text/vision/PDF/DOCX) + WS index session
frames + scoped read tool + manifest formatter + manifest injection
into home/task-brief/multi-project brief.

adiuvAI/ (15 commits): drizzle schema + scanner + indexer + WS frame
senders + tRPC projectFolders router + drizzle-executor actions +
daily rescan + i18n (5 locales) + 5 React components (FolderChip,
FolderLinkCard, FolderFileList, FolderUnlinkDialog, FilesSection) +
ProjectTabBar/ProjectDetail wiring + pre-flight quota check + toasts.
2026-05-12 13:11:14 +02:00
Roberto
0abed9563b docs: add project-folder integration implementation plan
32 bite-sized tasks across 6 phases: API foundation, indexer (text +
vision + PDF/DOCX), agent wiring, Electron schema + scanner, renderer
UI, end-to-end smoke verification. Linked spec:
docs/superpowers/specs/2026-05-11-project-folder-integration-design.md
2026-05-11 22:24:34 +02:00
Roberto
361f89a29d docs: add project-folder integration design spec
Approved design for linking a local folder to a project: lightweight
manifest with per-file LLM summaries, WS-streamed indexing pipeline,
pre-injected manifest for Home/Brief/Task-Brief agents, tier-gated
token+file-count quota recorded backend-side.
2026-05-11 22:08:44 +02:00
Roberto
74e2152596 chore: bump adiuvAI submodule (remove kbd hint from TaskFormDialog) 2026-05-08 16:11:57 +02:00
Roberto
649e4f00a5 Update adiuvAI: fix popover auto-close in TaskDetailSheet 2026-05-08 16:08:48 +02:00
Roberto
1c8c7e2ddc chore: bump adiuvAI submodule (task UX polish fixes)
Pulls in fixes for: card context menu, change-status icon, sheet live render,
composer alignment, project link relocation to sheet header, no comment toast.
2026-05-08 16:03:26 +02:00
Roberto
b111c76661 Bump adiuvAI: TaskDetailSheet header X/menu alignment fix 2026-05-08 15:38:03 +02:00
Roberto
804a0a5af3 Bump adiuvAI: TaskFormDialog due-time picker + TaskDetailSheet header overlap fix 2026-05-08 15:24:11 +02:00
Roberto
314d5656ae Update adiuvAI: Drizzle migrator replaces hand-rolled DB migrations
Bumps adiuvAI submodule to dac1d50 — initDb() now uses
drizzle-orm/better-sqlite3/migrator against src/main/db/migrations/
instead of a hand-rolled CREATE TABLE IF NOT EXISTS string + try/catch
ALTER blob. Includes a one-time bootstrap that seeds __drizzle_migrations
on legacy DBs so existing data (incl. 51 user tasks) is preserved.

Fixes the empty-tasks-list regression caused by migration 0004
(estimate column + task_attachments table) not being mirrored into
db/index.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:12:35 +02:00
Roberto
e073f4f774 feat: Task UX evolution — paginated table, detail sheet, attachments
Bumps adiuvAI submodule from dd3f144 to e104ffc, completing 27 commits
across 7 phases of the Task UX Evolution plan (docs/2026-05-08-task-ux-evolution-plan.md):

- Phase A: schema + backend (estimate column, task_attachments table, tRPC sub-router, cascade delete)
- Phase B: building blocks (AssigneeStack, StatusBadge, TaskAttachmentChip + hook, ChatInputBox comment variant)
- Phase C: TaskDetailSheet (replaces TaskDetailDialog) — sticky header/body/composer, attachments, clickable chip popovers
- Phase D: TaskFormDialog quick-capture form with property pills (replaces vertical NewTask/EditTask layout)
- Phase E: TaskTable + TaskTableRow + TaskPager + TaskListView orchestrator
- Phase F: project page tasks tab uses TaskListView (KanbanBoard removed)
- Phase G: i18n keys for all 5 locales (en/it/es/fr/de) + attachment toast keys
2026-05-08 14:45:43 +02:00
Roberto
20240c5fea docs: add Task UX Evolution implementation plan
29 tasks across 7 phases (schema+backend, building blocks, detail sheet,
form dialog, table+pager+list view, project page, i18n). Each task is a
single commit, manual smoke verify in dev (no test suite).
2026-05-08 13:05:58 +02:00
Roberto
310410350f docs: add Task UX Evolution design spec
Validated design for task list refactor: shadcn Table view with shared
pagination, right-side detail Sheet with attachments, redesigned
quick-capture create/edit dialog, project detail page reusing the same
list view.
2026-05-08 12:26:28 +02:00
27 changed files with 61180 additions and 13589 deletions

View File

@@ -76,7 +76,7 @@ Main Process (Node.js)
**Local-first storage, cloud AI.** All user data (clients, projects, tasks, notes, timelines) in local SQLite. AI lives entirely on the FastAPI backend — Electron orchestrator is a thin delegation shell that forwards to `/api/v1/device` WS and dispatches v3 typed stream frames + tool-call ↔ DrizzleExecutor round-trips back to renderer.
**IPC channels**:
- `'trpc'` — bidirectional tRPC request/response (all CRUD + auth + agent + memory proxy)
- `'trpc'` — bidirectional tRPC request/response (all CRUD + auth + scout + memory proxy)
- `'ai:stream'` — one-way v3 stream frames main → renderer
- `'ai:action'` — AI side-effects (e.g. agent auto-creates task)
@@ -85,19 +85,19 @@ Main Process (Node.js)
- `ipc.ts` — Custom tRPC↔IPC bridge
- `store.ts` — electron-store for `FormatPrefs` + `uiLanguage`; exports `getUiLanguage()`
- `router/index.ts` — All tRPC sub-routers (~1627 LOC)
- `db/schema.ts` — 10 tables: clients, projects, tasks, timelineEvents, timelineEventDependencies, notes, noteEdits, taskComments, agentRuns, agentRunActions
- `db/schema.ts` — 10 tables: clients, projects, tasks, timelineEvents, timelineEventDependencies, notes, noteEdits, taskComments, scoutRuns, scoutRunActions
- `db/index.ts` — Drizzle + better-sqlite3 (WAL), singleton `getDb()`, `initDb()` migrations
- `db/notes-backfill.ts` — Startup backfill: generates `aiSummary` for notes with null summary
- `ai/orchestrator.ts` — Thin backend-delegation layer (~304 LOC). Connectivity/auth guard → `BackendClient.sendHomeRequest()` / `sendFloatingRequest()` → forwards v3 stream frames to renderer. Also schedules daily-brief regeneration.
- `ai/token.ts` — Two-tier token storage (safeStorage + electron-store fallback)
- `agents/agent-scheduler.ts` — Local agent scheduling (filesystem agents)
- `api/backend-client.ts` — WS client to FastAPI: handles tool-call round-trips, v3 stream frame dispatch, journey + agent proxies
- `scouts/scout-scheduler.ts` — Local scout scheduling (filesystem scouts)
- `api/backend-client.ts` — WS client to FastAPI: handles tool-call round-trips, v3 stream frame dispatch, journey + scout proxies
- `api/drizzle-executor.ts` — Executes backend-issued tool calls against local SQLite. Wraps results through `formatRow()`/`formatRows()` using user FormatPrefs
- `auth/auth-manager.ts` — Login, register, logout, OAuth flow (singleton)
- `auth/backup-key.ts` — Device-specific AES-256 backup key (safeStorage, not password-derived)
- `auth/locale-defaults.ts` — Detects timezone, date/time format, language from OS locale
**tRPC routers** (in `appRouter`): `health`, `settings`, `clients`, `projects`, `tasks`, `timelineEvents`, `timelineEventDependencies`, `notes`, `noteEdits`, `taskComments`, `ai`, `auth`, `agent` (with `local` / `cloud` / `journey` sub-routers), `memory`.
**tRPC routers** (in `appRouter`): `health`, `settings`, `clients`, `projects`, `tasks`, `timelineEvents`, `timelineEventDependencies`, `notes`, `noteEdits`, `taskComments`, `ai`, `auth`, `scout` (with `local` / `cloud` / `journey` sub-routers), `memory`.
**Renderer** (`src/renderer/`): file-based routing via TanStack Router (`routeTree.gen.ts` auto-generated). shadcn/ui new-york theme, neutral colors. Path alias `@/*``src/renderer/*`. Notes editor: Milkdown (`@milkdown/crepe`).
@@ -107,16 +107,16 @@ Main Process (Node.js)
- `forge.config.ts` has cross-compilation hooks (downloads platform-specific native binaries for better-sqlite3)
- DB has no foreign key constraints — cascade deletes in tRPC procedures
- Timestamps are milliseconds (`Date.getTime()`), not ISO strings
- Notes use `aiSummary` (≤250 char, backend `gpt-4o-mini` via `POST /api/v1/agents/notes/summarize`) for AI navigation — LanceDB fully removed
- Notes use `aiSummary` (≤250 char, backend `gpt-4o-mini` via `POST /api/v1/scouts/notes/summarize`) for AI navigation — LanceDB fully removed
- AI note edits go through `noteEdits` HITL table (`type: append|insert|replace`, `status: pending|approved|rejected`); backend tool `propose_note_edit` → drizzle-executor inserts row; user approves/rejects in UI; auto-reject on missing anchor
- `checkpoints` table replaced by `timelineEvents` + `timelineEventDependencies` (events are typed `milestone|checkpoint|activity`, with optional dep edges)
- `agentRuns` + `agentRunActions` populated by backend-client on tool_call/run_complete frames; UI reads via `agent.runs` / `agent.runActions`
- `scoutRuns` + `scoutRunActions` populated by backend-client on tool_call/run_complete frames; UI reads via `scout.runs` / `scout.runActions`
**Settings Page (shared Electron + Web)**:
- Settings page runs in **both** Electron and standalone web SPA. Same React components — no duplication.
- **Platform Adapter**: `PlatformProvider` context (`src/renderer/lib/platform.tsx`) exposes `isElectron`/`isWeb`/`hasLocalAgents`/`hasFileDialog`. Components use `usePlatform()` to gate Electron-only features.
- **Web build**: `vite.web.config.mts``dist-web/`. Entry: `web.html``src/renderer/web-main.tsx` (uses `httpBatchLink` via `lib/httpLink.ts` instead of `ipcLink`).
- **Electron-only gating**: Device ID card and local agent filesystem gated behind `platform.isElectron`. On web: visible but disabled, not hidden.
- **Electron-only gating**: Device ID card and local scout filesystem gated behind `platform.isElectron`. On web: visible but disabled, not hidden.
- **Gotcha**: Do NOT add Electron-specific settings (server URL, native file pickers) without wrapping in `platform.isElectron`. Same component tree renders on web.
**Onboarding Wizard**:
@@ -134,7 +134,7 @@ Main Process (Node.js)
**i18n (Internationalization)**:
- `i18next` + `react-i18next` with bundled JSON translations (no lazy loading).
- Config in `src/renderer/i18n.ts`. 5 languages: EN, IT, ES, FR, DE. `SUPPORTED_LANGUAGES` exported for UI selectors.
- Translation files: `src/renderer/locales/{en,it,es,fr,de}/translation.json`. Namespaces: `nav`, `auth`, `tasks`, `settings`, `common`, `errors`, `home`, `timeline`, `projects`, `agents`.
- Translation files: `src/renderer/locales/{en,it,es,fr,de}/translation.json`. Namespaces: `nav`, `auth`, `tasks`, `settings`, `common`, `errors`, `home`, `timeline`, `projects`, `scouts`.
- **`common.*` namespace** holds shared labels (`save`, `cancel`, `delete`, `edit`, `add`, `rename`, `saving`, `deleting`, `creating`, `renameDescription`, `deleteTitle`). Check `common.*` before adding new key.
- Pluralization uses i18next `_one`/`_other` suffixes.
- `LanguageSync` component in `src/renderer/index.tsx` reads persisted `uiLanguage` from electron-store via tRPC on startup, syncs to i18next.
@@ -191,8 +191,8 @@ FastAPI app (app/main.py)
├── HTTP Routes (app/api/routes/) — all under /api/v1
│ ├── auth.py — register, login, refresh, profile, OAuth, onboarding, password
│ ├── chat.py — POST /chat, /chat/brief, /chat/embed
│ ├── agents.py — catalog, can-create, trigger, notes/summarize
│ ├── agent_setup.py — guided agent setup (journey)
│ ├── scouts.py — catalog, can-create, trigger, notes/summarize
│ ├── scout_setup.py — guided scout setup (journey)
│ ├── billing.py — Stripe checkout, webhook, subscription, invoices
│ ├── device_ws.py — WS /device (unified streaming endpoint: home, floating, brief, journey)
│ └── memory.py — core / relational / forget-all
@@ -225,9 +225,9 @@ FastAPI app (app/main.py)
└── Models (app/models.py) — SQLAlchemy 2.0 ORM
```
**HTTP route prefix**: every router included with `prefix="/api/v1"`. So `/api/v1/auth/...`, `/api/v1/chat`, `/api/v1/agents/...`, `/api/v1/memory/...`, `/api/v1/device` (WS).
**HTTP route prefix**: every router included with `prefix="/api/v1"`. So `/api/v1/auth/...`, `/api/v1/chat`, `/api/v1/scouts/...`, `/api/v1/memory/...`, `/api/v1/device` (WS).
**ORM models** (`app/models.py`): `User`, `RefreshToken`, `OAuthAccount`, `Subscription`, `LocalAgentConfig`, `CloudAgentConfig`, `AgentRunLog`, `MemoryCore`, `MemoryAssociative`, `MemoryEpisodic`, `MemoryProactive`, `ExtractionQueue`, `MemoryRelation`, `Plugin`. PostgreSQL (asyncpg + SQLAlchemy 2.0 async). Alembic migrations in `alembic/versions/`.
**ORM models** (`app/models.py`): `User`, `RefreshToken`, `OAuthAccount`, `Subscription`, `LocalScoutConfig`, `CloudScoutConfig`, `ScoutRunLog`, `MemoryCore`, `MemoryAssociative`, `MemoryEpisodic`, `MemoryProactive`, `ExtractionQueue`, `MemoryRelation`, `Plugin`. PostgreSQL (asyncpg + SQLAlchemy 2.0 async). Alembic migrations in `alembic/versions/`.
**Lifespan crons** (only if `settings.SCHEDULER_ENABLED`):
- `_memory_cron_tick` — hourly: drains Free-tier extraction queue + mines proactive patterns for Power+ users
@@ -235,7 +235,7 @@ FastAPI app (app/main.py)
**LLM routing**: backend agents own all intelligence. Tool calls describe client-side ops (JSON) → Electron `drizzle-executor` runs them against local SQLite → result returned to backend over WS. Tool loop cap inside agent runner prevents runaway iteration.
**Zero-trust data model**: backend never stores raw user content. PostgreSQL holds auth, billing, plugin metadata, encrypted memory (Core/Associative/Episodic/Proactive/Relational), agent configs, run logs.
**Zero-trust data model**: backend never stores raw user content. PostgreSQL holds auth, billing, plugin metadata, encrypted memory (Core/Associative/Episodic/Proactive/Relational), scout configs, run logs.
**Config**: `app/config/settings.py` — all env vars via Pydantic Settings. Copy `.env.example` to `.env` for local dev.

View File

@@ -1,15 +1,6 @@
{
"permissions": {
"allow": [
"mcp__langfuse__createTextPrompt",
"mcp__langfuse-docs__searchLangfuseDocs",
"Bash(python -m ruff check . --fix)",
"Bash(ruff check *)",
"Bash(powershell -Command \"cd 'c:\\\\\\\\_temp\\\\\\\\_adiuvai_workspace\\\\\\\\api'; .venv\\\\\\\\Scripts\\\\\\\\pytest.exe tests/test_memory_relations.py -v 2>&1 | Out-File -FilePath 'C:\\\\\\\\Users\\\\\\\\musso\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\pytest_phase3.txt' -Encoding UTF8; Get-Content 'C:\\\\\\\\Users\\\\\\\\musso\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\pytest_phase3.txt'\")",
"mcp__postgres__execute_sql",
"mcp__langfuse__listPrompts",
"mcp__langfuse__getPrompt"
]
"allow": []
},
"enabledPlugins": {
"caveman@caveman": true

View File

@@ -1,21 +0,0 @@
{
"permissions": {
"allow": [
"Skill(shadcn)",
"Bash(npm run *)",
"Bash(node -e \"const d = require\\('date-fns'\\); console.log\\(typeof d.eachDayOfInterval, typeof d.eachWeekOfInterval, typeof d.eachMonthOfInterval, typeof d.startOfMonth\\)\")",
"Bash(export LANGFUSE_PUBLIC_KEY=pk-lf-0e62a9eb-0978-4e2e-b3ad-bb36194701b8)",
"Bash(export LANGFUSE_SECRET_KEY=sk-lf-286c165f-1c84-4a36-b0b0-cfe5b680897d)",
"Bash(export LANGFUSE_HOST=https://langfuse.muticolturano.com)",
"Bash(npx langfuse-cli *)",
"mcp__langfuse-docs__getLangfuseDocsPage",
"Bash(python -c ' *)",
"WebFetch(domain:ui.shadcn.com)"
]
},
"enabledMcpjsonServers": [
"langfuse-docs",
"langfuse",
"postgres"
]
}

4
.gitignore vendored
View File

@@ -9,6 +9,8 @@ docs/node_modules
docs/package.json
docs/package-lock.json
tmp/
.superpowers/
graphify-out/cache/
graphify-out/manifest.json
graphify-out/cost.json
graphify-out/cost.json
.claude/settings.local.json

Submodule adiuvAI updated: dd3f1442b0...4b80bcb53b

2
api

Submodule api updated: 67562b8092...79a926e4d8

View File

@@ -0,0 +1,309 @@
# Task UX Evolution — Design
**Date:** 2026-05-08
**Scope:** adiuvAI desktop app (renderer + main process)
**Status:** Approved by user, ready for implementation plan
## Goal
Evolve the task management UX:
1. Replace the list-of-cards view with a paginated **shadcn Table**, keep the card grid as an alternative view, share pagination across both.
2. Replace `TaskDetailDialog` with a **right-side `Sheet`** (sticky header + scrolling body + sticky composer), add **attachments** support.
3. Redesign the create dialog as a **quick-capture form** with pill-style property controls. Edit dialog reuses the same shell.
4. Apply the same task list view inside the **project detail page**, scoped to that project.
Single spec, single implementation plan. All four subsystems ship together.
## Non-goals
- AI-driven estimate generation (column added now, populated by a future agent).
- Comment attachments (composer has no attach icon).
- Per-column header sorting in the table (existing `Order by` Select stays).
- Reporter / Tags fields (image reference includes them but spec excludes).
## 1. Architecture & shared state
A new `TaskListView` component owns the task list rendering for both the Tasks page and the Project detail page. It encapsulates the toolbar, the table or grid body, and the pager. The page consuming it passes a task array plus an optional `hideProjectColumn` flag.
**Persisted state (`localStorage`):**
- `tasksViewMode`: `'list' | 'grid'` (already exists, kept).
- `tasksPageSize`: `10 | 25 | 50 | 100` — default `25`.
Page index is component-local and resets per route entry. It also resets when the search, status filter, or `Order by` changes.
**Pagination scope:** Tasks page and Project page each maintain their own page state. Toggling list ↔ grid within a page preserves the current page.
**Why client-side slicing:** task list is already fully loaded via `trpc.tasks.list`. No backend pagination required at this scale.
## 2. Database schema
Two changes to `src/main/db/schema.ts`:
```ts
// tasks: add column
estimate: integer('estimate'), // minutes, nullable
// new table
export const taskAttachments = sqliteTable('task_attachments', {
id: text('id').primaryKey().$defaultFn(() => randomUUID()),
taskId: text('task_id').notNull(),
filename: text('filename').notNull(),
mimeType: text('mime_type'),
sizeBytes: integer('size_bytes').notNull(),
storedPath: text('stored_path').notNull(), // relative to userData/attachments
createdAt: integer('created_at').notNull(),
});
```
Migration generated with `drizzle-kit generate`. Per project convention, no foreign key constraint — cascade is handled in the `tasks.delete` tRPC procedure (delete attachment files + rows before deleting the task).
## 3. Attachments — file storage and IPC
**Storage path:** `app.getPath('userData') / attachments / <taskId> / <uuid>-<sanitizedFilename>`.
**Sanitization:** strip path separators, control characters, leading dots; cap filename at 200 chars.
**Limits:**
- Soft cap 50 MB per file. Larger files trigger a warning toast and are not uploaded.
- No per-task total cap.
**New tRPC sub-router `taskAttachments`** (in `src/main/router/index.ts`):
| Procedure | Input | Behavior |
|---|---|---|
| `list` | `{ taskId }` | Returns attachment rows for the task. |
| `pick` | `{}` | Main: `dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] })`. Returns `Array<{ path, name, size }>`. |
| `create` | `{ taskId, sourcePath, filename, sizeBytes, mimeType? }` | Main: `fs.mkdir(userData/attachments/<taskId>, recursive)`, copy file with new uuid name, insert row. |
| `delete` | `{ id }` | Look up row, `fs.unlink(storedPath)`, delete row. |
| `open` | `{ id }` | `shell.openPath(absoluteStoredPath)`. |
**Helper module `src/main/attachments/storage.ts`:** path resolution, sanitize, copy, delete. Keeps tRPC procedures thin.
**Tasks router updates:**
- `tasks.update` accepts `estimate?: number | null`.
- `tasks.delete` enumerates `taskAttachments` for the task and deletes files + rows before deleting the task row.
## 4. Table view
**Component:** `TaskTable` using shadcn `Table` / `TableHeader` / `TableBody` / `TableRow` / `TableCell`.
**Container styling (translucent card over gradient bg):**
```
bg-card/65 backdrop-blur-xl border border-border/50 rounded-lg shadow
```
The `--card` token is used (not a hard-coded color), so dark mode works.
**Columns:**
1. **Task** — title, single line, truncate with tooltip.
2. **Project**`Client Project` breadcrumb. Client text muted, project text foreground. Hidden when `hideProjectColumn` is set. Click navigates to the project page.
3. **Priority** — existing `<PriorityBadge>` component (arrow icon + colored text, no pill).
4. **Due**`formatDueDate(t.dueDate, prefs)`. Overdue: red text. None: muted `—`.
5. **Assignee**`<AssigneeStack>`: overlapping avatars (max 2 visible), `+N` chip if more, tooltip listing all. None: muted `—`.
**Row interaction:**
- Click row → opens `TaskDetailSheet`.
- Right-click / context menu (kept from current `TaskRow` behavior): **Edit**, **Delete**, **Change status →** submenu (To Do / In Progress / Done with checkmark on current).
**Sorting:** existing `Order by` Select in the toolbar remains the only sort control. No per-column header sort.
**Empty state:** existing `<Empty>` component spans all columns when the filtered list is empty.
## 5. Pagination
**Component:** `TaskPager` rendered in its own translucent card box below the list/grid (same style tokens as the table card, separate box).
**Layout:**
```
┌──────────────────────────────────────────────────────────────────┐
│ Showing 125 of 312 tasks Rows per page: [25 ▾] │
1 2 3 4 5 … 13
└──────────────────────────────────────────────────────────────────┘
```
**Behavior:**
- Page-number window: always include first, last, current. Up to 7 buttons total. Ellipsis when the gap is greater than 1.
- `ResizeObserver` on the pager → reduce visible buttons on narrow widths (7 → 5 → 3 → just prev/next).
- Page-size change resets `pageIndex` to 0.
- If filters trim the total below `pageIndex * pageSize`, snap `pageIndex` to the last valid page.
- Pager renders **for both list and grid views**, identically.
## 6. Detail sheet
**Replaces:** `TaskDetailDialog.tsx` → new `TaskDetailSheet.tsx` using shadcn `Sheet`, right side, width ~480 px.
**Three fixed regions:**
```
┌─────────────────────────────────┐ ← STICKY HEADER
│ Acme Communications │ breadcrumb (small, muted)
│ Draft Q2 investor update email │ title (18 px, semibold)
│ [↑ High priority] [● In progress] chip row
│ [⋯] │ overflow menu (Edit, Delete)
├─────────────────────────────────┤
│ │ ← SCROLLING BODY
│ ┌─ Properties card ──────────┐ │
│ │ Assignee | Due │ │
│ │ Estimate | Created │ │
│ │ Files: [chip] [chip] [+Add]│ │
│ └────────────────────────────┘ │
│ │
│ Description │
│ <body text> │
│ │
│ ── separator ── │
│ │
│ Comments · 4 │
│ <comment list, no inner scroll>│
│ │
├─────────────────────────────────┤ ← STICKY COMPOSER
│ [👤] ┌──────────────┐ [↑] │
│ │ Write comment│ │
│ └──────────────┘ │
└─────────────────────────────────┘
```
**Header chips:**
- Priority: existing `<PriorityBadge>` (arrow + colored text).
- Status: pill using existing `STATUS_CONFIG` colors.
- Both are clickable → popover to change.
**Properties card** (translucent inner box, 2-column grid):
- Assignee, Due, Estimate, Created — each row is small uppercase label + value.
- Estimate shows muted `—` until the AI agent ships.
- Files row spans both columns: horizontal chip strip. Each chip: `📎 filename · sizeKB ×`. `+ Add` is a dashed pill that triggers `taskAttachments.pick`.
- Click chip filename → `taskAttachments.open`.
- Click × → confirm + `taskAttachments.delete`.
**Comments:** no inner ScrollArea — they scroll with the body.
**Composer (sticky bottom):** reuses the home / AI input wrapper styling:
```
rounded-2xl bg-background/70 backdrop-blur-xl
border border-border/50 shadow-lg ring-1 ring-border/20
focus-within:shadow-xl focus-within:border-ring/50
```
Internally reuses the existing `ChatInputBox` component via a new `'comment'` variant (auto-grow textarea + `ArrowUp` send button, draft persistence, `⌘ + Enter` submit). **No attach icon in the composer.**
**Edit / Delete:** moved into the header overflow menu. The previous footer action bar is removed.
## 7. Create / Edit dialog
**Components:**
- `TaskFormDialog` — shared shell, props: `mode: 'create' | 'edit'`, initial values, `onSubmit`.
- `NewTaskDialog` and `EditTaskDialog` become thin wrappers (different default values + mutation).
**Layout:**
```
┌─ New task ─────────────────── ⌘+Enter to create ─┐
│ │
│ What needs to be done? ← 22 px input │
│ Add a description… ← textarea │
│ │
│ PROPERTIES │
│ [📁 Project: Acme Communications] │
│ [↑ Priority: High] [● Status: To Do] │
│ [📅 Due: Apr 30, 2026] │
│ [+ Add assignees] ← dashed (empty) │
│ │
│ ────────────────────────────────────────────────│
│ [📎] * [Cancel] [Create task] │
└──────────────────────────────────────────────────┘
```
`*` 📎 icon-pill is **only visible in Edit mode**. Attachments are blocked in Create mode (need a `taskId`); the user saves first, then attaches via the detail sheet or via Edit.
**Pill states:**
- Set: `bg-card/70`, solid border, label + value visible.
- Empty: dashed border, muted text.
**Pill click → field-specific shadcn `Popover`:**
- Project — Select w/ inline create flow (existing logic preserved: project + client + sub-client).
- Priority — three-option select (high / medium / low).
- Status — three-option select.
- Due — Calendar + optional hour/minute selectors.
- Assignees — existing Popover (known-assignees list + add-new).
**Keyboard:** `⌘ / Ctrl + Enter` submits. Title `autoFocus`.
## 8. Project page integration
**File:** `src/renderer/routes/projects.$projectId.tsx`.
The existing tasks tab content is replaced by `<TaskListView projectId={...} hideProjectColumn />`. The toolbar (status tabs, search, `Order by`, view toggle, **New task** button) is identical to the Tasks page. Pagination state is local to this page (separate from the Tasks page state). Clicking a row opens the same `TaskDetailSheet`.
No changes to other project tabs (overview, notes, timeline).
## 9. Files
**New files:**
```
src/renderer/components/tasks/
TaskListView.tsx — shared toolbar + table/grid + pager
TaskTable.tsx — shadcn Table renderer
TaskTableRow.tsx — single row + context menu
TaskPager.tsx — pagination card box
TaskDetailSheet.tsx — right-side Sheet replacing TaskDetailDialog
TaskFormDialog.tsx — shared shell for create/edit
TaskAttachmentChip.tsx — file chip
AssigneeStack.tsx — overlapping avatars + overflow chip
StatusBadge.tsx — status pill (existing STATUS_CONFIG colors)
src/main/
attachments/storage.ts — path resolution, sanitize, copy, delete helpers
```
**Modified files:**
```
src/renderer/routes/tasks.tsx — render <TaskListView>
src/renderer/routes/projects.$projectId.tsx — tasks tab uses <TaskListView hideProjectColumn>
src/renderer/components/tasks/NewTaskDialog.tsx — wrapper around TaskFormDialog (mode='create')
src/renderer/components/tasks/EditTaskDialog.tsx — wrapper around TaskFormDialog (mode='edit')
src/main/db/schema.ts — tasks.estimate, taskAttachments
src/main/db/migrations/ — new generated migration
src/main/router/index.ts — taskAttachments router; tasks.update accepts estimate; tasks.delete cascades attachments
```
**Deleted files:**
```
src/renderer/components/tasks/TaskDetailDialog.tsx — replaced by TaskDetailSheet
src/renderer/components/tasks/TaskRow.tsx — replaced by TaskTableRow (TaskCard kept for grid)
```
## 10. i18n keys
New keys in the `tasks.*` namespace, added to all five language files (en, it, es, fr, de):
```
tasks.colTask, tasks.colProject, tasks.colPriority, tasks.colDue, tasks.colAssignee
tasks.rowsPerPage, tasks.showingNofM (plural-aware), tasks.noAssignees
tasks.estimate, tasks.attachments, tasks.addFile, tasks.removeFile, tasks.fileTooLarge
tasks.changeStatus, tasks.properties
tasks.confirmDeleteAttachment
```
## 11. Out-of-scope follow-ups
- AI agent that generates `estimate` for a task.
- Comment attachments.
- Per-column sort in the table.
- Backend pagination (only needed if task counts grow much larger).
## 12. Implementation order (suggested)
A natural shipping order; the implementation plan can refine:
1. DB migration (estimate column + taskAttachments table).
2. Main-process attachments storage module + tRPC sub-router.
3. `TaskDetailSheet` with attachment UI (deletes the old dialog).
4. `TaskFormDialog` shared shell; rewire `NewTaskDialog` / `EditTaskDialog`.
5. `TaskListView`, `TaskTable`, `TaskPager`, `AssigneeStack`, `StatusBadge`. Wire into Tasks page.
6. Wire `TaskListView` into project detail page with `hideProjectColumn`.
7. i18n keys for all five languages.

Binary file not shown.

View File

@@ -0,0 +1,384 @@
# Contextual Sidebar Agent — Design
**Date:** 2026-05-14
**Status:** Approved, ready for implementation plan
**Scope:** Replace the legacy floating-chat (double-click-on-entity) flow with a contextual right-side chat sidebar that survives navigation, anchors to the current page's entity, and shares chat surface code with the home chat.
---
## 1. Goals
1. Replace the double-click "floating chat" with an explicit **adiuva trigger button** in the top-right of Timeline, Tasks, Projects and Notes pages.
2. Clicking the trigger opens a **right-side, resizable sidebar** with the same chat UI as the home chat.
3. The sidebar **persists across route changes** (no remount, no history reload). A silent system message informs the agent each time the user navigates.
4. The agent receives a **base context payload** every turn (`page`, `entityType`, `entityId`, `entityName`, `counts`) and can pull full details on demand via a single `get_page_details` tool.
5. Chat history **survives application restart** (SQLite-backed sessions, same model for home and contextual).
6. **All deprecated code paths removed** from frontend, main process, backend, Langfuse and electron-store. Pre-1.0 clean refactor [[feedback_clean_refactor_dev]].
Out of scope (next sprint): note write/update/summarize tools.
## 2. UX
### 2.1 Trigger button
- Bare elevated button anchored top-right of each page header.
- 48×48, 14px radius, dark surface (#1d1d20), multilayer shadow (Copilot-style elevation).
- Icon = `adiuvAI/assets/logo/logo-mark.svg` (compass needle, gold + light south for dark mode). Settles continuously (6s `compass-settle` keyframes, ported from `logo-mark.svg`).
- Hover: shadow deepens + subtle gold ambient glow halo behind the button. **No lift, no animation speedup.**
- Active: scale to 0.97.
- Pages with trigger: `/timeline`, `/tasks`, `/projects`, `/projects/$projectId`, `/notes/$noteId`.
- Home (`/`) does NOT mount the trigger — home keeps full-width `AIChatPanel`.
### 2.2 Sidebar shell
- Right side of `AppShell`, between the page and the screen edge.
- Resizable via shadcn `ResizablePanelGroup` + `ResizableHandle withHandle` (same control as task-brief, see [TaskCarousel.tsx:157-190](adiuvAI/src/renderer/components/brief/TaskCarousel.tsx#L157-L190)).
- Default size 32% of viewport. Min 24% (~320px on common widths). Max 55%.
- Width persisted as percentage in electron-store key `chat.sidebar.size`.
### 2.3 Sidebar layout
- **No header bar, no internal section dividers.** Pure chat surface.
- Two **elevated icon buttons floating top-right** (same recipe as trigger, 32×32 scale, 10px radius): `+` new chat, `✕` close.
- Messages scroll inside a single area. Bottom of the area is padded so that the last messages can scroll behind the input.
- Input is absolutely positioned at the bottom with a 64px gradient mask above it (`linear-gradient(to bottom, transparent → bg)`), so chat content fades visually under the translucent input box.
- Input box: `rgba(29,29,32,.85)`, `backdrop-filter: blur(14px)`, 16px radius, soft shadow — matches the home chat input treatment.
- No scope chip rendered. The user knows what page they are on; the agent sees the scope in the payload.
### 2.4 Sidebar lifecycle
- Mounted **once** at AppShell level inside `ContextualChatProvider`. Survives all route transitions.
- Default state: closed.
- Open state remembered for the current session (renderer memory); reset to closed on app restart.
- Sidebar is hidden on the home route regardless of open state.
### 2.5 Page-change behaviour
- Each page calls `useContextualScope(scope)` in its render.
- Provider diffs the new scope against the previous one. If they differ, it sends a `contextual_scope_update` WS frame to the backend.
- Backend appends a `system` message to the session buffer (`"User navigated to {scope}. Treat this as the new active context."`) and returns an ack. **No LLM call, no tokens.**
- User sees no visible divider in chat. The new context kicks in on the next user turn.
## 3. Architecture
```
┌─ AppShell (mounted once) ─────────────────────────────────────┐
│ │
│ ResizablePanelGroup direction="horizontal" │
│ ┌ Page (Outlet) ────────────┐ ┌ Sidebar (when !home) ──┐ │
│ │ useContextualScope({...}) │ │ ChatSurface │ │
│ │ Top-right trigger button │──│ + elevated controls │ │
│ └───────────────────────────┘ └────────────────────────┘ │
│ ContextualChatProvider │
│ { open, size, sessionId, scope, │
│ messages, isStreaming, send, toggle, │
│ setScope, newChat } │
└───────────────────────────────────────────────────────────────┘
▼ WS /api/v1/device
contextual_request | contextual_scope_update
run_contextual_stream()
Langfuse: contextual_system
Tools: get_page_details, create_task,
create_note, update_task,
create_timeline_event
```
Invariants:
- `ContextualChatProvider` is mounted once. Sidebar tree never unmounts on nav → no reload, full history kept in renderer state.
- The chat surface (`<ChatSurface>`) is a shared component reused by `AIChatPanel` (home) and `ContextualSidebar`.
- Sidebar history lives in SQLite via two new tables; renderer hydrates from disk on app start. Same persistence model used by home chat after the refactor.
- Backend is stateless w.r.t. chat history — each request carries the history payload from the client. Agent session buffer is purely an in-memory short-cache.
## 4. Frontend
### 4.1 New files (`adiuvAI/src/renderer/`)
| File | Purpose |
|---|---|
| `components/ai/ChatSurface.tsx` | Headless chat: messages list, streaming, markdown, input. Props: `{messages, onSend, isStreaming, variant: 'home' \| 'contextual'}` |
| `components/ai/ContextualSidebar.tsx` | Right sidebar shell: floating elevated controls, embeds `<ChatSurface variant="contextual">`, input fade. |
| `components/ai/AdiuvaTriggerButton.tsx` | 48px elevated trigger button with compass needle (`logo-mark.svg`). Reads `useContextualChat().toggle()`. |
| `context/ContextualChatContext.tsx` | Provider: `{open, size, sessionId, scope, messages, isStreaming, send, toggle, setScope, newChat, setSize}`. Hydrates session from SQLite on mount. |
| `hooks/useContextualScope.ts` | `useContextualScope(scope)` — page calls in render. Effect diffs scope; on change emits `contextual_scope_update`. |
| `hooks/useChatStream.ts` | Shared streaming engine extracted from `useAIChat.ts`. Consumed by both home and contextual. |
### 4.2 Edits
- `components/ai/AIChatPanel.tsx` — becomes a thin wrapper around `<ChatSurface variant="home">`. Keeps home-specific shell (suggestion chips, brief carousel hooks). All chat plumbing moves out.
- `hooks/useAIChat.ts``'home'` and `'global'` branches kept (via the new `useChatStream`). `'floating'` branch deleted.
- `components/ai/ChatInputBox.tsx``'floating'` cache key replaced by `'contextual'`. Old drafts are wiped (acceptable pre-1.0).
- `components/layout/AppShell.tsx` — wrap `<Outlet>` in `<ContextualChatProvider>`. When the current route is **not** home, render the outlet inside a `ResizablePanelGroup` with a second `ResizablePanel` for `<ContextualSidebar>` shown only when `chat.open === true`. On home, render the outlet directly (the full-width home `AIChatPanel` owns its own layout). Remove `<FloatingChatProvider>` wrap and `useDoubleClickAI()` call.
- Each route gains a `<AdiuvaTriggerButton>` in the page header area and calls `useContextualScope(scope)` in render:
- [routes/timeline.tsx](adiuvAI/src/renderer/routes/timeline.tsx)
- [routes/tasks.tsx](adiuvAI/src/renderer/routes/tasks.tsx)
- [routes/projects.tsx](adiuvAI/src/renderer/routes/projects.tsx) and `routes/projects.$projectId.tsx`
- [routes/notes.$noteId.tsx](adiuvAI/src/renderer/routes/notes.$noteId.tsx)
### 4.3 Scope payload shape
```ts
type ContextualScope =
| { page: 'timeline'; entityType: null }
| { page: 'tasks'; entityType: null }
| { page: 'projects-list'; entityType: null }
| { page: 'project';
entityType: 'project';
entityId: string;
entityName: string;
counts: { tasks: number; notes: number; milestones: number } }
| { page: 'note';
entityType: 'note';
entityId: string;
entityName: string;
projectId: string | null;
charCount: number };
```
For global list pages (`tasks`, `projects-list`, `timeline`), the scope may also include the renderer-side active filters so that `get_page_details` can apply them.
### 4.4 Elevated button recipe (Tailwind-compatible CSS, ported to component)
```css
.adiuva-btn {
width: 48px; height: 48px;
background: #1d1d20;
border: 1px solid rgba(255,255,255,.06);
border-radius: 14px;
box-shadow:
0 1px 0 rgba(255,255,255,.05) inset,
0 2px 4px -1px rgba(0,0,0,.5),
0 8px 16px -4px rgba(0,0,0,.55),
0 20px 36px -10px rgba(0,0,0,.6);
transition: box-shadow .3s ease, background .2s ease;
}
.adiuva-btn:hover {
background: #26262a;
box-shadow:
0 1px 0 rgba(255,255,255,.06) inset,
0 4px 8px -2px rgba(0,0,0,.6),
0 18px 28px -8px rgba(0,0,0,.65),
0 30px 60px -14px rgba(251,200,129,.22);
}
.adiuva-btn:active { transform: scale(.97); }
.adiuva-btn.sm { width: 32px; height: 32px; border-radius: 10px; }
.needle-g {
transform-origin: 32px 32px;
animation: compass-settle 6s ease-in-out infinite;
}
@keyframes compass-settle {
0% { transform: rotate(0deg); }
20% { transform: rotate(4deg); }
50% { transform: rotate(-3deg); }
80% { transform: rotate(2deg); }
100% { transform: rotate(0deg); }
}
```
Light-mode variant: bg `#ffffff`, border `#c8c3cd` (dusty lavender per brand), shadows softer; settle and halo unchanged. Light-mode colors confirmed against [adiuvAI/.claude/CLAUDE.md Design Context].
## 5. Backend
### 5.1 WS frames (`api/app/api/routes/device_ws.py`)
Dispatch table:
- `home_request` — unchanged.
- `contextual_request`**new**. `{ session_id, message, scope, history? }`. Handler: `_handle_contextual_request``run_contextual_stream`.
- `contextual_scope_update`**new**. `{ session_id, scope }`. Handler: appends system message to session buffer, returns `{ type: 'contextual_scope_ack' }`. No LLM call.
- `brief_request`, `task_brief_request` — unchanged (out of scope).
- `floating_request`**removed**.
### 5.2 Runner (`api/app/core/deep_agent.py`)
```python
async def run_contextual_stream(*, user, db, session_id, message, scope, history):
system_prompt = await get_prompt_or_fallback(
"contextual_system", _CONTEXTUAL_SYSTEM_PROMPT,
)
scope_block = _render_scope_block(scope)
sys = f"{system_prompt}\n\n## Current view\n{scope_block}"
sys += _language_instruction(user)
tools = [
get_page_details_tool,
create_task_tool,
update_task_tool,
create_note_tool,
create_timeline_event_tool,
]
yield from _run_agent_loop(
sys=sys, tools=tools, message=message, history=history,
user=user, db=db, session_id=session_id, channel="contextual",
)
```
`_render_scope_block(scope)` produces a single-paragraph human-readable summary like:
`"User is viewing the project Acme Q3 launch. It has 12 tasks (8 completed), 4 notes, 3 milestones. Active milestone: Beta cut, overdue 2 days."`
### 5.3 Langfuse prompt
- Create a **new** prompt `contextual_system` in Langfuse (do not rename `floating_system` yet — coexist during the rollout).
- After cutover (milestone M6), delete `floating_system` from Langfuse.
- Fallback constant `_CONTEXTUAL_SYSTEM_PROMPT` lives in `deep_agent.py`. Body (text):
```
You are adiuvAI's contextual assistant. The user is working inside the app and has opened a side chat anchored to a specific view ("current view"). Help them act on that view: recap, plan, create entities, answer questions.
Rules:
1. Base context (current view summary) is provided every turn. Treat it as ground truth for ids and names; never invent them.
2. When the user asks about details not in the base context (e.g. "what tasks are blocking the launch milestone"), call `get_page_details` for the relevant entity before answering. Don't guess.
3. When the user requests an action that creates or updates an entity:
- If the current view is a project and no project is specified, use the current project automatically.
- If the current view is the global Tasks / Projects / Timeline list and no project is specified, ASK before attaching to any project. Don't silently create orphan entities.
4. The current view can change mid-conversation (user navigates). When you see a system message "User navigated to ...", treat the new view as the active context. Prior turns remain visible but the active scope shifts.
5. Notes: you can read note bodies via `get_page_details({entityType:'note'})`. You CANNOT edit, summarize-to-replace, or append. Tell the user "note editing is coming in a later release" if asked.
6. Be concise. Default to 1-3 short paragraphs. Bullet lists fine. Don't restate the user's request.
7. Never expose ids in prose. Use names. Ids only travel through tool calls.
```
Home prompt `home_system` is unchanged.
### 5.4 Session buffer
Extend `app/core/agent_session_buffer.py` to accept `channel="contextual"`. The buffer is in-memory only; durable history lives client-side (§6).
## 6. Data model — client-side SQLite
Two new tables in [adiuvAI/src/main/db/schema.ts](adiuvAI/src/main/db/schema.ts):
```ts
export const aiChatSessions = sqliteTable("ai_chat_sessions", {
id: text("id").primaryKey(),
channel: text("channel").notNull(), // 'home' | 'contextual'
title: text("title"),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
lastScope: text("last_scope"), // JSON ContextualScope | null
});
export const aiChatMessages = sqliteTable("ai_chat_messages", {
id: text("id").primaryKey(),
sessionId: text("session_id").notNull(), // logical FK, cascade in tRPC
role: text("role").notNull(), // 'user' | 'assistant' | 'system'
content: text("content").notNull(),
toolCalls: text("tool_calls"), // JSON | null
toolResults: text("tool_results"), // JSON | null
scope: text("scope"), // JSON snapshot at msg time | null
createdAt: integer("created_at").notNull(),
});
```
Indexes: `(session_id, created_at)`, `(channel, updated_at DESC)`. New Drizzle migration `0011_ai_chat_history.sql`.
New tRPC sub-router `aiChat` in [adiuvAI/src/main/router/index.ts](adiuvAI/src/main/router/index.ts):
- `aiChat.listSessions({ channel })`
- `aiChat.getSession({ id })``{ session, messages }`
- `aiChat.createSession({ channel, initialScope? })``{ id }`
- `aiChat.appendMessage({ sessionId, role, content, toolCalls?, toolResults?, scope? })`
- `aiChat.deleteSession({ id })`
Renderer flow:
1. On `ContextualChatProvider` mount: read electron-store `chat.contextual.lastSessionId`. If set, `getSession` → hydrate. Else create on first send.
2. Each user send + assistant complete → `appendMessage`.
3. "New chat" button → `createSession`, swap stored sessionId.
Home chat receives the same persistence model (key `chat.home.lastSessionId`).
## 7. Tools
| Tool | Args | Result |
|---|---|---|
| `get_page_details` | `{ entityType, entityId }` for entity views; `{ entityType }` for list views | Snapshot JSON (see below) |
| `create_task` | `{ title, dueAt?, projectId?, ... }` | `{ taskId }` |
| `update_task` | `{ taskId, patch }` | `{ ok: true }` |
| `create_note` | `{ title, body?, projectId? }` | `{ noteId }` |
| `create_timeline_event` | `{ type, title, dueAt, projectId?, deps? }` | `{ eventId }` (verify tool exists in current backend during M5; if missing, scope it in or remove from this list) |
`get_page_details` is dispatched client-side via [drizzle-executor.ts](adiuvAI/src/main/api/drizzle-executor.ts). Supported scopes:
- `entityType: 'project'``{ project, tasks[], notes[summary only], milestones[], comments[] }`
- `entityType: 'task'``{ task, project, comments[], deps[] }`
- `entityType: 'note'``{ note: { ..., body } }`
- `entityType: 'tasks_all'``{ tasks[] filtered by renderer view filters }`
- `entityType: 'projects_all'``{ projects[] }`
- `entityType: 'timeline_all'``{ events[] }`
Default-projectId is **not** applied automatically by the executor. The prompt instructs the LLM to attach the current project when scope is a project; on global list pages the LLM is told to ask first.
Note write/edit tools (`update_note`, `summarize_note`, `append_to_note`) are explicitly out of scope and **not** wired into `tools` for `run_contextual_stream`.
## 8. Deprecation removal
**Renderer (adiuvAI/src/renderer/)**:
- DELETE `components/ai/FloatingChat.tsx`
- DELETE `context/FloatingChatContext.tsx`
- DELETE `hooks/useDoubleClickAI.ts`
- EDIT `components/layout/AppShell.tsx` — remove provider wrap and double-click hook.
- STRIP every `data-ai-section="..."` attribute. Known sites:
- [routes/tasks.tsx:16-25](adiuvAI/src/renderer/routes/tasks.tsx#L16-L25) (`tasks-overview`, `tasks-list`)
- [components/timeline/TimelineGanttView.tsx:95-110](adiuvAI/src/renderer/components/timeline/TimelineGanttView.tsx#L95-L110)
- [components/projects/ProjectDetail.tsx:62-100](adiuvAI/src/renderer/components/projects/ProjectDetail.tsx#L62-L100)
- [routes/notes.$noteId.tsx:44-50](adiuvAI/src/renderer/routes/notes.$noteId.tsx#L44-L50)
- Final grep sweep before merge — fail PR if any remain.
- EDIT `components/ai/ChatInputBox.tsx` — remove `'floating'` cache key.
- EDIT `hooks/useAIChat.ts` — remove `'floating'` branch.
**Main process (adiuvAI/src/main/)**:
- EDIT `api/backend-client.ts` — remove `sendFloatingRequest()`; add `sendContextualRequest()` + `sendContextualScopeUpdate()`.
- EDIT `ai/orchestrator.ts` — remove floating delegation; add contextual delegation.
- EDIT `db/schema.ts` — add `aiChatSessions` + `aiChatMessages`. New migration `0011_ai_chat_history.sql`.
- EDIT `router/index.ts` — add `aiChat` sub-router.
**Backend (api/app/)**:
- EDIT `api/routes/device_ws.py` — delete `_handle_floating_request` (line 292) and its dispatch entry; add `_handle_contextual_request` and `_handle_contextual_scope_update`.
- EDIT `core/deep_agent.py`:
- DELETE `run_floating_stream` (line 1460) and `_FLOATING_SYSTEM_PROMPT`.
- ADD `run_contextual_stream`, `_CONTEXTUAL_SYSTEM_PROMPT`, `_render_scope_block`.
- EDIT `core/agent_session_buffer.py` — accept `channel="contextual"`.
**Langfuse**:
- CREATE prompt `contextual_system` (body per §5.3).
- After M6 deploy verified: DELETE prompt `floating_system`.
**Tests (api/tests/)**:
- DELETE / RENAME `test_*floating*` to contextual equivalents.
- ADD tests:
- `contextual_scope_update` no LLM call, system message appended.
- Scope block rendering for each `entityType`.
- Tool list contains `get_page_details` and entity-create tools, NOT note edit tools.
- Default-project rule via prompt-driven smoke (mocked LLM).
**Electron-store keys**:
- ADD `chat.sidebar.size`, `chat.contextual.lastSessionId`, `chat.contextual.open`, `chat.home.lastSessionId`.
- REMOVE any `floating.*` keys present (sweep).
## 9. Milestones (commit-per-step, see [[feedback_plan_workflow]])
| # | Title | What ships | Verifiable by |
|---|---|---|---|
| **M1** | DB + persistence foundation | Schema, migration, `aiChat` tRPC sub-router | tRPC devtools |
| **M2** | ChatSurface refactor | `ChatSurface`, `useChatStream` extracted; home rewired; floating still works | Home chat unchanged behavior |
| **M3** | Backend contextual frame + prompt | `contextual_request`, `contextual_scope_update`, `run_contextual_stream`, Langfuse `contextual_system`, fallback constant; old floating still alive | Backend tests, manual WS frame |
| **M4** | Frontend sidebar shell + provider | `ContextualChatProvider`, `ContextualSidebar`, `AdiuvaTriggerButton`, `useContextualScope`; trigger on all 4 page types; `ResizablePanelGroup` in AppShell | Open sidebar on each page, chat works, survives nav |
| **M5** | Tools wiring | `get_page_details` dispatcher in drizzle-executor for all scopes; entity-create tools confirmed reachable | Manual recap + create-task smoke |
| **M6** | Deprecation sweep | Delete `FloatingChat*`, `useDoubleClickAI`, all `data-ai-section`, `_handle_floating_request`, `run_floating_stream`, `_FLOATING_SYSTEM_PROMPT`, `sendFloatingRequest`, floating draft cache, floating electron-store keys, Langfuse `floating_system` | Grep sweep, app still boots |
| **M7** | Polish | New-chat button UX, light-mode elevated styling, width persistence verified across restart, empty-state copy on global-list pages | Manual UX pass |
## 10. Risks
- **WS reconnect mid-stream:** existing `_mark_runs_disconnected` covers this. Client retries with same `sessionId`, replays history from SQLite.
- **Race: nav while assistant streaming.** Provider serializes outbound frames — `scope_update` is queued and applied after stream completes.
- **Resize during stream:** shadcn `ResizablePanel` re-renders cheap, no observable impact.
- **Migration rollout:** Langfuse `contextual_system` must exist in production before backend code referencing it deploys. Coordinate prompt-create in M3 step.
- **Old session continuity:** floating chat had no persisted history, so there is nothing to migrate. Existing draft cache `'floating'` keys are wiped on first run (acceptable pre-1.0).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
# Task Form Dialog — keyboard + header polish — Design
**Date:** 2026-05-14
**Scope:** adiuvAI renderer (`src/renderer/components/tasks/TaskFormDialog.tsx`) + supporting libs
**Status:** Approved by user (mockup at `docs/mockups/2026-05-14-task-form-dialog-mockup.html`), ready for implementation plan
## Goal
Port three UX features shipped in the timeline batch-add `AddEventDialog` (`docs/2026-05-08-task-ux-evolution-design.md` § timeline batch) into `TaskFormDialog`:
1. **Header style**`DialogTitle` + `DialogDescription` (no separator border), matching `AddEventDialog`.
2. **Full keyboard navigation** — Tab/Shift-Tab between fields & pills, arrow keys within pills row, Enter to open focused pill, arrow keys inside list popovers + calendar, Esc to close popover.
3. **Date + time via keyboard** — replace the Calendar + 2× hour/minute `Select` triplet with a typeable `DateField` that supports an optional `HH:MM` suffix and respects `FormatPrefs.dateFormat`.
## Non-goals
- Migrating `TaskFormDialog` to a Sheet (deferred — see `docs/2026-05-08-task-ux-evolution-plan.md`).
- Touching `NewTaskDialog` / `EditTaskDialog` wrappers (no behavior change).
- Changes to other property popovers' rendering beyond keyboard handling.
- Inline project creation flow (`InlineProjectForm`) — unchanged.
## 1. Header
Replace the current minimal header:
```tsx
<DialogHeader className="px-5 py-3 border-b border-border/40">
<DialogTitle className="text-sm font-medium">{...}</DialogTitle>
</DialogHeader>
```
with the `AddEventDialog` style:
```tsx
<DialogHeader>
<DialogTitle>{mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}</DialogTitle>
<DialogDescription>
{mode === 'create' ? t('tasks.newTaskDescription') : t('tasks.editTaskDescription')}
</DialogDescription>
</DialogHeader>
```
**No border-bottom** under the header — body flows directly under it. Keep the existing `bg-card/92 backdrop-blur-xl` overlay on `DialogContent`.
New i18n keys (all 5 languages): `tasks.newTaskDescription`, `tasks.editTaskDescription`.
## 2. Keyboard navigation
### Pills row — roving focus + arrow movement
The property pills (`Project · Priority · Status · Due · Assignees`) become a roving-tabindex group:
- Only one pill at a time has `tabindex={0}`; the rest have `tabindex={-1}`. Default focused pill = first (Project).
- `Tab` / `Shift+Tab` enters/exits the group as a single stop. Inside the group, `Tab` exits forward to the footer; on entry from the footer-side, focus restores to the last-focused pill.
- `ArrowRight` / `ArrowDown` → next pill (clamped at end).
- `ArrowLeft` / `ArrowUp` → previous pill (clamped at start).
- `Home` / `End` → first / last pill.
- `Enter` or `Space` on focused pill → open its popover.
Implementation: a small hook `useRovingFocus(ref, count)` returning `(index) => { tabIndex, onKeyDown, onFocus }`. Pills consume it inside `PropertyPill` (kept presentational) via a wrapping `<button>`.
`PropertyPill` is already a `<button>`-ish trigger via `<span>`. To support real focus rings + key events, change the trigger element rendered by `PopoverTrigger asChild` from `<span>` to a `<button type="button">`. Visible focus ring matches `--ring` via `focus-visible:ring-2 ring-ring/30`.
### List popovers — Project, Priority, Status, Assignees
shadcn `Popover` does not provide list semantics. Inside each popover content:
- Items render with `role="option"` (or `menuitem`) and roving `tabIndex` (active item = `0`, rest `-1`).
- When popover opens, focus moves to the currently-selected item (or first item).
- `ArrowDown` / `ArrowUp` move the active item; `Home`/`End` jump to ends.
- `Enter` / `Space` selects the active item.
- For single-select popovers (Project, Priority, Status) selection closes the popover and returns focus to the originating pill.
- For multi-select (Assignees) selection toggles; popover stays open. `Esc` closes and returns focus to the pill.
- `Tab` inside a popover closes the popover (focus returns to pill, then the next Tab advances normally).
Implementation: a single shared hook `useListboxKeys(items, opts)` consumed by each popover content. Items are sourced from existing data (`projectsList`, `knownAssignees`, hard-coded priority/status arrays).
### Calendar — keyboard
The shadcn `Calendar` already supports arrow-key day navigation and `Enter` to select (via react-day-picker). We need only to confirm that focus lands on the calendar grid when the Due popover opens. The new `DateField` (§3) replaces the current Popover+Calendar+Selects assembly and embeds the calendar.
### Description — Enter
Keep existing behavior: `Enter` inserts a newline in the description textarea. The form-level `⌘/Ctrl+Enter` submit handler already lives on the `<form>` element and continues to work; the footer's "⌘+Enter to create" hint is removed from the UI (the shortcut still works).
## 3. Date + time via keyboard
### Strategy — extend existing `DateField`
Reuse `src/renderer/components/ui/date-field.tsx` (already typeable, format-aware via `useFormatPrefs`, with embedded Calendar). Add **optional time** support behind a new prop `withTime?: boolean`.
When `withTime` is on:
- The text input accepts either a bare date (`30/04/2026`, `Apr 30`, `+3d`, `tomorrow`, …) or date-with-time suffix (`30/04/2026 14:30`).
- The Popover content gains a small `Time` row under the Calendar — two `Select`s (hour 0023, minute in 5-min steps) identical to the current TaskFormDialog implementation. They edit the time portion of the committed `Date`.
- Display value after commit: `<date in FormatPrefs.dateFormat> HH:MM` when time component is non-midnight, otherwise just the date.
### Parser extension (`lib/parseDate.ts`)
`parseDate(input, prefs, keywords)` adopts optional trailing time:
- Regex split: `RE_TIME = /\s+(\d{1,2}):(\d{2})\s*$/`.
- If matched, parse `HH`/`MM` (`023` / `059`), strip the suffix, parse remaining string with the existing logic, then set `hours` and `minutes` on the result.
- If time match is invalid (e.g. `25:99`), whole input is invalid.
Unit-test cases (existing tests if any get extended; otherwise small new file):
| Input | Format pref | Expected |
|---|---|---|
| `30/04/2026 14:30` | `dd/MM/yyyy` | 2026-04-30 14:30 local |
| `04/30/2026 09:00` | `MM/dd/yyyy` | 2026-04-30 09:00 |
| `2026-04-30 23:59` | `yyyy-MM-dd` | 2026-04-30 23:59 |
| `tomorrow 08:15` | any | next-day 08:15 |
| `30/04/2026 25:00` | any | invalid |
| `30/04/2026` | dd/MM | 2026-04-30 00:00 (date only, time unchanged) |
### Caller change in `TaskFormDialog`
The whole Due Popover block (Calendar + hour/minute Selects + clear button) is replaced by:
```tsx
<DateField
withTime
value={values.dueDate ? new Date(values.dueDate) : undefined}
onChange={(d) => setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }))}
placeholder={t('tasks.colDue')}
aria-label={t('tasks.colDue')}
/>
```
The pill itself remains for display when the field is collapsed. Two arrangements considered:
- **(A) Pill opens a popover containing the `DateField`** — keeps visual parity with the other pills. The `DateField` *inside* the popover is just an `Input` + Calendar, no nested Popover. Recommended.
- **(B) `DateField` replaces the pill inline in the row** — visually breaks the pill row.
Going with **(A)**. To avoid a nested-popover (`Popover` inside `PopoverContent`), `DateField` gains a `flat?: boolean` prop. When `flat` is set, it renders:
- the typeable `Input`,
- the `Calendar` inline (no internal `Popover` wrapper),
- the Time row (when `withTime`).
The Due pill's `PopoverContent` renders `<DateField withTime flat />`. Outside the task dialog, existing callers (e.g. `AddEventDialog`) keep using the default (non-flat) DateField with its own popover trigger.
The Due popover content:
```
┌─ Due popover ───────────────────┐
│ [📅 30/04/2026 14:30 ] │ ← typeable Input (parses date + time)
│ Calendar grid (kbd nav) │
│ ── ── ── ── ── ── ── ── ── ── │
│ Time: [HH ⌄] : [MM ⌄] [Clear] │ ← shown only when withTime
└─────────────────────────────────┘
```
(The mockup illustrated standalone segments; that was a sketch — the real impl reuses `DateField`'s single-input typeable parser, which is already keyboard-driven via `parseDate`.)
## 4. Files
**Modified:**
```
src/renderer/components/tasks/TaskFormDialog.tsx — new header; roving focus on pills row; replace Due popover with <DateField withTime />; drop the "⌘+Enter" hint
src/renderer/components/ui/date-field.tsx — new props withTime + flat; Time Selects; expanded onCommit/text-display logic
src/renderer/lib/parseDate.ts — accept optional trailing " HH:MM"
src/renderer/locales/{en,it,es,fr,de}/translation.json
— add tasks.newTaskDescription, tasks.editTaskDescription
```
**New (small, kept local to features):**
```
src/renderer/hooks/useRovingFocus.ts — generic roving-tabindex hook
src/renderer/hooks/useListboxKeys.ts — popover-list arrow/enter/esc handler
```
If a unit-test setup is later introduced for `parseDate`, add cases there. Not blocking.
## 5. Accessibility
- Pills row: `role="toolbar"` with `aria-label={t('tasks.properties')}`; pills are `<button>` with descriptive `aria-label` (e.g. `Project: Acme · Communications`).
- Listbox popovers: container `role="listbox"`, items `role="option"`, `aria-selected` on the chosen one. Single-select popovers also set `aria-activedescendant` on the listbox when convenient; otherwise rely on `.focus()`.
- Multi-select Assignees uses `aria-multiselectable="true"`.
- `DateField` keeps existing `aria-invalid` + `aria-describedby` semantics.
## 6. Out-of-scope follow-ups
- Project popover inline-create flow keyboard polish (currently a sub-form inside the popover — separate effort).
- `DateField` natural-language time keywords (e.g. `tomorrow 9am`) — only `HH:MM` accepted.
- Migrating `TaskFormDialog` shell to a Sheet — already deferred.
## 7. Implementation order (suggested)
1. `useRovingFocus` + `useListboxKeys` hooks (no UI changes).
2. `parseDate` time-suffix support; refresh existing parseDate tests.
3. `DateField` `withTime` prop + time Selects in Popover.
4. `TaskFormDialog`:
- Header swap (Title + Description, no border).
- Pills row wired to `useRovingFocus`; pill trigger element switched to `<button>`.
- Each list popover wired to `useListboxKeys`.
- Due popover content replaced by `<DateField withTime />`.
- Remove footer `⌘+Enter` hint.
5. i18n strings in all five languages.

File diff suppressed because it is too large Load Diff

862
docs/REFACTOR_PLAN.md Normal file
View File

@@ -0,0 +1,862 @@
# Refactoring & Security Plan — adiuvAI Workspace
**Generated:** 2026-06-12 · **Tree state:** `main` @ `315c5d0` (clean). Line numbers reference this state — execute file-split refactors (QUAL-*) **last**, they invalidate line references.
**Scope:** `adiuvAI/` (Electron), `api/` (FastAPI), `waitlist/`, `website/`.
**Method:** 8 parallel read-only audit agents (security ×2, dead-code/deps ×2, perf/correctness ×2, quality/types, waitlist/website), tool-assisted (`knip`, `npm audit`, `ruff`, `tsc --noEmit`, import-graph). Every finding verified against actual code.
**Legend:** `⚠️ REVIEW` = needs human or strong-model validation before execution. Do NOT hand these to a low-capability executor unattended.
---
## Summary Table
| ID | Category | Severity | File | Description |
|----|----------|----------|------|-------------|
| SEC-01 | Security | **Critical** | adiuvAI/src/main/api/drizzle-executor.ts | Backend can read ANY file on user's disk via generic FS tool handlers |
| SEC-02 | Security | High | api/app/api/middleware/rate_limit.py | Zero rate limiting on login/register/refresh (unauthenticated bypass) |
| SEC-03 | Security | High | api/app/models.py | Per-user Fernet key stored plaintext beside the ciphertext it protects |
| SEC-04 | Security | High | api/app/core/deep_agent.py +3 | Raw user content/PII sent to Langfuse (GDPR / L.132/2025) |
| SEC-05 | Security | High | adiuvAI/src/main/api/drizzle-executor.ts | Mass assignment: backend sets arbitrary columns on insert/update |
| SEC-06 | Security | High | adiuvAI/src/main/index.ts | No will-navigate / setWindowOpenHandler guards on main window |
| SEC-07 | Security | High | adiuvAI (app-wide) | No Content-Security-Policy |
| SEC-08 | Security | High | waitlist/app/rate_limit.py | Rate limit keyed on spoofable CF-Connecting-IP / X-Forwarded-For |
| SEC-09 | Security | High | waitlist/app/config.py | CONFIRM_SECRET defaults to per-process random → broken across workers |
| SEC-10 | Security | Medium | api/app/api/routes/auth.py | Login 500-crashes for social-only accounts (account-type oracle) |
| SEC-11 | Security | Medium | api/app/api/routes/auth.py | No password strength validation on register |
| SEC-12 | Security | Medium | api/app/api/routes/auth.py | No logout endpoint / refresh-token revocation |
| SEC-13 | Security | Medium | api + adiuvAI (WS handshake) | JWT in WebSocket URL query string (both sides) |
| SEC-14 | Security | Medium | api/app/config/settings.py | Insecure defaults (JWT_SECRET) with no prod startup guard |
| SEC-15 | Security | Medium | api (device_ws, scout_runner, deep_agent) | User message content in INFO logs (GDPR) |
| SEC-16 | Security | Medium | api/app/models.py | Relational-memory entity labels stored plaintext (third-party PII) |
| SEC-17 | Security | Medium | api/app/api/middleware/rate_limit.py | In-memory limiter state → ×4 bypass under gunicorn -w 4 |
| SEC-18 | Security | Medium | api (auth.py, scouts.py) | In-memory OAuth state stores break multi-worker; strand Gmail tokens |
| SEC-19 | Security | Medium | api/app/api/routes/scout_webhooks.py | Gmail Pub/Sub webhook fail-open when audience unset |
| SEC-20 | Security | Medium | adiuvAI/src/main/ipc.ts | IPC bridge doesn't validate sender; all procedures public |
| SEC-21 | Security | Medium | adiuvAI/src/main/ai/token.ts | Plaintext token + backup-key fallback in electron-store |
| SEC-22 | Security | Medium | adiuvAI/src/renderer/lib/httpLink.ts | Web SPA keeps JWT in localStorage |
| SEC-23 | Security | Medium | waitlist/app/security.py | Origin validation bypass via startswith |
| SEC-24 | Security | Medium | waitlist/app/routes.py | GDPR erasure + confirm triggered by bare GET (mail scanners) |
| SEC-25 | Security | Medium | waitlist/app/routes.py | Unauthenticated email re-send = mail-bombing vector |
| SEC-26 | Security | Medium | website/index.html | Third-party scripts without SRI; lucide@latest from unpkg |
| SEC-27 | Security | Medium | api/app/api/routes/device_ws.py | _index_sessions: no ownership check + never purged on disconnect |
| SEC-28 | Security | Medium | adiuvAI (db, attachments) | Local SQLite + attachments unencrypted at rest (product decision) |
| SEC-29 | Security | Low | api/app/api/routes/auth.py | Email enumeration via register 409 |
| SEC-30 | Security | Low | api/app/api/routes/device_ws.py | Internal exception strings returned to WS clients |
| SEC-31 | Security | Low | adiuvAI/src/main/router/index.ts | attachments.create accepts arbitrary sourcePath |
| SEC-32 | Security | Low | adiuvAI/src/main/index.ts | Scout Gmail deep link forwarded without local state check |
| SEC-33 | Security | Low | waitlist/app/routes.py | Partial email fragments in logs |
| SEC-34 | Security | Low | website (all pages) | No CSP / security headers on static site |
| SEC-35 | Security | Low | website/i18n.js | innerHTML i18n sink (latent XSS) |
| SEC-36 | Security | Low | adiuvAI/src/main/index.ts | sandbox not explicitly enabled in webPreferences |
| CORR-01 | Correctness | **Critical** | api/app/api/routes/device_ws.py | Chat tool calls: no timeout + send-before-register race → agent hangs forever |
| CORR-02 | Correctness | High | api/app/core/device_manager.py | WS unregister clobbers a freshly reconnected connection |
| CORR-03 | Correctness | High | api/app/core/memory_maintenance.py | Free-tier extraction drains with empty content — feature is a paid no-op |
| CORR-04 | Correctness | High | adiuvAI (renderer chat contexts) | Backend errors returned as success payloads ignored → chat wedged in "streaming" + listener leaks |
| CORR-05 | Correctness | High | adiuvAI/src/main/router/index.ts + drizzle-executor.ts | Cascade-delete gaps, zero transactions; AI-issued deletes orphan rows + disk files |
| CORR-06 | Correctness | High | adiuvAI (backend-client.ts + files/indexer.ts) | WS drop mid-index permanently wedges project in 'scanning' |
| CORR-07 | Correctness | Medium | adiuvAI/src/main/api/backend-client.ts | WS close handler clobbers newly opened connection (logout→login race) |
| CORR-08 | Correctness | Medium | api/app/core/memory_maintenance.py | Confidence decay applied per invocation, not per period |
| CORR-09 | Correctness | Medium | api (models + memory_middleware) | Check-then-insert races: memory_core / memory_relations lack unique constraints |
| CORR-10 | Correctness | Medium | api/app/api/routes/device_ws.py | Fire-and-forget create_task: no refs, no per-user concurrency cap |
| CORR-11 | Correctness | Medium | adiuvAI/src/main/router/index.ts | tasks.update unconditionally destroys briefing + chat history |
| CORR-12 | Correctness | Medium | adiuvAI/src/main/ai/orchestrator.ts | Stream errors emitted as plain stream_end → blank persisted messages |
| CORR-13 | Correctness | Medium | adiuvAI/src/main/api/backend-client.ts | withRetry retries non-idempotent POSTs (duplicate scout runs) |
| CORR-14 | Correctness | Medium | api/app/billing/stripe_service.py | Sync Stripe SDK calls block the event loop |
| CORR-15 | Correctness | Medium | api/app/api/routes/device_ws.py | run_home_stream swallows exceptions; client never gets error stream_end |
| CORR-16 | Correctness | Medium | api/app/api/routes/auth.py | delete_account: Stripe commit then DB deletes, broad except pass |
| CORR-17 | Correctness | Medium | adiuvAI/src/main/files + projectFolders.ts | Index-session TOCTOU + stale _active entry |
| CORR-18 | Correctness | Low | api/app/api/routes/scouts.py | Scout trigger TOCTOU → duplicate concurrent runs |
| CORR-19 | Correctness | Low | adiuvAI (router, drizzle-executor, auth-manager) | Swallowed-error triage: silent [] returns, bare catches, unguarded JSON.parse |
| CORR-20 | Correctness | Low | api/app/api/routes/auth.py | Naive/aware datetime kludge on refresh expiry |
| CORR-21 | Correctness | Low | adiuvAI (misc) | LanguageSync inside App body; fake AbortSignal; startsWith containment |
| PERF-01 | Performance | High | api/app/api/routes/auth.py | bcrypt runs on the event loop (rate-limit-exempt login path) |
| PERF-02 | Performance | High | api/app/core/deep_agent.py | Home channel buffers ALL tokens — streaming endpoint doesn't stream |
| PERF-03 | Performance | High | api/app/core/memory_maintenance.py | Hourly mining duplicates proactive rows; _load_proactive has no LIMIT |
| PERF-04 | Performance | Medium | adiuvAI/src/main/db/schema.ts | No SQLite indexes on any user-data table (sync full scans block IPC) |
| PERF-05 | Performance | Medium | api/app/models.py | Missing PG composite/pgvector indexes; refresh_tokens never purged |
| PERF-06 | Performance | Medium | adiuvAI (orchestrator + ChatSurface) | Per-token IPC message + full markdown re-parse per chunk |
| PERF-07 | Performance | Medium | api/app/core/deep_agent.py | Tool calls executed serially per LLM step (each a WS round-trip) |
| PERF-08 | Performance | Medium | api (deep_agent, scout_runner, langfuse_client) | Blocking lf.flush() per request; sync get_prompt on cache miss |
| PERF-09 | Performance | Medium | api/app/api/middleware/auth.py | get_current_user: 3 queries + full Fernet decrypt on EVERY request |
| PERF-10 | Performance | Medium | api/app/core/memory_middleware.py | Same User row re-fetched up to 4×/message; update_core commits per key |
| PERF-11 | Performance | Medium | adiuvAI/src/main/ai/orchestrator.ts | Health-check HTTP round-trip before every chat message |
| PERF-12 | Performance | Medium | adiuvAI/src/main/router/index.ts | Paid LLM brief regeneration fired by every week-relevant mutation |
| PERF-13 | Performance | Medium | adiuvAI (vite configs, ChatChartBlock, notes route) | No route code-splitting; recharts + Milkdown eager in initial bundle |
| PERF-14 | Performance | Medium | adiuvAI/src/renderer/index.tsx | Default QueryClient → refetch storms into sync SQLite on window focus |
| PERF-15 | Performance | Medium | adiuvAI (projectFolders.ts + indexer.ts) | Double full-folder walk; no WS backpressure (memory spike) |
| PERF-16 | Performance | Medium | api (scouts/engine.py, device_ws.py) | DB sessions held open across LLM calls (pool starvation) |
| PERF-17 | Performance | Medium | api/app/core/scout_runner.py | Per-file get_file_metadata WS round-trip during scans |
| PERF-18 | Performance | Low | api (embeddings.py, llm.py) | Fresh AsyncOpenAI client per call, never closed |
| PERF-19 | Performance | Low | adiuvAI/src/renderer/components/tasks/TaskListView.tsx | tasks.list returns all rows; pagination client-side |
| PERF-20 | Performance | Low | api/app/api/middleware/rate_limit.py | Limiter dict never sheds keys; tier read from JWT not DB |
| PERF-21 | Performance | Low | adiuvAI/src/main/api/drizzle-executor.ts | Unbounded page-details tables; uncapped pdf/docx base64 reads |
| PERF-22 | Performance | Low | adiuvAI/src/renderer/context | HeaderContext unmemoized value; ContextualChat re-renders route per token |
| DEAD-01 | Dead code | High | api/requirements.txt | 5 dead deps: pinecone, qdrant-client, boto3, moto[s3], google-auth-oauthlib |
| DEAD-02 | Dead code | High | api/app/core/scout_registry.py | Fully orphaned module (BaseAgent, zero importers) |
| DEAD-03 | Dead code | Medium | api/app/api/middleware/rate_limit.py | slowapi Limiter exported, zero decorated routes — dep removable |
| DEAD-04 | Dead code | Medium | api/app/api/routes/chat.py | HTTP /chat routes unused by Electron client (WS is the channel) |
| DEAD-05 | Dead code | Medium | adiuvAI/package.json | 4 unused deps: next-themes, mammoth, pdf-parse, @hello-pangea/dnd |
| DEAD-06 | Dead code | Medium | adiuvAI/src (5 files) | Dead modules: batch-types, useChatStream, useTaskBriefCache, ScoutRunLog, blocks barrel |
| DEAD-07 | Dead code | Medium | adiuvAI/src/main/auth/backup-key.ts | Documented architecture but zero importers — wire or remove |
| DEAD-08 | Dead code | Medium | adiuvAI/knip.json | Missing web-SPA entries → knip false-flags live files |
| DEAD-09 | Dead code | Medium | api/requirements.txt | langchain meta-package → langchain-core; redundant websockets pin |
| DEAD-10 | Dead code | Low | api (13 files) | 13 ruff F401 unused imports (auto-fixable) |
| DEAD-11 | Dead code | Low | adiuvAI/src (7 exports) | Dead utility exports (getRawSqlite, parseDateRange, formatTime, …) |
| DEAD-12 | Dead code | Low | api/requirements.txt + backend-client.ts | Dev deps in runtime requirements; stale endpoint doc comments |
| DEPS-01 | Dependencies | High | adiuvAI/package.json | ws 8.19.0 vulnerable (GHSA-58qx-3vcg-4xpx) — only runtime-reachable vuln |
| DEPS-02 | Dependencies | High | api/requirements.txt | python-jose floor 3.3.0 permits CVE-2024-33663/33664 versions |
| DEPS-03 | Dependencies | Medium | api/requirements.txt | cryptography floor 42.0.0 permits CVE-2024-26130 versions |
| DEPS-04 | Dependencies | Medium | api/ | No lock file — `>=` floors make installs unauditable |
| DEPS-05 | Dependencies | Medium | adiuvAI/package.json | eslint 8 + @typescript-eslint 5 both EOL |
| DEPS-06 | Dependencies | Medium | adiuvAI/package.json | Electron 40 → 42 (two majors of Chromium security patches behind) |
| DEPS-07 | Dependencies | Low | adiuvAI (transitive) | tmp/tar/esbuild advisories in forge/drizzle-kit toolchain — no fix, monitor |
| DEPS-08 | Dependencies | Low | adiuvAI/package.json | @types/ws in dependencies instead of devDependencies |
| QUAL-01 | Quality | High | adiuvAI/src/main/router/index.ts | God file: 1967 LOC, 15 sub-routers — split per domain |
| QUAL-02 | Quality | High | api/app/core/deep_agent.py | _run_single_agent vs _run_single_agent_stream ~90% duplicated |
| QUAL-03 | Quality | High | api (4 files) | Four hand-rolled LLM tool loops — extract one |
| QUAL-04 | Quality | High | adiuvAI/src/main/router/index.ts | CRUD repetition ×8 tables — extract factory |
| QUAL-05 | Quality | High | api/app/core/deep_agent.py | God file: 1329 LOC — split prompt-context / tools / runner |
| QUAL-06 | Quality | Medium | adiuvAI/src/main/api/backend-client.ts | God file 1191 LOC; 230-line openDeviceWebSocket; send* boilerplate ×6 |
| QUAL-07 | Quality | Medium | api/app/core/scout_runner.py | 1051 LOC; run_local_agent/run_cloud_agent 210-line twins |
| QUAL-08 | Quality | Medium | api/app/api/routes/device_ws.py | Protocol + business logic mixed — extract ws_handlers package |
| QUAL-09 | Quality | Medium | adiuvAI/src/renderer/components/projects/ProjectSidebar.tsx | 1292 LOC, 25+ useState — extract dialogs |
| QUAL-10 | Quality | Medium | api/app/api/routes/scouts.py | Ownership-check + 404 repeated 6× — FastAPI dependency |
| QUAL-11 | Quality | Medium | adiuvAI/src/main/router/index.ts | Three competing error-return styles, zero TRPCError |
| QUAL-12 | Quality | Medium | api/app/api/routes/device_ws.py | Raw-dict frames bypass pydantic schemas (also kills 7 type:ignores) |
| QUAL-13 | Quality | Medium | api + adiuvAI (WS boundary) | camelCase/snake_case contract untrusted (dual-read hedges) |
| QUAL-14 | Quality | Medium | api (auth.py, scouts.py) | OAuth route blocks → dedicated files; shared TTLStateStore |
| QUAL-15 | Quality | Low | api/app/agents | Copy-pasted _is_uuid + row formatting across agents |
| TYPE-01 | Type safety | High | adiuvAI/src/renderer/components/ai/blocks (3 files) | Broken relative imports — block types silently `any` |
| TYPE-02 | Type safety | High | api/ | No Python type checker configured at all |
| TYPE-03 | Type safety | Medium | adiuvAI/ | tsc --noEmit does not pass; no CI typecheck |
| TYPE-04 | Type safety | Medium | api/app/core/deep_agent.py | context: dict[str, Any] threaded everywhere — RequestContext model |
| TYPE-05 | Type safety | Medium | adiuvAI/src/main/api/drizzle-executor.ts | Cast cluster: table-as-Record ×5, bogus Boolean predicate casts |
| TYPE-06 | Type safety | Low | adiuvAI/src/renderer | (window as any).electronAI ×4 — global declaration file |
| TYPE-07 | Type safety | Low | api + adiuvAI | Misc: ToolResult TypedDict, 11 missing py annotations, vite/client types |
---
## Execution Order
Line numbers reference the current tree — **do mechanical fixes before file splits**.
**Phase 0 — Mechanical quick wins (safe for low-capability executor, no review needed):**
TYPE-01 → DEPS-01 → DEAD-10 → DEAD-01 → DEAD-02 → DEAD-05 → DEAD-08 → DEPS-08 → DEAD-11 → DEAD-12 → PERF-14 → PERF-18 → CORR-20
**Phase 1 — Critical & High security (ALL ⚠️ REVIEW, human validates each diff):**
SEC-01 → SEC-05 → SEC-06 → SEC-07 → SEC-20 (these four+one harden the Electron trust boundary together) → SEC-02 → SEC-14 → SEC-19 (share a startup-guard mechanism: do SEC-14 first) → SEC-09 → SEC-08 → SEC-04 → SEC-15 → SEC-03 (needs migration design)
**Phase 2 — Critical & High correctness:**
CORR-01 → CORR-02 (same files, same review session) → CORR-04 → CORR-12 (CORR-04 depends on CORR-12's error-frame contract — implement together) → CORR-06 → CORR-05 ⚠️ → CORR-03
**Phase 3 — Remaining Medium security + correctness:**
SEC-10 → SEC-11 → SEC-12 → SEC-13 (coordinated api+electron change) → SEC-17/SEC-18 (one Redis introduction covers both) → SEC-27 → SEC-21 → remaining SEC Mediums → CORR-07..CORR-17
**Phase 4 — Performance:**
PERF-01 → PERF-02 ⚠️ → PERF-03 → PERF-04 → PERF-05 (migrations) → PERF-09/PERF-10 (same subsystem) → PERF-06 → rest
**Phase 5 — Dependencies & tooling:**
DEPS-02 → DEPS-03 → DEPS-04 → TYPE-02 → TYPE-03 → DEPS-05 ⚠️ → DEPS-06 ⚠️
**Phase 6 — Structural refactors (after all above; invalidates line numbers):**
QUAL-02 ⚠️ → QUAL-03 ⚠️ → QUAL-01 → QUAL-04 ⚠️ → QUAL-05 → QUAL-06..QUAL-15 → TYPE-04..TYPE-07 → DEAD-04 ⚠️ / DEAD-07 ⚠️ (owner decisions)
---
# 1. Security
> Per instruction: **every SEC item is ⚠️ REVIEW** unless explicitly noted "mechanical". A botched auth check or sanitizer is worse than the original bug.
### SEC-01 — Backend can read ANY file on the user's disk ⚠️ REVIEW
- **File:** `adiuvAI/src/main/api/drizzle-executor.ts``handleListDirectory` L375-398, `handleReadFileContent` L400-436, `handleGetFileMetadata` L438-461
- **Severity:** Critical · OWASP A01:2021 Broken Access Control
- **Problem:** These handlers take a backend-supplied `path`, resolve it (`fs.promises.realpath(path.resolve(dirPath))`) and read it with **no allowlist or root containment**. `read_file_content` returns up to 500 KB of any file (`~/.ssh/id_rsa`, browser cookie DBs). The backend is semi-trusted by design; if compromised, blast radius = the entire user filesystem. The backend adds no guard either (`api/app/agents/filesystem_agent.py:24-33` passes absolute paths straight through). Contrast: `handleReadProjectFolderFile` (L511-529) does containment correctly.
- **Fix:** After realpath, assert the resolved path is inside an allowed root: the set of configured `projects.folderPath` values (query the projects table) plus nothing else. Use `const rel = path.relative(root, resolved); if (rel.startsWith('..') || path.isAbsolute(rel)) throw new ExecutorError('Access denied')`. Apply identically in all three handlers. Also fix the existing `startsWith` containment in `handleReadProjectFolderFile` L527 to use the same `path.relative` check (defeats `C:\proj\foo-evil` vs `C:\proj\foo`).
- **Risk:** Local filesystem scouts or agent flows that legitimately read outside project folders will break — verify which directories local scout configs reference and add those roots to the allowlist before locking down. Test: agent file-read inside project folder still works; read of `C:\Windows\win.ini` rejected.
### SEC-02 — No rate limiting on login/register/refresh ⚠️ REVIEW
- **File:** `api/app/api/middleware/rate_limit.py:41-48` (`_EXEMPT_PATHS`), `:88-91` (no-token pass-through)
- **Severity:** High · OWASP A07:2021 Identification & Authentication Failures
- **Problem:** Limiter only acts on requests with a valid Bearer JWT; `if not token: return await call_next(request)`. Login/register/refresh are unauthenticated → **never rate-limited**. Unlimited password guessing and refresh-token guessing.
- **Fix:** Remove `/api/v1/auth/login` and `/api/v1/auth/register` from `_EXEMPT_PATHS`. In `dispatch()`, when no token is present and `request.url.path.startswith("/api/v1/auth/")`, apply an IP-keyed sliding window (reuse the existing `_window` mechanism keyed on client IP, fixed limit e.g. 5/min for login/register/refresh, 429 on excess). Note this remains per-process until SEC-17 lands Redis.
- **Risk:** Shared-NAT users may hit limits; size window accordingly. Test: 6th login attempt in a minute → 429; authenticated traffic unaffected.
### SEC-03 — Fernet key stored in plaintext beside its ciphertext ⚠️ REVIEW
- **File:** `api/app/models.py:83` (`User.encryption_key`), read at `api/app/core/memory_middleware.py:559-566`
- **Severity:** High · OWASP A02:2021 Cryptographic Failures
- **Problem:** The per-user key that encrypts all memory tiers sits as a plaintext column in the same PostgreSQL DB. Any DB-level compromise or backup leak decrypts everything — the "encrypted at rest" zero-trust claim collapses.
- **Fix:** Introduce a KEK from env/KMS (model: the existing `OAUTH_ENCRYPTION_KEY` pattern in `api/app/integrations/__init__.py:90-102`). Store `Fernet(KEK).encrypt(user_key)` in the column; unwrap in `_get_fernet`. One-time Alembic data migration re-wrapping existing keys; keep a dual-read window (try unwrap, fall back to raw) during rollout, then remove fallback.
- **Risk:** Migration must be reversible and tested against a DB snapshot; losing the KEK = losing all memory data. Requires key-rotation runbook. This is design work — human required.
### SEC-04 — Raw user content & PII sent to Langfuse ⚠️ REVIEW
- **Files:** `api/app/core/deep_agent.py:912,926,933,975` · `api/app/scouts/engine.py:260-268` (email subject/sender/2000-char body) · `api/app/core/scout_runner.py:266-278`
- **Severity:** High · OWASP A09:2021 / GDPR Art. 5, 28, 44 / Italian Law 132/2025
- **Problem:** Full conversation history, tool I/O, and triaged **email content** are transmitted to Langfuse when configured. `hash_user_id()` only pseudonymizes the identifier; bodies go in clear. Requires Langfuse as contracted sub-processor; possible international transfer depending on `LANGFUSE_BASE_URL`.
- **Fix:** Add `LANGFUSE_CAPTURE_CONTENT: bool = False` to settings. Wrap every `input=`/`output=` content assignment in the three files: when flag is false, pass `{"redacted": True, "chars": len(...)}` instead. Keep metadata/token counts. Document Langfuse as sub-processor in the privacy policy regardless.
- **Risk:** Reduced debugging richness. Verify Langfuse prompt management (`get_prompt`) is unaffected — it's a separate path.
### SEC-05 — Mass assignment in drizzle-executor insert/update ⚠️ REVIEW
- **File:** `adiuvAI/src/main/api/drizzle-executor.ts``handleInsert` L268-290, `handleUpdate` L292-327
- **Severity:** High · OWASP A08:2021
- **Problem:** Table name is allowlisted, columns are not: `{ id: crypto.randomUUID(), ...data, createdAt: now }` spreads backend keys verbatim — any real column writable. Also: `...data` is spread **after** `id`, so a backend-supplied `id` overrides the executor's generated one (contradicting the code's own comment).
- **Fix:** Define a per-table writable-column allowlist (object map table→string[]). Before building `values`/`withTimestamp`, pick only allowlisted keys; always strip `id` and `createdAt` from incoming `data`/`updates`. Reorder insert to `{ ...picked, id: crypto.randomUUID(), createdAt: now, updatedAt: now }`.
- **Risk:** Additive validation. Test every agent CRUD flow (create task/note/event, update, propose_note_edit) still works — the allowlist must cover all fields agents legitimately set.
### SEC-06 — No navigation / window-open guards ⚠️ REVIEW
- **File:** `adiuvAI/src/main/index.ts``createWindow` L85-117 (handlers absent repo-wide)
- **Severity:** High · OWASP A05:2021
- **Problem:** No `setWindowOpenHandler`, no `will-navigate` guard. Any navigation to remote content lands in the privileged renderer (which has `window.electronTRPC`/`electronAI`).
- **Fix:** In `createWindow` add:
```ts
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https://')) shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (e, url) => {
const ok = (MAIN_WINDOW_VITE_DEV_SERVER_URL && url.startsWith(MAIN_WINDOW_VITE_DEV_SERVER_URL)) || url.startsWith('file://');
if (!ok) e.preventDefault();
});
```
- **Risk:** Minimal — app uses internal TanStack routing. Test external links in chat still open in OS browser.
### SEC-07 — No Content-Security-Policy ⚠️ REVIEW
- **File:** `adiuvAI/index.html` (no meta CSP); `src/main/index.ts` (no onHeadersReceived)
- **Severity:** High · OWASP A05:2021
- **Problem:** Zero CSP → no defense-in-depth for any future XSS sink; `connect-src` unrestricted (compromised renderer exfiltrates anywhere).
- **Fix:** In production only (`!IS_DEV`), register `session.defaultSession.webRequest.onHeadersReceived` injecting: `default-src 'self'; script-src 'self'; connect-src 'self' https://<backend-host> wss://<backend-host>; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'`. Backend host from existing config.
- **Risk:** Tailwind/shadcn need `style-src 'unsafe-inline'`; Vite dev needs exemption (gate on IS_DEV). Test packaged build fully: chat, charts, Milkdown editor, images.
### SEC-08 — Waitlist rate limit keyed on spoofable headers ⚠️ REVIEW
- **File:** `waitlist/app/rate_limit.py:19-32`
- **Severity:** High · OWASP A04:2021 / CWE-348
- **Problem:** `_get_client_ip` trusts `cf-connecting-ip` then `x-forwarded-for` unconditionally. Random header per request = fresh 5/min bucket → unlimited signup spam, Brevo quota burn, poisoned stored `ip_address`.
- **Fix:** Add `TRUSTED_PROXY_IPS: str = ""` setting. Trust `cf-connecting-ip` only when `request.client.host` is in that list; otherwise use `request.client.host`. Also firewall origin to Cloudflare ranges at infra level.
- **Risk:** Misconfigured trusted-proxy list rate-limits Cloudflare itself (all users share CF egress IPs) — must be deployed together with correct env value.
### SEC-09 — Waitlist CONFIRM_SECRET random default ⚠️ REVIEW
- **File:** `waitlist/app/config.py:19`; `waitlist/Dockerfile:21` (`-w 2`)
- **Severity:** High · OWASP A05:2021
- **Problem:** `CONFIRM_SECRET: str = secrets.token_hex(32)` — with 2 gunicorn workers and no env var, each worker has a different HMAC secret: ~50% of confirm/unsubscribe links fail silently; all tokens die on restart. Breaks double opt-in AND the GDPR erasure path.
- **Fix:** Remove default: `CONFIRM_SECRET: str` (required field — pydantic fails startup if unset). Update `.env.example` comment.
- **Risk:** Deploys without the env var now fail loudly at boot — that's the point. Coordinate with deploy.
### SEC-10 — Login 500 for social-only accounts ⚠️ REVIEW
- **File:** `api/app/api/routes/auth.py:169` (and `_verify_password` L73-74)
- **Severity:** Medium · OWASP A07:2021
- **Problem:** OAuth-only users have `password_hash=None`; `hashed.encode()` → uncaught `AttributeError` → 500. Differing responses (500 vs 401) = account-type oracle + crash.
- **Fix:** Replace the check with: `if user is None or user.password_hash is None or not _verify_password(body.password, user.password_hash): raise HTTPException(401, "Invalid credentials")`.
- **Risk:** None. Test: password login against a Google-only account → 401.
### SEC-11 — No password strength on register ⚠️ REVIEW (mechanical)
- **File:** `api/app/api/routes/auth.py:100-104`
- **Severity:** Medium · OWASP A07:2021
- **Problem:** `password: str` unconstrained (1-char/empty accepted); `_ChangePasswordRequest` already requires `min_length=8`. `email` is plain `str`, not `EmailStr`.
- **Fix:** Line 102: `password: str = Field(min_length=8)`; change `email: str` to `email: EmailStr` (import from pydantic). Mirror what change-password already does.
- **Risk:** None for new accounts. Existing weak-password accounts unaffected.
### SEC-12 — No logout / refresh-token revocation ⚠️ REVIEW
- **File:** `api/app/api/routes/auth.py` (endpoint absent; rotation only at L209)
- **Severity:** Medium · OWASP A07:2021
- **Problem:** No server-side logout; stolen refresh token valid 30 days; no reuse detection on rotated tokens.
- **Fix:** Add `POST /api/v1/auth/logout` (authenticated): body `{refresh_token: str}`, delete the matching `RefreshToken` row (SHA-256 the input, filter by hash + `user_id`). Optional `all: bool` to delete all the user's tokens. Wire Electron `auth-manager.ts` logout to call it before clearing local tokens.
- **Risk:** Electron + backend change together. Test logout then refresh → 401.
### SEC-13 — JWT in WebSocket URL query string ⚠️ REVIEW
- **Files:** `api/app/api/routes/device_ws.py:78` · `adiuvAI/src/main/api/backend-client.ts:895`
- **Severity:** Medium · OWASP A09:2021
- **Problem:** `?token=<jwt>` lands in reverse-proxy access logs, APM traces. Token grants full account access for 30 min.
- **Fix:** The Electron client uses the `ws` package which supports handshake headers: pass `{ headers: { Authorization: \`Bearer ${token}\` } }` as the second arg to `new WebSocket(url, opts)`. Backend: read `websocket.headers.get("authorization")` first, fall back to `?token=` for one release, then remove fallback. Interim mitigation: strip query strings for `/api/v1/ws/device` in proxy access logs.
- **Risk:** Coordinated two-repo change; old clients break when fallback removed. Web SPA (browser WebSocket) cannot set headers — if web ever uses the WS, it needs the ticket pattern instead.
### SEC-14 — Insecure config defaults, no prod guard ⚠️ REVIEW
- **File:** `api/app/config/settings.py:6-7`; `api/docker-compose.yml:22-23`
- **Severity:** Medium · OWASP A05:2021
- **Problem:** `JWT_SECRET = "change-me-in-production"` and `postgres:postgres` defaults; nothing refuses to start in prod with defaults → forgeable JWTs.
- **Fix:** Add a pydantic `model_validator` on `Settings`: if `ENV == "prod"` and (`JWT_SECRET == "change-me-in-production"` or `len(JWT_SECRET) < 32`), raise `ValueError` at import. Same guard hook used by SEC-19 (`GMAIL_PUBSUB_AUDIENCE` empty → fail in prod).
- **Risk:** Prod deploys with missing env now fail at boot (intended). Dev unaffected.
### SEC-15 — User message content in INFO logs ⚠️ REVIEW
- **Files:** `api/app/api/routes/device_ws.py:258-265,343` · `api/app/core/scout_runner.py:292-311` · `api/app/core/deep_agent.py:958-984` · `api/app/core/memory_middleware.py:528,554`
- **Severity:** Medium · OWASP A09:2021 / GDPR
- **Problem:** Chat messages (`message[:200]`), tool args/outputs (`[:800]`/`[:1200]`), memory queries logged at INFO — uncontrolled PII sink in centralized logs.
- **Fix:** Replace content interpolations with length+ID only (e.g. `logger.info("home_request user=%s chars=%d", user_id, len(message))`). Gate full content behind `logger.debug` + a `LOG_CONTENT=false` setting.
- **Risk:** Less debuggable ops. Each call site is a one-line change; review the full grep list before executing.
### SEC-16 — Relational-memory labels stored plaintext ⚠️ REVIEW
- **File:** `api/app/models.py:432-435`; predicates at `api/app/api/routes/memory.py:34-45`
- **Severity:** Medium · OWASP A02:2021 / GDPR
- **Problem:** `subject_label`/`object_label` hold names of user's contacts/employers (`works_at`, `reports_to`) in plaintext — third-party personal data. Only `notes_encrypted` protected.
- **Fix (decision):** Either encrypt labels with the user Fernet key (breaks label-match queries at `memory_middleware.py:421-428` — would need deterministic encryption or hashing for lookups) or formally document in the processing record and rely on SEC-03's KEK to mitigate DB-leak. **Recommend the latter** + SEC-03. Human decision required.
- **Risk:** Encrypting breaks `upsert_relation` matching — substantial rework. Don't hand to low-capability model.
### SEC-17 — In-memory rate limiter ×4 bypass ⚠️ REVIEW
- **File:** `api/app/api/middleware/rate_limit.py:67,81`
- **Severity:** Medium · OWASP A04:2021
- **Problem:** Per-process dicts under `gunicorn -w 4` → effective limit up to 4× tier limit, nondeterministic.
- **Fix:** Back the sliding window with Redis (`INCR` + `EXPIRE` or sorted-set window) keyed `rl:{user_id}:{minute}`. docker-compose already has a commented Redis service — enable it. Do together with SEC-18 (one Redis introduction).
- **Risk:** New infra dependency; need connection-failure fallback policy (fail-open vs fail-closed — recommend fail-open with warning log).
### SEC-18 — In-memory OAuth state stores ⚠️ REVIEW
- **Files:** `api/app/api/routes/auth.py:62` (`_pending_states`) · `api/app/api/routes/scouts.py:458,694-697` (`_pending_scout_oauth_states`, holds encrypted Gmail token between callback and finalize)
- **Severity:** Medium · OWASP A04:2021
- **Problem:** Multi-worker: authorize and callback can hit different workers → random 401s; scout flow strands the Gmail token; all state lost on redeploy.
- **Fix:** Move both to Redis with TTL (in-code comments already prescribe this). Implement one `TTLStateStore` (see QUAL-14) with `set(key, dict, ttl)` / `pop(key)`; Redis-backed in prod, dict-backed in tests.
- **Risk:** Prereq: Redis from SEC-17. Test full OAuth login + scout Gmail connect flows.
### SEC-19 — Gmail Pub/Sub webhook fail-open ⚠️ REVIEW
- **File:** `api/app/api/routes/scout_webhooks.py:48-52`; default at `api/app/config/settings.py:69`
- **Severity:** Medium · OWASP A05:2021/A07:2021
- **Problem:** Empty `GMAIL_PUBSUB_AUDIENCE` → `_verify_pubsub_jwt` returns True for ANY unauthenticated POST; attacker-supplied `emailAddress` triggers scout runs (LLM cost) for arbitrary users. Also unauthenticated → not rate-limited (SEC-02).
- **Fix:** Fail closed: `if not settings.GMAIL_PUBSUB_AUDIENCE: if settings.ENV == "prod": return False; logger.warning(...); return True`. Tie into SEC-14's startup guard.
- **Risk:** Prod deploys without the env var stop receiving Gmail push (intended — they were unverified anyway).
### SEC-20 — IPC bridge doesn't validate sender ⚠️ REVIEW
- **Files:** `adiuvAI/src/main/ipc.ts:44-87` · `adiuvAI/src/main/index.ts:122-131` (dialog + scope handlers)
- **Severity:** Medium · OWASP A01:2021
- **Problem:** `ipcMain.on('trpc', …)` dispatches without checking `event.senderFrame`; all router procedures are `publicProcedure`. Any frame that runs in the renderer gets full CRUD/auth/scout access. The cheap control that contains SEC-06/SEC-07 failures.
- **Fix:** At the top of each `ipcMain.on/handle` callback: validate `event.senderFrame.url` starts with the dev-server URL (dev) or the app's `file://` index (prod); silently drop otherwise. Extract a `isTrustedSender(event)` helper used by all four registration points (`trpc`, `ai:contextual-scope-update`, `dialog:showOpenDialog`, any others found at execution time).
- **Risk:** None today (no sub-frames). Test dev + packaged.
### SEC-21 — Plaintext token/backup-key fallback ⚠️ REVIEW
- **Files:** `adiuvAI/src/main/ai/token.ts:37-47` · `adiuvAI/src/main/auth/backup-key.ts:24-35`
- **Severity:** Medium · OWASP A02:2021
- **Problem:** When `safeStorage.isEncryptionAvailable()` is false (keyring-less Linux/WSL), JWTs and the AES backup key are written plaintext to electron-store JSON. `readFromStore` also silently returns raw string on decrypt failure (downgrade masking).
- **Fix:** (1) Tag values with `enc:`/`plain:` prefix on write; on read, never attempt plaintext interpretation of an `enc:` value. (2) When safeStorage unavailable: keep tokens in-memory only (re-login per launch) and emit a renderer-visible warning. Never persist the backup key plaintext — fail backup-key creation instead.
- **Risk:** Keyring-less Linux users lose session persistence. Migration: existing stored values have no prefix — treat unprefixed as legacy-plaintext, re-encrypt on first read.
### SEC-22 — Web SPA token in localStorage ⚠️ REVIEW
- **File:** `adiuvAI/src/renderer/lib/httpLink.ts:19`
- **Severity:** Medium (web build only) · OWASP A07:2021
- **Problem:** `localStorage.getItem('adiuvai-token')` — readable by any XSS.
- **Fix (decision):** httpOnly secure cookie set by backend (needs CSRF protection + CORS review) or in-memory + silent refresh. If deferring, at minimum pair with strict CSP on the web deployment. Product/infra decision — don't execute blindly.
### SEC-23 — Waitlist Origin startswith bypass ⚠️ REVIEW (mechanical)
- **File:** `waitlist/app/security.py:50-51`
- **Severity:** Medium · OWASP A01:2021 / CWE-346
- **Problem:** `origin.startswith(o)` → `https://adiuvai.com.evil.example` passes.
- **Fix:** `origin in allowed` exact match; for Referer, compare `urllib.parse.urlsplit(referer)._replace(path="", query="", fragment="")` scheme+host against allowed origins.
- **Risk:** None; existing tests cover the endpoint.
### SEC-24 — GDPR erasure + confirm on bare GET ⚠️ REVIEW
- **File:** `waitlist/app/routes.py:87` (confirm), `:125-154` (unsubscribe → `_anonymize_entry`)
- **Severity:** Medium · CWE-650
- **Problem:** Mail scanners (SafeLinks, Mimecast) follow GETs → can anonymize a subscriber or auto-confirm a bot-planted email before any human clicks.
- **Fix:** Both GET handlers render a minimal HTML page with `<form method="POST">` + the token in a hidden field; move the state change to new POST handlers (`POST /waitlist/confirm`, `POST /waitlist/unsubscribe`). Keep token validation identical.
- **Risk:** Email templates unchanged (links still GET the page). Update the 19-test suite: existing GET tests now assert the form page; add POST tests.
### SEC-25 — Unauthenticated re-send mail bombing ⚠️ REVIEW
- **File:** `waitlist/app/routes.py:52-62`
- **Severity:** Medium · OWASP API4:2023
- **Problem:** Re-POSTing a known email re-triggers `send_confirmation_email`. With SEC-08's spoof bypass: mail-bomb any subscribed address via your Brevo account.
- **Fix:** Add `last_email_sent_at` column (Alembic migration). In the existing-entry branch, skip the send if `last_email_sent_at` < 15 min ago (still return the generic success response — preserves anti-enumeration).
- **Risk:** Migration + test. Depends on SEC-08 for full mitigation.
### SEC-26 — Website scripts without SRI; lucide@latest ⚠️ REVIEW (mechanical)
- **File:** `website/index.html:44-48`
- **Severity:** Medium · OWASP A08:2021 / CWE-353
- **Problem:** GSAP + ScrollTrigger (cdnjs) and `lucide@latest` (unpkg) loaded with no `integrity`/`crossorigin`; `@latest` ships whatever unpkg serves — supply-chain risk on the page hosting the PII signup form.
- **Fix:** Pin lucide to an exact version; add `integrity="sha384-…" crossorigin="anonymous"` to all three script tags (compute hashes from the pinned files), or self-host under `website/assets/`.
- **Risk:** Hash must match exact file; test animations + icons after.
### SEC-27 — _index_sessions: no ownership check, never purged ⚠️ REVIEW
- **File:** `api/app/api/routes/device_ws.py:65` (registry), `:700` (cancel), `:732` (batch)
- **Severity:** Medium · OWASP A01:2021
- **Problem:** `_handle_index_file_batch` / `_handle_index_session_cancel` look up by `sessionId` without verifying `session["user_id"] == user_id` — a user who learns another's session UUID can cancel it or corrupt it. Sessions also leak forever on mid-index disconnect (no TTL, no cleanup in WS finally).
- **Fix:** (1) In both handlers, after lookup: `if session is None or session["user_id"] != user_id: send error frame, return`. (2) In the WS `finally` block, drop all `_index_sessions` entries whose `user_id == user_id` and `ws is websocket`.
- **Risk:** None — pure tightening. Test indexing E2E (test_ws_index_session suite exists).
### SEC-28 — Local data unencrypted at rest ⚠️ REVIEW (product decision)
- **Files:** `adiuvAI/src/main/db/index.ts:94` (plain SQLite), `adiuvAI/src/main/attachments/storage.ts:17-19`
- **Severity:** Medium (informational) · OWASP A02:2021
- **Problem:** All local user content (clients, projects, notes, attachments) plaintext on disk. For a data-sovereignty product, a deliberate gap.
- **Fix (decision):** Options: SQLCipher (better-sqlite3-multiple-ciphers fork), or document reliance on OS full-disk encryption, or encrypt attachments with the existing device backup key (DEAD-07). Roberto decides; do not execute without direction.
### SEC-29 — Register email enumeration · Low ⚠️ REVIEW
- **File:** `api/app/api/routes/auth.py:127` — 409 "Email already registered". Acceptable UX trade-off for most SaaS; mitigate via SEC-02 rate limiting. **Fix:** no code change now; revisit if email verification flow lands.
### SEC-30 — Internal exception strings to WS clients · Low ⚠️ REVIEW (mechanical)
- **File:** `api/app/api/routes/device_ws.py:467-468, 503-505, 539-541, 578-580, 625-631, 653-659`
- **Problem:** `error=str(exc)` / f-string exception text sent to renderer — can leak DB errors, paths.
- **Fix:** Replace each with a generic message (`"Internal error"` + the request_id); keep the existing server-side `logger.exception`. 6 call sites, mechanical.
### SEC-31 — attachments.create arbitrary sourcePath · Low ⚠️ REVIEW
- **File:** `adiuvAI/src/main/router/index.ts:1893-1915` → `attachments/storage.ts:25-37`
- **Problem:** Renderer-supplied absolute path copied without restriction; defense-in-depth issue (matters only post-SEC-06/20 compromise).
- **Fix:** Track paths returned by the `dialog:showOpenDialog` handler in a per-session `Set`; `attachments.create` rejects `sourcePath` not in the set. Cap file size (e.g. 100 MB).
- **Risk:** Drag-and-drop flows (if any) bypass the dialog — verify before enforcing.
### SEC-32 — Scout Gmail deep link no state check · Low ⚠️ REVIEW
- **File:** `adiuvAI/src/main/index.ts:46-54`
- **Problem:** `adiuvai://scout/oauth/gmail/callback` code+state forwarded to renderer with no pending-flow check (login OAuth path does this correctly in `auth-manager.ts:321-359`). Backend validates state server-side, limiting impact.
- **Fix:** Mirror `_pendingOAuth`: main process records pending Gmail-OAuth state when the flow starts; `handleDeepLink` drops callbacks whose state doesn't match.
### SEC-33 — Waitlist partial email in logs · Low
- **File:** `waitlist/app/routes.py:75`. `email[:3]` can reveal short local-parts. **Fix:** log entry `id` only. Mechanical.
### SEC-34 — Website missing CSP/security headers · Low ⚠️ REVIEW
- **Fix:** At the reverse proxy serving `website/`: add `Content-Security-Policy` (allowlist cdnjs/unpkg or self-hosted after SEC-26), `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`. Infra change, not repo code.
### SEC-35 — Website innerHTML i18n sink · Low ⚠️ REVIEW (mechanical)
- **File:** `website/i18n.js:461-464`
- **Problem:** `el.innerHTML = t[key]` — not exploitable today (hardcoded dictionary) but latent; `?lang=` is user-controlled.
- **Fix:** Use `textContent` for all keys except an explicit whitelist of the 4 `data-i18n-html` keys that genuinely contain markup; never derive `t[key]` content from URL/user input.
### SEC-36 — sandbox not explicit · Low ⚠️ REVIEW
- **File:** `adiuvAI/src/main/index.ts:97-98` (webPreferences)
- **Problem:** `contextIsolation: true`, `nodeIntegration: false` are set (good), fuses are strong, but `sandbox` not enabled. Preload only uses `contextBridge`/`ipcRenderer`, so sandbox should be compatible.
- **Fix:** Add `sandbox: true` to webPreferences; full regression pass on packaged build (preload behavior changes under sandbox).
---
# 2. Correctness & Safety
### CORR-01 — Chat tool calls: no timeout + send-before-register race ⚠️ REVIEW
- **File:** `api/app/api/routes/device_ws.py:238-245` (`_executor`); contrast correct pattern `api/app/core/scout_runner.py:190-198`
- **Severity:** Critical
- **Problem:** (1) `await future` with no `asyncio.wait_for` — if Electron never replies (renderer crash, swallowed executor exception) the agent run hangs forever. (2) Frame is sent **before** `create_pending_call` registers the future; a fast `tool_result` arriving first no-ops on unknown id → permanent hang.
- **Fix:** Reorder: `future = device_manager.create_pending_call(user_id, payload["id"])` THEN `await websocket.send_text(...)`. Wrap: `return await asyncio.wait_for(future, timeout=30)` with `except asyncio.TimeoutError:` → pop the entry from `device_manager` `pending_calls` and return `{"error": "tool call timed out"}` so the LLM loop can continue. Also pop the dict entry on timeout in the scout path (`device_manager.py:145` leaks it).
- **Risk:** 30 s must exceed slowest legitimate executor op (large folder file reads) — confirm against `read_project_folder_file` worst case. Test: kill renderer mid-tool-call → agent run ends with error within 30 s.
### CORR-02 — WS unregister clobbers reconnected connection ⚠️ REVIEW
- **Files:** `api/app/api/routes/device_ws.py:133-145` · `api/app/core/device_manager.py:73-81`
- **Severity:** High
- **Problem:** Message loop ends silently on disconnect; heartbeat keeps the handler alive up to 30 s. If the client reconnects in that window, `register()` stores the new connection, then the OLD handler's `finally` calls `unregister(user_id)` — **popping the new healthy connection** and cancelling its pending futures. Also user looks online during the zombie window (scout runs dispatched into void).
- **Fix:** (1) `device_manager.unregister(self, user_id, ws)`: only pop when `self._connections.get(user_id)` and `self._connections[user_id].ws is ws`. Update both call sites. (2) End the heartbeat promptly: replace `asyncio.gather(loop, heartbeat)` with `done, pending = await asyncio.wait({...}, return_when=FIRST_COMPLETED)` then cancel pending.
- **Risk:** Core connection lifecycle — test reconnect-within-30s, normal disconnect, heartbeat failure (test_device_ws suite exists).
### CORR-03 — Free-tier extraction drains with empty content
- **File:** `api/app/core/memory_maintenance.py:120-154`
- **Severity:** High
- **Problem:** `run_extraction(..., last_user_msg="", last_assistant_msg="", ...)` — `row.episode_id` never used. Every hourly tick: one wasted gpt-4o-mini call per queue row over `"User: \nAssistant: "`, then row deleted. Free-tier memory extraction has never worked, and it costs money.
- **Fix:** For each row, load `MemoryEpisodic` by `row.episode_id` (+ `user_id`), decrypt `summary` with the user's Fernet (reuse `memory_middleware._get_fernet` or pass middleware instance), pass decrypted content as the extraction input. Group rows per user into a single call. Skip+delete rows whose episode is missing.
- **Risk:** Test asserting extracted candidates reference episode content (extend test_memory_extraction). Watch cost: now real extractions run.
### CORR-04 — Backend errors as success payloads ignored → chat wedged
- **Files:** `adiuvAI/src/main/router/index.ts:957-960` (returns `{response:'', error}`) · `adiuvAI/src/renderer/context/ContextualChatContext.tsx:160,210-240` · `adiuvAI/src/renderer/components/brief/TaskBriefChat.tsx:125,144` · `adiuvAI/src/renderer/components/ai/AIChatPanel.tsx:313-319`. Correct template: `useAIChat.ts:226-256`.
- **Severity:** High
- **Problem:** When offline/unauthenticated, no stream frames ever arrive; the three listed call sites never check `data.error` on mutation success → `isStreaming` stays true forever, `send()` no-ops, stream listeners leak (each leaked listener receives every future `ai:stream` frame).
- **Fix:** Preferred (cleaner, pre-1.0): make `aiRouter.chat`/brief/research procedures **throw TRPCError** instead of returning `{error}` (ipcLink already propagates errors, `ipcLink.ts:76`); then `onError` is the single error path — update `useAIChat` accordingly. Each renderer call site: in the error path, call the active unsubscribe, push an error bubble, reset `isStreaming`/`isResearching`. Add `useEffect` unmount cleanup for `TaskBriefChat`'s send listener (L144). Delete dead `hooks/useChatStream.ts` (DEAD-06).
- **Risk:** Touches every chat surface. Test: stop backend → send in contextual sidebar, task brief, home chat → error bubble shown, re-send works. Implement together with CORR-12 (shared error-frame contract).
### CORR-05 — Cascade-delete gaps; zero transactions ⚠️ REVIEW
- **Files:** `adiuvAI/src/main/router/index.ts` — `projects.delete` L211-220, `clients.deleteWithCascade` L119-151, `timelineEvents.delete` L711-723, `notes.delete` L835-840, `tasks.delete` L511-534 · `adiuvAI/src/main/api/drizzle-executor.ts` `handleDelete` L329-340
- **Severity:** High (data integrity)
- **Problem:** No FK constraints, hand-rolled deletes, no `db.transaction()` anywhere in src/main: project delete orphans timelineEvents/notes/projectFolderFiles/noteEdits; event delete leaves dangling dependency edges that **poison the cycle-guard BFS** (L756-772, falsely rejects new deps); AI-issued deletes (`handleDelete`) cascade nothing — orphan rows + attachment files on disk; `tasks.delete` interleaves file unlinks between row deletes (partial state on crash).
- **Fix:** Pre-1.0 clean option (preferred per Roberto's preference): add `references(..., { onDelete: 'cascade' })` to schema + enable `PRAGMA foreign_keys = ON` in `db/index.ts`, generate migration. Alternative: shared `cascadeDelete{Task,Project,Client,Note,TimelineEvent}` helpers in a new `src/main/db/cascade.ts`, used by BOTH tRPC routers and `handleDelete`, wrapped in `db.transaction()` (better-sqlite3 sync transactions), file unlinks after commit.
- **Risk:** FK route requires verifying every insert ordering satisfies constraints, and migration against existing user DBs with already-orphaned rows (clean orphans first). Test matrix: delete project with events+deps+notes+pending edits; agent-delete task with attachments → no orphan rows, files removed. Human review required — this deletes data.
### CORR-06 — WS drop wedges folder indexing in 'scanning'
- **Files:** `adiuvAI/src/main/api/backend-client.ts:1099-1100` (listeners cleared, `onDone` never called) · `adiuvAI/src/main/files/indexer.ts:106-117` (only `finalize` resets status) · consumers refusing rescan: `drizzle-executor.ts:486`, `files/daily-rescan.ts:22`, `router/projectFolders.ts:61`
- **Severity:** High
- **Problem:** WS close clears `indexListeners` without invoking callbacks → `folderLastScanStatus` stays `'scanning'` forever → all future scans refuse to run. Recovery currently requires manual DB surgery.
- **Fix:** (1) In the WS close handler, before `indexListeners.clear()`: iterate and call each `onDone('error')`. (2) Startup recovery in `initDb()` or app-ready: `UPDATE projects SET folder_last_scan_status='error' WHERE folder_last_scan_status='scanning'`.
- **Risk:** None meaningful. Test: kill backend mid-index → status becomes 'error', rescan works.
### CORR-07 — backend-client close handler clobbers new socket
- **File:** `adiuvAI/src/main/api/backend-client.ts:1079-1104`
- **Severity:** Medium-High
- **Problem:** `ws.on('close')` unconditionally nulls `persistentWs`, stops heartbeat, rejects+clears ALL listener maps. Logout→login: the old socket's deferred close event destroys the new connection's state and schedules a duplicate reconnect.
- **Fix:** Capture `ws` in closure; first line of the close and error handlers: `if (this.persistentWs !== ws) return;`. Same guard inside the heartbeat interval callback.
- **Risk:** Low. Test logout+immediate login → exactly one socket, in-flight chat on new socket survives.
### CORR-08 — Decay applied per invocation, not per period
- **File:** `api/app/core/memory_maintenance.py:280-309` (proactive, runs hourly), `:61-104` (relations)
- **Severity:** Medium
- **Problem:** `periods = days_elapsed // PERIOD; new_confidence = confidence * FACTOR**periods` recomputed from immutable `created_at` and re-applied every tick — a row ≥8 days old loses 10% **per hour** (intended: per 7 days).
- **Fix:** Compute confidence as a pure function of age each read, OR add `last_decayed_at` column (migration) and decay only `(now - last_decayed_at) // PERIOD` periods, updating the column. Pure-function approach avoids the migration — preferred.
- **Risk:** Recompute approach changes stored semantics — confirm nothing else mutates `confidence` (confirmation boosts do; then `last_decayed_at` is the correct route). ⚠️ REVIEW the choice.
### CORR-09 — Check-then-insert races, missing unique constraints ⚠️ REVIEW
- **Files:** `api/app/core/memory_middleware.py:232-247` (`update_core`), `:421-450` (`upsert_relation`); `api/app/api/routes/auth.py:125-127` (register IntegrityError→500)
- **Severity:** Medium
- **Problem:** No `UNIQUE(user_id, key)` on memory_core, none on memory_relations `(user_id, subject_label, predicate, object_label)`. Concurrent writers (documented fire-and-forget language seeding + onboarding PUT) create duplicates; `scalar_one_or_none()` then raises `MultipleResultsFound`. Register duplicate-email race returns 500 instead of 409.
- **Fix:** Alembic migration adding both unique constraints (dedupe existing rows first: keep newest per key). Convert both writers to PostgreSQL `INSERT ... ON CONFLICT DO UPDATE` (copy the savepoint pattern from `scouts/engine.py:123-135`). Register: catch `IntegrityError` → 409.
- **Risk:** Migration fails if production data has duplicates — dedupe step mandatory. Test concurrent update_core calls.
### CORR-10 — Fire-and-forget create_task: no refs, no concurrency cap
- **Files:** `api/app/api/routes/device_ws.py:120,130,171-215`; also `scouts.py:247-249`, `memory_middleware.py:201`, `note_agent.py:84,112`
- **Severity:** Medium
- **Problem:** Bare `asyncio.create_task` — loop holds weak refs (GC can collect mid-flight); exceptions surface only at GC; a frame-spamming client spawns unbounded concurrent agent runs (WS path has no rate limit).
- **Fix:** Per-connection `tasks: set[asyncio.Task]`; on spawn: `t = create_task(...); tasks.add(t); t.add_done_callback(tasks.discard)`. In WS `finally`: cancel all. Cap: if ≥3 in-flight stream-producing requests for the connection, reply immediately with `stream_end` error frame "busy".
- **Risk:** Cap value is judgment — confirm 3 is right for legitimate parallel use (home + contextual + brief). ⚠️ REVIEW the cap.
### CORR-11 — tasks.update wipes briefing + chat history on every update
- **File:** `adiuvAI/src/main/router/index.ts:504-506`; hash helper exists at `:55-64`; staleness check at `:976-989`
- **Severity:** Medium (silent user-data loss)
- **Problem:** Every update — including a kanban status drag — deletes `taskBriefings` + `taskBriefChats`.
- **Fix:** Load the task before update; compute `hashTaskForBriefing(prev)` vs hash of merged next state; run the two deletes only when hashes differ.
- **Risk:** Verify `hashTaskForBriefing` covers exactly the fields the briefing depends on. Test: status drag preserves brief chat; title edit invalidates it.
### CORR-12 — Stream errors emitted as plain stream_end
- **File:** `adiuvAI/src/main/ai/orchestrator.ts:105,111,141,147,192,198`; renderer persistence of empty message `ContextualChatContext.tsx:187-204`
- **Severity:** Medium
- **Problem:** `onError` sends a normal `stream_end` (no error flag); catch blocks send `stream_end` with `requestId: ''` (matches nothing). Mid-stream failure looks like success → blank assistant bubble **persisted to SQLite**.
- **Fix:** Extend the stream-end frame with optional `error?: string` in `src/shared` types (mirror backend's existing error-capable `WsStreamEnd`). orchestrator: pass real requestId + error message; delete the `requestId: ''` sends. Renderers: on `error`, show error bubble, skip `appendMessage` persistence when content empty. Backend twin fix: CORR-15.
- **Risk:** Frame contract change across main↔renderer — do together with CORR-04.
### CORR-13 — withRetry retries non-idempotent POSTs ⚠️ REVIEW
- **File:** `adiuvAI/src/main/api/backend-client.ts:700-723`
- **Severity:** Medium
- **Problem:** `proxyPost` retried up to 3× on timeout/5xx/429 — a timed-out-but-processed `/scouts/trigger` or scout create duplicates server work; immediate retry on 429 worsens it.
- **Fix:** Add `{ retry?: boolean }` option to `proxyPost` defaulting false; enable only for verified-idempotent endpoints (reads proxied via POST, if any). Exclude `RateLimitError` from retry entirely (or respect Retry-After). GET/DELETE-by-id keep retry.
- **Risk:** Requires classifying every proxyPost call site idempotent-or-not — review the list.
### CORR-14 — Sync Stripe calls block the event loop
- **File:** `api/app/billing/stripe_service.py:71-79, 197, 225-238` (auto_paging_iter paginates network synchronously)
- **Severity:** Medium
- **Fix:** Wrap each Stripe call in `await asyncio.to_thread(...)`; for `auto_paging_iter`, collect inside the thread: `await asyncio.to_thread(lambda: list(itertools.islice(it, 100)))`.
- **Risk:** None behavioral. Mechanical once pattern set.
### CORR-15 — run_home_stream swallows exceptions; no error stream_end
- **File:** `api/app/api/routes/device_ws.py:295-307`; correct pattern at `:498-505` (brief)
- **Severity:** Medium
- **Problem:** On exception: logged, partial chunks stored as an episode, client never receives `stream_end` with error → renderer hangs (pairs with CORR-04).
- **Fix:** In the except block: send `WsStreamEnd(request_id=..., error="Internal error")` (generic per SEC-30); skip `store_episode` when no response text was produced.
- **Risk:** None. Test: raise inside agent → client gets error frame.
### CORR-16 — delete_account: partial transaction + broad except ⚠️ REVIEW
- **File:** `api/app/api/routes/auth.py:759-795`
- **Severity:** Medium
- **Problem:** Stripe cancel commits its own transaction, then memory deletes, then user delete + final commit; failure mid-way = cancelled subscription with live account. `except Exception: pass` at L786-787 hides genuine failures. (DB-level `ondelete="CASCADE"` makes the manual memory deletes redundant.)
- **Fix:** Reorder: all DB deletes in one transaction first (drop the redundant manual memory-table deletes — FK cascade covers them; verify each memory table's FK has `ondelete="CASCADE"` in migrations before relying on it), commit, THEN Stripe cancel; on Stripe failure log + enqueue retry rather than failing the deletion. Narrow the except to expected types.
- **Risk:** Account deletion is irreversible — human review + test with seeded data.
### CORR-17 — Index-session TOCTOU + stale _active entry
- **Files:** `adiuvAI/src/main/files/indexer.ts:67` (`startIndexSession`) · `adiuvAI/src/main/router/projectFolders.ts:84-99` · trigger sites `drizzle-executor.ts:487-501`, `files/daily-rescan.ts:22`
- **Severity:** Medium
- **Problem:** Three concurrent triggers can all pass the `!== 'scanning'` check before any writes 'scanning'. `_active.set` runs after `await startIndexSession` returns — a zero-delta session completes before the entry exists, leaving stale `{status:'starting'}`.
- **Fix:** Module-level `const _inFlight = new Set<string>()` in indexer.ts; first synchronous statement of `startIndexSession`: `if (_inFlight.has(projectId)) throw new Error('Scan already in progress'); _inFlight.add(projectId)`; remove in `finalize`'s finally. In projectFolders.ts, register the `_active` entry before awaiting.
- **Risk:** Low. Also add `.catch(console.error)` to the `void startIndexSession(...)` fire-and-forget sites (daily-rescan.ts:25, drizzle-executor.ts:497).
### CORR-18 — Scout trigger TOCTOU · Low
- **File:** `api/app/api/routes/scouts.py:225-249` (+ `scout_runner.py:577`)
- **Fix:** Add `stable_agent_id` to `_running_agents` synchronously in the route before `db.commit()`/`create_task`; discard on spawn failure. Keep the in-task add idempotent.
### CORR-19 — Swallowed-error triage · Low
- **Files/fixes (mechanical batch):**
- `adiuvAI/src/main/router/index.ts:1156-1164, 1276-1294, 1374-1382, 1429-1434` — scout list/labels/catalog/runs return `[]` on any error → return `{ data, error }` envelope (or throw, per QUAL-11's chosen convention).
- `adiuvAI/src/main/api/drizzle-executor.ts:566-568` — include `error: message` in the `kind:'error'` result.
- `adiuvAI/src/main/auth/auth-manager.ts:594` — wrap `JSON.parse(text)` in try/catch, return `{}` on failure.
- Documented-intentional `.catch(() => {})` seeding (router/index.ts:1577,1585) — leave as is.
### CORR-20 — Naive/aware datetime kludge · Low
- **File:** `api/app/api/routes/auth.py:205` — unconditional `.replace(tzinfo=timezone.utc)` would mislabel a non-UTC value. **Fix:** `if rt.expires_at.tzinfo is None: rt_exp = rt.expires_at.replace(tzinfo=timezone.utc) else: rt_exp = rt.expires_at` (pattern from `scout_runner.py:166-167`).
### CORR-21 — Renderer/main misc · Low
- `adiuvAI/src/renderer/index.tsx:22-30` — move `LanguageSync` component definition to module scope (currently declared inside `App`, remount trap).
- `adiuvAI/src/main/ipc.ts:68` — `undefined as unknown as AbortSignal`: make the field optional in the local type (see TYPE-05 family).
---
# 3. Performance
### PERF-01 — bcrypt on the event loop
- **File:** `api/app/api/routes/auth.py:69-74`, called at `:134, :169, :656-659`
- **Severity:** High
- **Problem:** 100-300 ms CPU per call freezes ALL WS streams/tool round-trips; login is currently rate-limit-exempt (SEC-02) → credential-stuffing stalls the whole server.
- **Fix:** `return await asyncio.to_thread(bcrypt.hashpw, password.encode(), bcrypt.gensalt())` etc. — make `_hash_password`/`_verify_password` async, update 3 call sites.
- **Risk:** None. Verify tests still pass (auth suite exists).
### PERF-02 — Home channel buffers all tokens (fake streaming) ⚠️ REVIEW
- **File:** `api/app/core/deep_agent.py:1219-1227` (token withholding), `:1075` (`ainvoke` not `astream`)
- **Severity:** High (UX latency)
- **Problem:** `run_home_stream` accumulates every token and yields ONE blob after the whole run (including tool loops). User sees nothing until the end. Root cause: `_normalize_tagged_list_lines` needs the full text.
- **Fix:** Switch the LLM call to `llm_with_tools.astream()` accumulating tool-call deltas; make `_normalize_tagged_list_lines` line-buffered (process and flush complete lines as they arrive, hold only the current partial line). Measure time-to-first-frame before/after.
- **Risk:** Tool-call delta accumulation across LangChain/LiteLLM is fiddly; the comment near L1080-1100 documents a past double-LLM-call bug that must not regress. Strong-model territory.
### PERF-03 — Hourly mining duplicates + unbounded prompt injection
- **File:** `api/app/core/memory_maintenance.py:175-253`; `api/app/core/memory_middleware.py:703-718` (`_load_proactive`, no LIMIT); cron at `api/app/main.py:46-77`
- **Severity:** High (cost + correctness)
- **Problem:** Hourly re-mining of the same 30-day window, 3-5 new rows per tick, no dedup → ~100 near-duplicate rows/user/day, all decrypted into every chat prompt.
- **Fix:** (1) `_load_proactive`: add `.order_by(confidence.desc()).limit(20)`. (2) Mine only when episodes newer than the user's latest `MemoryProactive.created_at` exist. (3) Dedup new patterns against existing (reuse `decide_action`). (4) Consider daily cadence for the mining portion of the cron.
- **Risk:** (1) is mechanical and urgent; (2)-(4) need the memory test suites re-run.
### PERF-04 — No SQLite indexes (Electron)
- **File:** `adiuvAI/src/main/db/schema.ts` (only ai_chat tables indexed, migration 0006)
- **Severity:** Medium (High at scale — scans are synchronous, blocking all IPC)
- **Fix:** Add Drizzle `index()` on: `tasks.projectId`, `tasks.dueDate`, `taskComments.taskId`, `taskAttachments.taskId`, `notes.projectId`, `noteEdits.noteId`, `timelineEvents.projectId`, `timelineEventDependencies.fromEventId`+`.toEventId`, `scoutRunActions.runId`; `uniqueIndex` on `projectFolderFiles(projectId, relativePath)` (indexer does a per-file SELECT by that pair — `files/indexer.ts:128-136`, O(n²)). `npx drizzle-kit generate`.
- **Risk:** Migration must apply cleanly on existing user DBs (bootstrapMigrationsLedger path) — test against a populated dev DB.
### PERF-05 — Missing PG indexes; refresh_tokens never purged
- **File:** `api/app/models.py` (+ alembic migration needed)
- **Severity:** Medium
- **Fix:** One Alembic migration adding: `memory_core(user_id, key)` (unique — shared with CORR-09), `memory_episodic(user_id, created_at DESC)`, `memory_relations(user_id, subject_label, predicate, object_label)` (unique — CORR-09), `scout_run_logs(user_id, started_at)`, HNSW index on `memory_associative.embedding` (`USING hnsw (embedding vector_cosine_ops)`), `refresh_tokens(expires_at)`. Add a daily cleanup to the existing cron: `DELETE FROM refresh_tokens WHERE expires_at < now()`.
- **Risk:** HNSW build time on large tables; coordinate with CORR-09 dedupe.
### PERF-06 — Per-token IPC + full markdown re-parse
- **Files:** `adiuvAI/src/main/ai/orchestrator.ts:103,139,180` · `adiuvAI/src/renderer/components/ai/ChatSurface.tsx:197-199,352-358` · `useAIChat.ts:185-188`, `ContextualChatContext.tsx:183-185`, `TaskBriefChat.tsx:98-101`
- **Severity:** Medium
- **Problem:** token → IPC message → setState → full ReactMarkdown re-parse of the accumulated text + smooth scroll, per token. Quadratic on long answers.
- **Fix:** Coalesce in main: buffer chunks per requestId, flush on 40 ms timer (and on stream_end). Renderer: render the in-flight message as plain text (whitespace-pre-wrap), run ReactMarkdown only on `stream_end`; throttle autoscroll with requestAnimationFrame.
- **Risk:** Ensure stream_end flushes the tail buffer; visual check that markdown "pops in" acceptably at end.
### PERF-07 — Serial tool calls per LLM step
- **File:** `api/app/core/deep_agent.py:953-986, 1103-1137`; `api/app/core/scout_runner.py:289-312`
- **Severity:** Medium
- **Fix:** `results = await asyncio.gather(*(t.ainvoke(args) for ...))`, then append `ToolMessage`s preserving the original tool_call id order. Apply in all three loops (or once, after QUAL-03's shared loop).
- **Risk:** Concurrent tool calls hit the Electron executor concurrently — drizzle-executor handlers are async over a sync driver, fine, but verify pending-call map handles parallel ids (it's keyed by id — fine). Order preservation matters for the LLM.
### PERF-08 — Blocking Langfuse flush/get_prompt
- **Files:** `api/app/core/deep_agent.py:1009-1010,1165-1166` · `scout_runner.py:323-324` · `memory_extraction.py:319-322` · `langfuse_client.py:99`
- **Severity:** Medium
- **Fix:** Delete the per-request `lf.flush()` calls (SDK background-flushes; keep one flush in app shutdown lifespan). Wrap `lf.get_prompt(...)` in `asyncio.to_thread` at the call sites in langfuse_client, or pre-warm the prompt cache at startup.
- **Risk:** Traces may lag a few seconds in Langfuse UI — acceptable.
### PERF-09 — get_current_user does 3 queries + full memory decrypt per request
- **File:** `api/app/api/middleware/auth.py:62-91`
- **Severity:** Medium
- **Fix:** Split dependencies: `get_current_user` (user+tier in ONE query via outerjoin on subscriptions, no memory) and `get_current_user_with_memory` (adds core-block decrypt). Switch only the routes that actually read `.memory` (chat/profile) to the latter; scout CRUD, billing, etc. use the light one.
- **Risk:** Grep every `current_user.memory` access before switching routes. Test suite covers auth paths.
### PERF-10 — Redundant memory-middleware queries; per-key commits
- **Files:** `api/app/core/memory_middleware.py` (`_get_fernet`/`_get_user_debug` per method) · `api/app/api/routes/auth.py:560-575` (`update_memory` loop)
- **Severity:** Medium
- **Fix:** Cache `(user, fernet, tier)` on the middleware instance per request (it's constructed per call — add lazy `_ctx` loaded once). `update_memory`: add a `update_core_many(dict)` method doing one SELECT of existing keys, one bulk write, one commit.
- **Risk:** Instance lifetime check: confirm MemoryMiddleware isn't a long-lived singleton (it takes `db` per call — if constructed per request, caching is safe).
### PERF-11 — Health check before every chat
- **File:** `adiuvAI/src/main/ai/orchestrator.ts:76-87`
- **Fix:** In `checkConnectivity`, return early-success when `backendClient.persistentWs?.readyState === OPEN` (expose a `isWsConnected()` getter); keep the HTTP health check only as fallback when WS down.
- **Risk:** None — `sendHomeRequest` already rejects with OfflineError when WS is down.
### PERF-12 — Paid brief regen per mutation
- **File:** `adiuvAI/src/main/router/index.ts:447,502,532,668,706,721` → `orchestrator.ts:240-254` (1.5 s debounce)
- **Severity:** Medium (LLM cost)
- **Fix:** Replace eager regeneration with dirty-flag: `invalidateBriefCache()` only marks the cache stale; regeneration happens lazily on next `dailyBrief` read or the existing scheduler slot tick. Delete `scheduleBriefRegeneration`.
- **Risk:** First home-page visit after edits pays the generation latency — show the stale brief while regenerating (cache already stores it).
### PERF-13 — No code-splitting; heavy eager imports
- **Files:** `adiuvAI/vite.renderer.config.mts:13-16`, `vite.web.config.mts:22` · `ChatChartBlock.tsx:20` (recharts) · `routes/notes.$noteId.tsx:27` (Milkdown)
- **Fix:** Add `autoCodeSplitting: true` to TanStackRouterVite in both configs. `React.lazy` + Suspense for `ChatChartBlock` (renders only on `<chart>` tag) and `MilkdownEditor`.
- **Risk:** Test chart block + notes editor render after splitting (Suspense fallback flash).
### PERF-14 — Default QueryClient → focus refetch storms (mechanical)
- **File:** `adiuvAI/src/renderer/index.tsx:15` (contrast `web-main.tsx:24`)
- **Fix:** `new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, refetchOnWindowFocus: false } } })` — local writes already invalidate explicitly.
- **Risk:** Multi-window staleness (single-window app — fine).
### PERF-15 — Double folder walk; no WS backpressure
- **Files:** `adiuvAI/src/main/router/projectFolders.ts:65` + `files/indexer.ts:67,191-215`
- **Fix:** Pass the pre-flight `ScanDelta` into `startIndexSession` (skip re-walk). In the batch loop, check `ws.bufferedAmount` and await drain (or gate on `onProgress` acks) before sending the next batch.
- **Risk:** Backpressure logic needs a stall timeout; test with a large folder.
### PERF-16 — DB sessions across LLM calls
- **Files:** `api/app/scouts/engine.py:48-70` · `api/app/api/routes/device_ws.py:736-829`; pool config `api/app/db.py:24-28`
- **Fix:** Restructure to short sessions: load needed rows → close → LLM work → new session for writes. Interim: raise `pool_size`/`max_overflow` and set explicit `pool_timeout`.
- **Risk:** Detached-instance errors after session close — copy needed attrs to plain objects first.
### PERF-17 — Per-file metadata WS round-trips
- **File:** `api/app/core/scout_runner.py:386-405`
- **Fix:** Extend the Electron `list_directory` executor result to include `mtimeMs`/`size` per entry (same `fs.stat` it already does for dirents — `drizzle-executor.ts:375-398`); backend reads mtimes from the listing, drops per-file `get_file_metadata` calls.
- **Risk:** Two-repo protocol change; version-skew tolerance (backend falls back to old path if field absent).
### PERF-18 — Per-call AsyncOpenAI clients (mechanical)
- **Files:** `api/app/core/embeddings.py:23` · `api/app/core/llm.py:154`
- **Fix:** Module-level `_client: AsyncOpenAI | None` lazy singleton; reuse across calls.
### PERF-19 — tasks.list client-side pagination · Low
- **Files:** `adiuvAI/src/main/router/index.ts:314-317` · `TaskListView.tsx:80,101`
- **Fix:** Pass `limit/offset` from TaskPager state; add `statusIn` filter server-side for the `active` pseudo-filter (L94-95). Defer until data sizes warrant.
### PERF-20 — Limiter dict growth; tier from JWT · Low
- **File:** `api/app/api/middleware/rate_limit.py:81,97-98,107-128`
- **Fix:** Delete empty lists after trim; superseded entirely if SEC-17's Redis lands. Tier-from-JWT staleness: document or read from DB (cheap once PERF-09's joined query exists).
### PERF-21 — Unbounded reads in drizzle-executor · Low
- **File:** `adiuvAI/src/main/api/drizzle-executor.ts:623-630` (page details), `:535-548` (pdf/docx/image base64 uncapped)
- **Fix:** Apply default limit 50 (as `handleSelect` does) to the `_all` page-detail queries; enforce the 500 KB cap on binary reads too (reject larger with explicit error).
### PERF-22 — Context re-render hotspots · Low
- **Files:** `adiuvAI/src/renderer/context/HeaderContext.tsx:30` (unmemoized value) · `ContextualChatContext.tsx:253-270` + `hooks/useContextualScope.ts:5`
- **Problem:** Every route re-renders per streamed sidebar token (scope consumers subscribe to the same context as `streamingContent`).
- **Fix:** `useMemo` the HeaderContext value. Split ContextualChatContext into stable-actions context (`setScope`, `open`, `toggle`) and volatile stream context (`messages`, `streamingContent`, `isStreaming`); `useContextualScope` consumes only the stable one.
---
# 4. Dead Code
### DEAD-01 — Five dead Python deps · High (mechanical)
- **File:** `api/requirements.txt` lines 12, 25, 26, 27, 31
- **Problem:** `boto3`, `moto[s3]`, `pinecone`, `qdrant-client`, `google-auth-oauthlib` — zero imports/refs anywhere (grep-verified; pgvector is the live vector path; `oauth_providers.py:71` explicitly states it does NOT use google-auth-oauthlib).
- **Fix:** Delete those 5 lines. Run `pytest` after. Keep `python-dotenv` (pydantic-settings `env_file` needs it) and `google-auth-httplib2` (transitive need of googleapiclient).
### DEAD-02 — Orphaned scout_registry.py · High (mechanical)
- **File:** `api/app/core/scout_registry.py` — `BaseAgent`, zero importers, zero subclasses, references deleted vector-store era. **Fix:** delete file; run pytest.
### DEAD-03 — slowapi dead · Medium (mechanical)
- **Files:** `api/app/api/middleware/rate_limit.py:26-28,52,66-67` · `middleware/__init__.py:10,17` · `requirements.txt:13`
- **Problem:** `Limiter` exported "for optional route-level decoration" — zero `@limiter` decorated routes exist.
- **Fix:** Remove the Limiter creation/exports and the requirement. **Sequencing:** do AFTER SEC-02 (which may or may not reuse slowapi — recommended fix reuses the custom window, so slowapi still goes).
### DEAD-04 — chat.py HTTP routes production-unused ⚠️ REVIEW (owner decision)
- **Files:** `api/app/api/routes/chat.py:40,63,105`; stale comments `adiuvAI/src/main/api/backend-client.ts:9,14`
- **Problem:** Electron client hits none of `POST /chat`, `/chat/brief`, `/chat/embed` (all traffic on `WS /api/v1/ws/device`). Routes are test-covered but production-dead — unless an external consumer exists.
- **Fix:** Roberto confirms no external consumers → delete routes + their tests (`test_brief_agent.py`/`test_middleware.py` portions that exercise them — keep middleware tests by retargeting another POST route). Either way, fix the stale doc comments in backend-client.ts (mentions nonexistent `WS /api/v1/chat/stream`) and `adiuvAI/src/shared/api-types.ts:338,390,425` (references nonexistent `/agents/*` HTTP routes). Note CLAUDE.md path drift: actual WS route is `/api/v1/ws/device`, docs say `/api/v1/device`.
### DEAD-05 — Four unused npm deps · Medium (mechanical)
- **File:** `adiuvAI/package.json:56,76,77,78`
- **Fix:** `npm uninstall next-themes mammoth pdf-parse @hello-pangea/dnd` (knip + grep verified zero imports; mammoth/pdf-parse are LanceDB-era leftovers).
### DEAD-06 — Dead Electron modules · Medium
- **Files (knip-verified, delete):** `adiuvAI/src/shared/batch-types.ts` (~5 KB), `src/renderer/hooks/useChatStream.ts` (has the CORR-04 bug — delete, don't fix), `src/renderer/hooks/useTaskBriefCache.ts`, `src/renderer/components/agents/ScoutRunLog.tsx`, `src/renderer/components/ai/blocks/index.tsx` (dead barrel).
- **⚠️ REVIEW one:** `blocks/ChatTableBlock.tsx` is dead only because the barrel is — check `api/app/core/output_formatter.py` first: if the backend emits table segments, wire ChatTableBlock into ChatSurface's segment switch instead of deleting (missing feature, not dead code).
### DEAD-07 — backup-key.ts unwired ⚠️ REVIEW (owner decision)
- **File:** `adiuvAI/src/main/auth/backup-key.ts` — zero importers, but CLAUDE.md documents it as deliberate architecture. **Fix:** Roberto decides: wire into the backup feature roadmap or delete (recoverable from git). Interacts with SEC-21 (its storage fallback) and SEC-28 (could encrypt attachments).
### DEAD-08 — knip.json missing web entries · Medium (mechanical)
- **File:** `adiuvAI/knip.json`
- **Problem:** `web-main.tsx` + `lib/httpLink.ts` false-flagged (live via `web.html:11`).
- **Fix:** Add `"web.html"` and `"src/renderer/web-main.tsx"` to the `entry` array; optionally add ignore rule for TanStack `Route` exports. Do this FIRST so subsequent knip runs are trustworthy.
### DEAD-09 — langchain meta-package; websockets pin · Medium (mechanical)
- **File:** `api/requirements.txt:4,20`
- **Fix:** Replace `langchain>=0.3.0` with `langchain-core>=0.3.0` (only `langchain_core`/`langchain_openai`/`langchain_litellm` are imported). Drop explicit `websockets` pin (uvicorn[standard] bundles it). Run pytest.
### DEAD-10 — Ruff F401 batch · Low (mechanical)
- **Fix:** `cd api && ruff check . --select F401 --fix` (13 findings: quota.py:7, deep_agent.py:1247, 11 test files). Keep the ERA001-flagged comment blocks (scouts.py:438-457, email_html.py:22 — documentation, add `# noqa: ERA001` if noisy).
### DEAD-11 — Dead TS exports · Low (mechanical)
- **Fix:** Remove `export` (or delete bodies): `getDbPath`/`getRawSqlite`/`closeDb` (`src/main/db/index.ts:118-137`), `generateAndCacheBrief` (`orchestrator.ts:257` — superseded if PERF-12 lands; coordinate), `attachmentsRoot` (`attachments/storage.ts:17`), `parseMutationsToEntityTags` (`useAIChat.ts:76`), `formatTime` (`lib/date.ts:72`), `parseDateRange` (`lib/parseDate.ts:151`). Keep error classes exported (instanceof checks); keep `webPlatform` until DEAD-08 confirms.
### DEAD-12 — Dev deps in runtime requirements · Low (mechanical)
- **File:** `api/requirements.txt:22-25,41`
- **Fix:** Move `pytest`, `pytest-asyncio`, `aiosqlite`, `ruff` to a new `requirements-dev.txt`; update Dockerfile to install only runtime file; CI installs both.
---
# 5. Dependencies
### DEPS-01 — ws 8.19.0 vulnerable · High (mechanical)
- **File:** `adiuvAI/package.json` — GHSA-58qx-3vcg-4xpx (uninitialized memory disclosure, affects ≤8.20.0). Only runtime-reachable vuln of the 58 npm audit hits. **Fix:** `npm audit fix` (→ ^8.21.0, semver-compatible).
### DEPS-02 — python-jose floor permits CVE versions · High (mechanical)
- **File:** `api/requirements.txt:10` — floor 3.3.0 permits CVE-2024-33663 (algorithm confusion) / CVE-2024-33664 (JWE DoS), fixed in 3.4.0. App pins HS256 with explicit `algorithms=[...]` everywhere (mitigates the confusion path), but raise anyway. **Fix:** `python-jose[cryptography]>=3.4.0`. **⚠️ REVIEW (later):** consider migrating to PyJWT (python-jose barely maintained) — separate ticket.
### DEPS-03 — cryptography floor · Medium (mechanical)
- **File:** `api/requirements.txt:34` — floor 42.0.0 permits CVE-2024-26130 versions. **Fix:** `cryptography>=43.0.1`.
### DEPS-04 — No Python lock file · Medium
- **Problem:** All `>=` floors; installed versions unauditable. **Fix:** Adopt `uv lock` or `pip-compile`; commit the lock; run `pip-audit` against it in CI. ⚠️ REVIEW tool choice.
### DEPS-05 — eslint 8 + @typescript-eslint 5 EOL ⚠️ REVIEW
- **File:** `adiuvAI/package.json:38-39` — ts-eslint v5 predates TS 5.x support; lint coverage degraded. **Fix:** Dedicated PR: eslint 9/10 flat-config migration + typescript-eslint 8. Medium effort (config rewrite).
### DEPS-06 — Electron 40 → 42 ⚠️ REVIEW
- **Fix:** Dedicated PR bumping electron; rebuild/retest better-sqlite3 (forge cross-compilation hooks download platform binaries — verify versions), full smoke of packaged app.
### DEPS-07 — Transitive toolchain advisories · Low (no action)
- `tmp`/`tar` via @electron-forge 7.x, `esbuild` via drizzle-kit — build-machine-only, **no fix available**. Do NOT run `npm audit fix --force` (downgrades forge/drizzle-kit). Monitor forge releases.
### DEPS-08 — @types/ws placement · Low (mechanical)
- **File:** `adiuvAI/package.json:65` — move to devDependencies.
---
# 6. Code Quality
> All QUAL items execute in **Phase 6**, after behavior fixes, because they move code and invalidate line references. Items marked ⚠️ involve behavior-preservation judgment.
### QUAL-01 — Split router/index.ts (1967 LOC) · High
- **Fix:** Split along existing boundaries into `src/main/router/{trpc.ts,clients.ts,projects.ts,tasks.ts,timeline.ts,notes.ts,ai.ts,scout.ts,auth.ts,memory.ts,settings.ts,attachments.ts}`; boundaries: clients 71-152, projects 154-228, tasks 230-560, timelineEvents 561-725, deps 726-787, notes 788-842, taskComments 843-877, settings 878-922, ai 923-1090, scout* 1091-1516, auth 1517-1729, memory 1730-1784, noteEdits 1785-1867, taskAttachments 1868-1946. `trpc.ts` holds the `t`/`router`/`publicProcedure` factory; `index.ts` becomes the `appRouter` merge + `AppRouter` export. Move brief helpers (L25-65) to `src/main/ai/brief-cache.ts` (with PERF-12).
- **Risk:** Low — routers are independent consts. Verify `AppRouter` type output unchanged (renderer compiles).
### QUAL-02 — Merge _run_single_agent twins ⚠️ REVIEW · High
- **File:** `api/app/core/deep_agent.py:873-1010` vs `:1013-1166` (~90% identical)
- **Fix:** Keep only the streaming generator; non-stream variant = join the tokens. **Preserve** the documented behavior at L1080-1100 (stream version intentionally avoids a second LLM call — past bug) as the single canonical path.
- **Risk:** Every chat path runs through this. Run full agent test suites; manual chat smoke.
### QUAL-03 — One LLM tool loop, four implementations ⚠️ REVIEW · High
- **Files:** `deep_agent.py:873,1013` · `scout_runner.py:222-324` · `scout_setup.py:237-349`
- **Fix:** New `api/app/core/tool_loop.py`: `async def run_tool_loop(llm, tools, messages, *, max_steps, lf_name, on_token=None)` yielding events. Four call sites become configuration. Use `contextlib.ExitStack` for Langfuse observations (kills the manual `__enter__`/`__exit__`). Parametrize observation names per site. Do AFTER QUAL-02 (reduces to three sites).
- **Risk:** Subtle per-site differences (Langfuse naming, error handling) — diff behavior carefully; PERF-07's gather lands here once.
### QUAL-04 — tRPC CRUD factory ⚠️ REVIEW · High
- **File:** `adiuvAI/src/main/router/index.ts` (8 tables repeat list/get/create/update/delete + `if (input.x !== undefined)` set-builders)
- **Fix:** `router/crud-factory.ts`: `makeCrudRouter(table, { createSchema, updateSchema, defaultOrder })`; plus `pickDefined(input, keys)` helper for update-set building. Prototype on `clients` first; domain procedures spread in.
- **Risk:** Generic factories can degrade tRPC type inference (`AppRouter` must stay exact for the renderer). If inference breaks, keep per-table routers and extract only `pickDefined` + create/update helpers.
### QUAL-05 — Split deep_agent.py (1329 LOC) · High
- **Fix:** (a) `app/core/prompt_context.py` ← `_*_injection` functions, `_request_context_block`, `format_folder_manifest`, `_prepare_context` (L43-519); (b) `app/core/agent_tools.py` ← `_memory_tools` (684-826), `_read_only_memory_tools`, `_brief_research_tools`, `_all_tools_for_user`, `_contextual_tools`, `get_page_details` (521-871); (c) `deep_agent.py` keeps runner + `run_*` entries. Pure moves; do after QUAL-02/03.
### QUAL-06 — backend-client.ts split + dispatch extract ⚠️ REVIEW · Medium
- **Fix:** (a) `backend-errors.ts` ← 5 error classes (L134-203); (b) extract `private dispatchFrame(frame)` + per-frame handlers from the 230-line `openDeviceWebSocket` (L876-1106); (c) `sendStreamFrame(frameType, payload, callbacks)` helper deduping the six `send*` methods (L276-660); (d) move `recordRunAction` (L55, SQLite write) to `src/main/scouts/scout-run-log.ts`. Full split into connection/requests modules optional second step.
- **Risk:** Shared mutable state (`streamListeners`, `persistentWs`) — extract as methods first, modules later. Do after CORR-07's guards land.
### QUAL-07 — scout_runner.py split + twin dedupe ⚠️ REVIEW · Medium
- **Fix:** `app/core/scouts/{local_runner.py,cloud_runner.py,runner_common.py}`; extract `_guard_device_online()` and `_process_items(items, process_one) -> RunStats` shared by the 210-line twins (`run_local_agent` 556-766 / `run_cloud_agent` 774-983).
- **Risk:** Per-item error semantics differ slightly between local/cloud — write characterization tests first (suites exist).
### QUAL-08 — device_ws.py handlers → package · Medium
- **Fix:** Keep `device_ws.py` as protocol layer (endpoint, `_message_loop`, `_heartbeat_loop`, `_mark_runs_disconnected`); move the 9 `_handle_*` to `app/api/ws_handlers/{chat,brief,journey,indexing}.py`. Handlers already take `(websocket, user_id, payload)`. Do with QUAL-12.
### QUAL-09 — ProjectSidebar.tsx (1292 LOC, 25+ useState) · Medium
- **Fix:** Extract `ProjectEditDialog.tsx`, `ClientManageDialogs.tsx`, `NewProjectDialog.tsx`, and a shared `ClientSelect` (the client+sub-client+create-inline combo duplicated between edit and new-project dialogs with parallel state pairs). Pure UI extraction.
### QUAL-10 — Ownership-check dependency · Medium (mechanical)
- **File:** `api/app/api/routes/scouts.py:344,372,391,407,702` (pattern ×6)
- **Fix:** `async def get_owned_cloud_scout(scout_id: UUID, current_user=Depends(get_current_user), db=Depends(get_session)) -> CloudScoutConfig` raising 404; use as route param. Same pattern available for other resource routes.
### QUAL-11 — Standardize tRPC error contract ⚠️ REVIEW · Medium
- **File:** `adiuvAI/src/main/router/index.ts` — three styles: `{error}` (L109,113), `{success:true}`, `{data,error}` envelopes (1157-1232), zero `TRPCError`.
- **Fix:** Standardize on throwing `TRPCError` (ipcLink propagates, `ipcLink.ts:76`); migrate procedure-by-procedure WITH their renderer consumers (each `useQuery/useMutation` error handling). Coordinates with CORR-04/CORR-19. Do during QUAL-01 split.
- **Risk:** Every consumer touched — easy to miss one. Grep all `\.error` accesses on tRPC results.
### QUAL-12 — Typed WS frames server-side · Medium
- **File:** `api/app/api/routes/device_ws.py:424,620,704,767-820` (raw `json.dumps` dicts) vs pydantic frames elsewhere; 7× `# type: ignore[union-attr]` (L293-294,382-383,491,567-568)
- **Fix:** Define pydantic models for index/journey reply frames in `app/schemas`; use a discriminated union (`Field(discriminator="type")`) for inbound frames, `model_validate` at dispatch, error frame on ValidationError. Kills the type:ignores and the hand-rolled snake/camel fallbacks (L673-676,746-750).
### QUAL-13 — Casing contract at WS boundary · Medium
- **Evidence:** `api/app/agents/note_agent.py:25` reads `row.get("aiSummary") or row.get("ai_summary")` — convention untrusted.
- **Fix:** Document the contract ("frames snake_case, tool-result rows camelCase") in `api/app/core/ws_context.py` docstring + `adiuvAI/src/shared/casing.ts`; delete the dual-read hedges; add a TypedDict for rows (TYPE-07).
### QUAL-14 — OAuth blocks → own files; shared TTLStateStore · Medium
- **Fix:** `app/api/routes/oauth.py` ← auth.py L45-67+299-505 (incl. extracting `link_or_create_oauth_user()` from the 124-line `oauth_callback`); `app/api/routes/scout_gmail_oauth.py` ← scouts.py L456-807. One `app/auth/state_store.py` `TTLStateStore` replacing both `_pending_states` dicts — the seam where SEC-18's Redis backend plugs in. Sequence: build TTLStateStore during SEC-18, move files in Phase 6.
### QUAL-15 — Agent helpers dedupe · Low (mechanical)
- **Fix:** `api/app/agents/_common.py` with `is_uuid()` (copies at task_agent.py:14-20, note_agent.py:15-22) and `format_rows(rows, line_fn, empty_msg)`.
---
# 7. Type Safety
### TYPE-01 — Broken relative imports, types silently `any` · High (mechanical)
- **Files:** `adiuvAI/src/renderer/components/ai/blocks/ChatEntityBlock.tsx:13`, `ChatChartBlock.tsx:27`, `ChatTimelineBlock.tsx:5`
- **Problem:** `'../../../../../shared/api-types'` (5 levels = repo root) — one `../` too many. `import type` so esbuild strips it without resolving; tsc confirms TS2307. Block data types are `any` throughout.
- **Fix:** Remove one `../` in each of the three imports (→ `'../../../../shared/api-types'`).
### TYPE-02 — No Python type checker · High ⚠️ REVIEW (config choices)
- **Fix:** Add `api/pyproject.toml` with `[tool.mypy]`: start `check_untyped_defs = true`, `warn_return_any = true`; per-module strict for `app/core`, `app/api`. Wire into CI beside `ruff check`. First run will surface real bugs — budget a cleanup pass.
### TYPE-03 — tsc doesn't pass; no CI typecheck · Medium
- **Evidence:** pre-existing errors at `backend-client.ts:963`, `drizzle-executor.ts:367`, `OnboardingFlow.tsx:157/165/291` (+ TYPE-01 cascade).
- **Fix:** After TYPE-01, fix the remaining tsc errors (inspect each — may be real bugs), then add `"typecheck": "tsc --noEmit"` script + CI step.
### TYPE-04 — RequestContext model for deep_agent · Medium
- **File:** `api/app/core/deep_agent.py` (19× `dict[str, Any]`; `context` threaded through L43-592+)
- **Fix:** `RequestContext` TypedDict/pydantic model in `app/schemas/` (trace_id, session_id, format_prefs, proactive hints, relational memory, …); `_trace_id_from_context`/`_session_id_from_context` (L574-590) become attribute access and get deleted. Do with QUAL-05.
### TYPE-05 — drizzle-executor cast cluster · Medium (mechanical)
- **File:** `adiuvAI/src/main/api/drizzle-executor.ts:73,159,262,317,335` (table-as-Record ×5), `:230,249` (`Boolean as unknown as (x) => x is SQL` — dead weight, conditions already `SQL[]`)
- **Fix:** One helper using drizzle's `getTableColumns()`: `getColumn(table, name): SQLiteColumn | undefined`; replace the record casts. Delete the Boolean predicate casts (`and(...conditions)` suffices).
### TYPE-06 — window.electronAI escapes · Low (mechanical)
- **Files:** `ContextualChatContext.tsx:148-150`, `CloudScoutConfigPanel.tsx:39`, `CloudScoutCreationFlow.tsx:47`, `httpLink.ts:17`
- **Fix:** `src/renderer/types/electron-api.d.ts` with `declare global { interface Window { electronAI?: ElectronAI; electronTRPC?: …; electronDialog?: … } }`; add `"types": ["vite/client"]` to tsconfig (fixes `(import.meta as any).env`).
### TYPE-07 — Boundary typing misc · Low
- `ToolResult` TypedDict in `api/app/core/ws_context.py` (`rows`, `row`, `error`) — types the most-trafficked boundary; pairs with QUAL-13.
- 11 missing return annotations: `api/app/api/routes/scouts.py` cloud CRUD handlers (302, 313, 337, 366, 758), `device_ws.get_session_buffer:321` — add once TYPE-02's checker exists.
- `Provider` Protocol in `app/integrations/__init__.py` for `fetch_messages`/`fetch_emails` (kills 2 type:ignores in scout_runner.py:851+).
- `adiuvAI/src/main/ipc.ts:55` `(response: any)` → type the tRPC response envelope; `SerializedTRPCResponse` in `src/shared/` closes the ipcLink seam (`ipcLink.ts:66,76`).
---
## Notes for the Executor
1. **Line numbers reference `main` @ `315c5d0`.** Re-verify each quoted snippet exists before editing; if it moved, find it by content, not line.
2. **Never execute ⚠️ REVIEW items unattended.** Security fixes especially: a sanitizer with a bypass or a broken auth check is worse than the original bug.
3. **After each Phase, run:** `cd api && ruff check . && pytest` · `cd adiuvAI && npm run lint && npx tsc --noEmit && npm run knip` (tsc becomes meaningful after TYPE-01/TYPE-03). There is no Electron test suite — manual smoke for renderer-affecting changes.
4. **Two-repo changes** (SEC-13, PERF-17, CORR-12, QUAL-13) must land backward-compatibly: backend accepts both old and new shapes for one release.
5. **Run `graphify update .` after each modifying session** (project rule).
6. Items needing **owner decisions before any execution:** SEC-16, SEC-22, SEC-28, DEAD-04, DEAD-07, CORR-08 (approach choice), DEPS-04 (tool choice).

203
docs/REFACTOR_PROGRESS.md Normal file
View File

@@ -0,0 +1,203 @@
# Refactor Execution Progress
Companion to [REFACTOR_PLAN.md](REFACTOR_PLAN.md). The plan is **read-only**: never edit it. All progress, deviations, and lessons go HERE.
## Rules for the executing model
1. Before starting an item: set its Status to `in-progress`.
2. After finishing: set Status (`done` / `blocked` / `needs-review` / `skipped`), fill Commit with the short SHA, add Notes if anything was non-obvious.
3. **⚠️ items:** implement on the branch, set Status to `needs-review`, do NOT merge. A human or stronger model reviews the diff.
4. If the code does not match the plan's description (moved, already fixed, different shape): set `blocked`, write what you found in Notes, move to the next item. Do not improvise.
5. If you learn something that affects later items (e.g. "FK cascade route chosen in CORR-05, so DEAD-11's helper is gone"), add it to **Lessons Learned** at the bottom — next session reads it before starting.
6. One commit per item: `<ID>: <one-line description>`.
7. End of session: append a row to **Session Log**.
**Status values:** `pending` · `in-progress` · `done` · `needs-review` (⚠️ implemented, awaiting human) · `blocked` (mismatch/failed, see Notes) · `skipped` (owner decision) · `n/a`
## Owner decisions required (blocks the marked items)
| Decision | Blocks | Roberto's answer |
|---|---|---|
| Encrypt relational labels vs document + KEK only | SEC-16 | _pending_ |
| Web SPA token: httpOnly cookie vs in-memory vs defer | SEC-22 | _pending_ |
| Local at-rest encryption: SQLCipher / OS FDE / backup-key | SEC-28 | _pending_ |
| Delete chat.py HTTP routes or keep as public API | DEAD-04 | _pending_ |
| Wire backup-key.ts or delete | DEAD-07 | _pending_ |
| Decay fix: pure-function vs last_decayed_at column | CORR-08 | _pending_ |
| Python lock tool: uv vs pip-compile | DEPS-04 | _pending_ |
---
## Phase 0 — Mechanical quick wins
Branch: `refactor/phase-0` · Verify after each: `cd api && ruff check . && pytest` / `cd adiuvAI && npm run lint && npx tsc --noEmit`
| ID | ⚠️ | Status | Commit | Notes |
|---|---|---|---|---|
| TYPE-01 | | pending | | |
| DEPS-01 | | pending | | |
| DEAD-10 | | pending | | |
| DEAD-01 | | pending | | |
| DEAD-02 | | pending | | |
| DEAD-05 | | pending | | |
| DEAD-08 | | pending | | |
| DEPS-08 | | pending | | |
| DEAD-11 | | pending | | |
| DEAD-12 | | pending | | |
| PERF-14 | | pending | | |
| PERF-18 | | pending | | |
| CORR-20 | | pending | | |
## Phase 1 — Critical & High security (ALL ⚠️ — implement, never merge unreviewed)
Branch: `refactor/phase-1-security`
| ID | ⚠️ | Status | Commit | Notes |
|---|---|---|---|---|
| SEC-01 | ⚠️ | pending | | |
| SEC-05 | ⚠️ | pending | | |
| SEC-06 | ⚠️ | pending | | |
| SEC-07 | ⚠️ | pending | | |
| SEC-20 | ⚠️ | pending | | |
| SEC-02 | ⚠️ | pending | | |
| SEC-14 | ⚠️ | pending | | do before SEC-19 (shared startup guard) |
| SEC-19 | ⚠️ | pending | | |
| SEC-09 | ⚠️ | pending | | |
| SEC-08 | ⚠️ | pending | | deploy together with env value |
| SEC-04 | ⚠️ | pending | | |
| SEC-15 | ⚠️ | pending | | |
| SEC-03 | ⚠️ | pending | | migration design — human-led |
## Phase 2 — Critical & High correctness
Branch: `refactor/phase-2-correctness`
| ID | ⚠️ | Status | Commit | Notes |
|---|---|---|---|---|
| CORR-01 | ⚠️ | pending | | with CORR-02, same files |
| CORR-02 | ⚠️ | pending | | |
| CORR-12 | | pending | | before CORR-04 (frame contract) |
| CORR-04 | | pending | | |
| CORR-06 | | pending | | |
| CORR-05 | ⚠️ | pending | | deletes data — human review |
| CORR-03 | | pending | | |
## Phase 3 — Medium security + remaining correctness
Branch: `refactor/phase-3`
| ID | ⚠️ | Status | Commit | Notes |
|---|---|---|---|---|
| SEC-10 | ⚠️ | pending | | |
| SEC-11 | ⚠️ | pending | | mechanical |
| SEC-12 | ⚠️ | pending | | two-repo |
| SEC-13 | ⚠️ | pending | | two-repo, keep fallback one release |
| SEC-17 | ⚠️ | pending | | Redis intro — with SEC-18 |
| SEC-18 | ⚠️ | pending | | |
| SEC-27 | ⚠️ | pending | | |
| SEC-21 | ⚠️ | pending | | |
| SEC-23 | ⚠️ | pending | | mechanical |
| SEC-24 | ⚠️ | pending | | |
| SEC-25 | ⚠️ | pending | | needs SEC-08 first |
| SEC-26 | ⚠️ | pending | | mechanical |
| SEC-16 | ⚠️ | skipped | | awaiting owner decision |
| SEC-22 | ⚠️ | skipped | | awaiting owner decision |
| SEC-28 | ⚠️ | skipped | | awaiting owner decision |
| SEC-29 | ⚠️ | pending | | no code change — verify + close |
| SEC-30 | ⚠️ | pending | | mechanical |
| SEC-31 | ⚠️ | pending | | |
| SEC-32 | ⚠️ | pending | | |
| SEC-33 | | pending | | |
| SEC-34 | ⚠️ | pending | | infra, not repo code |
| SEC-35 | ⚠️ | pending | | mechanical |
| SEC-36 | ⚠️ | pending | | full packaged-app regression |
| CORR-07 | | pending | | |
| CORR-08 | | skipped | | awaiting owner decision |
| CORR-09 | ⚠️ | pending | | migration + dedupe |
| CORR-10 | ⚠️ | pending | | cap value judgment |
| CORR-11 | | pending | | |
| CORR-13 | ⚠️ | pending | | classify call sites |
| CORR-14 | | pending | | |
| CORR-15 | | pending | | |
| CORR-16 | ⚠️ | pending | | irreversible path — review |
| CORR-17 | | pending | | |
| CORR-18 | | pending | | |
| CORR-19 | | pending | | |
| CORR-21 | | pending | | |
## Phase 4 — Performance
Branch: `refactor/phase-4-perf`
| ID | ⚠️ | Status | Commit | Notes |
|---|---|---|---|---|
| PERF-01 | | pending | | |
| PERF-02 | ⚠️ | pending | | strong-model territory |
| PERF-03 | | pending | | LIMIT part urgent + mechanical |
| PERF-04 | | pending | | test migration on populated dev DB |
| PERF-05 | | pending | | coordinate with CORR-09 |
| PERF-09 | | pending | | |
| PERF-10 | | pending | | |
| PERF-06 | | pending | | |
| PERF-07 | | pending | | or fold into QUAL-03 later |
| PERF-08 | | pending | | |
| PERF-11 | | pending | | |
| PERF-12 | | pending | | |
| PERF-13 | | pending | | |
| PERF-15 | | pending | | |
| PERF-16 | | pending | | |
| PERF-17 | | pending | | two-repo |
| PERF-19 | | pending | | defer OK |
| PERF-20 | | pending | | superseded if SEC-17 Redis lands |
| PERF-21 | | pending | | |
| PERF-22 | | pending | | |
## Phase 5 — Dependencies & tooling
Branch: `refactor/phase-5-deps`
| ID | ⚠️ | Status | Commit | Notes |
|---|---|---|---|---|
| DEPS-02 | | pending | | |
| DEPS-03 | | pending | | |
| DEPS-04 | | skipped | | awaiting owner decision (tool) |
| TYPE-02 | ⚠️ | pending | | config choices |
| TYPE-03 | | pending | | after TYPE-01 |
| DEPS-05 | ⚠️ | pending | | dedicated PR |
| DEPS-06 | ⚠️ | pending | | dedicated PR, native rebuild |
| DEPS-07 | | n/a | | monitor only |
## Phase 6 — Structural refactors (LAST — invalidates plan line numbers)
Branch: `refactor/phase-6-quality`
| ID | ⚠️ | Status | Commit | Notes |
|---|---|---|---|---|
| QUAL-02 | ⚠️ | pending | | before QUAL-03 |
| QUAL-03 | ⚠️ | pending | | |
| QUAL-01 | | pending | | with QUAL-11 |
| QUAL-04 | ⚠️ | pending | | prototype on clients first |
| QUAL-05 | | pending | | |
| QUAL-06 | ⚠️ | pending | | after CORR-07 |
| QUAL-07 | ⚠️ | pending | | characterization tests first |
| QUAL-08 | | pending | | with QUAL-12 |
| QUAL-09 | | pending | | |
| QUAL-10 | | pending | | |
| QUAL-11 | ⚠️ | pending | | |
| QUAL-12 | | pending | | |
| QUAL-13 | | pending | | |
| QUAL-14 | | pending | | store built in SEC-18; file moves here |
| QUAL-15 | | pending | | |
| TYPE-04 | | pending | | with QUAL-05 |
| TYPE-05 | | pending | | |
| TYPE-06 | | pending | | |
| TYPE-07 | | pending | | |
| DEAD-04 | ⚠️ | skipped | | awaiting owner decision |
| DEAD-07 | ⚠️ | skipped | | awaiting owner decision |
---
## Lessons Learned
_Append findings that affect later items. Format: `- [ID] lesson`_
(none yet)
## Session Log
| Date | Model | Phase | Items touched | Outcome |
|---|---|---|---|---|
| | | | | |

View File

@@ -0,0 +1,852 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Task Form Dialog — keyboard-driven mockup</title>
<style>
:root {
--bg: #f4edf3;
--canvas: #ebe4ea;
--card: #ffffff;
--card-soft: #fbf7fa;
--border: #c8c3cd;
--border-soft: #d8d4dc;
--text: #1a1a1a;
--muted: #6e6a73;
--primary: #fbc881;
--primary-fg: #4a3210;
--accent: #e9e5ee;
--ring: #8a8ea9;
--danger: #c4423a;
--green: #5a8a55;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0c0c0c;
--canvas: #161616;
--card: #1a1a1a;
--card-soft: #202020;
--border: #323232;
--border-soft: #2a2a2a;
--text: #f5f5f5;
--muted: #9a9a9a;
--primary: #fbc881;
--primary-fg: #4a3210;
--accent: #2a2a2a;
--ring: #8a8ea9;
}
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Geist", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
letter-spacing: -0.01em;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
padding: 40px 20px;
}
.page-hint {
position: fixed; left: 16px; top: 16px;
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 12px;
font-size: 12px; max-width: 280px; line-height: 1.5;
color: var(--muted);
}
.page-hint strong { color: var(--text); }
.page-hint kbd {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 11px;
background: var(--accent); border: 1px solid var(--border-soft);
border-radius: 4px; padding: 1px 5px;
}
/* Dialog */
.overlay {
width: 580px; max-width: 100%;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(20px);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 20px 50px -10px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.4) inset;
overflow: hidden;
}
@media (prefers-color-scheme: dark) {
.overlay { background: rgba(26,26,26,0.92); }
}
/* Header — AddEventDialog style: title + description, no separator */
.dlg-header {
padding: 18px 22px 8px;
}
.dlg-title {
font-size: 16px; font-weight: 600; margin: 0;
letter-spacing: -0.02em;
}
.dlg-desc {
margin: 4px 0 0; font-size: 13px; color: var(--muted);
line-height: 1.45;
}
/* Body */
.dlg-body { padding: 18px 22px 12px; }
.title-input {
width: 100%;
border: none; outline: none; background: transparent;
font: inherit; color: inherit;
font-size: 22px; font-weight: 500;
letter-spacing: -0.02em;
line-height: 1.2;
}
.title-input::placeholder { color: var(--muted); opacity: 0.7; }
.desc-input {
margin-top: 8px;
width: 100%;
border: none; outline: none; background: transparent;
font: inherit; color: inherit;
font-size: 13px;
resize: none;
line-height: 1.5;
}
.desc-input::placeholder { color: var(--muted); opacity: 0.7; }
/* Properties section */
.props-label {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--muted); margin: 14px 0 8px;
}
.pills { display: flex; flex-wrap: wrap; gap: 6px; }
/* Pill */
.pill {
display: inline-flex; align-items: center; gap: 6px;
height: 28px; padding: 0 10px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--card-soft);
font-size: 12px; color: var(--text);
cursor: pointer;
transition: background 120ms, border-color 120ms, box-shadow 120ms;
position: relative;
}
.pill[data-empty="true"] {
border-style: dashed;
color: var(--muted);
background: transparent;
}
.pill:focus-visible,
.pill[data-focused="true"] {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 3px rgba(138,142,169,0.25);
}
.pill .pill-label { color: var(--muted); }
.pill .pill-value { font-weight: 500; }
.pill .pill-sep { color: var(--muted); opacity: 0.5; }
.pill-icon { font-size: 11px; line-height: 1; }
.pill .pi-up { color: #c4423a; }
.pill .pi-mid { color: #b97a14; }
.pill .pi-down { color: var(--muted); }
/* Footer */
.dlg-footer {
display: flex; align-items: center; justify-content: space-between;
gap: 8px;
padding: 12px 22px;
border-top: 1px solid var(--border-soft);
background: rgba(0,0,0,0.015);
}
@media (prefers-color-scheme: dark) {
.dlg-footer { background: rgba(255,255,255,0.02); }
}
.kbd-hint { font-size: 11px; color: var(--muted); }
.kbd-hint kbd {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 10px;
background: var(--accent); border: 1px solid var(--border-soft);
border-radius: 4px; padding: 1px 5px;
}
.btn {
height: 30px; padding: 0 14px; border-radius: 8px;
font: inherit; font-size: 13px; font-weight: 500;
border: 1px solid var(--border);
background: transparent; color: var(--text);
cursor: pointer;
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(138,142,169,0.25);
border-color: var(--ring);
}
.btn-primary {
background: var(--primary);
color: var(--primary-fg);
border-color: transparent;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.footer-actions { display: flex; gap: 6px; }
/* Popover */
.popover {
position: absolute;
z-index: 100;
min-width: 220px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 32px -8px rgba(0,0,0,0.2);
padding: 4px;
display: none;
}
.popover[data-open="true"] { display: block; }
.pop-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
outline: none;
}
.pop-item:hover,
.pop-item:focus,
.pop-item[data-active="true"] { background: var(--accent); }
.pop-item:focus { box-shadow: inset 0 0 0 1px var(--ring); }
.pop-item .check { width: 14px; color: var(--muted); }
.pop-item[data-selected="true"] .check::before { content: "✓"; color: var(--text); }
/* DateField segments */
.datefield {
display: inline-flex; align-items: center;
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px 8px;
background: var(--card-soft);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
}
.datefield:focus-within {
border-color: var(--ring);
box-shadow: 0 0 0 3px rgba(138,142,169,0.25);
}
.segment {
min-width: 1.8ch; text-align: center; padding: 2px 1px;
border-radius: 3px; outline: none; cursor: text;
color: var(--text);
}
.segment[data-placeholder="true"] { color: var(--muted); opacity: 0.6; }
.segment:focus { background: var(--accent); }
.seg-sep { color: var(--muted); padding: 0 1px; user-select: none; }
.date-pop {
padding: 12px;
min-width: 280px;
}
.date-pop .field-label {
font-size: 11px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 6px;
}
.date-pop .cal {
margin-top: 12px;
border-top: 1px solid var(--border-soft);
padding-top: 10px;
}
.cal-head {
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; margin-bottom: 6px;
}
.cal-head .month { font-weight: 600; }
.cal-head button {
border: 1px solid var(--border); background: transparent;
border-radius: 6px; width: 22px; height: 22px;
color: var(--text); cursor: pointer;
}
.cal-grid {
display: grid; grid-template-columns: repeat(7, 1fr);
gap: 2px; font-size: 11px;
}
.cal-dow {
color: var(--muted); text-align: center;
padding: 4px 0; font-weight: 500;
}
.cal-day {
text-align: center; padding: 5px 0;
border-radius: 5px; cursor: pointer; outline: none;
color: var(--text);
}
.cal-day:focus,
.cal-day[data-active="true"] { background: var(--accent); }
.cal-day[data-selected="true"] {
background: var(--primary); color: var(--primary-fg);
}
.cal-day[data-other-month="true"] { color: var(--muted); opacity: 0.4; }
.pop-anchor { position: relative; display: inline-flex; }
</style>
</head>
<body>
<div class="page-hint">
<strong>Keyboard demo</strong><br>
<kbd>Tab</kbd>/<kbd>Shift+Tab</kbd> cycles fields + pills.<br>
<kbd>Enter</kbd> opens focused pill.<br>
<kbd></kbd>/<kbd></kbd> inside popovers and calendar.<br>
<kbd>Esc</kbd> closes popover.<br>
Due pill: type date directly (segment edit).
<hr style="border:none; border-top:1px solid var(--border-soft); margin:8px 0;">
<label style="font-size:11px;">FormatPrefs.dateFormat:
<select id="fmt-pref" style="margin-top:4px; width:100%; padding:4px; font: inherit; font-size:11px;">
<option value="dd/MM/yyyy">dd/MM/yyyy</option>
<option value="MM/dd/yyyy">MM/dd/yyyy</option>
<option value="yyyy-MM-dd">yyyy-MM-dd</option>
</select>
</label>
</div>
<div class="overlay" role="dialog" aria-modal="true" aria-labelledby="dlg-title">
<header class="dlg-header">
<h2 id="dlg-title" class="dlg-title">New task</h2>
<p class="dlg-desc">Capture what needs doing. Set properties below or skip and refine later.</p>
</header>
<div class="dlg-body">
<input class="title-input" id="f-title" placeholder="What needs to be done?" autofocus />
<textarea class="desc-input" id="f-desc" rows="3" placeholder="Add a description…"></textarea>
<div class="props-label">Properties</div>
<div class="pills" id="pills">
<!-- Project pill -->
<span class="pop-anchor">
<button type="button" class="pill" data-pill="project" data-empty="true" tabindex="0">
<span class="pill-icon">📁</span>
<span class="pill-label">Project</span>
</button>
<div class="popover" data-popover="project" role="listbox">
<div class="pop-item" data-active="true" data-value="">
<span class="check"></span>No project
</div>
<div class="pop-item" data-value="acme-comm">
<span class="check"></span>Acme · Communications
</div>
<div class="pop-item" data-value="testing-bot">
<span class="check"></span>Testing · AI ChatBot
</div>
<div class="pop-item" data-value="adiuvai-app">
<span class="check"></span>AdiuvAI · App
</div>
</div>
</span>
<!-- Priority pill -->
<span class="pop-anchor">
<button type="button" class="pill" data-pill="priority" tabindex="0">
<span class="pill-icon pi-mid"></span>
<span class="pill-label">Priority</span>
<span class="pill-sep">·</span>
<span class="pill-value">Medium</span>
</button>
<div class="popover" data-popover="priority" role="listbox" style="min-width:160px;">
<div class="pop-item" data-value="high"><span class="check"></span>High</div>
<div class="pop-item" data-active="true" data-selected="true" data-value="medium"><span class="check"></span>Medium</div>
<div class="pop-item" data-value="low"><span class="check"></span>Low</div>
</div>
</span>
<!-- Status pill -->
<span class="pop-anchor">
<button type="button" class="pill" data-pill="status" tabindex="0">
<span class="pill-icon"></span>
<span class="pill-label">Status</span>
<span class="pill-sep">·</span>
<span class="pill-value">To do</span>
</button>
<div class="popover" data-popover="status" role="listbox" style="min-width:170px;">
<div class="pop-item" data-active="true" data-selected="true" data-value="todo"><span class="check"></span>To do</div>
<div class="pop-item" data-value="in_progress"><span class="check"></span>In progress</div>
<div class="pop-item" data-value="done"><span class="check"></span>Done</div>
</div>
</span>
<!-- Due pill -->
<span class="pop-anchor">
<button type="button" class="pill" data-pill="due" data-empty="true" tabindex="0">
<span class="pill-icon">📅</span>
<span class="pill-label">Due</span>
</button>
<div class="popover date-pop" data-popover="due" role="dialog" style="min-width:300px;">
<div class="field-label">Date</div>
<div class="datefield" id="datefield" tabindex="-1"><!-- segments injected by JS --></div>
<div class="cal" id="calendar">
<div class="cal-head">
<button type="button" data-nav="-1"></button>
<span class="month" id="cal-month">May 2026</span>
<button type="button" data-nav="1"></button>
</div>
<div class="cal-grid" id="cal-grid"><!-- filled by JS --></div>
</div>
</div>
</span>
<!-- Assignees pill -->
<span class="pop-anchor">
<button type="button" class="pill" data-pill="assignees" data-empty="true" tabindex="0">
<span class="pill-icon"></span>
<span class="pill-label">Add assignees</span>
</button>
<div class="popover" data-popover="assignees" role="listbox">
<div class="pop-item" data-active="true" data-value="alex"><span class="check"></span>Alex Morgan</div>
<div class="pop-item" data-value="priya"><span class="check"></span>Priya Shah</div>
<div class="pop-item" data-value="yo"><span class="check"></span>You</div>
</div>
</span>
</div>
</div>
<footer class="dlg-footer">
<div></div>
<div class="footer-actions">
<button type="button" class="btn">Cancel</button>
<button type="button" class="btn btn-primary">Create task</button>
</div>
</footer>
</div>
<script>
/* ---------- popover open/close + arrow nav ---------- */
const pills = document.querySelectorAll('.pill');
const popovers = document.querySelectorAll('.popover');
function closeAllPopovers() {
popovers.forEach((p) => p.setAttribute('data-open', 'false'));
}
const pillArr = Array.from(pills);
pills.forEach((pill) => {
pill.addEventListener('click', (e) => openPopoverFor(pill));
pill.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openPopoverFor(pill);
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
const idx = pillArr.indexOf(pill);
const next = pillArr[Math.min(idx + 1, pillArr.length - 1)];
next && next.focus();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
const idx = pillArr.indexOf(pill);
const prev = pillArr[Math.max(idx - 1, 0)];
prev && prev.focus();
}
});
});
function openPopoverFor(pill) {
const which = pill.dataset.pill;
const pop = document.querySelector(`.popover[data-popover="${which}"]`);
if (!pop) return;
closeAllPopovers();
pop.setAttribute('data-open', 'true');
if (which === 'due') {
// focus first date segment
const firstSeg = pop.querySelector('.segment');
if (firstSeg) firstSeg.focus();
} else {
const items = pop.querySelectorAll('.pop-item');
items.forEach((i) => i.setAttribute('tabindex', '-1'));
const active = pop.querySelector('.pop-item[data-active="true"]') || items[0];
if (active) {
active.setAttribute('tabindex', '0');
active.focus();
}
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const open = document.querySelector('.popover[data-open="true"]');
if (open) {
e.preventDefault();
closePopover(open);
}
}
});
/* ---------- list popover keyboard ---------- */
popovers.forEach((pop) => {
if (pop.dataset.popover === 'due') return;
const items = Array.from(pop.querySelectorAll('.pop-item'));
items.forEach((it) => {
it.setAttribute('tabindex', '-1');
it.addEventListener('click', () => selectPopItem(pop, it));
it.addEventListener('keydown', (e) => onPopItemKey(e, pop, items, it));
});
});
function onPopItemKey(e, pop, items, item) {
const idx = items.indexOf(item);
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
moveFocus(items, Math.min(idx + 1, items.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
moveFocus(items, Math.max(idx - 1, 0));
} else if (e.key === 'Home') {
e.preventDefault(); moveFocus(items, 0);
} else if (e.key === 'End') {
e.preventDefault(); moveFocus(items, items.length - 1);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
selectPopItem(pop, item);
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
closePopover(pop);
} else if (e.key === 'Tab') {
closePopover(pop);
}
}
function moveFocus(items, target) {
items.forEach((i) => i.setAttribute('tabindex', '-1'));
const el = items[target];
el.setAttribute('tabindex', '0');
el.focus();
}
function closePopover(pop) {
pop.setAttribute('data-open', 'false');
const pill = document.querySelector(`.pill[data-pill="${pop.dataset.popover}"]`);
pill && pill.focus();
}
function selectPopItem(pop, item) {
const which = pop.dataset.popover;
if (which === 'assignees') {
item.toggleAttribute('data-selected');
} else {
pop.querySelectorAll('.pop-item').forEach((i) => i.removeAttribute('data-selected'));
item.setAttribute('data-selected', 'true');
}
updatePillFrom(pop);
if (which !== 'assignees') closePopover(pop);
}
function updatePillFrom(pop) {
const which = pop.dataset.popover;
const pill = document.querySelector(`.pill[data-pill="${which}"]`);
if (!pill) return;
if (which === 'assignees') {
const sel = Array.from(pop.querySelectorAll('.pop-item[data-selected="true"]'));
if (sel.length === 0) {
pill.setAttribute('data-empty', 'true');
pill.innerHTML = '<span class="pill-icon"></span><span class="pill-label">Add assignees</span>';
} else {
pill.removeAttribute('data-empty');
const names = sel.map((s) => s.textContent.trim());
pill.innerHTML = `<span class="pill-icon">👤</span><span class="pill-label">Assignees</span><span class="pill-sep">·</span><span class="pill-value">${names.join(', ')}</span>`;
}
return;
}
const cur = pop.querySelector('.pop-item[data-selected="true"]');
if (which === 'project') {
if (!cur || cur.dataset.value === '') {
pill.setAttribute('data-empty', 'true');
pill.innerHTML = '<span class="pill-icon">📁</span><span class="pill-label">Project</span>';
} else {
pill.removeAttribute('data-empty');
pill.innerHTML = `<span class="pill-icon">📁</span><span class="pill-label">Project</span><span class="pill-sep">·</span><span class="pill-value">${cur.textContent.trim()}</span>`;
}
} else if (which === 'priority') {
const v = cur.dataset.value;
const icon = v === 'high' ? '<span class="pill-icon pi-up">↑</span>'
: v === 'low' ? '<span class="pill-icon pi-down">↓</span>'
: '<span class="pill-icon pi-mid">→</span>';
const label = v[0].toUpperCase() + v.slice(1);
pill.innerHTML = `${icon}<span class="pill-label">Priority</span><span class="pill-sep">·</span><span class="pill-value">${label}</span>`;
} else if (which === 'status') {
const v = cur.dataset.value;
const icon = v === 'done' ? '✓' : v === 'in_progress' ? '◐' : '○';
const label = v === 'in_progress' ? 'In progress' : v === 'todo' ? 'To do' : 'Done';
pill.innerHTML = `<span class="pill-icon">${icon}</span><span class="pill-label">Status</span><span class="pill-sep">·</span><span class="pill-value">${label}</span>`;
}
}
/* ---------- DateField — format-aware segments ---------- */
const SEG_DEFS = {
day: { len: 2, min: 1, max: 31, ph: 'DD' },
month: { len: 2, min: 1, max: 12, ph: 'MM' },
year: { len: 4, min: 1900, max: 2100, ph: 'YYYY' },
hour: { len: 2, min: 0, max: 23, ph: 'HH' },
minute: { len: 2, min: 0, max: 59, ph: 'MM' },
};
const FMT_LAYOUT = {
'dd/MM/yyyy': [['day','/'],['month','/'],['year',null]],
'MM/dd/yyyy': [['month','/'],['day','/'],['year',null]],
'yyyy-MM-dd': [['year','-'],['month','-'],['day',null]],
};
let currentFmt = 'dd/MM/yyyy';
function renderDateField() {
const df = document.getElementById('datefield');
const cur = readDateField();
df.innerHTML = '';
const layout = FMT_LAYOUT[currentFmt].concat([null, ['hour',':'], ['minute', null]]);
layout.forEach((entry) => {
if (entry === null) {
const sp = document.createElement('span');
sp.className = 'seg-sep'; sp.innerHTML = '&nbsp;&nbsp;';
df.appendChild(sp); return;
}
const [key, sep] = entry;
const def = SEG_DEFS[key];
const seg = document.createElement('span');
seg.className = 'segment';
seg.contentEditable = 'true';
seg.dataset.seg = key;
seg.dataset.len = def.len; seg.dataset.min = def.min; seg.dataset.max = def.max;
const v = cur[key];
if (v == null) {
seg.dataset.placeholder = 'true';
seg.textContent = def.ph;
} else {
seg.dataset.placeholder = 'false';
seg.textContent = String(v).padStart(def.len, '0');
}
df.appendChild(seg);
if (sep) {
const s = document.createElement('span');
s.className = 'seg-sep'; s.textContent = sep;
df.appendChild(s);
}
});
bindDateSegments();
}
function bindDateSegments() {
const dfSegments = Array.from(document.querySelectorAll('.segment'));
dfSegments.forEach((seg, idx) => {
seg.addEventListener('focus', () => {
if (seg.dataset.placeholder === 'true') {
seg.textContent = '';
}
// select all
const range = document.createRange();
range.selectNodeContents(seg);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
});
seg.addEventListener('blur', () => {
const len = parseInt(seg.dataset.len, 10);
const min = parseInt(seg.dataset.min, 10);
const max = parseInt(seg.dataset.max, 10);
let v = seg.textContent.replace(/\D/g, '');
if (!v) {
seg.dataset.placeholder = 'true';
seg.textContent = seg.dataset.seg.toUpperCase().slice(0,len).padEnd(len, seg.dataset.seg[0].toUpperCase());
// reset to nice placeholder
const ph = { day:'DD', month:'MM', year:'YYYY', hour:'HH', minute:'MM' }[seg.dataset.seg];
seg.textContent = ph;
return;
}
let n = parseInt(v, 10);
if (n < min) n = min;
if (n > max) n = max;
seg.dataset.placeholder = 'false';
seg.textContent = String(n).padStart(len, '0');
refreshSelectedDay();
});
seg.addEventListener('keydown', (e) => {
const len = parseInt(seg.dataset.len, 10);
if (e.key === 'ArrowRight' || (e.key === '/' || e.key === ':') ) {
e.preventDefault();
const next = dfSegments[idx + 1];
if (next) next.focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prev = dfSegments[idx - 1];
if (prev) prev.focus();
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const min = parseInt(seg.dataset.min, 10);
const max = parseInt(seg.dataset.max, 10);
const cur = parseInt(seg.textContent.replace(/\D/g,''), 10);
const base = isNaN(cur) ? min : cur;
let n = base + (e.key === 'ArrowUp' ? 1 : -1);
if (n < min) n = max;
if (n > max) n = min;
seg.dataset.placeholder = 'false';
seg.textContent = String(n).padStart(len, '0');
refreshSelectedDay();
} else if (/^\d$/.test(e.key)) {
const cur = seg.textContent.replace(/\D/g,'');
if (cur.length >= len) {
e.preventDefault();
seg.textContent = e.key;
// place caret at end
}
// when reaching len, advance to next segment after this char
setTimeout(() => {
if ((seg.textContent || '').replace(/\D/g,'').length >= len) {
const next = dfSegments[idx + 1];
if (next) next.focus();
}
}, 0);
} else if (e.key === 'Backspace' && seg.textContent === '') {
const prev = dfSegments[idx - 1];
if (prev) { e.preventDefault(); prev.focus(); }
} else if (e.key === 'Enter') {
e.preventDefault();
seg.blur();
const pop = document.querySelector('.popover[data-popover="due"]');
closePopover(pop);
updateDuePill();
} else if (e.key === 'Escape') {
e.preventDefault();
const pop = document.querySelector('.popover[data-popover="due"]');
closePopover(pop);
}
});
});
}
function readDateFieldFromDOM() {
return readDateField();
}
renderDateField();
document.getElementById('fmt-pref').addEventListener('change', (e) => {
currentFmt = e.target.value;
renderDateField();
updateDuePill();
});
function readDateField() {
const get = (k) => {
const s = document.querySelector(`.segment[data-seg="${k}"]`);
if (!s || s.dataset.placeholder === 'true') return null;
const v = s.textContent.replace(/\D/g,'');
return v ? parseInt(v, 10) : null;
};
return {
day: get('day'), month: get('month'), year: get('year'),
hour: get('hour'), minute: get('minute'),
};
}
function formatDateValue(d) {
const day = String(d.day).padStart(2,'0');
const month = String(d.month).padStart(2,'0');
const year = String(d.year);
switch (currentFmt) {
case 'MM/dd/yyyy': return `${month}/${day}/${year}`;
case 'yyyy-MM-dd': return `${year}-${month}-${day}`;
default: return `${day}/${month}/${year}`;
}
}
function updateDuePill() {
const d = readDateField();
const pill = document.querySelector('.pill[data-pill="due"]');
if (d.day && d.month && d.year) {
pill.removeAttribute('data-empty');
const time = d.hour != null && d.minute != null
? ` ${String(d.hour).padStart(2,'0')}:${String(d.minute).padStart(2,'0')}` : '';
pill.innerHTML = `<span class="pill-icon">📅</span><span class="pill-label">Due</span><span class="pill-sep">·</span><span class="pill-value">${formatDateValue(d)}${time}</span>`;
} else {
pill.setAttribute('data-empty', 'true');
pill.innerHTML = '<span class="pill-icon">📅</span><span class="pill-label">Due</span>';
}
}
/* ---------- Mini calendar ---------- */
let calYear = 2026, calMonth = 5; // May 2026
function renderCalendar() {
const grid = document.getElementById('cal-grid');
document.getElementById('cal-month').textContent =
new Date(calYear, calMonth - 1, 1).toLocaleString(undefined, { month: 'long', year: 'numeric' });
grid.innerHTML = '';
const dows = ['Mo','Tu','We','Th','Fr','Sa','Su'];
dows.forEach((d) => {
const el = document.createElement('div');
el.className = 'cal-dow'; el.textContent = d;
grid.appendChild(el);
});
const first = new Date(calYear, calMonth - 1, 1);
const offset = (first.getDay() + 6) % 7; // Mon-first
const daysInMonth = new Date(calYear, calMonth, 0).getDate();
const daysPrev = new Date(calYear, calMonth - 1, 0).getDate();
for (let i = offset - 1; i >= 0; i--) {
const el = document.createElement('div');
el.className = 'cal-day';
el.dataset.otherMonth = 'true';
el.textContent = daysPrev - i;
grid.appendChild(el);
}
for (let d = 1; d <= daysInMonth; d++) {
const el = document.createElement('div');
el.className = 'cal-day';
el.tabIndex = 0;
el.textContent = d;
el.dataset.day = d;
el.addEventListener('click', () => pickCalDay(d));
el.addEventListener('keydown', (e) => onCalKey(e, d));
grid.appendChild(el);
}
refreshSelectedDay();
}
function refreshSelectedDay() {
const d = readDateField();
const days = document.querySelectorAll('.cal-day[data-day]');
days.forEach((el) => el.removeAttribute('data-selected'));
if (d.day && d.month === calMonth && d.year === calYear) {
const tgt = document.querySelector(`.cal-day[data-day="${d.day}"]`);
if (tgt) tgt.setAttribute('data-selected', 'true');
}
}
function pickCalDay(d) {
const segDay = document.querySelector('.segment[data-seg="day"]');
const segMonth = document.querySelector('.segment[data-seg="month"]');
const segYear = document.querySelector('.segment[data-seg="year"]');
segDay.dataset.placeholder = 'false'; segDay.textContent = String(d).padStart(2,'0');
segMonth.dataset.placeholder = 'false'; segMonth.textContent = String(calMonth).padStart(2,'0');
segYear.dataset.placeholder = 'false'; segYear.textContent = String(calYear);
refreshSelectedDay();
updateDuePill();
}
function onCalKey(e, d) {
const grid = document.getElementById('cal-grid');
const days = Array.from(grid.querySelectorAll('.cal-day[data-day]'));
const idx = days.findIndex((el) => parseInt(el.dataset.day,10) === d);
let target = null;
if (e.key === 'ArrowRight') target = days[idx + 1];
else if (e.key === 'ArrowLeft') target = days[idx - 1];
else if (e.key === 'ArrowDown') target = days[idx + 7];
else if (e.key === 'ArrowUp') target = days[idx - 7];
else if (e.key === 'Enter') { e.preventDefault(); pickCalDay(d); return; }
if (target) { e.preventDefault(); target.focus(); }
}
document.querySelectorAll('[data-nav]').forEach((b) => {
b.addEventListener('click', () => {
const dir = parseInt(b.dataset.nav, 10);
calMonth += dir;
if (calMonth < 1) { calMonth = 12; calYear--; }
if (calMonth > 12) { calMonth = 1; calYear++; }
renderCalendar();
});
});
renderCalendar();
/* ---------- click outside closes popovers ---------- */
document.addEventListener('mousedown', (e) => {
const inPop = e.target.closest('.popover');
const inPill = e.target.closest('.pill');
if (!inPop && !inPill) closeAllPopovers();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,967 @@
# Floating Chat Deprecation Sweep Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Delete every floating-chat code path across the adiuvAI Electron app — components, context, hooks, DOM attributes, stream branches, main-process methods, and shared-type artefacts — in five clean commits.
**Architecture:** The contextual sidebar (M4) fully replaces floating chat; floating is dead code with no live consumers. Each task targets one layer: (1) renderer components/context/hooks, (2) DOM data-attributes, (3) renderer hook logic, (4) main-process IPC/orchestrator, (5) store/localStorage keys sweep.
**Tech Stack:** TypeScript, React 19, Electron (main + preload + renderer), tRPC v11, Zod, electron-store.
---
## Pre-flight: baseline tsc check
- [ ] **Run tsc before touching anything**
```bash
cd /c/Users/PC-Roby/Documents/_adiuvai_workspace/adiuvAI
source ~/.nvm/nvm.sh && npx tsc --noEmit 2>&1 | tail -20
```
Record the number of pre-existing errors (expected: 0). Any errors here are pre-existing and not your fault.
---
## Task M6.1 — Delete FloatingChat component, context, hook + AppShell cleanup
**Files:**
- Delete: `src/renderer/components/ai/FloatingChat.tsx`
- Delete: `src/renderer/context/FloatingChatContext.tsx`
- Delete: `src/renderer/hooks/useDoubleClickAI.ts`
- Modify: `src/renderer/components/layout/AppShell.tsx`
- Modify: `src/renderer/components/projects/ProjectDetail.tsx`
- Modify: `src/renderer/components/timeline/TimelineGanttView.tsx`
- Modify: `src/renderer/routes/notes.$noteId.tsx`
- Modify: `src/renderer/routes/tasks.tsx`
### Context
Six files import from `FloatingChatContext` or `FloatingChat`. After deleting the three source files, all six must be cleaned up.
`ProjectDetail.tsx`, `TimelineGanttView.tsx`, `notes.$noteId.tsx`, and `tasks.tsx` import `useFloatingChat()` and call `registerSection`/`unregisterSection`. These are pure floating-chat wiring — they register DOM regions with the floating panel so it could anchor itself. With floating chat gone, these calls become dead code and must be removed entirely (no replacement needed; the contextual sidebar uses `useContextualScope` for scope awareness, which is already imported in tasks.tsx and ProjectDetail.tsx).
`AppShell.tsx` wraps the tree in `<FloatingChatProvider>` (line 151) and calls `useDoubleClickAI()` inside `AppShellInner` (line 166).
### Steps
- [ ] **Step 1: Confirm all consumers before deletion**
```bash
grep -rn "FloatingChat\|useFloatingChat\|FloatingChatProvider\|useDoubleClickAI" src/renderer/
```
Expected output matches: `AppShell.tsx`, `FloatingChat.tsx`, `FloatingChatContext.tsx`, `useDoubleClickAI.ts`, `ProjectDetail.tsx`, `TimelineGanttView.tsx`, `notes.$noteId.tsx`, `tasks.tsx`. If any unexpected file appears, stop and report before proceeding.
- [ ] **Step 2: Delete the three source files**
```bash
git rm src/renderer/components/ai/FloatingChat.tsx
git rm src/renderer/context/FloatingChatContext.tsx
git rm src/renderer/hooks/useDoubleClickAI.ts
```
- [ ] **Step 3: Clean AppShell.tsx**
Open `src/renderer/components/layout/AppShell.tsx`.
Remove line 28:
```ts
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
```
Remove lines 7071:
```ts
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
import { FloatingChatProvider } from '@/context/FloatingChatContext';
```
In `AppShell` (around line 149163), change:
```tsx
export function AppShell({ children }: AppShellProps) {
return (
<FloatingChatProvider>
<ExpandedClientsProvider>
<TaskBriefingProvider>
<HeaderProvider>
<div className="flex w-full h-full">
<AppShellInner>{children}</AppShellInner>
</div>
</HeaderProvider>
</TaskBriefingProvider>
</ExpandedClientsProvider>
</FloatingChatProvider>
);
}
```
to:
```tsx
export function AppShell({ children }: AppShellProps) {
return (
<ExpandedClientsProvider>
<TaskBriefingProvider>
<HeaderProvider>
<div className="flex w-full h-full">
<AppShellInner>{children}</AppShellInner>
</div>
</HeaderProvider>
</TaskBriefingProvider>
</ExpandedClientsProvider>
);
}
```
In `AppShellInner` (around line 166), remove:
```ts
useDoubleClickAI();
```
Search for `<FloatingChatPortal />` in the file (around line 288) and delete that line.
- [ ] **Step 4: Clean ProjectDetail.tsx**
Open `src/renderer/components/projects/ProjectDetail.tsx`.
Remove line 24:
```ts
import { useFloatingChat } from '@/context/FloatingChatContext';
```
Remove line 63:
```ts
const { registerSection, unregisterSection } = useFloatingChat();
```
Remove the `useEffect` block that calls `registerSection`/`unregisterSection` (lines ~91101):
```ts
useEffect(() => {
if (isLoading || !project) return;
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksRef, projectId });
registerSection({ id: 'project-notes', label: 'Notes', ref: notesRef, projectId });
return () => {
unregisterSection('project-summary');
unregisterSection('project-tasks');
unregisterSection('project-notes');
};
}, [projectId, isLoading, project, registerSection, unregisterSection]);
```
- [ ] **Step 5: Clean TimelineGanttView.tsx**
Open `src/renderer/components/timeline/TimelineGanttView.tsx`.
Remove line 31:
```ts
import { useFloatingChat } from '@/context/FloatingChatContext';
```
Remove line 95:
```ts
const { registerSection, unregisterSection } = useFloatingChat();
```
Remove the `useEffect` that calls `registerSection`/`unregisterSection` (lines ~107110):
```ts
useEffect(() => {
registerSection({ id: sectionId, label: sectionLabel, ref: sectionRef, projectId });
return () => unregisterSection(sectionId);
}, [sectionId, sectionLabel, projectId, registerSection, unregisterSection]);
```
- [ ] **Step 6: Clean notes.$noteId.tsx**
Open `src/renderer/routes/notes.$noteId.tsx`.
Remove line 29:
```ts
import { useFloatingChat } from '@/context/FloatingChatContext';
```
Remove line 117:
```ts
const { registerSection, unregisterSection } = useFloatingChat();
```
Remove the `useEffect` that calls `registerSection`/`unregisterSection` (lines ~119128):
```ts
useEffect(() => {
registerSection({
id: 'note-editor',
label: 'Note Editor',
ref: editorRef,
projectId: noteProjectId,
anchorMode: 'right-margin',
});
return () => unregisterSection('note-editor');
}, [noteId, noteProjectId, registerSection, unregisterSection]);
```
- [ ] **Step 7: Clean tasks.tsx**
Open `src/renderer/routes/tasks.tsx`.
Remove line 5:
```ts
import { useFloatingChat } from '@/context/FloatingChatContext';
```
Remove line 18:
```ts
const { registerSection, unregisterSection } = useFloatingChat();
```
Remove the `useEffect` that calls `registerSection`/`unregisterSection` (lines ~2027):
```ts
useEffect(() => {
registerSection({ id: 'tasks-overview', label: 'Tasks Overview', ref: overviewRef });
registerSection({ id: 'tasks-list', label: 'Task List', ref: listRef });
return () => {
unregisterSection('tasks-overview');
unregisterSection('tasks-list');
};
}, [registerSection, unregisterSection]);
```
Also remove the `overviewRef` and `listRef` ref declarations if they are now unused (check whether they are still referenced by the JSX `ref=` attributes after M6.2 strips `data-ai-section`):
```ts
const overviewRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
```
Note: After M6.2 removes `data-ai-section` attributes, `ref={overviewRef}` and `ref={listRef}` in the JSX will also be gone, making the refs fully dead. Remove them here (or in M6.2 — just do it in whichever task you're in when you notice them).
- [ ] **Step 8: Type check**
```bash
source ~/.nvm/nvm.sh && npx tsc --noEmit 2>&1 | grep -i "error" | head -30
```
Expected: zero errors related to FloatingChat, FloatingChatContext, useDoubleClickAI. If any appear, locate the offending file and remove the import/usage.
- [ ] **Step 9: Commit**
```bash
git add src/renderer/components/layout/AppShell.tsx \
src/renderer/components/projects/ProjectDetail.tsx \
src/renderer/components/timeline/TimelineGanttView.tsx \
src/renderer/routes/notes.\$noteId.tsx \
src/renderer/routes/tasks.tsx
git commit -m "$(cat <<'EOF'
refactor(contextual): delete FloatingChat, FloatingChatContext, useDoubleClickAI
Replaced by ContextualChatProvider + AdiuvaTriggerButton in M4.
Pre-1.0 clean removal — no deprecation period.
EOF
)"
```
---
## Task M6.2 — Strip all `data-ai-section` attributes
**Files:**
- Modify: `src/renderer/components/projects/ProjectDetail.tsx`
- Modify: `src/renderer/components/timeline/TimelineGanttView.tsx`
- Modify: `src/renderer/routes/notes.$noteId.tsx`
- Modify: `src/renderer/routes/tasks.tsx`
### Context
`data-ai-section` was used by `useDoubleClickAI` to walk the DOM and find the nearest section anchor to open floating chat. Now that floating chat is gone, these attributes are purely dead markup. The contextual sidebar uses `scope` payloads, not DOM attributes.
Current occurrences (7 total):
- `ProjectDetail.tsx:538``data-ai-section="project-summary"`
- `ProjectDetail.tsx:620``data-ai-section="project-tasks"`
- `ProjectDetail.tsx:631``data-ai-section="project-notes"`
- `TimelineGanttView.tsx:239``data-ai-section={sectionId}`
- `notes.$noteId.tsx:291``data-ai-section="note-editor"`
- `tasks.tsx:42``data-ai-section="tasks-overview"`
- `tasks.tsx:61``data-ai-section="tasks-list"`
### Steps
- [ ] **Step 1: List all occurrences**
```bash
grep -rn "data-ai-section" src/renderer/
```
Confirm you see exactly the 7 occurrences listed above. If more appear, handle them too.
- [ ] **Step 2: Remove from ProjectDetail.tsx**
For each JSX element with a `data-ai-section` prop, delete only that prop (keep `className` and all others). Example:
Find:
```tsx
<div
ref={summaryRef}
data-ai-section="project-summary"
className="..."
>
```
Change to:
```tsx
<div
ref={summaryRef}
className="..."
>
```
Repeat for `project-tasks` and `project-notes`.
- [ ] **Step 3: Remove from TimelineGanttView.tsx**
Find the element at line ~239 and remove only the `data-ai-section={sectionId}` prop. Keep `ref={sectionRef}` and any other props.
Also check: after M6.1 removed `registerSection`/`unregisterSection`, there may now be `sectionRef`, `sectionId`, `sectionLabel` variables declared but unused. Remove any that are now dead. Check with:
```bash
grep -n "sectionRef\|sectionId\|sectionLabel" src/renderer/components/timeline/TimelineGanttView.tsx
```
If these variables are only used by the removed `useEffect` and the now-removed `data-ai-section` attribute, delete their declarations too.
- [ ] **Step 4: Remove from notes.$noteId.tsx**
Find the `<ScrollArea>` at line ~291:
```tsx
<ScrollArea ref={editorRef} data-ai-section="note-editor" className="flex-1 min-h-0">
```
Change to:
```tsx
<ScrollArea ref={editorRef} className="flex-1 min-h-0">
```
Note: `editorRef` is still used by the existing scroll area ref, so keep it.
- [ ] **Step 5: Remove from tasks.tsx**
Remove `data-ai-section="tasks-overview"` from the div at line ~42 and `data-ai-section="tasks-list"` from the div at line ~61. Also remove `ref={overviewRef}` and `ref={listRef}` from those elements if the refs were removed in M6.1.
If `overviewRef` and `listRef` are now unused (no JSX `ref=` and no other use), delete their `useRef` declarations too.
- [ ] **Step 6: Verify clean**
```bash
grep -rn "data-ai-section" src/renderer/
echo "exit=$?"
```
Expected: no output, `exit=1` (grep found nothing).
- [ ] **Step 7: Type check + commit**
```bash
source ~/.nvm/nvm.sh && npx tsc --noEmit 2>&1 | grep -i "error" | head -20
```
Expected: zero errors.
```bash
git add src/renderer/components/projects/ProjectDetail.tsx \
src/renderer/components/timeline/TimelineGanttView.tsx \
src/renderer/routes/notes.\$noteId.tsx \
src/renderer/routes/tasks.tsx
git commit -m "$(cat <<'EOF'
refactor(contextual): strip all data-ai-section attributes
Section-anchoring obsolete now that there is no floating chat.
The contextual sidebar uses scope payload, not DOM attributes.
EOF
)"
```
---
## Task M6.3 — Drop `'floating'` from `useAIChat` + `ChatInputBox` + `useChatStream`
**Files:**
- Modify: `src/renderer/hooks/useAIChat.ts`
- Modify: `src/renderer/hooks/useChatStream.ts`
- Modify: `src/renderer/components/ai/ChatInputBox.tsx`
- Modify: `src/renderer/components/brief/TaskBriefChat.tsx`
### Context
`useAIChat.ts` has `FloatingDomainSignal` type, `'floating'` in `UIChatContext.type` union, a `scope` field, a `'floating'` cache-key branch, `isFloating` logic in `handleSend`, `floating_domain` switch case, and `onDomainSignalRef`. All dead after M6.1 deleted `FloatingChat.tsx` which was the only consumer.
`useChatStream.ts` has a `floating_domain` case and the `onDomainSignal` option — kept during M2.1 explicitly "until M6 removes floating." Now is the time.
`ChatInputBox.tsx` has `'floating'` as a variant in `ChatInputBoxVariant` with its own style entry. This is a visual variant only — the type can be removed if nothing still passes `variant="floating"`. After `FloatingChat.tsx` is gone, nothing does.
`TaskBriefChat.tsx` passes `mode: 'floating'` to `chatMutation.mutate(...)`. After removing the floating mode from the tRPC schema (M6.4), this becomes a type error. Fix it here by removing `mode: 'floating'` and `scope` from the mutation call — task briefs will fall through to the default home orchestrator, or better: use `mode: 'contextual'` with the scope. Since task brief already passes `scope: { type: 'task', id: taskId }`, change `mode: 'floating'` to `mode: 'contextual'`.
`parseMutationsToEntityTags` and `TABLE_TO_ENTITY` in `useAIChat.ts` are still needed — `useChatStream.ts` imports `parseMutationsToEntityTags` from `useAIChat.ts`. Keep both exports.
### Steps
- [ ] **Step 1: Edit useAIChat.ts**
Open `src/renderer/hooks/useAIChat.ts`.
**Delete lines 413** (the `FloatingDomainSignal` type):
```ts
export type FloatingDomainSignal =
| 'tasks'
| 'notes'
| 'timelines'
| 'projects'
| {
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
id?: string | null;
section?: 'task' | 'timeline' | 'note' | null;
};
```
**Replace the `UIChatContext` interface** (lines 2331):
```ts
export interface UIChatContext {
type: 'global' | 'project' | 'floating';
projectId?: string;
/** For floating mode — the entity scope to pass to the backend. */
scope?: {
type: 'task' | 'project' | 'note' | 'timeline';
id?: string;
};
}
```
with:
```ts
export interface UIChatContext {
type: 'global' | 'project';
projectId?: string;
}
```
**Delete the `UseAIChatOptions` interface** (lines 5052):
```ts
interface UseAIChatOptions {
onDomainSignal?: (domain: FloatingDomainSignal) => void;
}
```
**Update the `useAIChat` function signature** — remove the `options` parameter:
```ts
export function useAIChat(defaultContext: UIChatContext, options?: UseAIChatOptions): UseAIChatReturn {
```
becomes:
```ts
export function useAIChat(defaultContext: UIChatContext): UseAIChatReturn {
```
**Update `getContextCacheKey`** — replace the entire function body:
```ts
function getContextCacheKey(ctx: UIChatContext): string {
if (ctx.type === 'global') return 'global';
if (ctx.type === 'project') return `project:${ctx.projectId ?? ''}`;
// Floating chat should keep a single continuous session while the panel is open,
// even when route/section context changes due floating-domain navigation.
return 'floating';
}
```
with:
```ts
function getContextCacheKey(ctx: UIChatContext): string {
if (ctx.type === 'global') return 'global';
return `project:${ctx.projectId ?? ''}`;
}
```
**Update the `useMemo` for `contextCacheKey`** — remove `defaultContext.scope?.type` and `defaultContext.scope?.id` from deps:
```ts
const contextCacheKey = useMemo(
() => getContextCacheKey(defaultContext),
[defaultContext.type, defaultContext.projectId, defaultContext.scope?.type, defaultContext.scope?.id],
);
```
becomes:
```ts
const contextCacheKey = useMemo(
() => getContextCacheKey(defaultContext),
[defaultContext.type, defaultContext.projectId],
);
```
**Remove `onDomainSignalRef`** (lines 151152):
```ts
const onDomainSignalRef = useRef(options?.onDomainSignal);
onDomainSignalRef.current = options?.onDomainSignal;
```
**Remove the `floating_domain` case** from the `switch (event.type)` block (lines 237239):
```ts
case 'floating_domain':
onDomainSignalRef.current?.(event.domain);
break;
```
**Remove `isFloating` and the conditional spread** in `handleSend` (lines 249259):
```ts
const isFloating = ctx.type === 'floating';
chatMutationRef.current.mutate(
{
requestId,
message: trimmed,
conversationHistory,
sessionId: sessionIdRef.current,
...(isFloating && ctx.scope
? { mode: 'floating' as const, scope: ctx.scope }
: {}),
},
```
becomes:
```ts
chatMutationRef.current.mutate(
{
requestId,
message: trimmed,
conversationHistory,
sessionId: sessionIdRef.current,
},
```
- [ ] **Step 2: Edit useChatStream.ts**
Open `src/renderer/hooks/useChatStream.ts`.
Remove `onDomainSignal` from `UseChatStreamArgs` interface (lines 1718):
```ts
/** Optional: legacy floating_domain pivot signal. Kept until M6 removes floating. */
onDomainSignal?: (domain: unknown) => void;
```
Remove `onDomainSignal` from the destructuring of `useChatStream`'s argument (line 25):
```ts
onDomainSignal,
```
Remove `domainRef` declarations (lines 3334):
```ts
const domainRef = useRef(onDomainSignal);
domainRef.current = onDomainSignal;
```
Remove the `floating_domain` case from the switch block (lines 7072):
```ts
case 'floating_domain':
domainRef.current?.(event.domain);
break;
```
- [ ] **Step 3: Edit ChatInputBox.tsx**
Open `src/renderer/components/ai/ChatInputBox.tsx`.
The `'floating'` variant in `ChatInputBoxVariant` (line 12) and its entry in `VARIANT_STYLES` (lines 3035) should be removed since no live code passes `variant="floating"` any more.
Change:
```ts
type ChatInputBoxVariant = 'panel' | 'floating' | 'comment';
```
to:
```ts
type ChatInputBoxVariant = 'panel' | 'comment';
```
Remove the `floating` entry from `VARIANT_STYLES` (lines 3035):
```ts
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,
},
```
Also remove the comment about FloatingChat on line 52:
```ts
// Re-init when the cache key changes (context switches in FloatingChat).
```
Replace with a neutral comment or just remove the comment line.
- [ ] **Step 4: Fix TaskBriefChat.tsx**
Open `src/renderer/components/brief/TaskBriefChat.tsx`.
At line ~174184, change:
```ts
chatMutation.mutate(
{
requestId,
message: trimmed,
conversationHistory,
sessionId,
mode: 'floating',
scope: { type: 'task', id: taskId },
briefMode: true,
briefingContext: briefingText || undefined,
},
```
to:
```ts
chatMutation.mutate(
{
requestId,
message: trimmed,
conversationHistory,
sessionId,
mode: 'contextual',
scope: { type: 'task', id: taskId },
briefMode: true,
briefingContext: briefingText || undefined,
},
```
- [ ] **Step 5: Type check**
```bash
source ~/.nvm/nvm.sh && npx tsc --noEmit 2>&1 | grep -i "error" | head -30
```
Expected: zero errors. If tsc reports that `'floating'` is no longer valid for some type (from M6.4 not yet done), note it — the router schema still allows `'floating'` until M6.4. If tsc reports that `useAIChat` call sites are broken, check them:
```bash
grep -rn "useAIChat" src/renderer/
```
All call sites should be passing only `type: 'global'` or `type: 'project'` contexts after FloatingChat.tsx was deleted.
- [ ] **Step 6: Commit**
```bash
git add src/renderer/hooks/useAIChat.ts \
src/renderer/hooks/useChatStream.ts \
src/renderer/components/ai/ChatInputBox.tsx \
src/renderer/components/brief/TaskBriefChat.tsx
git commit -m "$(cat <<'EOF'
refactor(contextual): drop 'floating' branch from useAIChat and useChatStream
UIChatContext is now 'global' | 'project' only. Floating domain
signal, scope field, and onDomainSignal callback removed. ChatInputBox
no longer defines floating variant. TaskBriefChat migrated to contextual mode.
EOF
)"
```
---
## Task M6.4 — Main process: drop `sendFloatingRequest`, `orchestrateFloating`, floating mode
**Files:**
- Modify: `src/main/api/backend-client.ts`
- Modify: `src/main/ai/orchestrator.ts`
- Modify: `src/main/router/index.ts`
- Modify: `src/preload/trpc.ts`
- Modify: `src/renderer/lib/ipcLink.ts`
- Modify: `src/shared/api-types.ts`
### Context
`backend-client.ts` has `sendFloatingRequest` (lines 398458) and a `floating_domain` case in its WS message handler (lines 10601063).
`orchestrator.ts` has `OrchestrateFloatingInput` interface (lines 4049) and `orchestrateFloating` function (lines 142173). It also imports `WsFloatingRequest` from shared types.
`router/index.ts` imports `orchestrateFloating` (line 16) and uses it at lines 950960. The `mode` enum must change from `z.enum(['home', 'floating', 'contextual'])` to `z.enum(['contextual'])`.
`preload/trpc.ts` defines the `V3StreamEvent` union which includes `floating_domain` (lines 2942).
`renderer/lib/ipcLink.ts` defines a duplicate `V3StreamEvent` type which also includes `floating_domain` (lines 1633).
`shared/api-types.ts` defines `WsFloatingDomainSchema` (lines 217228) and `WsFloatingDomain` type.
Check also whether `WsFloatingRequest` type is still needed by anything after removing `orchestrateFloating`:
```bash
grep -rn "WsFloatingRequest\|WsFloatingDomain" src/
```
### Steps
- [ ] **Step 1: Delete sendFloatingRequest from backend-client.ts**
Open `src/main/api/backend-client.ts`.
Delete the entire `sendFloatingRequest` method (lines ~394458), from the JSDoc comment through the closing `}`.
Also delete the `floating_domain` case in the WS message handler (lines ~10601063):
```ts
case 'floating_domain': {
const listener = this.streamListeners.get(frame.data.requestId);
listener?.onDomain(frame.data.domain);
break;
}
```
Also check whether `onDomain` is still used in `StreamListener` type or elsewhere. If `onDomain` callback is only referenced by `sendFloatingRequest` and the `floating_domain` case, delete the `onDomain` field from `StreamListener` too (search for `onDomain` to confirm all uses).
- [ ] **Step 2: Delete orchestrateFloating from orchestrator.ts**
Open `src/main/ai/orchestrator.ts`.
Delete lines 4049 (`OrchestrateFloatingInput` interface):
```ts
interface OrchestrateFloatingInput {
message: string;
requestId?: string;
sessionId?: string;
scope: WsFloatingRequest['scope'];
conversationHistory?: WsFloatingRequest['conversationHistory'];
briefMode?: boolean;
briefingContext?: string;
sender?: Electron.WebContents;
}
```
Delete lines 116 of the import section's `WsFloatingRequest` import. Change:
```ts
import type { WsFloatingRequest } from '../../shared/api-types';
```
to nothing (remove the line entirely), since `WsFloatingRequest` is only used by `OrchestrateFloatingInput`.
Delete the entire `orchestrateFloating` function (lines 142173):
```ts
export async function orchestrateFloating(input: OrchestrateFloatingInput): Promise<OrchestrateResult> {
...
}
```
Update the docstring at the top of the file (lines 110) — remove the reference to `sendFloatingRequest()`:
```
* 2. Delegates to BackendClient.sendHomeRequest() / sendFloatingRequest()
```
becomes:
```
* 2. Delegates to BackendClient.sendHomeRequest() / sendContextualRequest()
```
- [ ] **Step 3: Update router/index.ts**
Open `src/main/router/index.ts`.
Change line 16 — remove `orchestrateFloating` from the import:
```ts
import { orchestrate, orchestrateFloating, orchestrateContextual, orchestrateTaskBriefResearch, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
```
becomes:
```ts
import { orchestrate, orchestrateContextual, orchestrateTaskBriefResearch, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
```
Change line 933 — update the `mode` enum:
```ts
mode: z.enum(['home', 'floating', 'contextual']).optional(),
```
becomes:
```ts
mode: z.enum(['contextual']).optional(),
```
Delete the floating branch in the mutation handler (lines 950960):
```ts
if (input.mode === 'floating' && input.scope) {
return await orchestrateFloating({
message: input.message,
requestId: input.requestId,
sessionId: input.sessionId,
scope: input.scope as Parameters<typeof orchestrateFloating>[0]['scope'],
conversationHistory: input.conversationHistory,
briefMode: input.briefMode,
briefingContext: input.briefingContext,
sender: ctx.sender,
});
}
```
The resulting mutation handler should flow: `if contextual → orchestrateContextual`, else `→ orchestrate`.
- [ ] **Step 4: Update preload/trpc.ts**
Open `src/preload/trpc.ts`.
Remove the `floating_domain` case from the `V3StreamEvent` union (lines 2942):
```ts
| {
type: 'floating_domain';
requestId: string;
domain:
| 'tasks'
| 'notes'
| 'timelines'
| 'projects'
| {
type: 'task' | 'timeline' | 'project' | 'note' | 'node';
id?: string | null;
section?: 'task' | 'timeline' | 'note' | null;
};
};
```
The `V3StreamEvent` union becomes:
```ts
type V3StreamEvent =
| { type: 'stream_start'; requestId: string }
| { type: 'stream_text'; requestId: string; chunk: string }
| { type: 'stream_end'; requestId: string; mutations?: unknown[] };
```
- [ ] **Step 5: Update renderer/lib/ipcLink.ts**
Open `src/renderer/lib/ipcLink.ts`.
Remove the `floating_domain` case from the `V3StreamEvent` union (lines 1633) the same way as done in preload. The type becomes:
```ts
type V3StreamEvent =
| { type: 'stream_start'; requestId: string }
| { type: 'stream_text'; requestId: string; chunk: string }
| { type: 'stream_end'; requestId: string; mutations?: unknown[] };
```
- [ ] **Step 6: Update shared/api-types.ts**
Open `src/shared/api-types.ts`.
Check whether `WsFloatingDomainSchema` / `WsFloatingDomain` are imported anywhere:
```bash
grep -rn "WsFloatingDomain\|WsFloatingRequest" src/
```
If `WsFloatingRequest` is imported only by the now-deleted orchestrator import, and `WsFloatingDomain` is imported nowhere, delete both from `shared/api-types.ts`:
- Delete `WsFloatingDomainSchema` Zod object (lines 217228)
- Delete `export type WsFloatingDomain = z.infer<typeof WsFloatingDomainSchema>;` (line 229)
- If `WsFloatingRequest` type/schema exists, delete it too (search for it in the file)
- [ ] **Step 7: Sweep for any remaining floating references in main**
```bash
grep -rn "floating\|Floating" src/main/ src/preload/ | grep -v node_modules | grep -v "\.md:"
```
Any remaining references to floating in these directories should be removed. Common residuals:
- `onDomain` callback type in `StreamListener` (if `sendFloatingRequest` was its only consumer)
- Stale comments in `backend-client.ts` mentioning floating
- [ ] **Step 8: Type check**
```bash
source ~/.nvm/nvm.sh && npx tsc --noEmit 2>&1 | grep -i "error" | head -30
```
Expected: zero errors. The `TaskBriefChat.tsx` now sends `mode: 'contextual'` which is valid per the updated schema.
- [ ] **Step 9: Commit**
```bash
git add src/main/api/backend-client.ts \
src/main/ai/orchestrator.ts \
src/main/router/index.ts \
src/preload/trpc.ts \
src/renderer/lib/ipcLink.ts \
src/shared/api-types.ts
git commit -m "$(cat <<'EOF'
refactor(contextual): main process drops sendFloatingRequest and floating mode
ai.chat tRPC procedure now accepts mode='contextual' (or unset for home).
Orchestrator loses the floating delegation branch. Backend client method
and WsFloatingDomain shared type removed.
EOF
)"
```
---
## Task M6.8 — Sweep electron-store and localStorage `'floating'` keys
**Files:**
- Inspect: `src/main/store.ts`
- Inspect: `src/renderer/` (any localStorage usage)
### Context
`src/main/store.ts` defines the electron-store schema — no `floating.*` keys exist in the current schema (verified: `AppSettings` has `sidebarCollapsed`, `encryptedTokens`, `backendUrl`, `deviceId`, `dailyBriefCache`, `localAgents`, `formatPrefs`, `uiLanguage`, `timelineZoom`). No floating keys.
This task is a verification sweep. If nothing is found, the commit is skipped.
### Steps
- [ ] **Step 1: Search for any floating.* key usage**
```bash
grep -rn "floating\." src/main/ src/preload/ src/renderer/ | grep -v node_modules | grep -v "\.md:"
```
Also check localStorage:
```bash
grep -rn "localStorage" src/renderer/ | grep -v node_modules | grep "float"
```
- [ ] **Step 2: Decision point**
If Step 1 returns NO results (or only results already cleaned by M6.1M6.4), this task is a no-op. Skip the commit and note in the final report: "M6.8: no floating.* store or localStorage keys found — no commit needed."
If Step 1 returns results with actual floating key reads/writes in store.ts or localStorage calls:
- Delete the key from `AppSettings` interface and the `defaults` object in `store.ts`
- Delete any `localStorage.getItem('floating.*')` or `setItem('floating.*', ...)` calls
- [ ] **Step 3: Conditional commit**
Only run this if changes were made in Step 2:
```bash
git add -A
git status --short # confirm what's staged
git commit -m "$(cat <<'EOF'
chore(contextual): purge residual 'floating' keys from store and renderer
EOF
)"
```
---
## Final Self-Review Checklist
After all commits, run these verification checks and paste the output into your report:
- [ ] **FloatingChat imports gone**
```bash
grep -rn "FloatingChat\|useFloatingChat\|FloatingChatProvider\|useDoubleClickAI" src/renderer/
```
Expected: no output.
- [ ] **data-ai-section gone**
```bash
grep -rn "data-ai-section" src/renderer/
```
Expected: no output.
- [ ] **floating string in renderer hooks**
```bash
grep -rn "'floating'" src/renderer/hooks/
```
Expected: no output (sidebar.tsx uses `"floating"` as a layout variant — that is unrelated and harmless).
- [ ] **UIChatContext type**
```bash
grep -n "type:" src/renderer/hooks/useAIChat.ts | head -5
```
Expected: `'global' | 'project'` only.
- [ ] **tRPC schema**
```bash
grep -n "floating" src/main/router/index.ts
```
Expected: no output.
- [ ] **sendFloatingRequest gone**
```bash
grep -rn "sendFloatingRequest\|orchestrateFloating" src/main/
```
Expected: no output.
- [ ] **Final tsc**
```bash
source ~/.nvm/nvm.sh && npx tsc --noEmit 2>&1 | grep -c "error TS"
```
Expected: `0`

View File

@@ -0,0 +1,386 @@
# Project Folder Integration — Design
**Date:** 2026-05-11
**Status:** Approved (brainstorming complete)
**Author:** Roberto + Claude
## Goal
Let users link a local (or shared-PC) folder to an adiuvAI project. Adiuvai scans the folder, generates per-file summaries via LLM, and exposes the resulting manifest to the Home, Brief, and Task-Brief agents so they can answer project questions with awareness of the user's local files.
## Non-goals
- Multi-folder linking per project (deferred — 1 folder per project for now).
- Full-text RAG over file contents (we use lightweight per-file summaries instead).
- File editing from inside adiuvAI (read-only).
- Token-usage display in the project UI (recorded backend-side; dedicated Settings page comes later).
- Web SPA support (Electron-only — web SPA has no filesystem access).
## Strategy
**Hybrid AI File System (manifest first, optional wiki tier later).**
Phase 1 (this spec): build a lightweight manifest — for each indexable file record `(relativePath, kind, size, mtime, 1-line LLM summary)`. The agent receives the manifest pre-injected into its system prompt and reads full file contents lazily via a scoped tool.
Phase 2 (future, out of scope): for folders above N files or on user opt-in, generate per-folder + per-file wiki summaries written to a structured index. Not built now.
## Architecture
```
┌─────────────────────── adiuvAI (Electron) ───────────────────────┐
│ Renderer (React) │
│ • Project hero: <FolderChip> (status glance) │
│ • <FilesTab>: link/unlink, browse, rescan, progress, browser │
│ • <FolderBrowser>: tree view of manifest │
│ │
│ Main (Node) │
│ • db/schema.ts: +projectFolderFiles, +projects.folderPath │
│ • files/scanner.ts: walk + filter + mtime delta │
│ • files/indexer.ts: orchestrates WS index session │
│ • files/daily-rescan.ts: 24h-stale check on app start │
│ • router/projectFolders.ts: tRPC procedures │
│ • api/backend-client.ts: +sendIndexBatch frame │
│ • api/drizzle-executor.ts: +read_project_folder_manifest, │
│ +read_project_folder_file actions │
└───────────────────────────────────────────────────────────────────┘
│ /api/v1/device WS
┌────────────────────────── api (FastAPI) ─────────────────────────┐
│ device_ws.py: +index_file_batch / +index_file_result frames │
│ core/folder_indexer.py: summarize text / vision per file │
│ core/deep_agent.py: pre-inject manifest when project context set │
│ agents/folder_agent.py: scoped read_project_folder_file tool │
│ billing/tier_manager.py: +folder_max_files, +folder_monthly_tokens│
│ models.py: +AgentRunLog.tokens_used, +MonthlyTokenUsage table │
└───────────────────────────────────────────────────────────────────┘
```
**Privacy invariant:** file content travels to the backend only transiently — for summarization — and is never persisted there. Summaries and manifest entries live in the local SQLite database. Token usage is recorded backend-side because it gates the user's tier quota.
## Decisions
| Topic | Decision |
|-------|----------|
| Retrieval strategy | Hybrid: manifest first, optional wiki tier later (phase 2, out of scope) |
| File scope | Text whitelist (.md, .txt, .pdf, .docx, .csv, code) + images (.png/.jpg) summarized via gpt-4o-mini vision |
| Cardinality | One folder per project |
| Rescan triggers | Manual button + daily auto (24h staleness check on app start) + on-demand mtime delta when manifest is read |
| Rate-limit metric | Tokens-per-month per user **and** total file-count cap per folder, both tier-gated |
| Indexing pipeline | WS streaming over existing `/api/v1/device` with new frame types |
| Agent access | Pre-inject manifest into system prompt; lazy reads via scoped `read_project_folder_file` tool |
| UI placement | Hero chip + dedicated "Files" tab in `ProjectTabBar` |
| Platform | Electron-only (web SPA: tab disabled) |
| Token-usage display | Out of scope (record backend-side, surface in Settings later) |
## Schema Changes
### adiuvAI local SQLite (`src/main/db/schema.ts`)
Extend `projects`:
```typescript
projects: {
// existing columns...
folderPath: text('folder_path'), // nullable absolute path or UNC
folderLastScannedAt: integer('folder_last_scanned_at'),// ms, nullable
folderLastScanStatus: text('folder_last_scan_status'), // 'idle' | 'scanning' | 'error'
folderTotalFiles: integer('folder_total_files').default(0),
}
```
New table `projectFolderFiles`:
```typescript
projectFolderFiles: {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(), // FK projects.id (no DB constraint per convention)
relativePath: text('relative_path').notNull(), // path relative to folderPath
ext: text('ext').notNull(), // '.md', '.png', ...
kind: text('kind').notNull(), // 'text' | 'image' | 'pdf' | 'docx' | 'skipped' | 'error'
sizeBytes: integer('size_bytes').notNull(),
mtimeMs: integer('mtime_ms').notNull(),
summary: text('summary'), // nullable, ≤500 chars
summaryUpdatedAt: integer('summary_updated_at'),
// Unique index: (projectId, relativePath)
}
```
### api Postgres (alembic migration)
```python
op.add_column('agent_run_logs',
sa.Column('tokens_used', sa.Integer(), nullable=False, server_default='0'))
op.create_table('monthly_token_usage',
sa.Column('user_id', UUID(as_uuid=False), ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('year_month', sa.String(7), nullable=False), # 'YYYY-MM'
sa.Column('feature', sa.String(64), nullable=False), # 'folder_index'
sa.Column('tokens_used', sa.Integer, nullable=False, server_default='0'),
sa.PrimaryKeyConstraint('user_id', 'year_month', 'feature'),
)
```
### Tier matrix (`app/billing/tier_manager.py`)
| Feature | Free | Pro | Power | Team |
|-------------------------|------|-----|-------|------|
| `folder_max_files` | 200 | 5000| -1 | -1 |
| `folder_monthly_tokens` | 100k | 2M | -1 | -1 |
## Indexing Pipeline
### New WS frame types on `/api/v1/device`
| Direction | Frame | Payload |
|-----------|---------------------------|---------|
| C → S | `index_session_start` | `{ sessionId, projectId, totalFiles }` |
| C → S | `index_file_batch` | `{ sessionId, files: [{relPath, kind, content/imageB64, sizeBytes, mtimeMs}] }` (batches of 5) |
| S → C | `index_file_result` | `{ sessionId, relPath, summary, tokensUsed, error? }` |
| S → C | `index_session_progress` | `{ sessionId, processed, total }` |
| C → S | `index_session_cancel` | `{ sessionId }` |
| S → C | `index_session_done` | `{ sessionId, status: 'completed' \| 'cancelled' \| 'quota_exceeded' \| 'error' }` |
### Flow (Electron `files/indexer.ts`)
1. tRPC `projectFolders.startScan({ projectId })`.
2. `scanner.ts` walks `folderPath`:
- Filter by whitelist (text exts + .png/.jpg).
- Apply size cap (1 MB / file).
- Compute mtime delta vs `projectFolderFiles`.
- Returns `{ newFiles[], changedFiles[], deletedFiles[] }`.
3. Backend pre-flight: `POST /api/v1/billing/quota/check { feature: 'folder_index', estimated_files: N }`:
- Rejects 402 if `folder_max_files` exceeded for the user's tier.
- Rejects 402 if `folder_monthly_tokens` already exhausted.
4. Open `index_session_start` over WS.
5. For each batch of 5 files:
- Read content (text) or base64-encode (image).
- Send `index_file_batch`.
- Await `index_file_result × 5`.
- Upsert `projectFolderFiles` row with the returned summary.
- Backend atomically increments `MonthlyTokenUsage` and writes a row in `AgentRunLog` with `tokens_used`.
6. Send `index_session_done`. Update `projects.folderLastScannedAt`, `.folderTotalFiles`, `.folderLastScanStatus = 'idle'`.
7. Delete `projectFolderFiles` rows for `deletedFiles`.
### Backend (`core/folder_indexer.py`)
- `summarize_text(content, ext) → (summary, tokens)` via `gpt-4o-mini`, Langfuse prompt `folder_file_summary_text`.
- `summarize_image(b64) → (summary, tokens)` via `gpt-4o-mini` vision, Langfuse prompt `folder_file_summary_image`.
- After each summarization, atomically increment `MonthlyTokenUsage(user_id, year_month, 'folder_index', +tokens)`. If the increment would exceed cap, the call returns a `quota_exceeded` error in `index_file_result`, and the session sends `index_session_done(status='quota_exceeded')`.
### Rescan triggers
- **Manual button** → tRPC `projectFolders.startScan` mutation.
- **On-demand mtime check** → inside `read_project_folder_manifest` drizzle-executor action: if any tracked mtime is stale, fire-and-forget `startScan` before returning the current manifest.
- **Daily auto** → `app.on('ready')` iterates user projects; if `folderLastScannedAt < now 24h` and `folderPath != null`, queue `startScan`.
`projects.folderLastScanStatus === 'scanning'` blocks new scan triggers (manual button disabled, daily auto + mtime on-demand both skip).
## Agent Integration
### Manifest pre-injection
In `core/deep_agent.py`, every agent run that has a resolved `projectId` builds a compact manifest block and prepends it to the system prompt:
```
<linked_folder>
path: D:\Clients\Acme\Brand (214 files, scanned 2h ago)
files:
- /briefs/kickoff.md [text] Project kickoff notes; scope, stakeholders, deadlines
- /logos/logo-v3.png [image] Final logo, golden-yellow palette on white
- /research/competitor.pdf [pdf] Competitor brand audit, 12 entries
...
</linked_folder>
```
Format: `relativePath [kind] summary`. If the rendered block exceeds ~3000 tokens, truncate to the top N files by `mtimeMs DESC` and append:
```
… {M} more files omitted, use read_project_folder_file to access by path
```
The backend pulls the manifest via the new drizzle-executor action:
```
action: read_project_folder_manifest
data: { projectId }
returns: { folderPath, lastScannedAt, files: [{relPath, kind, summary}] }
```
### projectId resolution per agent
- `run_task_brief_research_stream``task.projectId`.
- `run_home` — null unless the user message is project-scoped (via `@project` mention or active project context passed from renderer).
- `run_brief` — backend cannot enumerate projects directly because projects live in the local SQLite. It calls a new `execute_on_client` action `list_projects_with_folder_manifests` that returns `[{ projectId, projectName, folderPath, lastScannedAt, files: [{relPath, kind, summary}] }]` for every project that has a linked folder. The backend then builds a **multi-project compact manifest** (top 5 most-recently-modified files per project).
### New scoped tool (`agents/folder_agent.py`)
```python
@tool
async def read_project_folder_file(project_id: str, relative_path: str) -> str:
"""Read full content of a file inside the project's linked folder."""
result = await execute_on_client(
action="read_project_folder_file",
data={"projectId": project_id, "relativePath": relative_path},
)
return result.get("content", "") or f"File not found: {relative_path}"
```
Backed by a new `drizzle-executor` action that:
1. Looks up `projects.folderPath` for the projectId.
2. Resolves `path.join(folderPath, relativePath)` with traversal guard (`..` and absolute paths rejected).
3. Reads the file via the existing fs helpers. Image → returns base64. Text → returns content (size-capped).
The existing journey-only `FILESYSTEM_TOOLS` are not added to home/brief/task-brief; only the new scoped tool is bound.
## UI
### Hero chip (`ProjectDetail.tsx`)
```tsx
<FolderChip
projectId={project.id}
folderPath={project.folderPath}
totalFiles={project.folderTotalFiles}
lastScannedAt={project.folderLastScannedAt}
scanStatus={project.folderLastScanStatus}
onClick={() => scrollToTab('files')}
/>
```
States:
- **Unlinked:** dashed pill "📁 Link folder" + Sparkles icon.
- **Linked idle:** "📁 214 files · 2h ago" with soft golden-yellow background.
- **Scanning:** "📁 indexing 47/214" + spinner.
- **Error:** "📁 Scan failed" red-tinted; click → Files tab.
### Files tab
Add `'files'` to `SECTIONS` in `ProjectTabBar.tsx`. The tab body:
```
┌──────────────────────────────────────────────────┐
│ Linked folder │
│ ┌────────────────────────────────────────────┐ │
│ │ 📁 D:\Clients\Acme\Brand [⋯ menu] │ │
│ │ 214 files · last scanned 2h ago │ │
│ │ [Rescan] [Unlink] │ │
│ └────────────────────────────────────────────┘ │
│ │
│ Files (filter: [All] [Text] [Images] [PDF]) │
│ ┌────────────────────────────────────────────┐ │
│ │ briefs/kickoff.md │ │
│ │ Project kickoff notes; scope, deadlines │ │
│ │ logos/logo-v3.png │ │
│ │ Final logo, golden-yellow on white │ │
│ │ ... │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
```
### Empty state (no folder linked)
```
<Empty>
Sparkles
Link a project folder
Connect a local folder so AI agents can read its files
when answering questions about this project.
[Choose folder...] ← opens Electron dialog.showOpenDialog
</Empty>
```
### New components (`src/renderer/components/projects/folder/`)
- `FolderChip.tsx`
- `FilesSection.tsx` (mounts inside `ProjectDetail`)
- `FolderLinkCard.tsx` (path + actions)
- `FolderFileList.tsx` (virtualized list of manifest entries)
- `FolderUnlinkDialog.tsx`
### Platform gating
Feature is **Electron-only**. Wrap entry points in `platform.isElectron`. On the web SPA, the Files tab renders disabled with a tooltip "Folder linking available in desktop app".
### Folder dialog
New tRPC `projectFolders.chooseFolder` mutation invokes `dialog.showOpenDialog({ properties: ['openDirectory'] })` in the main process and returns the selected path.
### i18n
Add `projects.folder.*` keys (title, link CTA, browse, rescan, unlink, status strings, empty state copy, error toasts) to all 5 locale JSON files: en, it, es, fr, de.
## Error Handling
### Quota exhaustion
- Pre-flight 402 → toast `"Folder too big for {tier} plan — max {N} files"` or `"Monthly token budget exhausted (resets {date})"`. Folder not linked.
- Mid-scan `quota_exceeded` frame → partial manifest kept, scan marked `error`, toast as above, banner in Files tab `"Indexing paused — quota exhausted"`.
### Path errors
- Folder no longer exists at scan start → tRPC throws → toast `"Folder not found: {path}"`. `folderLastScanStatus = 'error'`. User offered Unlink or Re-link.
- Permission denied on a file during scan → file skipped, logged in `projectFolderFiles` with `kind='skipped'`, no summary. Skipped files appear greyed in the Files tab.
- Path traversal attempt in `read_project_folder_file` (relativePath contains `..` or is absolute) → tool returns `"Access denied"`; backend logs a warning. Hard fail, no fallback.
### Network / WS failures
- WS drop mid-scan: the in-flight session is abandoned server-side and the local `folderLastScanStatus` is flipped from `'scanning'` to `'error'`. The next trigger (manual rescan, daily auto, or the next on-demand mtime check) starts a **new** session; because the scanner's mtime delta only re-summarizes files whose `mtimeMs` changed (or that have no row yet), already-indexed files are skipped naturally — there is no explicit session-resume protocol.
- Backend 5xx on summarize → file marked `kind='error'`, retried in the next rescan, not auto-retried inline.
### File-type fallbacks
- PDF parse fails (corrupt) → skipped, `kind='skipped'` with `summary='Could not extract text'`.
- Image too large (>5 MB) → skipped with reason. Cap is a constant in `files/scanner.ts`.
- DOCX or other unsupported types → skipped silently with extension noted.
### Concurrent scan guard
`projects.folderLastScanStatus === 'scanning'` blocks new scan triggers. Manual button shows "Scanning..." disabled; daily auto + mtime on-demand both check the status flag first.
### Manifest size overflow
If the agent's pre-injected `<linked_folder>` block would exceed ~3000 tokens, the backend truncates to the top N files by `mtimeMs DESC` and appends an "M more files omitted" hint.
### Tool call on unlinked project
`read_project_folder_file` when `folderPath === null` returns `"No folder linked to project {projectId}"`. The agent can recover and answer without folder context.
## Testing
### API (`api/tests/`)
| File | Coverage |
|------|----------|
| `test_folder_indexer.py` | `summarize_text` / `summarize_image` happy path, token recording, Langfuse prompt linking |
| `test_folder_quota.py` | Pre-flight 402 rejects (max_files + monthly_tokens), atomic increment + `quota_exceeded` mid-stream, monthly reset at `year_month` rollover |
| `test_ws_index_session.py` | Session lifecycle, cancel mid-stream, abandoned-on-disconnect (next scan skips already-indexed files via mtime delta), bad batch payload validation |
| `test_folder_agent_tool.py` | `read_project_folder_file` happy path, unlinked project, traversal guard (`../`, absolute) |
| `test_manifest_injection.py` | `<linked_folder>` block formatting, truncation past 3k tokens, multi-project brief manifest, null projectId skips injection |
Reuse fixtures in `tests/conftest.py` and WS test helpers (`ws_unified` already covers session lifecycle).
### Electron / adiuvAI
No automated test suite currently. Manual smoke checks during development:
- Link folder → manifest populated → unlink → manifest rows deleted.
- Scan a synthetic dir with mixed text/image/binary → only whitelisted indexed.
- mtime delta: change one file, rescan only re-indexes that file.
- Disconnect WS mid-scan → status flips to `'error'`; next manual rescan re-indexes only the remaining files (mtime delta).
### Eval (Langfuse)
Build a test set of 10 representative folders (mix of markdown, code, PDFs, images). Score summary quality (LLM-as-judge) and token efficiency. Link scores to the prompt version per the existing `LOCAL_AGENT_V2_PLAN.md` pattern.
## Out of scope (this spec)
- Phase-2 wiki tier (per-folder + per-file structured summaries).
- Multi-folder per project.
- Web SPA support.
- Token-usage display UI (Settings page comes later).
- File editing from inside adiuvAI.
- Live file watcher (chokidar). Daily + manual + on-demand mtime is enough for now.
## Open questions (none)
All resolved during brainstorming.

View File

@@ -0,0 +1,299 @@
# Timeline Batch Add — Design
**Date:** 2026-05-13
**Status:** Draft, awaiting user review
**Scope:** `adiuvAI/` submodule only
## Problem
Adding timeline events today goes through `AddEventDialog.tsx` one event at a time. The dialog already supports a sequential "add then add another" loop, but:
- Each event commits immediately on Enter (no client-side staging).
- The project picker appears at the bottom, after type/title/date.
- Date entry requires opening a calendar popover — keyboard-hostile.
- A user planning a project (kickoff + milestones + activities) clicks through the dialog 510 times to seed a project's timeline.
## Goal
Single dialog session lets the user pick a project, stage multiple timeline events of mixed types, review, and commit the batch. Fully operable without a mouse.
## Non-goals
- New backend endpoint. Re-use `trpc.timelineEvents.create` per event.
- Reordering staged events. Order is "as added".
- Bulk import (CSV/paste). Out of scope.
- Cross-project batch. One batch = one project.
## Architecture
Refactor `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` in place. Same callsites (`routes/timeline.tsx`, `components/projects/ProjectDetail.tsx`), same props (`open`, `onOpenChange`, `defaultProjectId?`, `onRecordHistory?`).
Two new shared primitives extracted from this work:
- `adiuvAI/src/renderer/lib/parseDate.ts` — pure date parser, locale-aware.
- `adiuvAI/src/renderer/components/ui/date-field.tsx` — controlled date input with typed entry + popover fallback.
Existing `EditEventDialog.tsx` migrates to `<DateField>` as part of this work. `TaskFormDialog.tsx` is **out of scope** — it uses `TZDate` plus time-of-day (H/M) selectors, which DateField does not cover. A follow-up pass should add `timezone` + `showTime` props to DateField, then migrate TaskFormDialog.
## State model
```ts
type StagedEvent = {
id: string; // nanoid, local-only key
title: string;
type: 'milestone' | 'checkpoint' | 'activity';
date: Date;
endDate?: Date; // activity only
};
type Mode = { kind: 'add' } | { kind: 'edit'; id: string };
// In AddEventDialog
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
const [staged, setStaged] = useState<StagedEvent[]>([]);
const [mode, setMode] = useState<Mode>({ kind: 'add' });
// Form fields:
const [title, setTitle] = useState('');
const [type, setType] = useState<TimelineEventType>('milestone');
const [date, setDate] = useState<Date | undefined>();
const [endDate, setEndDate] = useState<Date | undefined>();
const [focusedRowId, setFocusedRowId] = useState<string | null>(null);
```
## Layout
```
┌─── Add timeline events ─────────────────┐
│ │
│ Project [ Search project… ▾ ] │ ← hidden when defaultProjectId set
│ │ locked when staged.length > 0
│ ┌─ Staged list (scrollable, max ~6) ─┐ │
│ │ ✓ Kickoff milestone 15/03 ✕│ │
│ │ ✓ Phase 1 checkpoint 22/03 ✕│ │
│ └───────────────────────────────────┘ │
│ ───────────────────────────────────── │
│ ( Milestone | Checkpoint | Activity ) │
│ [ Event title… ] │
│ [ Date ] [End date ] │ ← end shown only for activity
│ │
│ [Cancel] [Add ↵] [Save N] │
└─────────────────────────────────────────┘
```
States:
1. **Fresh open** — empty staged list with hint text, form ready, focus on project picker (or title if `defaultProjectId`).
2. **N staged, form ready** — staged list visible, form empty, focus on title.
3. **Row focused** — form dimmed (`opacity-50 pointer-events-none`), staged row has focus ring.
4. **Editing row** — form populated from row, "Add ↵" button reads "Update ↵", row in list highlighted.
## Components
### Shared primitives (new)
**`lib/parseDate.ts`** — pure functions, no React.
```ts
export function parseDate(
input: string,
prefs: FormatPrefs,
baseDate?: Date,
): Date | null;
export function parseDateRange(
input: string,
prefs: FormatPrefs,
baseDate?: Date,
): { from: Date; to?: Date } | null;
```
Accepts:
- Keywords: `today`, `tomorrow`, `yesterday` (i18n-aware via current `i18n.language`)
- Relative: `+Nd`, `+Nw`, `+Nm`, `-Nd`
- Weekday names in current UI language (next occurrence): `mon`/`monday`, `lun`/`lunedì`, etc.
- Partial date: `DD/MM` or `MM/DD` (per `prefs.dateFormat`) → current year, year-rollover if past
- Full date: `DD/MM/YYYY`, `MM/DD/YYYY`, `YYYY-MM-DD` (per prefs)
Returns `null` on unparseable. No date library — small regex + native `Date`.
**`components/ui/date-field.tsx`** — controlled input.
```ts
type DateFieldProps = {
value: Date | undefined;
onChange: (d: Date | undefined) => void;
placeholder?: string;
minDate?: Date;
autoFocus?: boolean;
invalidMessage?: string;
className?: string;
'aria-label'?: string;
id?: string;
onCommit?: (d: Date) => void; // fired on Enter after valid parse
};
```
Internal: text input + calendar icon button → Popover wrapping shadcn `Calendar`. Reads `useFormatPrefs()` internally.
Behavior:
- Display formatted value (via `formatDate(prefs)`) when input not focused and value valid.
- Show raw user text while focused.
- Parse on blur and on Enter — if valid, call `onChange(date)`; if invalid, set `aria-invalid="true"` and red ring.
- Alt+↓ opens popover. Calendar selection commits via `onChange` and closes popover.
- `Enter` inside input: `e.preventDefault()`, parse, call `onChange(parsed)`, then call optional `onCommit?: (d: Date) => void` prop synchronously with the parsed value. Parent uses `onCommit` to stage without relying on `useState` flush. If invalid, no `onCommit` call, no propagation.
### Internal to AddEventDialog (not exported)
**`<ProjectPickerRow>`** — shadcn `Command` inside `Popover`. Typeable filter. Disabled when `staged.length > 0` (visual: muted, tooltip "Project locked after first event"). Hidden when `defaultProjectId` set.
**`<StagedList>`** — `<ul role="listbox" aria-label="Staged events">`. Empty state: muted hint `t('timeline.emptyStagedHint')`. Each row `<li role="option" tabIndex={-1}>` with:
- Type badge (color from existing palette — chart-1/2/3 mapping by type)
- Title (truncate)
- Date(s), formatted per prefs
- ✕ icon button (hover-visible only) for mouse users; aria-label `t('timeline.removeRow')`
Roving tabindex managed by `focusedRowId`. List itself has `tabIndex={0}` when no row focused, so Tab reaches it.
**`<EventForm>`** — wraps:
- `ToggleGroup` for type (existing pattern)
- `Input` for title (autoFocus when `mode.kind === 'add'`)
- `<DateField>` for `date`
- `<DateField>` for `endDate`, mounted only when `type === 'activity'`, `minDate` = `date`
## Keyboard map
| Context | Key | Action |
|----------------------|----------------|--------|
| Project picker open | type | filter list |
| | ↑/↓ | nav results |
| | Enter | select, focus title |
| | Esc | close picker |
| Form, any field | Tab/Shift+Tab | cycle: project → type → title → date → endDate → footer buttons |
| | Enter (valid) | stage event (add) or update row (edit), focus title |
| | Ctrl+Enter | save batch (if N ≥ 1) |
| | Esc | close dialog (confirm if staged > 0) |
| Title field | ↑ (caret at 0) | focus last staged row |
| Type toggle | ←/→ | cycle types |
| Date field | Alt+↓ | open calendar popover |
| | Enter | parse + commit + advance focus |
| Staged row | ↑/↓ | move focus |
| | Enter | load row → form, mode=edit |
| | Del/Backspace | remove row, focus next or form |
| | Esc | focus form title |
| Footer Save button | Enter/Space | save batch |
## Data flow
```
type+title+date entered, Enter pressed
→ validateForm()
→ if mode.add: setStaged([...staged, newEvent]); resetForm(); focusTitle()
→ if mode.edit: setStaged(staged.map(e => e.id===mode.id ? newEvent : e)); setMode({kind:'add'}); resetForm(); focusTitle()
Save N pressed (or Ctrl+Enter)
→ for each staged event:
results = await Promise.allSettled(
staged.map(e => createEvent.mutateAsync({
title: e.title,
date: e.date.getTime(),
endDate: e.endDate?.getTime(),
type: e.type,
projectId: defaultProjectId || projectId || undefined,
}))
)
→ for each fulfilled: onRecordHistory?.({kind:'create', id, payload:...})
→ utils.timelineEvents.list.invalidate() // once, not per event
→ if all fulfilled: notify success, close
→ if partial: keep rejected rows in staged, mark with error tooltip, notify warning
→ if all rejected: notify error, no rows removed
```
## Error handling
Per-field (inline, no toast):
- Empty title → submit disabled, Enter no-op.
- Unparseable date → red ring on `DateField`, `aria-invalid="true"`, submit disabled.
- Activity `endDate < date` → red ring on end field, message `timeline.endBeforeStart`, submit disabled.
- No project selected (picker shown) → submit disabled, picker gets focus ring.
Batch failure (per data flow above):
- All success → toast + close.
- Partial → keep failed rows with error tooltip, toast warns count.
- All fail → toast error, dialog stays open.
Edge cases:
- Dialog closed mid-batch: fire-and-forget mutations continue server-side; UI suppresses their toasts after close (track via local `closedRef`).
- Project deleted between selection and submit → falls into partial-fail path. Acceptable.
- `defaultProjectId` for deleted project → already handled by existing callsite contracts.
## i18n keys (added to all 5 locales)
```
timeline.endBeforeStart "End must be after start"
timeline.dateInvalid "Unrecognized date"
timeline.batchCreated_one "1 event created"
timeline.batchCreated_other "{{count}} events created"
timeline.batchPartial "{{ok}} created, {{failed}} failed"
timeline.batchFailed "Could not create events"
timeline.staged_one "1 event staged"
timeline.staged_other "{{count}} events staged"
timeline.emptyStagedHint "Type a title, set a date, press Enter"
timeline.editRow "Edit"
timeline.removeRow "Remove"
timeline.projectLocked "Project locked after first event"
timeline.confirmCloseStaged "Discard {{count}} staged events?"
timeline.saveAll "Save {{count}}"
timeline.update "Update"
common.add existing — re-use
common.cancel existing — re-use
```
Date parser keywords (per locale):
```
date.keyword.today
date.keyword.tomorrow
date.keyword.yesterday
date.keyword.weekdays array, mon..sun in locale (short + long)
```
## File touch list
New:
- `adiuvAI/src/renderer/lib/parseDate.ts`
- `adiuvAI/src/renderer/components/ui/date-field.tsx`
Modified:
- `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` — full rewrite to staged-batch model.
- `adiuvAI/src/renderer/components/timeline/EditEventDialog.tsx` — swap popover+Calendar for `<DateField>`.
- `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` — add i18n keys above.
Untouched:
- Backend (`api/`): no schema, no router changes.
- tRPC contracts: re-use `timelineEvents.create`.
- DB schema: no migration.
## Testing
Repo has no automated test suite (per `adiuvAI/.claude/CLAUDE.md`). Manual verification before merge:
- [ ] Open from `/timeline`: project picker visible, locks after first staged.
- [ ] Open from `ProjectDetail`: project picker hidden, preset used.
- [ ] Parse — type and verify: `today`, `tomorrow`, `+3d`, `+1w`, `mon`, `15/03`, `15/03/26`, `2026-03-15`. Repeat with `dateFormat` switched in Settings.
- [ ] Switch UI language to IT, type `oggi`, `domani`, `lun` — parse works.
- [ ] Stage 3 mixed-type events, Save → all created, history records 3 entries, toast plural correct.
- [ ] Stage 2, kill network mid-save → failed row stays with error tooltip, toast warns count.
- [ ] Pure keyboard run: open dialog, Tab to project, type+Enter, type title, Tab, type date, Enter (stage), repeat ×3, Ctrl+Enter (save). Mouse never touched.
- [ ] ↑ from title moves to last row, Enter loads to form for edit, Esc returns to form.
- [ ] Del on focused row removes it, focus advances.
- [ ] Esc with staged > 0 shows confirm; cancel keeps dialog, OK closes.
- [ ] `EditEventDialog` opens, `DateField` shows existing date formatted, edit and save works.
- [ ] Reduced-motion preference respected (no popover spring if user has it).
## Open questions
None known at design time. Resolved during brainstorming:
- Batch model: stage then commit all.
- Project scope: one project per batch, locked after first event.
- Date entry: typed input with smart parse, calendar popover as fallback.
- Range entry: two fields (start → Tab → end).
- Row edit: arrow nav, Enter edit, Del remove.
- Components: `DateField` + `parseDate` extracted as shared primitives, migrate `EditEventDialog` in this work. `TaskFormDialog` deferred (needs timezone + time-of-day support on DateField).

View File

@@ -0,0 +1,327 @@
# Scouts Refactor + Gmail Integration — Design
**Date:** 2026-05-15
**Status:** Draft, awaiting user review
**Owner:** Roberto
## Summary
Rename the existing "Agents" subsystem to "Scouts" across the entire stack (UI, code, Postgres, SQLite, Langfuse), then add the first cloud scout — Gmail — using a two-stage pipeline that respects zero-trust (no email content stored on backend) and human-in-the-loop (no entities created autonomously).
The implementation is split into four phases. Phases 13 ship now. Phase 4 (Stage 2 categorization, HITL surface in the brief, conversion-to-entity mutations) is deferred to the planned task-brief rework.
## Goals
- Unify the user-facing "data source watchers" concept under one name: **Scout**.
- Land a `SourceConnector` abstraction so future cloud scouts (Slack/Teams/Outlook/RSS/...) reuse the same engine, queue, delivery channel, and HITL surface — only the per-source connector is new.
- Ship a Gmail scout end-to-end with: OAuth, push (`users.watch`) + cron-fallback polling, BE-side spam triage, encrypted token storage, opt-in spam auto-trash.
- Preserve zero-trust: Gmail bodies are fetched transiently for the triage LLM call and discarded; only `{message_id, scout_id, verdict, status}` is persisted on BE.
- Preserve HITL on the cloud path: scouts never create tasks/projects/events/notes autonomously; they accumulate proposals that the user resolves later from the brief.
## Non-Goals (Phase 4, separate spec)
- Stage 2 categorization agent prompt + tool palette.
- HITL UI in the task brief (suggestion cards, approve/reject controls, convert-to-entity mutations, `list_pending_scout_suggestions` brief tool).
- Local scout behavior change. Local directory monitor keeps current "auto-create" semantics. HITL is opt-in for local scouts in a future migration.
- Schema unification of `LocalScoutConfig` + `CloudScoutConfig`. They have different behaviors; keep separate tables.
- Connectors other than Gmail (Slack/Teams/Outlook).
- Stripe/billing changes (existing tier checks suffice).
## Constraints
- **Pre-1.0 dev**: no production users, no backwards-compatibility shims, no Alembic data migrations beyond rename. Drop-and-recreate is acceptable where simpler.
- **Zero-trust**: BE never persists user content. Gmail bodies are read transiently for the triage LLM call only.
- **HITL (cloud path)**: scouts produce proposals, never entities.
- **Spam auto-trash**: off by default per scout; opt-in via UI toggle. Action is "move to Trash" (Gmail's 30d recovery), never permanent delete.
- **Reusability**: cloud-scout pipeline (connector → triage → queue → deliver-on-connect → HITL) is shared infra; Gmail is just the first connector.
## Architecture
### Two-stage pipeline (cloud scouts only)
```
[Gmail] --push/cron--> [BE Stage 1: Triage] [Electron Stage 2: Categorize]
| |
v v
fetch body (transient) drain queue on WS reconnect
| |
v v
LLM relevance call fetch metadata for each msg
| |
+-- spam + auto_trash_spam: archive v
| insert scout_suggestions row
+-- relevant: insert queue row (category='unprocessed' stub
until Phase 4)
```
**Stage 1 (BE, always-on):** verdict only. Stores `{msg_id, verdict, status}`. No content.
**Stage 2 (Electron, on connect):** Phase 3 ships a stub that simply mirrors the queue into a local SQLite table with `category='unprocessed'`. Phase 4 swaps in the real categorization agent.
### Local scouts (unchanged behaviorally)
Local directory monitor keeps current Electron-side scheduling and auto-creation. Only renames apply.
### SourceConnector abstraction
A `SourceConnector` Protocol owns all source-specific I/O. The shared `ScoutEngine` owns triage, queueing, delivery, and ack handling. To add a new cloud scout: implement one connector class + register it.
```python
# app/scouts/connectors/base.py
class SourceConnector(Protocol):
source_type: str # "gmail"
async def list_new(self, scout: CloudScoutConfig) -> list[ItemRef]: ...
async def fetch_metadata(self, scout: CloudScoutConfig, ref: ItemRef) -> ItemMetadata: ...
async def fetch_content(self, scout: CloudScoutConfig, ref: ItemRef) -> ItemContent: ...
async def archive(self, scout: CloudScoutConfig, ref: ItemRef) -> None: ...
async def setup_watch(self, scout: CloudScoutConfig) -> None: ...
async def renew_watch(self, scout: CloudScoutConfig) -> None: ...
```
`ItemContent.body_text` is in-memory only; never persisted.
### ScoutEngine
```python
class ScoutEngine:
async def trigger_scout(self, scout_id: UUID) -> None: ...
async def _process_item(self, scout, connector, ref) -> None: ...
async def deliver_pending(self, user_id: UUID, ws: DeviceWS) -> None: ...
```
Both webhook and cron-fallback entry points call `trigger_scout`.
## Data Model
### Postgres (BE)
#### Renames (Phase 1, single Alembic migration)
| Before | After |
|--------------------------------|--------------------------------|
| Table `local_agent_configs` | `local_scout_configs` |
| Table `cloud_agent_configs` | `cloud_scout_configs` |
| Table `agent_run_logs` | `scout_run_logs` |
| Column `agent_config` | `scout_config` |
| Column `agent_id` (FKs) | `scout_id` |
| Column `agent_run_id` | `scout_run_id` |
| Class `LocalAgentConfig` | `LocalScoutConfig` |
| Class `CloudAgentConfig` | `CloudScoutConfig` |
| Class `AgentRunLog` | `ScoutRunLog` |
#### New (Phase 2)
```sql
CREATE TABLE scout_triage_queue (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id),
scout_id uuid NOT NULL REFERENCES cloud_scout_configs(id),
source_type text NOT NULL, -- "gmail"
source_msg_ref text NOT NULL, -- gmail message id
triage_verdict text NOT NULL, -- "relevant"
triage_reason text, -- short LLM reason for debug
status text NOT NULL DEFAULT 'queued', -- queued | delivered | acked | expired
triaged_at timestamptz NOT NULL DEFAULT now(),
delivered_at timestamptz,
acked_at timestamptz,
expires_at timestamptz NOT NULL, -- triaged_at + 30d
UNIQUE (scout_id, source_msg_ref) -- idempotent webhook retries
);
CREATE INDEX ON scout_triage_queue (user_id, status);
CREATE INDEX ON scout_triage_queue (expires_at) WHERE status != 'acked';
```
#### Alterations to `cloud_scout_configs` (Phase 2)
```sql
ALTER TABLE cloud_scout_configs ADD COLUMN auto_trash_spam boolean NOT NULL DEFAULT false;
ALTER TABLE cloud_scout_configs ADD COLUMN gmail_history_id text;
ALTER TABLE cloud_scout_configs ADD COLUMN gmail_watch_expires_at timestamptz;
ALTER TABLE cloud_scout_configs ADD COLUMN device_inactivity_pause_days int NOT NULL DEFAULT 14;
```
OAuth tokens continue to live in the existing `cloud_scout_configs.oauth_token_encrypted` column. Encryption mechanism (key derivation, rotation) is reused unchanged. A pre-implementation investigation step will document the current key-management story so we know the threat model; hardening, if needed, is out of scope.
### SQLite (Electron, Drizzle)
#### Renames (Phase 1)
| Before | After |
|---------------------|---------------------|
| `agent_runs` | `scout_runs` |
| `agent_run_actions` | `scout_run_actions` |
| Col `agent_id` | `scout_id` |
#### New (Phase 2)
```typescript
export const scoutSuggestions = sqliteTable('scout_suggestions', {
id: text().primaryKey(),
scoutId: text().notNull(),
sourceType: text().notNull(), // "gmail"
sourceMsgRef: text().notNull(),
category: text().notNull(), // "unprocessed" until Phase 4
payload: text(), // JSON, populated by Phase 4
rawSubject: text(), // populated on delivery
rawSnippet: text(), // populated on delivery
status: text().notNull(), // pending | approved | rejected | expired
proposedAt: integer().notNull(), // ms epoch
resolvedAt: integer(),
resolvedEntityType: text(), // "task" | "project" | ... after Phase 4 approval
resolvedEntityId: text(),
});
```
`rawSubject` + `rawSnippet` are stored locally to render the HITL card without re-hitting Gmail every render. Body is still NOT stored — fetched on-demand via a tool call when the user explicitly opens the suggestion.
## WebSocket Frame Contract
Existing `/api/v1/device` channel. Two new frame types.
```typescript
// BE → Electron
{
type: 'scout_proposal',
proposal: {
id: string,
scoutId: string,
sourceType: 'gmail',
sourceMsgRef: string,
rawSubject: string | null,
rawSnippet: string | null,
category: 'unprocessed',
payload: null
}
}
// Electron → BE
{ type: 'scout_proposal_ack', proposalId: string }
```
On WS reconnect, BE's `ScoutEngine.deliver_pending(user_id, ws)` selects all `status='queued'` rows for the user, calls `connector.fetch_metadata` per row (subject + snippet only), sends one `scout_proposal` frame each, and flips `status='delivered'` + sets `delivered_at` upon ack.
## Stage 1 Triage Detail
```
Webhook (Pub/Sub) or cron tick
-> ScoutEngine.trigger_scout(scout_id)
-> if device inactive > N days: skip (pause)
-> connector.list_new(scout) -> [ItemRef]
-> for each ref:
- if (scout_id, source_msg_ref) already in queue: skip (idempotent)
- content = await connector.fetch_content(scout, ref) # transient
- verdict = await ScoutEngine._triage_llm(scout, content) # gpt-4o-mini
- if verdict == spam:
- if scout.auto_trash_spam: connector.archive(...)
- return # not queued
- INSERT scout_triage_queue row
-> UPDATE cloud_scout_configs.last_run_at
-> INSERT scout_run_logs row
```
### Triage LLM contract
- **Prompt name (Langfuse):** `scout-triage-system` — source-agnostic, parameterized by `source_type`.
- **Input:** `{source_type, scout_name, scout_purpose, item_subject, item_sender, item_body_truncated_2k}`.
- **Output (structured, Pydantic `TriageVerdict`):** `{verdict: "relevant" | "spam", reason: str, confidence: float}`.
- **Cost guard:** body truncated at 2k chars before LLM call.
### Failure modes
- LLM call fails: log error, leave message unprocessed, retry on next webhook/cron.
- Gmail 401 (refresh exhausted): mark scout `enabled=false`, surface re-auth prompt to user via WS frame on next device connect.
- Pub/Sub webhook unverified JWT: 401.
## Gmail Push Setup
- On scout enable: `GmailConnector.setup_watch(scout)` calls `users.watch` against a single project-wide Pub/Sub topic.
- `gmail_watch_expires_at` stored. Watches expire after 7 days.
- Weekly cron `_scout_watch_renewal_tick` re-issues `watch` for any scout whose expiry is within 24h.
- Webhook route: `POST /api/v1/scouts/webhooks/gmail`. Verifies Pub/Sub-signed JWT, resolves user via the email address in the payload, enqueues triage job.
- Cron fallback (`_scout_cron_tick`, runs each scout's `schedule_cron`): polls `users.history.list` since `gmail_history_id`, updates `gmail_history_id` after.
## Terminology Refactor (Detail)
### Renamed
| Surface | Before | After |
|-------------------|-----------------------------------------------------|-----------------------------------------------------|
| Settings nav | `settings.agents` "Agents" | `settings.scouts` "Scouts" |
| Subtitle/desc | `settings.agentsSubtitle`, `agentsDescription` | `settings.scoutsSubtitle`, `scoutsDescription` |
| `agents.*` keys | `noAgentsYet`, `createAgent`, `yourAgents`, etc. | `scouts.noScoutsYet`, `createScout`, `yourScouts` |
| `toast.agent.*` | `created`, `runStarted`, etc. | `toast.scout.*` |
| Components | `AgentsSection`, `AgentRow`, `LocalAgentConfigPanel`, `CloudAgentConfigPanel`, `InlineAgentCreationStepper` | `ScoutsSection`, `ScoutRow`, `LocalScoutConfigPanel`, `CloudScoutConfigPanel`, `InlineScoutCreationStepper` |
| TS types | `LocalAgentConfig`, `CloudAgentConfig` | `LocalScoutConfig`, `CloudScoutConfig` |
| tRPC router | `agent.local`, `agent.cloud`, `agent.journey`, `agent.runs`, `agent.runActions` | `scout.local`, `scout.cloud`, `scout.journey`, `scout.runs`, `scout.runActions` |
| Drizzle tables | `agent_runs`, `agent_run_actions` | `scout_runs`, `scout_run_actions` |
| Main process | `src/main/agents/agent-scheduler.ts` | `src/main/scouts/scout-scheduler.ts` |
| BE routes | `/api/v1/agents/*`, `/api/v1/agent-setup` | `/api/v1/scouts/*`, `/api/v1/scout-setup` |
| BE modules | `routes/agents.py`, `routes/agent_setup.py`, `core/agent_runner.py`, `core/agent_session_buffer.py`, `core/agent_registry.py` | `routes/scouts.py`, `routes/scout_setup.py`, `core/scout_runner.py`, `core/scout_session_buffer.py`, `core/scout_registry.py` |
| Postgres tables | `local_agent_configs`, `cloud_agent_configs`, `agent_run_logs` | `local_scout_configs`, `cloud_scout_configs`, `scout_run_logs` |
| Postgres columns | `agent_config`, `agent_id`, `agent_run_id` | `scout_config`, `scout_id`, `scout_run_id` |
| SQLAlchemy models | `LocalAgentConfig`, `CloudAgentConfig`, `AgentRunLog` | `LocalScoutConfig`, `CloudScoutConfig`, `ScoutRunLog` |
| Langfuse prompts | user-facing scout prompts named `agent-*` | recreate as `scout-*`; delete old |
| i18n | 5 langs (en/it/es/fr/de) | all updated atomically |
### Kept as-is
- `app/agents/*` Python module — these are LLM helper agents (task_agent, project_agent, note_agent, timeline_agent, filesystem_agent) invoked internally by `deep_agent`. Different concept from user-facing scouts. Renaming would create semantic clash with LLM-agent terminology.
- `/api/v1/device` WS endpoint name (already source-neutral).
- All `tool_call`, `run_complete`, etc. WS frame types unrelated to scouts.
## Phasing
### Phase 1 — Rename only
- Single PR. Single Alembic migration. Single Drizzle migration.
- All renames listed above land together. App still works, existing local scout still runs. No new behavior.
### Phase 2 — Connector abstraction skeleton
- New module `app/scouts/connectors/{base,registry,gmail}.py`.
- New module `app/scouts/engine.py`.
- New table `scout_triage_queue` + alterations to `cloud_scout_configs`.
- New SQLite table `scout_suggestions` (Drizzle).
- New WS frame types `scout_proposal` + `scout_proposal_ack`.
- No user-facing change yet.
### Phase 3 — Gmail scout end-to-end
- Settings UI: "Add Gmail scout" → OAuth consent (separate scope set: `gmail.readonly` + `gmail.modify`) → encrypted token stored in `cloud_scout_configs.oauth_token_encrypted` → save scout config.
- Pub/Sub topic + webhook route + JWT verify.
- `setup_watch` on enable; weekly `renew_watch` cron.
- Cron-fallback `_scout_cron_tick` per scout.
- Triage LLM (gpt-4o-mini, Langfuse `scout-triage-system`).
- Spam auto-trash toggle (default off) per scout.
- Device-inactivity pause logic.
- WS deliver-on-reconnect drains queue → `scout_proposal` frames → ack handler → SQLite `scout_suggestions` insert with `category='unprocessed'` (Phase 4 swaps real categorization in).
- "Read full email" tool call: Electron requests body for a suggestion → BE `GmailConnector.fetch_content` → returns body transiently in tool result.
### Phase 4 — Deferred (separate spec, with task-brief rework)
- Stage 2 categorization agent (prompt + tool palette: `list_projects`, `list_tasks`, `search_notes`, memory).
- HITL UI surface in the brief: suggestion cards, approve/reject controls, "convert to task | event | note | project | actionable-only" actions.
- `list_pending_scout_suggestions` brief tool.
- Convert-to-entity mutations.
- Future connectors (Slack/Teams/Outlook/...).
## Testing Surface
- **Phase 1:** existing pytest suite still green with renamed identifiers (auth, ws_unified, schemas, models, etc.). UI smoke: settings page renders, existing local scout runs.
- **Phase 2:** unit tests for `ScoutEngine` w/ mocked `SourceConnector`. Idempotency test (replay same `source_msg_ref`).
- **Phase 3:** integration tests for Gmail webhook → triage → queue insertion (mocked `GmailConnector` for content fetch and LLM). E2E (manual): connect a real Gmail account on dev, send an email, observe queue row appear, reconnect device, observe `scout_suggestions` row land with subject/snippet.
## Open Questions (none blocking)
- OAuth-token encryption key derivation (app-global vs user-derived) — investigation step in implementation plan; document current state, security hardening is out of scope.
- Pub/Sub topic naming and IAM setup (one topic project-wide vs per-environment) — operational detail to decide during Phase 3.
## Risks
- Pub/Sub setup is per-Google-Cloud-project and requires console IAM grants — first-time setup friction.
- Gmail `users.watch` quota: 1 watch per user. We use one watch per scout, but a user has only one Gmail scout per Gmail account, so this is fine.
- `_pending_states` dict pattern in existing OAuth flow is in-memory — Pub/Sub webhook can run on any worker, so any cross-request state must be in DB, not in-memory. This design uses no in-memory state; safe.
## Acceptance
- All renames land atomically; app boots; existing local scout still operates.
- A user can connect Gmail through the Scouts settings page, see the scout marked enabled, send themselves a test email, and observe a `scout_suggestions` row appear in their local DB with `category='unprocessed'`, `rawSubject`, and `rawSnippet` populated, after the next WS reconnect.
- Spam emails (per LLM triage) are not queued; if `auto_trash_spam=true` they appear in Gmail Trash.
- BE never persists email bodies. Verified by code review of triage flow + grep for `body_text` writes.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,186 @@
# Cloud Scout Creation Flow (Gmail) — Design
**Date:** 2026-05-16
**Status:** Draft, awaiting user review
**Owner:** Roberto
**Predecessor:** [2026-05-15-scouts-refactor-and-gmail-integration-design.md](2026-05-15-scouts-refactor-and-gmail-integration-design.md) (Phases 13 shipped)
## Summary
The scout creation stepper (`InlineScoutCreationStepper`) and the cloud config panel (`CloudScoutConfigPanel`) still expose the pre-refactor local-agent config shape — a data-type picker, a batch-interval select, and a user-authored extraction-prompt builder — for **all** scouts, including Gmail. These fields contradict the two-stage HITL pipeline shipped in Phases 13:
- **Categorization is automatic and deferred to Phase 4.** The scout categorizes every relevant email itself (task / event / note / project) and proposes via the brief. Users do not pick extraction types.
- **The triage prompt is server-side and IP-protected** (Langfuse `scout-triage-system`, zero-trust). Users must not author it.
- **Gmail is push-primary** (Pub/Sub `watch`). The cron schedule is a fallback only; surfacing it as a "batch interval" implies emails arrive every N hours, which is false.
This design branches the creation flow so cloud scouts (Gmail) get a slim flow fitting the new pipeline — name, focus text, spam auto-trash, and a label/sender filter, with OAuth performed during creation — while local-directory scouts keep their current full flow untouched. The cloud config panel is rewritten to match (full edit parity).
## Goals
- Cloud (Gmail) creation collects only fields the new pipeline actually uses.
- OAuth happens during creation; the scout is live immediately on connect.
- A label + sender filter lets the user scope which emails the scout watches.
- A free-text "focus" field steers triage (`scout_purpose`) without exposing the prompt.
- The cloud config panel offers full edit parity (focus, filter, auto-trash, connection management).
- Fix the Phase-3 follow-up: the BE cloud serializer now returns `oauthConnected`, `filterConfig`, and `gmail_address`.
## Non-Goals
- Stage-2 categorization agent and the brief HITL surface (Phase 4, separate spec).
- Teams / Outlook slim flows. They share the cloud branch path but their connectors don't exist yet; their catalog cards are disabled with a "coming soon" marker.
- Changes to the local-directory creation flow (untouched).
- `date_range` filter (a watch is ongoing, not time-bounded).
- New pending-token OAuth machinery — we reuse the existing scout-id-bound OAuth by creating the scout at the connect step.
## Constraints
- Pre-1.0 dev — no production users, no migration shims beyond what Alembic needs.
- Zero-trust + IP-protected triage prompt preserved — the focus field maps to `scout_purpose`, never to the raw prompt.
- Reuse existing OAuth (`startGmailOAuth` / `completeGmailOAuth` + deep-link callback) and existing `GmailClient._build_gmail_query` (already reads `labels` + `senders`).
## Architecture
### Stepper branch
`InlineScoutCreationStepper` becomes a thin router. The template-pick step (Step 1) stays shared. After a template is chosen, the stepper delegates by `selectedTemplate.type`:
- `local_directory``LocalScoutCreationFlow` (the current 3-step body, extracted verbatim).
- cloud (Gmail) → `CloudScoutCreationFlow` (new).
This extraction keeps each flow in its own focused component rather than piling `if (cloud)` branches through the existing local logic.
### Cloud (Gmail) flow
```
Step 1 (shared): Choose template → Gmail
Step 2 — Connect & Basics:
- Name (required)
- Focus text (optional) → prompt_template → scout_purpose
- Auto-trash spam toggle (off) → auto_trash_spam
- [Connect Gmail] button:
1. scout.cloud.create({ name, provider:'gmail', dataTypes:[],
promptTemplate, autoTrashSpam, filterConfig:{} })
→ returns scout id (dormant, no token yet)
2. startGmailOAuth({ scoutId }) → browser consent
3. deep-link callback → completeGmailOAuth({ code, state })
→ token stored, setup_watch fires, gmail_address persisted → scout live
Step 3 — Filter (post-connect):
- scout.cloud.gmailLabels({ scoutId }) → populate label multi-select
- Labels multi-select + sender/domain allowlist chips
- [Save] → scout.cloud.update({ id, filterConfig:{ labels, senders } })
- [Skip] → leaves filter empty (watch all INBOX)
- [Finish] → closes stepper, invalidates scout lists
```
**Why create at the connect step:** the existing BE OAuth flow binds the token to an existing `scout_id` (`/scouts/oauth/gmail/authorize?scout_id=…`). Creating the dormant scout at connect reuses that flow with zero new machinery. The filter is applied as an `update` after connect — within the same stepper, it reads as one continuous flow.
### Abandon handling
- Bail **before** connect → dormant unconnected scout row remains; its row shows the same "Connect Gmail" CTA (identical to today's post-create connect path).
- Bail **after** connect, before filter → live INBOX-wide scout (empty filter). Functional; editable later in the config panel.
Both are acceptable pre-1.0; neither leaves corrupt state.
## Fields & Data Mapping
| UI field | Step | Stored as | Notes |
|----------|------|-----------|-------|
| Name | 2 | `name` | required |
| Focus text | 2 | `prompt_template` | free-text → `scout_purpose` in triage; optional |
| Auto-trash spam | 2 | `auto_trash_spam` | toggle, default **off** |
| Labels | 3 | `filter_config.labels: string[]` | multi-select of fetched Gmail labels; empty = all INBOX |
| Senders | 3 | `filter_config.senders: string[]` | chips (`alice@x.com` or `@client.co`); optional |
**Dropped from cloud:** data-types picker (`dataTypes` sent as `[]`), batch interval (`scheduleCron` omitted → BE default), extraction-prompt builder (`PromptBuilderChat` not rendered for cloud).
`filter_config = { labels?: string[], senders?: string[] }` — matches the existing `GmailClient._build_gmail_query`, so no query-builder change is needed; we just populate the config from the UI instead of sending `{}`.
## Backend Changes
### 1. Gmail label listing
- `GmailConnector.list_labels(scout) -> list[dict]` — calls `users().labels().list()`, returns `[{id, name}]` (user + system labels), wrapped in `asyncio.to_thread`. Returns `[]` if no token.
- Route `GET /api/v1/scouts/cloud/{scout_id}/gmail-labels` — auth-guarded, loads scout (ownership check), calls connector.
- tRPC `scout.cloud.gmailLabels({ scoutId })``proxyGet`.
### 2. Gmail disconnect / stop watch
- `GmailConnector.stop_watch(scout)` — calls `users().stop()`, swallows errors (watch may already be expired).
- Route `POST /api/v1/scouts/cloud/{scout_id}/gmail-disconnect` — clears `oauth_token_encrypted`, nulls `gmail_history_id` + `gmail_watch_expires_at` + `gmail_address`, sets `enabled=false`, calls `stop_watch`.
- tRPC `scout.cloud.disconnectGmail({ scoutId })`.
### 3. `scout.cloud.create` input — loosen + extend
- `scheduleCron`: required → **optional** (BE applies its default when omitted).
- `dataTypes`: stays; cloud sends `[]`.
- Add `autoTrashSpam?: boolean` (default `false`).
- `promptTemplate`: already present (carries focus text).
- `filterConfig`: already present (now populated).
- BE `POST /scouts/cloud` must accept + persist `auto_trash_spam`.
### 4. `scout.cloud.update` input — extend for config-panel parity
- Add `autoTrashSpam?`, `promptTemplate?`, `filterConfig?` (all optional, partial update).
- BE `PUT/PATCH /scouts/cloud/{id}` must apply these columns.
### 5. Cloud serializer — return the new fields
The BE cloud list/get serializer must return:
- `auto_trash_spam`
- `filter_config`
- `prompt_template`
- `gmail_address`
- computed `oauthConnected = oauth_token_encrypted is not None`
(`oauthConnected` was added to the TS type in Phase 3 but never populated by the BE — fixed here.)
### 6. `gmail_address` column — Alembic 009
- `ALTER TABLE cloud_scout_configs ADD COLUMN gmail_address VARCHAR(320) NULL`.
- Populated on OAuth callback from the Gmail profile (`users().getProfile().emailAddress` or OIDC `userinfo` email).
- SQLAlchemy model field `gmail_address: Mapped[str | None]`.
### Shared TS type `CloudScoutConfig`
Add: `autoTrashSpam: boolean`, `filterConfig?: { labels?: string[]; senders?: string[] }`, `promptTemplate?: string`, `gmailAddress?: string | null`. (`oauthConnected` already present.)
## Config Panel Parity (`CloudScoutConfigPanel`)
Rewrite the expanded edit view to the slim model:
- **Connection status block:**
- Not connected → amber "Connect Gmail" CTA (existing `startGmailOAuth`).
- Connected → "Connected as `<gmailAddress>`" + "Reconnect" (re-run `startGmailOAuth`) + "Disconnect" (`disconnectGmail`).
- **Focus text** — editable textarea bound to `prompt_template`.
- **Filter** — label multi-select (via `scout.cloud.gmailLabels`) + sender chips, bound to `filter_config`.
- **Auto-trash spam** — toggle bound to `auto_trash_spam`.
- **Save changes** — single `scout.cloud.update({ id, promptTemplate, filterConfig, autoTrashSpam })`.
**Removed:** data-types checkboxes, schedule select, "Customize AI prompt" journey button.
## Catalog Gating (Teams / Outlook)
The catalog currently shows Local Directory, Gmail, Teams, Outlook cards. The cloud branch only implements Gmail. Teams and Outlook cards are rendered **disabled** with a "coming soon" marker until their connectors exist, preventing a user from entering a half-built cloud flow for an unimplemented provider.
## i18n
New keys in all 5 languages (`en/it/es/fr/de`):
`scouts.focusLabel`, `scouts.focusPlaceholder`, `scouts.autoTrashSpam`, `scouts.autoTrashHint`, `scouts.filterLabels`, `scouts.filterSenders`, `scouts.filterSendersPlaceholder`, `scouts.watchAllInbox`, `scouts.connectedAs`, `scouts.reconnect`, `scouts.disconnect`, `scouts.skipFilter`, `scouts.finish`, plus the cloud stepper step headers (currently hardcoded English in the stepper — extracted to keys during the branch).
## Testing
- **BE unit:** `GmailConnector.list_labels` + `stop_watch` (mocked Gmail service). `scout.cloud.create` with omitted `scheduleCron` applies the default and persists `auto_trash_spam`. Cloud serializer returns `oauthConnected` + `filterConfig` + `gmail_address`.
- **BE migration:** Alembic 009 revision-graph check (head = 009, parent = 008).
- **Electron:** no test suite — `tsc --noEmit` + manual smoke: create a Gmail scout end-to-end, connect, pick labels, confirm a `cloud_scout_configs` row with the focus/filter/auto-trash values and a populated `gmail_address`.
## Acceptance
- Creating a Gmail scout shows only name + focus + auto-trash, then a Connect step, then a label/sender filter step — no data-type picker, no batch interval, no extraction-prompt builder.
- After connect, the scout row shows "Connected as `<email>`".
- The config panel edits focus, filter, and auto-trash, and can disconnect/reconnect.
- Local-directory scout creation is unchanged.
- Teams/Outlook cards are visibly disabled.
- BE cloud list returns `oauthConnected`, `filterConfig`, `gmail_address`.
## Open Questions
None blocking.
## Risks
- **Label fetch latency:** `users().labels().list()` is one extra round-trip after OAuth. Acceptable; show a loading state on the multi-select.
- **Dormant-scout litter:** abandoned flows leave dormant/unfiltered scouts. Pre-1.0 acceptable; a future cleanup job could prune never-connected scouts older than N days.
- **`gmail_address` PII:** stored plaintext (it's the user's own address, already in their JWT identity). Not sensitive beyond existing storage.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff