# 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> 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<typeof aiChatSessions>;
export type NewAiChatSession = InferInsertModel<typeof aiChatSessions>;
export type AiChatMessage = InferSelectModel<typeof aiChatMessages>;
export type NewAiChatMessage = InferInsertModel<typeof aiChatMessages>;
```

- [ ] **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_<auto_named>.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: '<uuid>' }`. 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<string, unknown> = {
      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<HTMLDivElement>(null);
  useEffect(() => {
    scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
  }, [messages.length, streamingContent]);

  return (
    <div className="flex flex-col h-full relative">
      <div
        ref={scrollRef}
        className="flex-1 overflow-auto px-4 flex flex-col gap-4"
        style={{ paddingBottom: bottomPadPx, paddingTop: variant === 'contextual' ? 64 : 16 }}
      >
        {messages.map((m) => (
          <div
            key={m.id}
            className={
              m.role === 'user'
                ? 'self-end max-w-[80%] rounded-2xl bg-muted px-3 py-2 text-sm'
                : 'max-w-[92%] text-sm leading-relaxed'
            }
          >
            {m.role === 'assistant' ? <ReactMarkdown>{m.content}</ReactMarkdown> : m.content}
          </div>
        ))}
        {isStreaming && (
          <div className="max-w-[92%] text-sm leading-relaxed opacity-90">
            <ReactMarkdown>{streamingContent || '…'}</ReactMarkdown>
          </div>
        )}
      </div>

      {aboveInputSlot}

      <div className={variant === 'contextual' ? 'absolute inset-x-0 bottom-0 px-4 pb-3 pointer-events-none' : 'px-4 pb-3'}>
        {variant === 'contextual' && (
          <div
            className="h-16 -mx-4 -mt-16 pointer-events-none"
            style={{
              background:
                'linear-gradient(to bottom, transparent 0%, rgba(20,20,22,.9) 60%, #141416 100%)',
            }}
          />
        )}
        <div className="pointer-events-auto">
          <ChatInputBox onSend={onSend} disabled={isStreaming} cacheKey={cacheKey} />
        </div>
      </div>
    </div>
  );
});
```

- [ ] **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 `<ChatSurface variant="home" …>`**

Edit `AIChatPanel.tsx`. Remove the message-loop JSX and the `ChatInputBox` block. Replace with:

```tsx
<ChatSurface
  messages={messages}
  streamingContent={streamingContent}
  isStreaming={isStreaming}
  onSend={handleSend}
  cacheKey={cacheKey}
  variant="home"
  aboveInputSlot={/* keep existing suggestion-chip / brief-carousel JSX here */}
/>
```

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<string | null>(() =>
  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<ContextualChatState | null>(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<string | null>(() => localStorage.getItem(SESSION_KEY));
  const [scope, setScopeState] = useState<ContextualScope | null>(null);
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  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<string>('');
  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<ContextualChatState>(() => ({
    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 <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}

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 (
    <svg width={size} height={size} viewBox="0 0 64 64" fill="none" aria-hidden="true">
      <g className="adiuva-needle-g">
        <path d="M32,4 L48,32 L16,32 Z" fill="#fbc881" />
        <path d="M16,32 L48,32 L32,60 Z" fill={southColor} />
        <circle cx="32" cy="32" r="2.5" fill="#fff" opacity="0.3" />
      </g>
    </svg>
  );
}
```

- [ ] **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 (
    <button
      type="button"
      onClick={toggle}
      title="Ask adiuvAI"
      aria-pressed={open}
      className="adiuva-btn"
    >
      <AdiuvaIcon />
    </button>
  );
}
```

- [ ] **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 (
    <div className="relative h-full bg-[#141416]">
      <div className="absolute top-3 right-3 flex gap-2 z-10">
        <button type="button" className="adiuva-btn sm" title="New chat" onClick={newChat}>
          <Plus size={14} />
        </button>
        <button type="button" className="adiuva-btn sm" title="Close sidebar" onClick={close}>
          <X size={14} />
        </button>
      </div>

      <ChatSurface
        messages={messages}
        streamingContent={streamingContent}
        isStreaming={isStreaming}
        onSend={send}
        cacheKey={`contextual:${sessionId ?? 'none'}`}
        variant="contextual"
      />
    </div>
  );
}
```

- [ ] **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 `<Outlet>` (and only `<Outlet>`) 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 <Outlet />; // single column, no sidebar
  }

  return (
    <ResizablePanelGroup orientation="horizontal" className="h-full w-full">
      <ResizablePanel defaultSize={100 - size} minSize={45}>
        <Outlet />
      </ResizablePanel>
      <ResizableHandle withHandle />
      <ResizablePanel defaultSize={size} minSize={24} maxSize={55} onResize={(s) => setSize(s)}>
        <ContextualSidebar />
      </ResizablePanel>
    </ResizablePanelGroup>
  );
}
```

Then in the existing `AppShell` JSX, replace the `<Outlet />` rendering with:

```tsx
<ContextualChatProvider>
  <MainArea />
</ContextualChatProvider>
```

Keep `<FloatingChatProvider>` 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 `<AdiuvaTriggerButton />` 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 `<div className="flex items-center justify-between p-3 border-b">`. Pattern:

```tsx
<div className="flex items-center justify-between px-4 py-3">
  <h1 className="text-sm font-medium">{title}</h1>
  <AdiuvaTriggerButton />
</div>
```

- [ ] **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.
