diff --git a/docs/2026-05-14-contextual-sidebar-agent-plan.md b/docs/2026-05-14-contextual-sidebar-agent-plan.md new file mode 100644 index 0000000..40eb0d3 --- /dev/null +++ b/docs/2026-05-14-contextual-sidebar-agent-plan.md @@ -0,0 +1,2431 @@ +# Contextual Sidebar Agent — 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:** Replace the legacy double-click floating-chat flow with a contextual right-side sidebar that opens from an adiuva trigger button, persists across navigation, and shares the chat surface with the home chat. + +**Architecture:** Renderer chat surface extracted into a shared headless `ChatSurface` reused by the existing `AIChatPanel` (home) and a new `ContextualSidebar`. A `ContextualChatProvider` is mounted once in `AppShell` so the sidebar tree never unmounts during route transitions. Each page calls `useContextualScope({...})` in render to publish its current context; the provider diffs and emits a silent backend scope update on change. Chat history is persisted in SQLite (new `aiChatSessions` + `aiChatMessages` tables) for both channels. On the backend, a new `contextual_request` / `contextual_scope_update` WS pair replaces `floating_request`; `run_contextual_stream` injects the rendered scope block and uses Langfuse prompt `contextual_system`. + +**Tech Stack:** Electron + React 19 + TanStack Router (renderer), tRPC v11 over a custom IPC bridge, Drizzle ORM + better-sqlite3 (main), FastAPI + WebSocket + LiteLLM (backend), shadcn/ui resizable panels, Langfuse for prompts. + +**Spec:** [docs/2026-05-14-contextual-sidebar-agent-design.md](2026-05-14-contextual-sidebar-agent-design.md) + +--- + +## Conventions + +- **Workflow:** Bite-sized steps. One step at a time. Commit at the end of each task. Stop if a step fails — diagnose root cause, don't band-aid. +- **Commit message style:** project uses Conventional-style prefixes (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`). Each commit body should explain the why, not the what. +- **Adi adiuvAI has no frontend test suite** (per `adiuvAI/.claude/CLAUDE.md`). Frontend tasks are validated by manual smoke (`npm start` from `adiuvAI/`) — be explicit about the smoke path. +- **Backend has pytest.** Backend tasks must include a failing test before implementation. +- **Pre-1.0 dev** — minor breaking changes are accepted (clearing the `floating` draft cache, deleting Langfuse `floating_system` prompt after cutover, etc.). User feedback `feedback_clean_refactor_dev` applies. +- **Submodules:** `adiuvAI/` and `api/` are git submodules. Each commit happens **inside the submodule directory**. After each submodule commit, bump the pointer in the workspace repo with a `chore: bump submodule` commit, matching the existing pattern (see `git log --oneline` in workspace root). + +--- + +# Milestone M1 — DB + persistence foundation + +Adds the two SQLite tables and the `aiChat` tRPC sub-router. No UI change, no behavior change. Verifiable via tRPC devtools. + +## Task M1.1: Add `aiChatSessions` + `aiChatMessages` to schema + +**Files:** +- Modify: `adiuvAI/src/main/db/schema.ts` + +- [ ] **Step 1: Append two table definitions and type exports** + +Open `adiuvAI/src/main/db/schema.ts`. After the last `export const ... = sqliteTable(...)` block (and before the file's final `}`/EOF, **outside** any other declaration), append: + +```ts +export const aiChatSessions = sqliteTable('ai_chat_sessions', { + id: text('id').primaryKey(), + channel: text('channel', { enum: ['home', 'contextual'] }).notNull(), + title: text('title'), + createdAt: integer('created_at', { mode: 'number' }).notNull(), + updatedAt: integer('updated_at', { mode: 'number' }).notNull(), + lastScope: text('last_scope'), +}); + +export const aiChatMessages = sqliteTable('ai_chat_messages', { + id: text('id').primaryKey(), + sessionId: text('session_id').notNull(), + role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(), + content: text('content').notNull(), + toolCalls: text('tool_calls'), + toolResults: text('tool_results'), + scope: text('scope'), + createdAt: integer('created_at', { mode: 'number' }).notNull(), +}); + +export type AiChatSession = InferSelectModel; +export type NewAiChatSession = InferInsertModel; +export type AiChatMessage = InferSelectModel; +export type NewAiChatMessage = InferInsertModel; +``` + +- [ ] **Step 2: Run TypeScript compile to verify** + +```bash +cd adiuvAI +source ~/.nvm/nvm.sh +npx tsc --noEmit +``` + +Expected: exit 0 (or unchanged warning count vs. baseline). + +## Task M1.2: Generate Drizzle migration + +**Files:** +- Create: `adiuvAI/src/main/db/migrations/0006_.sql` (drizzle-kit names it) + +- [ ] **Step 1: Generate migration** + +```bash +cd adiuvAI +source ~/.nvm/nvm.sh +npx drizzle-kit generate +``` + +Expected: a new migration file appears under `src/main/db/migrations/` numbered `0006_*.sql` plus an updated `meta/_journal.json`. + +- [ ] **Step 2: Open the new migration and verify content** + +It MUST contain `CREATE TABLE \`ai_chat_sessions\`` and `CREATE TABLE \`ai_chat_messages\`` with all columns from M1.1. If it contains drops of unrelated tables, STOP — the schema was changed in an unintended way; investigate before continuing. + +- [ ] **Step 3: Add the index statements at the bottom of the same migration** + +Append to the new migration file: + +```sql +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `ai_chat_messages_session_created_idx` ON `ai_chat_messages` (`session_id`, `created_at`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `ai_chat_sessions_channel_updated_idx` ON `ai_chat_sessions` (`channel`, `updated_at`); +``` + +- [ ] **Step 4: Apply migration in dev** + +```bash +cd adiuvAI +source ~/.nvm/nvm.sh +npx drizzle-kit push +``` + +Expected: "Changes applied" with no destructive prompts. If drizzle-kit prompts to drop columns/tables, ABORT — there's an unintended diff. + +- [ ] **Step 5: Commit** + +```bash +cd adiuvAI +git add src/main/db/schema.ts src/main/db/migrations/0006_*.sql src/main/db/migrations/meta/ +git commit -m "feat(db): add ai_chat_sessions and ai_chat_messages tables + +Local chat history persistence. Same model used by both home and +contextual channels. Indexes on (session_id, created_at) and +(channel, updated_at) for ordering and listing." +``` + +## Task M1.3: Add `aiChat` tRPC sub-router + +**Files:** +- Create: `adiuvAI/src/main/router/ai-chat.ts` +- Modify: `adiuvAI/src/main/router/index.ts` + +- [ ] **Step 1: Inspect existing router to learn the local `procedure` / `router` setup** + +Open `adiuvAI/src/main/router/index.ts` and note the exact import style (likely `import { router, procedure } from './trpc'` or similar) and how an existing sub-router (e.g. `tasksRouter`) is structured. Mirror that style exactly in the new file. + +- [ ] **Step 2: Create `ai-chat.ts` with five procedures** + +Create `adiuvAI/src/main/router/ai-chat.ts`: + +```ts +import { z } from 'zod'; +// IMPORTANT: import `router` and `procedure` (or whatever names the project uses) +// from the same place existing sub-routers do. Mirror existing tasks/projects router style. +import { router, procedure } from './trpc'; // adjust path/symbol names to match the project +import { getDb } from '../db'; +import { aiChatSessions, aiChatMessages } from '../db/schema'; +import { eq, desc, and, asc } from 'drizzle-orm'; + +const ChannelSchema = z.enum(['home', 'contextual']); +const RoleSchema = z.enum(['user', 'assistant', 'system']); + +export const aiChatRouter = router({ + listSessions: procedure + .input(z.object({ channel: ChannelSchema })) + .query(({ input }) => { + const db = getDb(); + return db + .select() + .from(aiChatSessions) + .where(eq(aiChatSessions.channel, input.channel)) + .orderBy(desc(aiChatSessions.updatedAt)) + .all(); + }), + + getSession: procedure + .input(z.object({ id: z.string() })) + .query(({ input }) => { + const db = getDb(); + const session = db + .select() + .from(aiChatSessions) + .where(eq(aiChatSessions.id, input.id)) + .get(); + if (!session) return null; + const messages = db + .select() + .from(aiChatMessages) + .where(eq(aiChatMessages.sessionId, input.id)) + .orderBy(asc(aiChatMessages.createdAt)) + .all(); + return { session, messages }; + }), + + createSession: procedure + .input(z.object({ + channel: ChannelSchema, + initialScope: z.string().optional(), + })) + .mutation(({ input }) => { + const db = getDb(); + const id = crypto.randomUUID(); + const now = Date.now(); + db.insert(aiChatSessions).values({ + id, + channel: input.channel, + title: null, + createdAt: now, + updatedAt: now, + lastScope: input.initialScope ?? null, + }).run(); + return { id }; + }), + + appendMessage: procedure + .input(z.object({ + sessionId: z.string(), + role: RoleSchema, + content: z.string(), + toolCalls: z.string().optional(), + toolResults: z.string().optional(), + scope: z.string().optional(), + })) + .mutation(({ input }) => { + const db = getDb(); + const id = crypto.randomUUID(); + const now = Date.now(); + db.insert(aiChatMessages).values({ + id, + sessionId: input.sessionId, + role: input.role, + content: input.content, + toolCalls: input.toolCalls ?? null, + toolResults: input.toolResults ?? null, + scope: input.scope ?? null, + createdAt: now, + }).run(); + db.update(aiChatSessions) + .set({ updatedAt: now, lastScope: input.scope ?? null }) + .where(eq(aiChatSessions.id, input.sessionId)) + .run(); + return { id }; + }), + + deleteSession: procedure + .input(z.object({ id: z.string() })) + .mutation(({ input }) => { + const db = getDb(); + db.delete(aiChatMessages).where(eq(aiChatMessages.sessionId, input.id)).run(); + db.delete(aiChatSessions).where(eq(aiChatSessions.id, input.id)).run(); + return { ok: true }; + }), +}); +``` + +- [ ] **Step 3: Register `aiChat` in `appRouter`** + +In `adiuvAI/src/main/router/index.ts`, import the new router and add it to the merged router: + +```ts +import { aiChatRouter } from './ai-chat'; + +export const appRouter = router({ + // ...existing sub-routers... + aiChat: aiChatRouter, +}); +``` + +- [ ] **Step 4: Type check** + +```bash +cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit +``` + +Expected: exit 0. + +- [ ] **Step 5: Smoke test in dev — open Electron, run procedure from devtools** + +```bash +cd adiuvAI && source ~/.nvm/nvm.sh && npm start +``` + +In the renderer devtools console (when app is up): + +```js +await window.__trpcClient.aiChat.createSession.mutate({ channel: 'home' }) +``` + +(Or invoke via a temporary `useQuery` in any mounted component if no devtools client global exists.) + +Expected: `{ id: '' }`. Then `aiChat.listSessions({ channel: 'home' })` returns at least one row. + +- [ ] **Step 6: Commit** + +```bash +cd adiuvAI +git add src/main/router/ai-chat.ts src/main/router/index.ts +git commit -m "feat(router): add aiChat tRPC sub-router + +CRUD for chat sessions and messages, used by both home and contextual +channels. No UI consumer yet — added ahead of refactor." +``` + +## Task M1.4: Bump submodule pointer in workspace + +- [ ] **Step 1: Bump pointer** + +```bash +cd /c/Users/PC-Roby/Documents/_adiuvai_workspace +git add adiuvAI +git commit -m "chore: bump adiuvAI submodule — aiChat persistence tables + router" +``` + +--- + +# Milestone M2 — ChatSurface refactor (no behavior change) + +Extracts shared chat logic from `useAIChat` + `AIChatPanel`. Home chat keeps working identically and gains SQLite-backed persistence. Floating chat still works (its rewrite happens in M3/M4/M6). + +## Task M2.1: Extract `useChatStream` hook (streaming engine + WS plumbing) + +**Files:** +- Create: `adiuvAI/src/renderer/hooks/useChatStream.ts` +- Modify: `adiuvAI/src/renderer/hooks/useAIChat.ts` + +The current `useAIChat` mixes three concerns: cache-key derivation, stream subscription, and tRPC mutation. We split out the streaming engine. + +- [ ] **Step 1: Create the engine** + +Create `adiuvAI/src/renderer/hooks/useChatStream.ts`: + +```ts +import { useCallback, useRef, useState } from 'react'; +import { trpc } from '@/lib/trpc'; +import type { ChatMessage } from './useAIChat'; // moved later; OK to leave the cycle for one commit + +export type ChatStreamMode = + | { kind: 'home' } + | { kind: 'contextual'; scope: unknown }; + +export interface UseChatStreamArgs { + sessionId: string; + /** + * Called when the assistant message is fully assembled. The caller is + * responsible for persisting it (e.g. via `aiChat.appendMessage`). + */ + onAssistantMessage: (msg: ChatMessage) => void; + onError: (msg: ChatMessage) => void; +} + +export function useChatStream({ sessionId, onAssistantMessage, onError }: UseChatStreamArgs) { + const [isStreaming, setIsStreaming] = useState(false); + const [streamingContent, setStreamingContent] = useState(''); + const streamingContentRef = useRef(''); + const chatMutation = trpc.ai.chat.useMutation(); + const chatMutationRef = useRef(chatMutation); + chatMutationRef.current = chatMutation; + + const send = useCallback((args: { + message: string; + history: { role: 'user' | 'assistant'; content: string }[]; + mode: ChatStreamMode; + }) => { + setIsStreaming(true); + setStreamingContent(''); + streamingContentRef.current = ''; + const requestId = crypto.randomUUID(); + + const unsubscribe = window.electronAI.onStreamEvent((event) => { + if (event.requestId !== requestId) return; + switch (event.type) { + case 'stream_text': + streamingContentRef.current += event.chunk; + setStreamingContent(streamingContentRef.current); + break; + case 'stream_end': { + onAssistantMessage({ + id: crypto.randomUUID(), + role: 'assistant', + content: streamingContentRef.current, + }); + setStreamingContent(''); + streamingContentRef.current = ''; + setIsStreaming(false); + unsubscribe(); + break; + } + } + }); + + const mutationInput: Record = { + requestId, + message: args.message, + conversationHistory: args.history, + sessionId, + }; + if (args.mode.kind === 'contextual') { + mutationInput.mode = 'contextual'; + mutationInput.scope = args.mode.scope; + } + + chatMutationRef.current.mutate(mutationInput as never, { + onError: (err) => { + unsubscribe(); + onError({ + id: crypto.randomUUID(), + role: 'assistant', + content: err.message || 'An unexpected error occurred.', + error: true, + }); + setStreamingContent(''); + streamingContentRef.current = ''; + setIsStreaming(false); + }, + }); + }, [sessionId, onAssistantMessage, onError]); + + return { send, isStreaming, streamingContent }; +} +``` + +- [ ] **Step 2: Type check** + +```bash +cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit +``` + +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +cd adiuvAI +git add src/renderer/hooks/useChatStream.ts +git commit -m "refactor(chat): extract useChatStream hook + +Shared streaming engine for home (and forthcoming contextual) channels. +useAIChat still owns cache-key + tRPC dispatch; that wiring is migrated +in the next commit." +``` + +## Task M2.2: Extract `ChatSurface` component + +**Files:** +- Create: `adiuvAI/src/renderer/components/ai/ChatSurface.tsx` + +The headless chat surface: messages list, markdown rendering, input. It receives all state from props — no streaming or persistence inside. + +- [ ] **Step 1: Identify the message-rendering + input section in `AIChatPanel.tsx`** + +Open `adiuvAI/src/renderer/components/ai/AIChatPanel.tsx` and locate: +1. The map over `messages` that renders user vs. assistant bubbles with `ReactMarkdown`. +2. The `ChatInputBox` usage. +3. The streaming-content placeholder (the in-flight assistant bubble showing `streamingContent`). + +These are the three blocks that move into `ChatSurface`. + +- [ ] **Step 2: Create `ChatSurface.tsx`** + +Create `adiuvAI/src/renderer/components/ai/ChatSurface.tsx`: + +```tsx +import { memo, useRef, useEffect } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { ChatInputBox } from './ChatInputBox'; +import type { ChatMessage } from '@/hooks/useAIChat'; + +export interface ChatSurfaceProps { + messages: ChatMessage[]; + streamingContent: string; + isStreaming: boolean; + onSend: (text: string) => void; + cacheKey: string; + variant: 'home' | 'contextual'; + /** Extra slot rendered above the input (e.g. suggestion chips on home). */ + aboveInputSlot?: React.ReactNode; + /** Bottom padding so messages can scroll under a fade/translucent input (contextual). */ + bottomPadPx?: number; +} + +export const ChatSurface = memo(function ChatSurface({ + messages, + streamingContent, + isStreaming, + onSend, + cacheKey, + variant, + aboveInputSlot, + bottomPadPx = 120, +}: ChatSurfaceProps) { + const scrollRef = useRef(null); + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); + }, [messages.length, streamingContent]); + + return ( +
+
+ {messages.map((m) => ( +
+ {m.role === 'assistant' ? {m.content} : m.content} +
+ ))} + {isStreaming && ( +
+ {streamingContent || '…'} +
+ )} +
+ + {aboveInputSlot} + +
+ {variant === 'contextual' && ( +
+ )} +
+ +
+
+
+ ); +}); +``` + +- [ ] **Step 3: Type check** + +```bash +cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit +``` + +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +cd adiuvAI +git add src/renderer/components/ai/ChatSurface.tsx +git commit -m "refactor(chat): extract ChatSurface presentational component + +Shared between home and contextual channels. Variant prop selects +between the home (full-width, fixed-bottom input) and contextual +(absolute-positioned translucent input with gradient fade) layouts." +``` + +## Task M2.3: Rewire `AIChatPanel` as a thin wrapper + +**Files:** +- Modify: `adiuvAI/src/renderer/components/ai/AIChatPanel.tsx` + +- [ ] **Step 1: Replace the message map + input block with ``** + +Edit `AIChatPanel.tsx`. Remove the message-loop JSX and the `ChatInputBox` block. Replace with: + +```tsx + +``` + +Keep the existing imports for icons, `Link`, etc. — they're still used in `aboveInputSlot`. Remove now-unused imports of `ReactMarkdown` from this file (it lives in `ChatSurface` now). + +- [ ] **Step 2: Add the new import** + +```tsx +import { ChatSurface } from './ChatSurface'; +``` + +- [ ] **Step 3: Type check + run app, verify home chat unchanged** + +```bash +cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit && npm start +``` + +Smoke: open the home page, send a message, confirm: +- streaming text appears +- final assistant message renders with markdown +- suggestion chips (if rendered above input) still appear in the right place +- input draft persistence works (type, navigate away, come back, draft remembered) + +- [ ] **Step 4: Commit** + +```bash +cd adiuvAI +git add src/renderer/components/ai/AIChatPanel.tsx +git commit -m "refactor(chat): home AIChatPanel uses ChatSurface + +Pure refactor, no behavior change." +``` + +## Task M2.4: Wire home chat persistence to `aiChat` tables + +**Files:** +- Modify: `adiuvAI/src/renderer/components/ai/AIChatPanel.tsx` + +Currently home chat history lives only in the in-memory `chatSessionCache` map inside `useAIChat`. We add a parallel SQLite persistence so closing/reopening the app preserves history. + +- [ ] **Step 1: Hydrate from electron-store + tRPC on mount** + +Inside `AIChatPanel`, before the existing `useAIChat({ type: 'global' })` call, add: + +```tsx +import { useEffect, useState } from 'react'; +import { trpc } from '@/lib/trpc'; + +const HOME_SESSION_KEY = 'chat.home.lastSessionId'; +const [homeSessionId, setHomeSessionId] = useState(() => + localStorage.getItem(HOME_SESSION_KEY), +); +const utils = trpc.useUtils(); +const createSession = trpc.aiChat.createSession.useMutation(); +const appendMessage = trpc.aiChat.appendMessage.useMutation(); + +useEffect(() => { + let cancelled = false; + (async () => { + if (!homeSessionId) { + const { id } = await createSession.mutateAsync({ channel: 'home' }); + if (cancelled) return; + localStorage.setItem(HOME_SESSION_KEY, id); + setHomeSessionId(id); + } else { + // Hydrate: load past messages and seed the in-memory cache for useAIChat('global'). + const session = await utils.aiChat.getSession.fetch({ id: homeSessionId }); + if (cancelled || !session) return; + // Note: useAIChat seeds from its module-scope cache; pre-populate it here. + // For now we just rely on the cache being warm after the first send; + // a follow-up task can pre-load past messages into the cache directly. + } + })(); + return () => { cancelled = true; }; +// eslint-disable-next-line react-hooks/exhaustive-deps +}, [homeSessionId]); +``` + +(Yes, `localStorage` here rather than electron-store — electron-store is main-process only and this is renderer-side. Per spec, persisting the sessionId on the renderer side via `localStorage` is acceptable; the durable data lives in SQLite.) + +- [ ] **Step 2: Wire `onAssistantMessage` + user-send to `appendMessage`** + +Currently `useAIChat` does not expose an "assistant message persisted" callback. Add a useEffect that watches `messages` and appends any new entries since the last persisted index. Inside `AIChatPanel`: + +```tsx +const persistedCountRef = useRef(0); +useEffect(() => { + if (!homeSessionId) return; + const fresh = messages.slice(persistedCountRef.current); + for (const m of fresh) { + appendMessage.mutate({ + sessionId: homeSessionId, + role: m.role, + content: m.content, + }); + } + persistedCountRef.current = messages.length; +}, [messages, homeSessionId, appendMessage]); +``` + +- [ ] **Step 3: Smoke** + +```bash +cd adiuvAI && source ~/.nvm/nvm.sh && npm start +``` + +Send a message on home. Confirm via devtools console: + +```js +await window.__trpcClient.aiChat.listSessions.query({ channel: 'home' }) +``` + +Expect at least one session with `updatedAt` near now. `aiChat.getSession({ id })` returns the user + assistant messages. + +- [ ] **Step 4: Commit** + +```bash +cd adiuvAI +git add src/renderer/components/ai/AIChatPanel.tsx +git commit -m "feat(chat): persist home chat history to SQLite + +Home chat now creates an aiChatSessions row on first use and +appends every user/assistant message. Session id persisted in +localStorage so reopening the app reattaches to the same row. +Hydration of past messages is deferred to a follow-up — current +behavior already matches the previous in-memory cache." +``` + +## Task M2.5: Bump submodule + +- [ ] **Step 1: Bump** + +```bash +cd /c/Users/PC-Roby/Documents/_adiuvai_workspace +git add adiuvAI +git commit -m "chore: bump adiuvAI submodule — ChatSurface refactor + home persistence" +``` + +--- + +# Milestone M3 — Backend contextual frame + prompt + tests + +Add the new WS frames, runner, and Langfuse prompt. Old `floating_*` still alive — they coexist for the duration of M3–M5. + +## Task M3.1: Add `ContextualScope` Pydantic models + scope-block renderer + +**Files:** +- Create: `api/app/schemas/contextual.py` + +- [ ] **Step 1: Failing test** + +Create `api/tests/test_contextual_scope.py`: + +```python +import pytest +from app.schemas.contextual import ContextualScope, render_scope_block + + +def test_render_project_scope(): + scope = ContextualScope( + page="project", + entity_type="project", + entity_id="p1", + entity_name="Acme Q3 launch", + counts={"tasks": 12, "notes": 4, "milestones": 3}, + ) + block = render_scope_block(scope) + assert "Acme Q3 launch" in block + assert "12 tasks" in block + assert "4 notes" in block + assert "3 milestones" in block + # Never expose ids in rendered block — only names. + assert "p1" not in block + + +def test_render_list_scope_no_entity(): + scope = ContextualScope(page="tasks", entity_type=None) + block = render_scope_block(scope) + assert "tasks" in block.lower() + # No fake entity_id leakage + assert "None" not in block + + +def test_render_note_scope_includes_char_count(): + scope = ContextualScope( + page="note", + entity_type="note", + entity_id="n1", + entity_name="Meeting 14 May", + project_id="p1", + char_count=4280, + ) + block = render_scope_block(scope) + assert "Meeting 14 May" in block + assert "4280" in block or "4,280" in block + + +def test_parses_camelcase_payload_from_renderer(): + # Renderer ships camelCase JSON. Pydantic alias_generator handles it. + payload = { + "page": "project", + "entityType": "project", + "entityId": "p1", + "entityName": "Acme", + "counts": {"tasks": 5, "notes": 1, "milestones": 2}, + } + scope = ContextualScope.model_validate(payload) + assert scope.entity_id == "p1" + assert scope.entity_name == "Acme" +``` + +- [ ] **Step 2: Run — verify FAIL** + +```bash +cd api +pytest tests/test_contextual_scope.py -v +``` + +Expected: FAIL with `ModuleNotFoundError: app.schemas.contextual`. + +- [ ] **Step 3: Implement** + +Create `api/app/schemas/contextual.py`: + +```python +from __future__ import annotations +from typing import Literal, Optional +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +PageType = Literal[ + "timeline", + "tasks", + "projects-list", + "project", + "note", +] + +EntityType = Literal["project", "note", "task", "timeline_event"] + + +class ContextualScope(BaseModel): + # The renderer sends camelCase keys (entityType, entityId, etc.). + # We use Python snake_case here and let pydantic alias-map them. + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + page: PageType + entity_type: Optional[EntityType] = None + entity_id: Optional[str] = None + entity_name: Optional[str] = None + project_id: Optional[str] = None + char_count: Optional[int] = None + counts: Optional[dict[str, int]] = None + # For list views: snapshot of renderer-side active filters. + filters: Optional[dict] = None + + +def render_scope_block(scope: ContextualScope) -> str: + if scope.entity_type == "project": + c = scope.counts or {} + parts = [ + f"User is viewing the project {scope.entity_name!r}.", + f"{c.get('tasks', 0)} tasks, " + f"{c.get('notes', 0)} notes, " + f"{c.get('milestones', 0)} milestones.", + ] + return " ".join(parts) + if scope.entity_type == "note": + return ( + f"User is viewing the note {scope.entity_name!r} " + f"({scope.char_count or 0} characters)." + ) + # List pages + if scope.page == "tasks": + return "User is viewing the global Tasks list (all projects)." + if scope.page == "timeline": + return "User is viewing the global Timeline view." + if scope.page == "projects-list": + return "User is viewing the Projects list." + return f"User is on page {scope.page}." +``` + +- [ ] **Step 4: Run — verify PASS** + +```bash +cd api && pytest tests/test_contextual_scope.py -v +``` + +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +cd api +git add app/schemas/contextual.py tests/test_contextual_scope.py +git commit -m "feat(contextual): scope schema and render_scope_block + +Pydantic model mirroring the renderer's ContextualScope union. +render_scope_block produces a single-paragraph human-readable +summary for the contextual agent system prompt." +``` + +## Task M3.2: Add `_CONTEXTUAL_SYSTEM_PROMPT` fallback constant + +**Files:** +- Modify: `api/app/core/deep_agent.py` + +- [ ] **Step 1: Add constant near `_FLOATING_SYSTEM_PROMPT`** + +Open `api/app/core/deep_agent.py`. Locate `_FLOATING_SYSTEM_PROMPT = ...`. Immediately below it, add: + +```python +_CONTEXTUAL_SYSTEM_PROMPT = """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. +""" +``` + +- [ ] **Step 2: Sanity check the import line + run a quick syntax test** + +```bash +cd api && python -c "from app.core.deep_agent import _CONTEXTUAL_SYSTEM_PROMPT; print(len(_CONTEXTUAL_SYSTEM_PROMPT))" +``` + +Expected: a number > 500. + +- [ ] **Step 3: Commit** + +```bash +cd api +git add app/core/deep_agent.py +git commit -m "feat(contextual): add _CONTEXTUAL_SYSTEM_PROMPT fallback + +Used by run_contextual_stream when Langfuse prompt +'contextual_system' is unavailable." +``` + +## Task M3.3: Add `run_contextual_stream` runner + +**Files:** +- Modify: `api/app/core/deep_agent.py` + +- [ ] **Step 1: Failing test** + +Create `api/tests/test_run_contextual.py`: + +```python +import pytest +from unittest.mock import AsyncMock, patch +from app.schemas.contextual import ContextualScope + + +@pytest.mark.asyncio +async def test_run_contextual_stream_includes_scope_block(monkeypatch): + from app.core import deep_agent + + captured = {} + + async def fake_loop(*, sys, tools, message, history, user, db, session_id, channel): + captured["sys"] = sys + captured["channel"] = channel + captured["tool_names"] = [getattr(t, "name", str(t)) for t in tools] + if False: + yield # generator type + return + + monkeypatch.setattr(deep_agent, "_run_agent_loop", fake_loop) + + scope = ContextualScope( + page="project", + entity_type="project", + entity_id="p1", + entity_name="Acme", + counts={"tasks": 1, "notes": 0, "milestones": 0}, + ) + + async for _ in deep_agent.run_contextual_stream( + user=AsyncMock(), + db=AsyncMock(), + session_id="s1", + message="hi", + scope=scope, + history=[], + ): + pass + + assert captured["channel"] == "contextual" + assert "Acme" in captured["sys"] + # Tool list must include get_page_details and entity-create tools, NOT note edit tools. + names = captured["tool_names"] + assert "get_page_details" in names + assert "create_task" in names + assert "create_note" in names + assert "update_task" in names + assert "propose_note_edit" not in names # next sprint + assert "summarize_note" not in names +``` + +- [ ] **Step 2: Run — verify FAIL** + +```bash +cd api && pytest tests/test_run_contextual.py -v +``` + +Expected: FAIL with `AttributeError: module 'app.core.deep_agent' has no attribute 'run_contextual_stream'`. + +- [ ] **Step 3: Implement** + +In `api/app/core/deep_agent.py`, after `run_floating_stream`, add: + +```python +from app.schemas.contextual import ContextualScope, render_scope_block + + +async def run_contextual_stream( + *, + user, + db, + session_id: str, + message: str, + scope: ContextualScope, + history: list[dict], +): + 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) + + # Tool list — keep in sync with the prompt's rule about which actions are allowed. + tools = [ + get_page_details_tool, + create_task_tool, + update_task_tool, + create_note_tool, + create_timeline_event_tool, + ] + + async for frame in _run_agent_loop( + sys=sys, + tools=tools, + message=message, + history=history, + user=user, + db=db, + session_id=session_id, + channel="contextual", + ): + yield frame +``` + +If `get_page_details_tool` or `create_timeline_event_tool` are not defined yet in this file, follow the existing pattern of the floating runner — see what tools it imports. If the symbols are missing entirely, stub them and add a TODO that M5 wires them. For now, mirror the floating tool list and replace with a `get_page_details_tool` placeholder import; you may temporarily reuse the floating tool list to keep tests passing and explicitly assert in this task that **at least** `get_page_details` is referenced. + +- [ ] **Step 4: Run — verify PASS** + +```bash +cd api && pytest tests/test_run_contextual.py -v +``` + +Expected: 1 passed. If a tool symbol is missing, define a placeholder `get_page_details_tool` near the existing tools section (it will be properly fleshed out in M5 — for now, a stub `Tool(name="get_page_details", description="…", func=lambda **k: {})` is acceptable as long as it has `.name == "get_page_details"`). + +- [ ] **Step 5: Commit** + +```bash +cd api +git add app/core/deep_agent.py tests/test_run_contextual.py +git commit -m "feat(contextual): run_contextual_stream + +New agent runner. Injects rendered scope block into the system +prompt, uses Langfuse 'contextual_system' (fallback constant on +miss), and exposes get_page_details + entity-create tools. +Note-edit tools are intentionally excluded — next sprint." +``` + +## Task M3.4: Add WS frame handlers in `device_ws.py` + +**Files:** +- Modify: `api/app/api/routes/device_ws.py` + +- [ ] **Step 1: Failing test for `contextual_scope_update` no-op** + +Create `api/tests/test_contextual_ws.py`: + +```python +import pytest +from unittest.mock import AsyncMock, MagicMock + + +@pytest.mark.asyncio +async def test_handle_contextual_scope_update_appends_system_message_no_llm(): + from app.api.routes import device_ws + + ws = AsyncMock() + buffer = MagicMock() + buffer.append_system_message = MagicMock() + user = MagicMock() + db = AsyncMock() + + payload = { + "type": "contextual_scope_update", + "session_id": "s1", + "scope": { + "page": "project", + "entity_type": "project", + "entity_id": "p1", + "entity_name": "Acme", + "counts": {"tasks": 1, "notes": 0, "milestones": 0}, + }, + } + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(device_ws, "get_session_buffer", lambda *a, **kw: buffer) + await device_ws._handle_contextual_scope_update(ws, payload, user, db) + + # Acked, no LLM call. + ws.send_json.assert_awaited_once() + sent = ws.send_json.await_args.args[0] + assert sent["type"] == "contextual_scope_ack" + assert sent["session_id"] == "s1" + buffer.append_system_message.assert_called_once() +``` + +- [ ] **Step 2: Run — verify FAIL** + +```bash +cd api && pytest tests/test_contextual_ws.py -v +``` + +Expected: FAIL (`AttributeError`). + +- [ ] **Step 3: Implement handlers** + +In `api/app/api/routes/device_ws.py`, locate `_handle_floating_request`. Below it, add: + +```python +from app.schemas.contextual import ContextualScope, render_scope_block +from app.core.deep_agent import run_contextual_stream + + +async def _handle_contextual_request(ws, payload, user, db): + session_id = payload["session_id"] + message = payload["message"] + scope = ContextualScope.model_validate(payload["scope"]) + history = payload.get("history", []) + async for frame in run_contextual_stream( + user=user, + db=db, + session_id=session_id, + message=message, + scope=scope, + history=history, + ): + await ws.send_json(frame) + + +async def _handle_contextual_scope_update(ws, payload, user, db): + session_id = payload["session_id"] + scope = ContextualScope.model_validate(payload["scope"]) + block = render_scope_block(scope) + buffer = get_session_buffer(session_id, channel="contextual") + buffer.append_system_message( + f"User navigated to a new view. {block} Treat this as the new active context." + ) + await ws.send_json({ + "type": "contextual_scope_ack", + "session_id": session_id, + }) +``` + +In the dispatch switch (locate `if payload["type"] == "floating_request": await _handle_floating_request(...)`), add adjacent branches: + +```python +elif payload["type"] == "contextual_request": + await _handle_contextual_request(ws, payload, user, db) +elif payload["type"] == "contextual_scope_update": + await _handle_contextual_scope_update(ws, payload, user, db) +``` + +If `get_session_buffer` does not yet accept a `channel` kwarg, see M3.5. + +- [ ] **Step 4: Run — verify PASS** + +```bash +cd api && pytest tests/test_contextual_ws.py tests/test_run_contextual.py tests/test_contextual_scope.py -v +``` + +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +cd api +git add app/api/routes/device_ws.py tests/test_contextual_ws.py +git commit -m "feat(contextual): WS frames contextual_request + contextual_scope_update + +contextual_request invokes run_contextual_stream and forwards +frames. contextual_scope_update appends a synthetic system +message to the session buffer (no LLM call) and returns +contextual_scope_ack." +``` + +## Task M3.5: Extend `agent_session_buffer` to accept `channel` + +**Files:** +- Modify: `api/app/core/agent_session_buffer.py` + +- [ ] **Step 1: Open the file and confirm the current signature** + +If `get_session_buffer` already accepts a `channel` kwarg, skip this task entirely. + +- [ ] **Step 2: Add the kwarg (optional, default `"home"`)** + +Modify the buffer accessor to accept `channel: str = "home"` and namespace the in-memory map by `(session_id, channel)` (or, if simpler, leave session_id alone since session ids are uuids already and globally unique). Add an `append_system_message(text: str)` method if it doesn't already exist: + +```python +def append_system_message(self, text: str) -> None: + self.messages.append({"role": "system", "content": text}) +``` + +- [ ] **Step 3: Run all contextual tests + any existing buffer tests** + +```bash +cd api && pytest tests/ -k "buffer or contextual" -v +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +cd api +git add app/core/agent_session_buffer.py +git commit -m "feat(buffer): accept channel kwarg + append_system_message + +Used by the contextual_scope_update handler to inject a synthetic +system message without invoking the LLM." +``` + +## Task M3.6: Create Langfuse `contextual_system` prompt (manual) + +**Files:** (no code change) + +- [ ] **Step 1: Use /langfuse skill to create the prompt** + +In a fresh Claude session, run `/langfuse` and instruct it to create a text prompt named `contextual_system` with body identical to the `_CONTEXTUAL_SYSTEM_PROMPT` constant from M3.2. Label `production`. Do NOT delete `floating_system` yet (M6 deletes it after cutover). + +- [ ] **Step 2: Verify from a Python REPL** + +```bash +cd api +python -c " +import asyncio +from app.core.langfuse_client import get_prompt +print(asyncio.run(get_prompt('contextual_system'))[:120]) +" +``` + +Expected: first ~120 chars of the prompt body. If `None` or fallback fires, repeat /langfuse step. (Exact accessor name may differ; use whatever `get_prompt_or_fallback` uses internally.) + +- [ ] **Step 3: Commit (no code, just marker)** + +No commit. Note in plan execution log that Langfuse prompt was created. (Optional: add a comment in `_CONTEXTUAL_SYSTEM_PROMPT` referencing the Langfuse name + date.) + +## Task M3.7: Bump submodule + workspace pointer + +- [ ] **Step 1: Bump** + +```bash +cd /c/Users/PC-Roby/Documents/_adiuvai_workspace +git add api +git commit -m "chore: bump api submodule — contextual frames + run_contextual_stream" +``` + +--- + +# Milestone M4 — Frontend sidebar shell + provider + trigger + +Adds the sidebar UI, provider, hook, trigger button, and route wiring. Old floating still alive — both reachable. + +## Task M4.1: `ContextualChatProvider` + +**Files:** +- Create: `adiuvAI/src/renderer/context/ContextualChatContext.tsx` + +- [ ] **Step 1: Create the provider** + +```tsx +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { trpc } from '@/lib/trpc'; +import type { ChatMessage } from '@/hooks/useAIChat'; + +export interface ContextualScope { + page: 'timeline' | 'tasks' | 'projects-list' | 'project' | 'note'; + entityType?: 'project' | 'note' | null; + entityId?: string; + entityName?: string; + projectId?: string | null; + counts?: { tasks?: number; notes?: number; milestones?: number }; + charCount?: number; + filters?: unknown; +} + +interface ContextualChatState { + open: boolean; + size: number; + sessionId: string | null; + scope: ContextualScope | null; + messages: ChatMessage[]; + isStreaming: boolean; + streamingContent: string; + toggle: () => void; + close: () => void; + newChat: () => void; + setSize: (s: number) => void; + setScope: (s: ContextualScope) => void; + send: (text: string) => void; +} + +const Ctx = createContext(null); + +const SESSION_KEY = 'chat.contextual.lastSessionId'; +const SIZE_KEY = 'chat.sidebar.size'; +const OPEN_KEY = 'chat.contextual.open'; + +function readNumber(k: string, fallback: number) { + const v = localStorage.getItem(k); + return v ? Number(v) || fallback : fallback; +} + +export function ContextualChatProvider({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(() => localStorage.getItem(OPEN_KEY) === '1'); + const [size, setSizeState] = useState(() => readNumber(SIZE_KEY, 32)); + const [sessionId, setSessionId] = useState(() => localStorage.getItem(SESSION_KEY)); + const [scope, setScopeState] = useState(null); + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingContent, setStreamingContent] = useState(''); + const streamRef = useRef(''); + + const utils = trpc.useUtils(); + const createSession = trpc.aiChat.createSession.useMutation(); + const appendMessage = trpc.aiChat.appendMessage.useMutation(); + const chatMutation = trpc.ai.chat.useMutation(); + + // Ensure a session exists once. + useEffect(() => { + if (sessionId) { + utils.aiChat.getSession.fetch({ id: sessionId }).then((res) => { + if (res?.messages) { + setMessages( + res.messages + .filter((m) => m.role !== 'system') + .map((m) => ({ id: m.id, role: m.role as 'user' | 'assistant', content: m.content })), + ); + } + }); + } else { + createSession.mutateAsync({ channel: 'contextual' }).then(({ id }) => { + localStorage.setItem(SESSION_KEY, id); + setSessionId(id); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const setSize = useCallback((s: number) => { + setSizeState(s); + localStorage.setItem(SIZE_KEY, String(s)); + }, []); + + const toggle = useCallback(() => { + setOpen((o) => { + const next = !o; + localStorage.setItem(OPEN_KEY, next ? '1' : '0'); + return next; + }); + }, []); + + const close = useCallback(() => { + setOpen(false); + localStorage.setItem(OPEN_KEY, '0'); + }, []); + + const newChat = useCallback(async () => { + const { id } = await createSession.mutateAsync({ channel: 'contextual' }); + localStorage.setItem(SESSION_KEY, id); + setSessionId(id); + setMessages([]); + }, [createSession]); + + const lastScopeKeyRef = useRef(''); + const setScope = useCallback((s: ContextualScope) => { + const key = JSON.stringify(s); + if (key === lastScopeKeyRef.current) return; + lastScopeKeyRef.current = key; + setScopeState(s); + if (sessionId) { + // Fire-and-forget scope update via main process. + window.electronAI.sendContextualScopeUpdate?.({ sessionId, scope: s }); + } + }, [sessionId]); + + const send = useCallback((text: string) => { + if (!sessionId || !scope) return; + const trimmed = text.trim(); + if (!trimmed || isStreaming) return; + + const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: trimmed }; + setMessages((prev) => [...prev, userMsg]); + appendMessage.mutate({ sessionId, role: 'user', content: trimmed, scope: JSON.stringify(scope) }); + + const requestId = crypto.randomUUID(); + setIsStreaming(true); + setStreamingContent(''); + streamRef.current = ''; + + const unsub = window.electronAI.onStreamEvent((event) => { + if (event.requestId !== requestId) return; + if (event.type === 'stream_text') { + streamRef.current += event.chunk; + setStreamingContent(streamRef.current); + } else if (event.type === 'stream_end') { + const final = streamRef.current; + setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'assistant', content: final }]); + appendMessage.mutate({ sessionId, role: 'assistant', content: final, scope: JSON.stringify(scope) }); + setStreamingContent(''); + streamRef.current = ''; + setIsStreaming(false); + unsub(); + } + }); + + chatMutation.mutate( + { + requestId, + message: trimmed, + conversationHistory: messages.slice(-20).map((m) => ({ role: m.role, content: m.content })), + sessionId, + mode: 'contextual', + scope, + } as never, + { + onError: (err) => { + unsub(); + setMessages((prev) => [ + ...prev, + { id: crypto.randomUUID(), role: 'assistant', content: err.message, error: true }, + ]); + setIsStreaming(false); + }, + }, + ); + }, [sessionId, scope, isStreaming, messages, appendMessage, chatMutation]); + + const value = useMemo(() => ({ + open, size, sessionId, scope, messages, isStreaming, streamingContent, + toggle, close, newChat, setSize, setScope, send, + }), [open, size, sessionId, scope, messages, isStreaming, streamingContent, toggle, close, newChat, setSize, setScope, send]); + + return {children}; +} + +export function useContextualChat() { + const v = useContext(Ctx); + if (!v) throw new Error('useContextualChat must be used within ContextualChatProvider'); + return v; +} +``` + +- [ ] **Step 2: Type check** + +```bash +cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit +``` + +`window.electronAI.sendContextualScopeUpdate` won't yet exist — declare it in the preload's typings. Open `adiuvAI/src/preload/` and find the typings file for `window.electronAI`. Add: + +```ts +sendContextualScopeUpdate?: (args: { sessionId: string; scope: unknown }) => void; +``` + +Re-run typecheck; expect 0. + +- [ ] **Step 3: Commit** + +```bash +cd adiuvAI +git add src/renderer/context/ContextualChatContext.tsx src/preload/ +git commit -m "feat(contextual): ContextualChatProvider + +Holds open/size/sessionId/scope/messages/streaming state. Creates +or hydrates a contextual session on mount, persists messages, +and fires scope updates through window.electronAI." +``` + +## Task M4.2: `useContextualScope` hook + +**Files:** +- Create: `adiuvAI/src/renderer/hooks/useContextualScope.ts` + +- [ ] **Step 1: Create** + +```ts +import { useEffect } from 'react'; +import { useContextualChat, type ContextualScope } from '@/context/ContextualChatContext'; + +export function useContextualScope(scope: ContextualScope) { + const { setScope } = useContextualChat(); + const key = JSON.stringify(scope); + useEffect(() => { + setScope(scope); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd adiuvAI +git add src/renderer/hooks/useContextualScope.ts +git commit -m "feat(contextual): useContextualScope hook + +Pages call this in render with their current scope. Provider +diffs by JSON key and only fires updates on real change." +``` + +## Task M4.3: `AdiuvaTriggerButton` + +**Files:** +- Create: `adiuvAI/src/renderer/components/ai/AdiuvaTriggerButton.tsx` +- Create: `adiuvAI/src/renderer/components/ai/AdiuvaIcon.tsx` + +- [ ] **Step 1: Inline compass needle icon** + +Create `adiuvAI/src/renderer/components/ai/AdiuvaIcon.tsx`: + +```tsx +export function AdiuvaIcon({ size = 22, southColor = '#e5e5e7' }: { size?: number; southColor?: string }) { + return ( + + ); +} +``` + +- [ ] **Step 2: Trigger button** + +Create `adiuvAI/src/renderer/components/ai/AdiuvaTriggerButton.tsx`: + +```tsx +import { useContextualChat } from '@/context/ContextualChatContext'; +import { AdiuvaIcon } from './AdiuvaIcon'; + +export function AdiuvaTriggerButton() { + const { toggle, open } = useContextualChat(); + return ( + + ); +} +``` + +- [ ] **Step 3: Add CSS to the global stylesheet** + +Open `adiuvAI/src/renderer/globals.css` and append: + +```css +.adiuva-btn { + position: relative; + width: 48px; + height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--card); + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border-radius: 14px; + cursor: pointer; + transition: box-shadow .3s ease, background .2s ease; + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 6%, transparent), + 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); + color: inherit; +} +.adiuva-btn:hover { + background: color-mix(in srgb, var(--card) 92%, white 8%); + box-shadow: + inset 0 1px 0 color-mix(in srgb, white 6%, transparent), + 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; } + +.adiuva-needle-g { + transform-origin: 32px 32px; + animation: adiuva-compass-settle 6s ease-in-out infinite; +} +@keyframes adiuva-compass-settle { + 0% { transform: rotate(0deg); } + 20% { transform: rotate(4deg); } + 50% { transform: rotate(-3deg); } + 80% { transform: rotate(2deg); } + 100% { transform: rotate(0deg); } +} +@media (prefers-reduced-motion: reduce) { + .adiuva-needle-g { animation: none; } +} +``` + +- [ ] **Step 4: Commit** + +```bash +cd adiuvAI +git add src/renderer/components/ai/AdiuvaIcon.tsx src/renderer/components/ai/AdiuvaTriggerButton.tsx src/renderer/globals.css +git commit -m "feat(contextual): adiuva trigger button + compass icon + +Elevated 48px button with continuous compass-settle animation. +Hover deepens shadow and adds a gold ambient glow." +``` + +## Task M4.4: `ContextualSidebar` shell + +**Files:** +- Create: `adiuvAI/src/renderer/components/ai/ContextualSidebar.tsx` + +- [ ] **Step 1: Create** + +```tsx +import { Plus, X } from 'lucide-react'; +import { useContextualChat } from '@/context/ContextualChatContext'; +import { ChatSurface } from './ChatSurface'; + +export function ContextualSidebar() { + const { messages, isStreaming, streamingContent, send, newChat, close, sessionId } = useContextualChat(); + + return ( +
+
+ + +
+ + +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd adiuvAI +git add src/renderer/components/ai/ContextualSidebar.tsx +git commit -m "feat(contextual): ContextualSidebar shell + +Top-right elevated controls (new chat, close) and a ChatSurface +in contextual variant. No header, no scope chip." +``` + +## Task M4.5: Mount in `AppShell` behind `ResizablePanelGroup` + +**Files:** +- Modify: `adiuvAI/src/renderer/components/layout/AppShell.tsx` + +- [ ] **Step 1: Wrap `` (and only ``) in the provider** + +At the top of the render, wrap the outlet area: + +```tsx +import { ContextualChatProvider, useContextualChat } from '@/context/ContextualChatContext'; +import { ContextualSidebar } from '@/components/ai/ContextualSidebar'; +import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'; +import { useLocation } from '@tanstack/react-router'; + +function MainArea() { + const loc = useLocation(); + const isHome = loc.pathname === '/'; + const { open, size, setSize } = useContextualChat(); + + if (isHome || !open) { + return ; // single column, no sidebar + } + + return ( + + + + + + setSize(s)}> + + + + ); +} +``` + +Then in the existing `AppShell` JSX, replace the `` rendering with: + +```tsx + + + +``` + +Keep `` for now (M6 removes it). + +- [ ] **Step 2: Smoke** + +```bash +cd adiuvAI && source ~/.nvm/nvm.sh && npm start +``` + +Open the app. Navigate to `/tasks`. No visible change (sidebar is closed by default). Open devtools and run: + +```js +window.localStorage.setItem('chat.contextual.open','1'); location.reload(); +``` + +After reload (on `/tasks`), the right panel is visible with an empty `ContextualSidebar`. On `/` (home), it is NOT visible. Pass = the sidebar slot exists on non-home routes. + +- [ ] **Step 3: Commit** + +```bash +cd adiuvAI +git add src/renderer/components/layout/AppShell.tsx +git commit -m "feat(contextual): mount sidebar via ResizablePanelGroup in AppShell + +Provider wraps Outlet so contextual chat survives all route +transitions. Sidebar is hidden on home and when chat.open is +false. Width is persisted via the provider." +``` + +## Task M4.6: Add trigger + scope hook to each page + +**Files (one task per route, but commit can batch):** +- Modify: `adiuvAI/src/renderer/routes/timeline.tsx` +- Modify: `adiuvAI/src/renderer/routes/tasks.tsx` +- Modify: `adiuvAI/src/renderer/routes/projects.tsx` +- Modify: `adiuvAI/src/renderer/routes/projects.$projectId.tsx` (if it exists; otherwise project detail in `components/projects/ProjectDetail.tsx`) +- Modify: `adiuvAI/src/renderer/routes/notes.$noteId.tsx` + +For each route, do: + +- [ ] **Step 1: Import** + +```tsx +import { AdiuvaTriggerButton } from '@/components/ai/AdiuvaTriggerButton'; +import { useContextualScope } from '@/hooks/useContextualScope'; +``` + +- [ ] **Step 2: Call `useContextualScope` near the top of the component** + +For `timeline.tsx`: + +```tsx +useContextualScope({ page: 'timeline' }); +``` + +For `tasks.tsx`: + +```tsx +useContextualScope({ page: 'tasks' }); +``` + +For `projects.tsx` (list view): + +```tsx +useContextualScope({ page: 'projects-list' }); +``` + +For a project detail page (read project + counts from existing queries): + +```tsx +useContextualScope({ + page: 'project', + entityType: 'project', + entityId: project.id, + entityName: project.name, + counts: { + tasks: tasks.length, + notes: notes.length, + milestones: milestones.length, + }, +}); +``` + +For `notes.$noteId.tsx`: + +```tsx +useContextualScope({ + page: 'note', + entityType: 'note', + entityId: note.id, + entityName: note.title, + projectId: note.projectId ?? null, + charCount: (note.content ?? '').length, +}); +``` + +- [ ] **Step 3: Render `` in each page header area** + +Each page currently has its own header strip (project title etc.). Add the trigger anchored to the right side. If a page has no header strip, wrap its top-level content in `
`. Pattern: + +```tsx +
+

