# 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.