Compare commits

...

23 Commits

Author SHA1 Message Date
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
11 changed files with 23715 additions and 4306 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.

Submodule adiuvAI updated: 81fe6d29e2...1a4cfb07a5

2
api

Submodule api updated: cc0e258e8c...0833db239c

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

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