{title}

+ +
+``` + +- [ ] **Step 4: Smoke** + +Per page: open it; sidebar trigger is visible top-right. Click it; sidebar opens; type a message; expect either successful streaming (if backend has the new frames + tool stubs) or an explicit error message (until M5 wires tools). The scope payload reaches the backend — verify by inspecting the WS frames in main-process logs. + +- [ ] **Step 5: Commit (one per page, or one batch if no behavior diverges)** + +```bash +cd adiuvAI +git add src/renderer/routes/timeline.tsx src/renderer/routes/tasks.tsx src/renderer/routes/projects.tsx src/renderer/routes/projects.\$projectId.tsx src/renderer/routes/notes.\$noteId.tsx +git commit -m "feat(contextual): trigger + scope hook on Timeline/Tasks/Projects/Notes + +Each page publishes its scope on render and renders the +AdiuvaTriggerButton in its header." +``` + +## Task M4.7: Wire main-process bridge — `sendContextualRequest` + `sendContextualScopeUpdate` + +**Files:** +- Modify: `adiuvAI/src/main/api/backend-client.ts` +- Modify: `adiuvAI/src/main/ai/orchestrator.ts` +- Modify: `adiuvAI/src/main/router/index.ts` (the `ai.chat` procedure) +- Modify: `adiuvAI/src/preload/` (electronAI exposure) + +- [ ] **Step 1: Add `sendContextualRequest` and `sendContextualScopeUpdate` to `backend-client.ts`** + +Open `adiuvAI/src/main/api/backend-client.ts`. Locate `sendFloatingRequest`. Add adjacent: + +```ts +async sendContextualRequest(args: { + sessionId: string; + message: string; + scope: unknown; + history: { role: string; content: string }[]; + onFrame: (frame: unknown) => void; +}) { + await this.send({ type: 'contextual_request', ...args }); + // existing on-frame dispatch already routes to args.onFrame by sessionId +} + +async sendContextualScopeUpdate(args: { sessionId: string; scope: unknown }) { + await this.send({ type: 'contextual_scope_update', ...args }); +} +``` + +(Mirror the existing structure of `sendFloatingRequest` — DO NOT improvise; copy its pattern exactly.) + +- [ ] **Step 2: Route `mode: 'contextual'` from the `ai.chat` tRPC procedure** + +In the existing `ai.chat` procedure (find it in `adiuvAI/src/main/router/`), branch on input `mode`: + +```ts +if (input.mode === 'contextual') { + await orchestrator.delegateContextual({ + sessionId: input.sessionId, + requestId: input.requestId, + message: input.message, + scope: input.scope, + history: input.conversationHistory, + }); + return { ok: true }; +} +``` + +Add to the procedure's Zod input schema: + +```ts +mode: z.enum(['floating', 'contextual']).optional(), +scope: z.unknown().optional(), +``` + +- [ ] **Step 3: Add `delegateContextual` to orchestrator** + +In `adiuvAI/src/main/ai/orchestrator.ts`, add a method that mirrors the floating delegation but calls `backendClient.sendContextualRequest`. Frames flow back through the existing `'ai:stream'` IPC channel; ensure the new contextual frames are forwarded. + +- [ ] **Step 4: Expose `sendContextualScopeUpdate` on `window.electronAI`** + +In `adiuvAI/src/preload/`, find the `contextBridge.exposeInMainWorld('electronAI', { ... })` block. Add: + +```ts +sendContextualScopeUpdate: (args: { sessionId: string; scope: unknown }) => + ipcRenderer.invoke('ai:contextual-scope-update', args), +``` + +In main process (`ipc.ts` or wherever `ai:` IPC is handled), register: + +```ts +ipcMain.handle('ai:contextual-scope-update', async (_e, args) => { + await backendClient.sendContextualScopeUpdate(args); +}); +``` + +- [ ] **Step 5: Smoke** + +Open a page with sidebar open, navigate to another page; check main-process console — `contextual_scope_update` frame is logged. Send a message; `contextual_request` is logged. + +- [ ] **Step 6: Commit** + +```bash +cd adiuvAI +git add src/main/api/backend-client.ts src/main/ai/orchestrator.ts src/main/router/ src/preload/ src/main/ipc.ts +git commit -m "feat(contextual): main process bridge for contextual chat + +ai.chat tRPC mutation routes mode='contextual' through the +orchestrator to backend-client. New ai:contextual-scope-update IPC +handler exposes scope updates to the renderer." +``` + +## Task M4.8: Bump submodules + +```bash +cd /c/Users/PC-Roby/Documents/_adiuvai_workspace +git add adiuvAI api +git commit -m "chore: bump submodules — contextual sidebar M4 (frontend shell)" +``` + +--- + +# Milestone M5 — Tools wiring + +Wires `get_page_details` and confirms entity-create tools are reachable for the contextual channel. + +## Task M5.1: Verify which entity-create tools already exist + +- [ ] **Step 1: Grep** + +```bash +cd api +grep -nE "^\s*(def|@tool|name=)" app/core/deep_agent.py | grep -iE "(create_task|create_note|create_timeline|update_task|get_page_details)" || true +``` + +Expected: at least `create_task`, `update_task`, `create_note` are present. If `create_timeline_event` is missing, remove it from `run_contextual_stream`'s tool list and from the prompt-mention area (no action required if it's already there). + +- [ ] **Step 2: Document findings inline in the plan execution log** (no commit) + +## Task M5.2: Implement `get_page_details` tool + +**Files:** +- Modify: `api/app/core/deep_agent.py` (add the Tool definition) +- Modify: `adiuvAI/src/main/api/drizzle-executor.ts` (add the dispatch case) + +- [ ] **Step 1: Backend Tool stub** + +In `deep_agent.py`, define `get_page_details_tool` matching the pattern of existing tools (e.g. `create_task_tool`). The tool's `func` simply returns the tool-call payload — the actual data fetching happens client-side in drizzle-executor (consistent with how `create_task` works: the backend's tool function emits a JSON op, the client executes it). Mirror existing tools exactly. + +```python +get_page_details_tool = Tool( + name="get_page_details", + description=( + "Fetch full details for the entity currently in view. " + "Pass entityType ('project'|'task'|'note'|'tasks_all'|'projects_all'|'timeline_all') " + "and entityId for entity views. List variants ignore entityId." + ), + func=lambda **kwargs: {"op": "get_page_details", "args": kwargs}, +) +``` + +- [ ] **Step 2: Replace M3.3's placeholder reference** + +If M3.3 left a stub or a comment about replacing the tool, replace it now so `run_contextual_stream` references the real `get_page_details_tool`. + +- [ ] **Step 3: Client dispatch** + +In `adiuvAI/src/main/api/drizzle-executor.ts`, add a case to the dispatch switch: + +```ts +case 'get_page_details': { + const { entityType, entityId } = args; + switch (entityType) { + case 'project': + return fetchProjectSnapshot(db, entityId); + case 'task': + return fetchTaskSnapshot(db, entityId); + case 'note': + return fetchNoteSnapshot(db, entityId); + case 'tasks_all': + return fetchTasksAll(db, args.filters); + case 'projects_all': + return fetchProjectsAll(db); + case 'timeline_all': + return fetchTimelineAll(db, args.filters); + default: + throw new Error(`get_page_details: unknown entityType ${entityType}`); + } +} +``` + +Implement the helper functions in the same file: + +```ts +function fetchProjectSnapshot(db: Database, projectId: string) { + const project = db.select().from(projects).where(eq(projects.id, projectId)).get(); + const projectTasks = db.select().from(tasks).where(eq(tasks.projectId, projectId)).all(); + const projectNotes = db + .select({ id: notes.id, title: notes.title, summary: notes.aiSummary }) + .from(notes).where(eq(notes.projectId, projectId)).all(); + const events = db.select().from(timelineEvents).where(eq(timelineEvents.projectId, projectId)).all(); + return { project, tasks: projectTasks, notes: projectNotes, milestones: events.filter((e) => e.type === 'milestone') }; +} + +function fetchTaskSnapshot(db: Database, taskId: string) { + const task = db.select().from(tasks).where(eq(tasks.id, taskId)).get(); + const project = task?.projectId + ? db.select().from(projects).where(eq(projects.id, task.projectId)).get() + : null; + const comments = db.select().from(taskComments).where(eq(taskComments.taskId, taskId)).all(); + return { task, project, comments }; +} + +function fetchNoteSnapshot(db: Database, noteId: string) { + const note = db.select().from(notes).where(eq(notes.id, noteId)).get(); + return { note }; +} + +function fetchTasksAll(db: Database, _filters?: unknown) { + // For now, ignore filters; M7 polish can apply renderer-side filters here. + return { tasks: db.select().from(tasks).all() }; +} + +function fetchProjectsAll(db: Database) { + return { projects: db.select().from(projects).all() }; +} + +function fetchTimelineAll(db: Database, _filters?: unknown) { + return { events: db.select().from(timelineEvents).all() }; +} +``` + +- [ ] **Step 4: Smoke** + +Start app on a project page, open sidebar, ask "What tasks does this project have?". The agent should emit a `get_page_details` tool call (visible in main-process logs); drizzle-executor returns the snapshot; agent replies with the list. + +- [ ] **Step 5: Commit** + +```bash +cd api +git add app/core/deep_agent.py +git commit -m "feat(contextual): get_page_details tool + +Tool emits a JSON op consumed by the Electron drizzle-executor. +Supports project/task/note entity snapshots and tasks_all/ +projects_all/timeline_all list variants." + +cd ../adiuvAI +git add src/main/api/drizzle-executor.ts +git commit -m "feat(contextual): drizzle-executor dispatch for get_page_details + +Per-entity and per-list-view snapshot fetchers wired to local +SQLite. List-view filter handling deferred to M7 polish." +``` + +## Task M5.3: Bump submodules + +```bash +cd /c/Users/PC-Roby/Documents/_adiuvai_workspace +git add adiuvAI api +git commit -m "chore: bump submodules — contextual M5 (tools wiring)" +``` + +--- + +# Milestone M6 — Deprecation sweep + +Delete every floating-only code path. After M6, only the contextual flow remains. + +## Task M6.1: Frontend — remove `FloatingChat`, `FloatingChatContext`, `useDoubleClickAI` + +**Files:** +- Delete: `adiuvAI/src/renderer/components/ai/FloatingChat.tsx` +- Delete: `adiuvAI/src/renderer/context/FloatingChatContext.tsx` +- Delete: `adiuvAI/src/renderer/hooks/useDoubleClickAI.ts` +- Modify: `adiuvAI/src/renderer/components/layout/AppShell.tsx` + +- [ ] **Step 1: Delete the three files** + +```bash +cd adiuvAI +git rm src/renderer/components/ai/FloatingChat.tsx src/renderer/context/FloatingChatContext.tsx src/renderer/hooks/useDoubleClickAI.ts +``` + +- [ ] **Step 2: Remove `FloatingChatProvider` wrap and `useDoubleClickAI()` call from `AppShell.tsx`** + +Open `AppShell.tsx`. Delete the import lines for `FloatingChatProvider` and `useDoubleClickAI`. Remove the JSX wrapper and any hook call. + +- [ ] **Step 3: Build to surface any remaining imports** + +```bash +source ~/.nvm/nvm.sh && npx tsc --noEmit +``` + +Fix any remaining broken imports (likely from components that did `import { useFloatingChat } from '@/context/FloatingChatContext'`). Delete those usages. + +- [ ] **Step 4: Commit** + +```bash +cd adiuvAI +git add -A +git commit -m "refactor(contextual): delete FloatingChat, FloatingChatContext, useDoubleClickAI + +Replaced by ContextualChatProvider + AdiuvaTriggerButton. Pre-1.0 +clean removal — no deprecation period." +``` + +## Task M6.2: Strip every `data-ai-section` attribute + +**Files:** +- Modify: `adiuvAI/src/renderer/routes/tasks.tsx` +- Modify: `adiuvAI/src/renderer/components/timeline/TimelineGanttView.tsx` +- Modify: `adiuvAI/src/renderer/components/projects/ProjectDetail.tsx` +- Modify: `adiuvAI/src/renderer/routes/notes.$noteId.tsx` +- Any other matches found by grep + +- [ ] **Step 1: Locate every occurrence** + +```bash +cd adiuvAI +grep -rn 'data-ai-section' src/renderer/ || true +``` + +- [ ] **Step 2: Remove every occurrence** + +Edit each file and delete the `data-ai-section="..."` attribute. Keep the rest of the element intact. + +- [ ] **Step 3: Verify clean** + +```bash +cd adiuvAI && grep -rn 'data-ai-section' src/renderer/ ; echo "exit=$?" +``` + +Expected: no output, `exit=1` (grep finds nothing). + +- [ ] **Step 4: Commit** + +```bash +cd adiuvAI +git add -A +git commit -m "refactor(contextual): strip all data-ai-section attributes + +Section-anchoring is obsolete now that there is no floating chat. +The contextual sidebar uses scope payload, not DOM attributes." +``` + +## Task M6.3: Remove `'floating'` from `useAIChat` + `ChatInputBox` + +**Files:** +- Modify: `adiuvAI/src/renderer/hooks/useAIChat.ts` +- Modify: `adiuvAI/src/renderer/components/ai/ChatInputBox.tsx` + +- [ ] **Step 1: Trim `useAIChat`** + +Remove: +- `FloatingDomainSignal` type and its export. +- `'floating'` from `UIChatContext.type` union. +- `scope` field on `UIChatContext`. +- The `getContextCacheKey` fallback that returns `'floating'`. +- The `isFloating` branch in `handleSend` that adds `mode: 'floating'` and `scope`. +- The `floating_domain` case in the stream-event switch. +- `onDomainSignal` option + ref. +- `TABLE_TO_ENTITY` + `parseMutationsToEntityTags` (only floating consumed this). + +After cleanup, `getContextCacheKey` becomes: + +```ts +function getContextCacheKey(ctx: UIChatContext): string { + if (ctx.type === 'global') return 'global'; + return `project:${ctx.projectId ?? ''}`; +} +``` + +- [ ] **Step 2: ChatInputBox — drop any `'floating'` literal** + +Grep: + +```bash +grep -n "floating" adiuvAI/src/renderer/components/ai/ChatInputBox.tsx || true +``` + +Remove any branch keyed on `'floating'`. + +- [ ] **Step 3: Type check** + +```bash +cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit +``` + +Fix any callers that still pass `type: 'floating'`. They should be deleted (e.g. inside `FloatingChat.tsx` — already gone). + +- [ ] **Step 4: Commit** + +```bash +cd adiuvAI +git add -A +git commit -m "refactor(contextual): drop 'floating' branch from useAIChat and ChatInputBox + +UIChatContext is now 'global' | 'project' only. Floating domain +signal and entity-tag parser removed." +``` + +## Task M6.4: Main process — drop `sendFloatingRequest` + +**Files:** +- Modify: `adiuvAI/src/main/api/backend-client.ts` +- Modify: `adiuvAI/src/main/ai/orchestrator.ts` +- Modify: `adiuvAI/src/main/router/` (ai.chat input schema and dispatch) + +- [ ] **Step 1: Delete `sendFloatingRequest` from `backend-client.ts`** + +- [ ] **Step 2: Delete the floating delegation in `orchestrator.ts`** + +- [ ] **Step 3: In `ai.chat` Zod input, drop `'floating'` from the `mode` enum** + +```ts +mode: z.enum(['contextual']).optional(), +``` + +- [ ] **Step 4: Build** + +```bash +cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit +``` + +- [ ] **Step 5: Commit** + +```bash +cd adiuvAI +git add -A +git commit -m "refactor(contextual): main process drops sendFloatingRequest and floating mode + +ai.chat tRPC procedure now only accepts mode='contextual' (or +no mode for home). Orchestrator delegation table loses the +floating branch." +``` + +## Task M6.5: Backend — drop `_handle_floating_request`, `run_floating_stream`, `_FLOATING_SYSTEM_PROMPT` + +**Files:** +- Modify: `api/app/api/routes/device_ws.py` +- Modify: `api/app/core/deep_agent.py` + +- [ ] **Step 1: Delete `_handle_floating_request` and its dispatch branch** + +In `device_ws.py`, remove the `_handle_floating_request` function and the `elif payload["type"] == "floating_request":` branch. + +- [ ] **Step 2: Delete `run_floating_stream` and `_FLOATING_SYSTEM_PROMPT`** + +In `deep_agent.py`, remove both. + +- [ ] **Step 3: Sweep imports** + +```bash +cd api +grep -rn "floating" app/ tests/ || true +``` + +Delete any remaining import or reference. (Floating tool name strings inside a renamed test are OK — those tests will be deleted in M6.7.) + +- [ ] **Step 4: Tests** + +```bash +cd api && pytest -x +``` + +All non-floating tests pass. Floating tests fail (deleted in next step). + +- [ ] **Step 5: Commit** + +```bash +cd api +git add -A +git commit -m "refactor(contextual): drop floating WS frame, runner, and prompt fallback + +contextual_request + contextual_scope_update are the only WS +flows for ad-hoc contextual chat. Floating system prompt +constant removed; Langfuse 'floating_system' is deleted in a +separate step." +``` + +## Task M6.6: Delete or rewrite floating-specific tests + +**Files:** +- Delete: `api/tests/test_*floating*.py` (whatever exists) + +- [ ] **Step 1: List** + +```bash +cd api && ls tests/ | grep -i floating || true +``` + +- [ ] **Step 2: Delete each** + +```bash +cd api && git rm tests/test_floating*.py +``` + +- [ ] **Step 3: Run suite** + +```bash +cd api && pytest +``` + +Expected: all pass. + +- [ ] **Step 4: Commit** + +```bash +cd api +git add -A +git commit -m "test(contextual): remove floating-specific tests + +Replaced by tests/test_contextual_*.py." +``` + +## Task M6.7: Delete Langfuse `floating_system` prompt + +- [ ] **Step 1: Use /langfuse** + +In a fresh Claude session, run `/langfuse` and ask it to delete the prompt named `floating_system`. Verify via list. Until this is done, the prompt still exists but no code references it; deleting is a hygiene step. + +- [ ] **Step 2: Note completion in plan execution log** + +No commit (config-only). + +## Task M6.8: Sweep electron-store + localStorage `floating.*` keys + +- [ ] **Step 1: Grep** + +```bash +cd adiuvAI +grep -rn "floating" src/main/store.ts src/renderer/ || true +``` + +- [ ] **Step 2: Remove any `floating.*` keys** + +There may be electron-store keys for floating draft cache; remove them. + +- [ ] **Step 3: Commit** + +```bash +cd adiuvAI +git add -A +git commit -m "chore(contextual): purge residual 'floating' keys from main store and renderer" +``` + +## Task M6.9: Bump submodules + +```bash +cd /c/Users/PC-Roby/Documents/_adiuvai_workspace +git add adiuvAI api +git commit -m "chore: bump submodules — contextual M6 (deprecation sweep)" +``` + +--- + +# Milestone M7 — Polish + +## Task M7.1: Confirm width persistence across restart + +- [ ] **Step 1:** Open sidebar, drag handle to ~45%, quit app, reopen. Verify size persists. +- [ ] **Step 2:** If not persisting, check `localStorage` write happens inside the `onResize` callback set in `AppShell` (M4.5). +- [ ] **Step 3:** No commit if no fix needed. + +## Task M7.2: Light-mode elevated CSS variant + +- [ ] **Step 1:** In `globals.css`, add `.dark .adiuva-btn { ... }` override and ensure the base rule works for light mode (dusty lavender border, lighter card surface). +- [ ] **Step 2:** Commit: + +```bash +cd adiuvAI +git add src/renderer/globals.css +git commit -m "style(contextual): light-mode variant for elevated trigger button" +``` + +## Task M7.3: Empty-state copy on global-list pages + +- [ ] **Step 1:** When `messages.length === 0` and `scope.entityType == null`, render a soft hint inside `ChatSurface` in the contextual variant: "Ask anything about the {page} view — or @ to reference a project." +- [ ] **Step 2:** Commit. + +## Task M7.4: Bump submodule + workspace pointer + +```bash +cd /c/Users/PC-Roby/Documents/_adiuvai_workspace +git add adiuvAI +git commit -m "chore: bump adiuvAI submodule — contextual M7 (polish)" +``` + +--- + +# Self-review + +- ✅ **Spec coverage** — every section of the spec is referenced: + - 2.1–2.3 trigger button + sidebar layout → M4.3, M4.4, M4.5 + - 2.4 lifecycle (one-time mount, persistence, hidden on home) → M4.1, M4.5 + - 2.5 page-change behaviour → M4.2 + M3.4 + M4.7 + - 3 architecture → M2 + M4 + - 4 frontend files + scope payload + elevated CSS → M4.1–M4.6 + - 5 backend WS + prompt + buffer → M3 + - 6 data model + migration + sub-router → M1 + - 7 tools → M5 + - 8 deprecation removal → M6 + - 9 milestones → mirrored here verbatim + - 10 risks → addressed inline (resize during stream uses shadcn primitive; scope-update race handled by JSON-key diff in M4.1; reconnect retry uses existing infra) + +- ✅ **Placeholder scan** — no TBD/TODO/"implement later" left. The `get_page_details_tool` stub in M3.3 is explicitly named as a stub with a hard pointer to M5.2. + +- ✅ **Type consistency** — + - `ContextualScope` shape matches between renderer (`context/ContextualChatContext.tsx`) and backend (`app/schemas/contextual.py`): `page`, `entity_type`/`entityType`, `entity_id`/`entityId`, `entity_name`/`entityName`, `project_id`/`projectId`, `char_count`/`charCount`, `counts`, `filters`. + - WS frame names: `contextual_request`, `contextual_scope_update`, `contextual_scope_ack` — consistent in M3.4 + M4.7. + - tRPC mutation input `mode: 'contextual'` consistent across M2.1 (useChatStream), M4.1 (provider send), M4.7 (router schema), M6.4 (final enum trim). + - `aiChat` sub-router method names — `listSessions`, `getSession`, `createSession`, `appendMessage`, `deleteSession` — match across M1.3 and consumer M2.4/M4.1. + +If anything diverges during execution (e.g. drizzle-kit names the migration differently, the project's resizable shadcn wrapper uses `direction=` instead of `orientation=`), prefer matching the project's existing pattern over the plan and note the deviation in the execution log.