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>
78 KiB
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 startfromadiuvAI/) — 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
floatingdraft cache, deleting Langfusefloating_systemprompt after cutover, etc.). User feedbackfeedback_clean_refactor_devapplies. - Submodules:
adiuvAI/andapi/are git submodules. Each commit happens inside the submodule directory. After each submodule commit, bump the pointer in the workspace repo with achore: bump <submodule> submodulecommit, matching the existing pattern (seegit log --onelinein 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/routersetup
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.tswith 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
aiChatinappRouter
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:
- The map over
messagesthat renders user vs. assistant bubbles withReactMarkdown. - The
ChatInputBoxusage. - 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 toappendMessage
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 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:
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_updateno-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 incomponents/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
useContextualScopenear 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(theai.chatprocedure) -
Modify:
adiuvAI/src/preload/(electronAI exposure) -
Step 1: Add
sendContextualRequestandsendContextualScopeUpdatetobackend-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 theai.chattRPC 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
delegateContextualto 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
sendContextualScopeUpdateonwindow.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
FloatingChatProviderwrap anduseDoubleClickAI()call fromAppShell.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:
FloatingDomainSignaltype and its export.'floating'fromUIChatContext.typeunion.scopefield onUIChatContext.- The
getContextCacheKeyfallback that returns'floating'. - The
isFloatingbranch inhandleSendthat addsmode: 'floating'andscope. - The
floating_domaincase in the stream-event switch. onDomainSignaloption + 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
sendFloatingRequestfrombackend-client.ts -
Step 2: Delete the floating delegation in
orchestrator.ts -
Step 3: In
ai.chatZod input, drop'floating'from themodeenum
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_requestand 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_streamand_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
localStoragewrite happens inside theonResizecallback set inAppShell(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 === 0andscope.entityType == null, render a soft hint insideChatSurfacein 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.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_toolstub in M3.3 is explicitly named as a stub with a hard pointer to M5.2. -
✅ Type consistency —
ContextualScopeshape 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). aiChatsub-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.