Files
workspace/docs/2026-05-14-contextual-sidebar-agent-plan.md
Roberto 9d00c5d06d docs: contextual sidebar implementation plan
Step-by-step plan across M1-M7 with bite-sized TDD-style tasks,
exact code, commit boundaries, and submodule pointer bumps.
Source: docs/2026-05-14-contextual-sidebar-agent-design.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:39:26 +02:00

78 KiB
Raw Permalink Blame History

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


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:

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

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`andCREATE 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:

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

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:

import { aiChatRouter } from './ai-chat';

export const appRouter = router({
  // ...existing sub-routers...
  aiChat: aiChatRouter,
});
  • Step 4: Type check
cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit

Expected: exit 0.

  • Step 5: Smoke test in dev — open Electron, run procedure from devtools
cd adiuvAI && source ~/.nvm/nvm.sh && npm start

In the renderer devtools console (when app is up):

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

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
cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit

Expected: exit 0.

  • Step 3: Commit
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:

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
cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit

Expected: exit 0.

  • Step 4: Commit
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:

<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
import { ChatSurface } from './ChatSurface';
  • Step 3: Type check + run app, verify home chat unchanged
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

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:

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:

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
cd adiuvAI && source ~/.nvm/nvm.sh && npm start

Send a message on home. Confirm via devtools console:

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

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:

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

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
cd api && pytest tests/test_contextual_scope.py -v

Expected: 3 passed.

  • Step 5: Commit
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:

_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
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
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:

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

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

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

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:

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

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
cd api && pytest tests/ -k "buffer or contextual" -v

Expected: all pass.

  • Step 4: Commit
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
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
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

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

sendContextualScopeUpdate?: (args: { sessionId: string; scope: unknown }) => void;

Re-run typecheck; expect 0.

  • Step 3: Commit
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

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

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:

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:

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

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

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:

<ContextualChatProvider>
  <MainArea />
</ContextualChatProvider>

Keep <FloatingChatProvider> for now (M6 removes it).

  • Step 2: Smoke
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:

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
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
import { AdiuvaTriggerButton } from '@/components/ai/AdiuvaTriggerButton';
import { useContextualScope } from '@/hooks/useContextualScope';
  • Step 2: Call useContextualScope near the top of the component

For timeline.tsx:

useContextualScope({ page: 'timeline' });

For tasks.tsx:

useContextualScope({ page: 'tasks' });

For projects.tsx (list view):

useContextualScope({ page: 'projects-list' });

For a project detail page (read project + counts from existing queries):

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:

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:

<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)
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:

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:

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:

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:

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:

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

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

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:

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:

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

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

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

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
cd adiuvAI && grep -rn 'data-ai-section' src/renderer/ ; echo "exit=$?"

Expected: no output, exit=1 (grep finds nothing).

  • Step 4: Commit
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:

function getContextCacheKey(ctx: UIChatContext): string {
  if (ctx.type === 'global') return 'global';
  return `project:${ctx.projectId ?? ''}`;
}
  • Step 2: ChatInputBox — drop any 'floating' literal

Grep:

grep -n "floating" adiuvAI/src/renderer/components/ai/ChatInputBox.tsx || true

Remove any branch keyed on 'floating'.

  • Step 3: Type check
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
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

mode: z.enum(['contextual']).optional(),
  • Step 4: Build
cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit
  • Step 5: Commit
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
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
cd api && pytest -x

All non-floating tests pass. Floating tests fail (deleted in next step).

  • Step 5: Commit
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

cd api && ls tests/ | grep -i floating || true
  • Step 2: Delete each
cd api && git rm tests/test_floating*.py
  • Step 3: Run suite
cd api && pytest

Expected: all pass.

  • Step 4: Commit
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
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
cd adiuvAI
git add -A
git commit -m "chore(contextual): purge residual 'floating' keys from main store and renderer"

Task M6.9: Bump submodules

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

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.12.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.1M4.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.