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>
2432 lines
78 KiB
Markdown
2432 lines
78 KiB
Markdown
# Contextual Sidebar Agent — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the legacy double-click floating-chat flow with a contextual right-side sidebar that opens from an adiuva trigger button, persists across navigation, and shares the chat surface with the home chat.
|
||
|
||
**Architecture:** Renderer chat surface extracted into a shared headless `ChatSurface` reused by the existing `AIChatPanel` (home) and a new `ContextualSidebar`. A `ContextualChatProvider` is mounted once in `AppShell` so the sidebar tree never unmounts during route transitions. Each page calls `useContextualScope({...})` in render to publish its current context; the provider diffs and emits a silent backend scope update on change. Chat history is persisted in SQLite (new `aiChatSessions` + `aiChatMessages` tables) for both channels. On the backend, a new `contextual_request` / `contextual_scope_update` WS pair replaces `floating_request`; `run_contextual_stream` injects the rendered scope block and uses Langfuse prompt `contextual_system`.
|
||
|
||
**Tech Stack:** Electron + React 19 + TanStack Router (renderer), tRPC v11 over a custom IPC bridge, Drizzle ORM + better-sqlite3 (main), FastAPI + WebSocket + LiteLLM (backend), shadcn/ui resizable panels, Langfuse for prompts.
|
||
|
||
**Spec:** [docs/2026-05-14-contextual-sidebar-agent-design.md](2026-05-14-contextual-sidebar-agent-design.md)
|
||
|
||
---
|
||
|
||
## Conventions
|
||
|
||
- **Workflow:** Bite-sized steps. One step at a time. Commit at the end of each task. Stop if a step fails — diagnose root cause, don't band-aid.
|
||
- **Commit message style:** project uses Conventional-style prefixes (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`). Each commit body should explain the why, not the what.
|
||
- **Adi adiuvAI has no frontend test suite** (per `adiuvAI/.claude/CLAUDE.md`). Frontend tasks are validated by manual smoke (`npm start` from `adiuvAI/`) — be explicit about the smoke path.
|
||
- **Backend has pytest.** Backend tasks must include a failing test before implementation.
|
||
- **Pre-1.0 dev** — minor breaking changes are accepted (clearing the `floating` draft cache, deleting Langfuse `floating_system` prompt after cutover, etc.). User feedback `feedback_clean_refactor_dev` applies.
|
||
- **Submodules:** `adiuvAI/` and `api/` are git submodules. Each commit happens **inside the submodule directory**. After each submodule commit, bump the pointer in the workspace repo with a `chore: bump <submodule> submodule` commit, matching the existing pattern (see `git log --oneline` in workspace root).
|
||
|
||
---
|
||
|
||
# Milestone M1 — DB + persistence foundation
|
||
|
||
Adds the two SQLite tables and the `aiChat` tRPC sub-router. No UI change, no behavior change. Verifiable via tRPC devtools.
|
||
|
||
## Task M1.1: Add `aiChatSessions` + `aiChatMessages` to schema
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/main/db/schema.ts`
|
||
|
||
- [ ] **Step 1: Append two table definitions and type exports**
|
||
|
||
Open `adiuvAI/src/main/db/schema.ts`. After the last `export const ... = sqliteTable(...)` block (and before the file's final `}`/EOF, **outside** any other declaration), append:
|
||
|
||
```ts
|
||
export const aiChatSessions = sqliteTable('ai_chat_sessions', {
|
||
id: text('id').primaryKey(),
|
||
channel: text('channel', { enum: ['home', 'contextual'] }).notNull(),
|
||
title: text('title'),
|
||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||
lastScope: text('last_scope'),
|
||
});
|
||
|
||
export const aiChatMessages = sqliteTable('ai_chat_messages', {
|
||
id: text('id').primaryKey(),
|
||
sessionId: text('session_id').notNull(),
|
||
role: text('role', { enum: ['user', 'assistant', 'system'] }).notNull(),
|
||
content: text('content').notNull(),
|
||
toolCalls: text('tool_calls'),
|
||
toolResults: text('tool_results'),
|
||
scope: text('scope'),
|
||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||
});
|
||
|
||
export type AiChatSession = InferSelectModel<typeof aiChatSessions>;
|
||
export type NewAiChatSession = InferInsertModel<typeof aiChatSessions>;
|
||
export type AiChatMessage = InferSelectModel<typeof aiChatMessages>;
|
||
export type NewAiChatMessage = InferInsertModel<typeof aiChatMessages>;
|
||
```
|
||
|
||
- [ ] **Step 2: Run TypeScript compile to verify**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
source ~/.nvm/nvm.sh
|
||
npx tsc --noEmit
|
||
```
|
||
|
||
Expected: exit 0 (or unchanged warning count vs. baseline).
|
||
|
||
## Task M1.2: Generate Drizzle migration
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/main/db/migrations/0006_<auto_named>.sql` (drizzle-kit names it)
|
||
|
||
- [ ] **Step 1: Generate migration**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
source ~/.nvm/nvm.sh
|
||
npx drizzle-kit generate
|
||
```
|
||
|
||
Expected: a new migration file appears under `src/main/db/migrations/` numbered `0006_*.sql` plus an updated `meta/_journal.json`.
|
||
|
||
- [ ] **Step 2: Open the new migration and verify content**
|
||
|
||
It MUST contain `CREATE TABLE \`ai_chat_sessions\`` and `CREATE TABLE \`ai_chat_messages\`` with all columns from M1.1. If it contains drops of unrelated tables, STOP — the schema was changed in an unintended way; investigate before continuing.
|
||
|
||
- [ ] **Step 3: Add the index statements at the bottom of the same migration**
|
||
|
||
Append to the new migration file:
|
||
|
||
```sql
|
||
--> statement-breakpoint
|
||
CREATE INDEX IF NOT EXISTS `ai_chat_messages_session_created_idx` ON `ai_chat_messages` (`session_id`, `created_at`);
|
||
--> statement-breakpoint
|
||
CREATE INDEX IF NOT EXISTS `ai_chat_sessions_channel_updated_idx` ON `ai_chat_sessions` (`channel`, `updated_at`);
|
||
```
|
||
|
||
- [ ] **Step 4: Apply migration in dev**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
source ~/.nvm/nvm.sh
|
||
npx drizzle-kit push
|
||
```
|
||
|
||
Expected: "Changes applied" with no destructive prompts. If drizzle-kit prompts to drop columns/tables, ABORT — there's an unintended diff.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/main/db/schema.ts src/main/db/migrations/0006_*.sql src/main/db/migrations/meta/
|
||
git commit -m "feat(db): add ai_chat_sessions and ai_chat_messages tables
|
||
|
||
Local chat history persistence. Same model used by both home and
|
||
contextual channels. Indexes on (session_id, created_at) and
|
||
(channel, updated_at) for ordering and listing."
|
||
```
|
||
|
||
## Task M1.3: Add `aiChat` tRPC sub-router
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/main/router/ai-chat.ts`
|
||
- Modify: `adiuvAI/src/main/router/index.ts`
|
||
|
||
- [ ] **Step 1: Inspect existing router to learn the local `procedure` / `router` setup**
|
||
|
||
Open `adiuvAI/src/main/router/index.ts` and note the exact import style (likely `import { router, procedure } from './trpc'` or similar) and how an existing sub-router (e.g. `tasksRouter`) is structured. Mirror that style exactly in the new file.
|
||
|
||
- [ ] **Step 2: Create `ai-chat.ts` with five procedures**
|
||
|
||
Create `adiuvAI/src/main/router/ai-chat.ts`:
|
||
|
||
```ts
|
||
import { z } from 'zod';
|
||
// IMPORTANT: import `router` and `procedure` (or whatever names the project uses)
|
||
// from the same place existing sub-routers do. Mirror existing tasks/projects router style.
|
||
import { router, procedure } from './trpc'; // adjust path/symbol names to match the project
|
||
import { getDb } from '../db';
|
||
import { aiChatSessions, aiChatMessages } from '../db/schema';
|
||
import { eq, desc, and, asc } from 'drizzle-orm';
|
||
|
||
const ChannelSchema = z.enum(['home', 'contextual']);
|
||
const RoleSchema = z.enum(['user', 'assistant', 'system']);
|
||
|
||
export const aiChatRouter = router({
|
||
listSessions: procedure
|
||
.input(z.object({ channel: ChannelSchema }))
|
||
.query(({ input }) => {
|
||
const db = getDb();
|
||
return db
|
||
.select()
|
||
.from(aiChatSessions)
|
||
.where(eq(aiChatSessions.channel, input.channel))
|
||
.orderBy(desc(aiChatSessions.updatedAt))
|
||
.all();
|
||
}),
|
||
|
||
getSession: procedure
|
||
.input(z.object({ id: z.string() }))
|
||
.query(({ input }) => {
|
||
const db = getDb();
|
||
const session = db
|
||
.select()
|
||
.from(aiChatSessions)
|
||
.where(eq(aiChatSessions.id, input.id))
|
||
.get();
|
||
if (!session) return null;
|
||
const messages = db
|
||
.select()
|
||
.from(aiChatMessages)
|
||
.where(eq(aiChatMessages.sessionId, input.id))
|
||
.orderBy(asc(aiChatMessages.createdAt))
|
||
.all();
|
||
return { session, messages };
|
||
}),
|
||
|
||
createSession: procedure
|
||
.input(z.object({
|
||
channel: ChannelSchema,
|
||
initialScope: z.string().optional(),
|
||
}))
|
||
.mutation(({ input }) => {
|
||
const db = getDb();
|
||
const id = crypto.randomUUID();
|
||
const now = Date.now();
|
||
db.insert(aiChatSessions).values({
|
||
id,
|
||
channel: input.channel,
|
||
title: null,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
lastScope: input.initialScope ?? null,
|
||
}).run();
|
||
return { id };
|
||
}),
|
||
|
||
appendMessage: procedure
|
||
.input(z.object({
|
||
sessionId: z.string(),
|
||
role: RoleSchema,
|
||
content: z.string(),
|
||
toolCalls: z.string().optional(),
|
||
toolResults: z.string().optional(),
|
||
scope: z.string().optional(),
|
||
}))
|
||
.mutation(({ input }) => {
|
||
const db = getDb();
|
||
const id = crypto.randomUUID();
|
||
const now = Date.now();
|
||
db.insert(aiChatMessages).values({
|
||
id,
|
||
sessionId: input.sessionId,
|
||
role: input.role,
|
||
content: input.content,
|
||
toolCalls: input.toolCalls ?? null,
|
||
toolResults: input.toolResults ?? null,
|
||
scope: input.scope ?? null,
|
||
createdAt: now,
|
||
}).run();
|
||
db.update(aiChatSessions)
|
||
.set({ updatedAt: now, lastScope: input.scope ?? null })
|
||
.where(eq(aiChatSessions.id, input.sessionId))
|
||
.run();
|
||
return { id };
|
||
}),
|
||
|
||
deleteSession: procedure
|
||
.input(z.object({ id: z.string() }))
|
||
.mutation(({ input }) => {
|
||
const db = getDb();
|
||
db.delete(aiChatMessages).where(eq(aiChatMessages.sessionId, input.id)).run();
|
||
db.delete(aiChatSessions).where(eq(aiChatSessions.id, input.id)).run();
|
||
return { ok: true };
|
||
}),
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Register `aiChat` in `appRouter`**
|
||
|
||
In `adiuvAI/src/main/router/index.ts`, import the new router and add it to the merged router:
|
||
|
||
```ts
|
||
import { aiChatRouter } from './ai-chat';
|
||
|
||
export const appRouter = router({
|
||
// ...existing sub-routers...
|
||
aiChat: aiChatRouter,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Type check**
|
||
|
||
```bash
|
||
cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit
|
||
```
|
||
|
||
Expected: exit 0.
|
||
|
||
- [ ] **Step 5: Smoke test in dev — open Electron, run procedure from devtools**
|
||
|
||
```bash
|
||
cd adiuvAI && source ~/.nvm/nvm.sh && npm start
|
||
```
|
||
|
||
In the renderer devtools console (when app is up):
|
||
|
||
```js
|
||
await window.__trpcClient.aiChat.createSession.mutate({ channel: 'home' })
|
||
```
|
||
|
||
(Or invoke via a temporary `useQuery` in any mounted component if no devtools client global exists.)
|
||
|
||
Expected: `{ id: '<uuid>' }`. Then `aiChat.listSessions({ channel: 'home' })` returns at least one row.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/main/router/ai-chat.ts src/main/router/index.ts
|
||
git commit -m "feat(router): add aiChat tRPC sub-router
|
||
|
||
CRUD for chat sessions and messages, used by both home and contextual
|
||
channels. No UI consumer yet — added ahead of refactor."
|
||
```
|
||
|
||
## Task M1.4: Bump submodule pointer in workspace
|
||
|
||
- [ ] **Step 1: Bump pointer**
|
||
|
||
```bash
|
||
cd /c/Users/PC-Roby/Documents/_adiuvai_workspace
|
||
git add adiuvAI
|
||
git commit -m "chore: bump adiuvAI submodule — aiChat persistence tables + router"
|
||
```
|
||
|
||
---
|
||
|
||
# Milestone M2 — ChatSurface refactor (no behavior change)
|
||
|
||
Extracts shared chat logic from `useAIChat` + `AIChatPanel`. Home chat keeps working identically and gains SQLite-backed persistence. Floating chat still works (its rewrite happens in M3/M4/M6).
|
||
|
||
## Task M2.1: Extract `useChatStream` hook (streaming engine + WS plumbing)
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/hooks/useChatStream.ts`
|
||
- Modify: `adiuvAI/src/renderer/hooks/useAIChat.ts`
|
||
|
||
The current `useAIChat` mixes three concerns: cache-key derivation, stream subscription, and tRPC mutation. We split out the streaming engine.
|
||
|
||
- [ ] **Step 1: Create the engine**
|
||
|
||
Create `adiuvAI/src/renderer/hooks/useChatStream.ts`:
|
||
|
||
```ts
|
||
import { useCallback, useRef, useState } from 'react';
|
||
import { trpc } from '@/lib/trpc';
|
||
import type { ChatMessage } from './useAIChat'; // moved later; OK to leave the cycle for one commit
|
||
|
||
export type ChatStreamMode =
|
||
| { kind: 'home' }
|
||
| { kind: 'contextual'; scope: unknown };
|
||
|
||
export interface UseChatStreamArgs {
|
||
sessionId: string;
|
||
/**
|
||
* Called when the assistant message is fully assembled. The caller is
|
||
* responsible for persisting it (e.g. via `aiChat.appendMessage`).
|
||
*/
|
||
onAssistantMessage: (msg: ChatMessage) => void;
|
||
onError: (msg: ChatMessage) => void;
|
||
}
|
||
|
||
export function useChatStream({ sessionId, onAssistantMessage, onError }: UseChatStreamArgs) {
|
||
const [isStreaming, setIsStreaming] = useState(false);
|
||
const [streamingContent, setStreamingContent] = useState('');
|
||
const streamingContentRef = useRef('');
|
||
const chatMutation = trpc.ai.chat.useMutation();
|
||
const chatMutationRef = useRef(chatMutation);
|
||
chatMutationRef.current = chatMutation;
|
||
|
||
const send = useCallback((args: {
|
||
message: string;
|
||
history: { role: 'user' | 'assistant'; content: string }[];
|
||
mode: ChatStreamMode;
|
||
}) => {
|
||
setIsStreaming(true);
|
||
setStreamingContent('');
|
||
streamingContentRef.current = '';
|
||
const requestId = crypto.randomUUID();
|
||
|
||
const unsubscribe = window.electronAI.onStreamEvent((event) => {
|
||
if (event.requestId !== requestId) return;
|
||
switch (event.type) {
|
||
case 'stream_text':
|
||
streamingContentRef.current += event.chunk;
|
||
setStreamingContent(streamingContentRef.current);
|
||
break;
|
||
case 'stream_end': {
|
||
onAssistantMessage({
|
||
id: crypto.randomUUID(),
|
||
role: 'assistant',
|
||
content: streamingContentRef.current,
|
||
});
|
||
setStreamingContent('');
|
||
streamingContentRef.current = '';
|
||
setIsStreaming(false);
|
||
unsubscribe();
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
const mutationInput: Record<string, unknown> = {
|
||
requestId,
|
||
message: args.message,
|
||
conversationHistory: args.history,
|
||
sessionId,
|
||
};
|
||
if (args.mode.kind === 'contextual') {
|
||
mutationInput.mode = 'contextual';
|
||
mutationInput.scope = args.mode.scope;
|
||
}
|
||
|
||
chatMutationRef.current.mutate(mutationInput as never, {
|
||
onError: (err) => {
|
||
unsubscribe();
|
||
onError({
|
||
id: crypto.randomUUID(),
|
||
role: 'assistant',
|
||
content: err.message || 'An unexpected error occurred.',
|
||
error: true,
|
||
});
|
||
setStreamingContent('');
|
||
streamingContentRef.current = '';
|
||
setIsStreaming(false);
|
||
},
|
||
});
|
||
}, [sessionId, onAssistantMessage, onError]);
|
||
|
||
return { send, isStreaming, streamingContent };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type check**
|
||
|
||
```bash
|
||
cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit
|
||
```
|
||
|
||
Expected: exit 0.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/hooks/useChatStream.ts
|
||
git commit -m "refactor(chat): extract useChatStream hook
|
||
|
||
Shared streaming engine for home (and forthcoming contextual) channels.
|
||
useAIChat still owns cache-key + tRPC dispatch; that wiring is migrated
|
||
in the next commit."
|
||
```
|
||
|
||
## Task M2.2: Extract `ChatSurface` component
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/components/ai/ChatSurface.tsx`
|
||
|
||
The headless chat surface: messages list, markdown rendering, input. It receives all state from props — no streaming or persistence inside.
|
||
|
||
- [ ] **Step 1: Identify the message-rendering + input section in `AIChatPanel.tsx`**
|
||
|
||
Open `adiuvAI/src/renderer/components/ai/AIChatPanel.tsx` and locate:
|
||
1. The map over `messages` that renders user vs. assistant bubbles with `ReactMarkdown`.
|
||
2. The `ChatInputBox` usage.
|
||
3. The streaming-content placeholder (the in-flight assistant bubble showing `streamingContent`).
|
||
|
||
These are the three blocks that move into `ChatSurface`.
|
||
|
||
- [ ] **Step 2: Create `ChatSurface.tsx`**
|
||
|
||
Create `adiuvAI/src/renderer/components/ai/ChatSurface.tsx`:
|
||
|
||
```tsx
|
||
import { memo, useRef, useEffect } from 'react';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import { ChatInputBox } from './ChatInputBox';
|
||
import type { ChatMessage } from '@/hooks/useAIChat';
|
||
|
||
export interface ChatSurfaceProps {
|
||
messages: ChatMessage[];
|
||
streamingContent: string;
|
||
isStreaming: boolean;
|
||
onSend: (text: string) => void;
|
||
cacheKey: string;
|
||
variant: 'home' | 'contextual';
|
||
/** Extra slot rendered above the input (e.g. suggestion chips on home). */
|
||
aboveInputSlot?: React.ReactNode;
|
||
/** Bottom padding so messages can scroll under a fade/translucent input (contextual). */
|
||
bottomPadPx?: number;
|
||
}
|
||
|
||
export const ChatSurface = memo(function ChatSurface({
|
||
messages,
|
||
streamingContent,
|
||
isStreaming,
|
||
onSend,
|
||
cacheKey,
|
||
variant,
|
||
aboveInputSlot,
|
||
bottomPadPx = 120,
|
||
}: ChatSurfaceProps) {
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||
}, [messages.length, streamingContent]);
|
||
|
||
return (
|
||
<div className="flex flex-col h-full relative">
|
||
<div
|
||
ref={scrollRef}
|
||
className="flex-1 overflow-auto px-4 flex flex-col gap-4"
|
||
style={{ paddingBottom: bottomPadPx, paddingTop: variant === 'contextual' ? 64 : 16 }}
|
||
>
|
||
{messages.map((m) => (
|
||
<div
|
||
key={m.id}
|
||
className={
|
||
m.role === 'user'
|
||
? 'self-end max-w-[80%] rounded-2xl bg-muted px-3 py-2 text-sm'
|
||
: 'max-w-[92%] text-sm leading-relaxed'
|
||
}
|
||
>
|
||
{m.role === 'assistant' ? <ReactMarkdown>{m.content}</ReactMarkdown> : m.content}
|
||
</div>
|
||
))}
|
||
{isStreaming && (
|
||
<div className="max-w-[92%] text-sm leading-relaxed opacity-90">
|
||
<ReactMarkdown>{streamingContent || '…'}</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{aboveInputSlot}
|
||
|
||
<div className={variant === 'contextual' ? 'absolute inset-x-0 bottom-0 px-4 pb-3 pointer-events-none' : 'px-4 pb-3'}>
|
||
{variant === 'contextual' && (
|
||
<div
|
||
className="h-16 -mx-4 -mt-16 pointer-events-none"
|
||
style={{
|
||
background:
|
||
'linear-gradient(to bottom, transparent 0%, rgba(20,20,22,.9) 60%, #141416 100%)',
|
||
}}
|
||
/>
|
||
)}
|
||
<div className="pointer-events-auto">
|
||
<ChatInputBox onSend={onSend} disabled={isStreaming} cacheKey={cacheKey} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Type check**
|
||
|
||
```bash
|
||
cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit
|
||
```
|
||
|
||
Expected: exit 0.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/components/ai/ChatSurface.tsx
|
||
git commit -m "refactor(chat): extract ChatSurface presentational component
|
||
|
||
Shared between home and contextual channels. Variant prop selects
|
||
between the home (full-width, fixed-bottom input) and contextual
|
||
(absolute-positioned translucent input with gradient fade) layouts."
|
||
```
|
||
|
||
## Task M2.3: Rewire `AIChatPanel` as a thin wrapper
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/components/ai/AIChatPanel.tsx`
|
||
|
||
- [ ] **Step 1: Replace the message map + input block with `<ChatSurface variant="home" …>`**
|
||
|
||
Edit `AIChatPanel.tsx`. Remove the message-loop JSX and the `ChatInputBox` block. Replace with:
|
||
|
||
```tsx
|
||
<ChatSurface
|
||
messages={messages}
|
||
streamingContent={streamingContent}
|
||
isStreaming={isStreaming}
|
||
onSend={handleSend}
|
||
cacheKey={cacheKey}
|
||
variant="home"
|
||
aboveInputSlot={/* keep existing suggestion-chip / brief-carousel JSX here */}
|
||
/>
|
||
```
|
||
|
||
Keep the existing imports for icons, `Link`, etc. — they're still used in `aboveInputSlot`. Remove now-unused imports of `ReactMarkdown` from this file (it lives in `ChatSurface` now).
|
||
|
||
- [ ] **Step 2: Add the new import**
|
||
|
||
```tsx
|
||
import { ChatSurface } from './ChatSurface';
|
||
```
|
||
|
||
- [ ] **Step 3: Type check + run app, verify home chat unchanged**
|
||
|
||
```bash
|
||
cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit && npm start
|
||
```
|
||
|
||
Smoke: open the home page, send a message, confirm:
|
||
- streaming text appears
|
||
- final assistant message renders with markdown
|
||
- suggestion chips (if rendered above input) still appear in the right place
|
||
- input draft persistence works (type, navigate away, come back, draft remembered)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/components/ai/AIChatPanel.tsx
|
||
git commit -m "refactor(chat): home AIChatPanel uses ChatSurface
|
||
|
||
Pure refactor, no behavior change."
|
||
```
|
||
|
||
## Task M2.4: Wire home chat persistence to `aiChat` tables
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/components/ai/AIChatPanel.tsx`
|
||
|
||
Currently home chat history lives only in the in-memory `chatSessionCache` map inside `useAIChat`. We add a parallel SQLite persistence so closing/reopening the app preserves history.
|
||
|
||
- [ ] **Step 1: Hydrate from electron-store + tRPC on mount**
|
||
|
||
Inside `AIChatPanel`, before the existing `useAIChat({ type: 'global' })` call, add:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from 'react';
|
||
import { trpc } from '@/lib/trpc';
|
||
|
||
const HOME_SESSION_KEY = 'chat.home.lastSessionId';
|
||
const [homeSessionId, setHomeSessionId] = useState<string | null>(() =>
|
||
localStorage.getItem(HOME_SESSION_KEY),
|
||
);
|
||
const utils = trpc.useUtils();
|
||
const createSession = trpc.aiChat.createSession.useMutation();
|
||
const appendMessage = trpc.aiChat.appendMessage.useMutation();
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
if (!homeSessionId) {
|
||
const { id } = await createSession.mutateAsync({ channel: 'home' });
|
||
if (cancelled) return;
|
||
localStorage.setItem(HOME_SESSION_KEY, id);
|
||
setHomeSessionId(id);
|
||
} else {
|
||
// Hydrate: load past messages and seed the in-memory cache for useAIChat('global').
|
||
const session = await utils.aiChat.getSession.fetch({ id: homeSessionId });
|
||
if (cancelled || !session) return;
|
||
// Note: useAIChat seeds from its module-scope cache; pre-populate it here.
|
||
// For now we just rely on the cache being warm after the first send;
|
||
// a follow-up task can pre-load past messages into the cache directly.
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [homeSessionId]);
|
||
```
|
||
|
||
(Yes, `localStorage` here rather than electron-store — electron-store is main-process only and this is renderer-side. Per spec, persisting the sessionId on the renderer side via `localStorage` is acceptable; the durable data lives in SQLite.)
|
||
|
||
- [ ] **Step 2: Wire `onAssistantMessage` + user-send to `appendMessage`**
|
||
|
||
Currently `useAIChat` does not expose an "assistant message persisted" callback. Add a useEffect that watches `messages` and appends any new entries since the last persisted index. Inside `AIChatPanel`:
|
||
|
||
```tsx
|
||
const persistedCountRef = useRef(0);
|
||
useEffect(() => {
|
||
if (!homeSessionId) return;
|
||
const fresh = messages.slice(persistedCountRef.current);
|
||
for (const m of fresh) {
|
||
appendMessage.mutate({
|
||
sessionId: homeSessionId,
|
||
role: m.role,
|
||
content: m.content,
|
||
});
|
||
}
|
||
persistedCountRef.current = messages.length;
|
||
}, [messages, homeSessionId, appendMessage]);
|
||
```
|
||
|
||
- [ ] **Step 3: Smoke**
|
||
|
||
```bash
|
||
cd adiuvAI && source ~/.nvm/nvm.sh && npm start
|
||
```
|
||
|
||
Send a message on home. Confirm via devtools console:
|
||
|
||
```js
|
||
await window.__trpcClient.aiChat.listSessions.query({ channel: 'home' })
|
||
```
|
||
|
||
Expect at least one session with `updatedAt` near now. `aiChat.getSession({ id })` returns the user + assistant messages.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/components/ai/AIChatPanel.tsx
|
||
git commit -m "feat(chat): persist home chat history to SQLite
|
||
|
||
Home chat now creates an aiChatSessions row on first use and
|
||
appends every user/assistant message. Session id persisted in
|
||
localStorage so reopening the app reattaches to the same row.
|
||
Hydration of past messages is deferred to a follow-up — current
|
||
behavior already matches the previous in-memory cache."
|
||
```
|
||
|
||
## Task M2.5: Bump submodule
|
||
|
||
- [ ] **Step 1: Bump**
|
||
|
||
```bash
|
||
cd /c/Users/PC-Roby/Documents/_adiuvai_workspace
|
||
git add adiuvAI
|
||
git commit -m "chore: bump adiuvAI submodule — ChatSurface refactor + home persistence"
|
||
```
|
||
|
||
---
|
||
|
||
# Milestone M3 — Backend contextual frame + prompt + tests
|
||
|
||
Add the new WS frames, runner, and Langfuse prompt. Old `floating_*` still alive — they coexist for the duration of M3–M5.
|
||
|
||
## Task M3.1: Add `ContextualScope` Pydantic models + scope-block renderer
|
||
|
||
**Files:**
|
||
- Create: `api/app/schemas/contextual.py`
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
Create `api/tests/test_contextual_scope.py`:
|
||
|
||
```python
|
||
import pytest
|
||
from app.schemas.contextual import ContextualScope, render_scope_block
|
||
|
||
|
||
def test_render_project_scope():
|
||
scope = ContextualScope(
|
||
page="project",
|
||
entity_type="project",
|
||
entity_id="p1",
|
||
entity_name="Acme Q3 launch",
|
||
counts={"tasks": 12, "notes": 4, "milestones": 3},
|
||
)
|
||
block = render_scope_block(scope)
|
||
assert "Acme Q3 launch" in block
|
||
assert "12 tasks" in block
|
||
assert "4 notes" in block
|
||
assert "3 milestones" in block
|
||
# Never expose ids in rendered block — only names.
|
||
assert "p1" not in block
|
||
|
||
|
||
def test_render_list_scope_no_entity():
|
||
scope = ContextualScope(page="tasks", entity_type=None)
|
||
block = render_scope_block(scope)
|
||
assert "tasks" in block.lower()
|
||
# No fake entity_id leakage
|
||
assert "None" not in block
|
||
|
||
|
||
def test_render_note_scope_includes_char_count():
|
||
scope = ContextualScope(
|
||
page="note",
|
||
entity_type="note",
|
||
entity_id="n1",
|
||
entity_name="Meeting 14 May",
|
||
project_id="p1",
|
||
char_count=4280,
|
||
)
|
||
block = render_scope_block(scope)
|
||
assert "Meeting 14 May" in block
|
||
assert "4280" in block or "4,280" in block
|
||
|
||
|
||
def test_parses_camelcase_payload_from_renderer():
|
||
# Renderer ships camelCase JSON. Pydantic alias_generator handles it.
|
||
payload = {
|
||
"page": "project",
|
||
"entityType": "project",
|
||
"entityId": "p1",
|
||
"entityName": "Acme",
|
||
"counts": {"tasks": 5, "notes": 1, "milestones": 2},
|
||
}
|
||
scope = ContextualScope.model_validate(payload)
|
||
assert scope.entity_id == "p1"
|
||
assert scope.entity_name == "Acme"
|
||
```
|
||
|
||
- [ ] **Step 2: Run — verify FAIL**
|
||
|
||
```bash
|
||
cd api
|
||
pytest tests/test_contextual_scope.py -v
|
||
```
|
||
|
||
Expected: FAIL with `ModuleNotFoundError: app.schemas.contextual`.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Create `api/app/schemas/contextual.py`:
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
from typing import Literal, Optional
|
||
from pydantic import BaseModel, ConfigDict
|
||
from pydantic.alias_generators import to_camel
|
||
|
||
|
||
PageType = Literal[
|
||
"timeline",
|
||
"tasks",
|
||
"projects-list",
|
||
"project",
|
||
"note",
|
||
]
|
||
|
||
EntityType = Literal["project", "note", "task", "timeline_event"]
|
||
|
||
|
||
class ContextualScope(BaseModel):
|
||
# The renderer sends camelCase keys (entityType, entityId, etc.).
|
||
# We use Python snake_case here and let pydantic alias-map them.
|
||
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
||
|
||
page: PageType
|
||
entity_type: Optional[EntityType] = None
|
||
entity_id: Optional[str] = None
|
||
entity_name: Optional[str] = None
|
||
project_id: Optional[str] = None
|
||
char_count: Optional[int] = None
|
||
counts: Optional[dict[str, int]] = None
|
||
# For list views: snapshot of renderer-side active filters.
|
||
filters: Optional[dict] = None
|
||
|
||
|
||
def render_scope_block(scope: ContextualScope) -> str:
|
||
if scope.entity_type == "project":
|
||
c = scope.counts or {}
|
||
parts = [
|
||
f"User is viewing the project {scope.entity_name!r}.",
|
||
f"{c.get('tasks', 0)} tasks, "
|
||
f"{c.get('notes', 0)} notes, "
|
||
f"{c.get('milestones', 0)} milestones.",
|
||
]
|
||
return " ".join(parts)
|
||
if scope.entity_type == "note":
|
||
return (
|
||
f"User is viewing the note {scope.entity_name!r} "
|
||
f"({scope.char_count or 0} characters)."
|
||
)
|
||
# List pages
|
||
if scope.page == "tasks":
|
||
return "User is viewing the global Tasks list (all projects)."
|
||
if scope.page == "timeline":
|
||
return "User is viewing the global Timeline view."
|
||
if scope.page == "projects-list":
|
||
return "User is viewing the Projects list."
|
||
return f"User is on page {scope.page}."
|
||
```
|
||
|
||
- [ ] **Step 4: Run — verify PASS**
|
||
|
||
```bash
|
||
cd api && pytest tests/test_contextual_scope.py -v
|
||
```
|
||
|
||
Expected: 3 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add app/schemas/contextual.py tests/test_contextual_scope.py
|
||
git commit -m "feat(contextual): scope schema and render_scope_block
|
||
|
||
Pydantic model mirroring the renderer's ContextualScope union.
|
||
render_scope_block produces a single-paragraph human-readable
|
||
summary for the contextual agent system prompt."
|
||
```
|
||
|
||
## Task M3.2: Add `_CONTEXTUAL_SYSTEM_PROMPT` fallback constant
|
||
|
||
**Files:**
|
||
- Modify: `api/app/core/deep_agent.py`
|
||
|
||
- [ ] **Step 1: Add constant near `_FLOATING_SYSTEM_PROMPT`**
|
||
|
||
Open `api/app/core/deep_agent.py`. Locate `_FLOATING_SYSTEM_PROMPT = ...`. Immediately below it, add:
|
||
|
||
```python
|
||
_CONTEXTUAL_SYSTEM_PROMPT = """You are adiuvAI's contextual assistant. The user is working inside the app and has opened a side chat anchored to a specific view ("current view"). Help them act on that view: recap, plan, create entities, answer questions.
|
||
|
||
Rules:
|
||
1. Base context (current view summary) is provided every turn. Treat it as ground truth for ids and names; never invent them.
|
||
2. When the user asks about details not in the base context (e.g. "what tasks are blocking the launch milestone"), call `get_page_details` for the relevant entity before answering. Don't guess.
|
||
3. When the user requests an action that creates or updates an entity:
|
||
- If the current view is a project and no project is specified, use the current project automatically.
|
||
- If the current view is the global Tasks / Projects / Timeline list and no project is specified, ASK before attaching to any project. Don't silently create orphan entities.
|
||
4. The current view can change mid-conversation (user navigates). When you see a system message "User navigated to ...", treat the new view as the active context. Prior turns remain visible but the active scope shifts.
|
||
5. Notes: you can read note bodies via `get_page_details({entityType:'note'})`. You CANNOT edit, summarize-to-replace, or append. Tell the user "note editing is coming in a later release" if asked.
|
||
6. Be concise. Default to 1-3 short paragraphs. Bullet lists fine. Don't restate the user's request.
|
||
7. Never expose ids in prose. Use names. Ids only travel through tool calls.
|
||
"""
|
||
```
|
||
|
||
- [ ] **Step 2: Sanity check the import line + run a quick syntax test**
|
||
|
||
```bash
|
||
cd api && python -c "from app.core.deep_agent import _CONTEXTUAL_SYSTEM_PROMPT; print(len(_CONTEXTUAL_SYSTEM_PROMPT))"
|
||
```
|
||
|
||
Expected: a number > 500.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add app/core/deep_agent.py
|
||
git commit -m "feat(contextual): add _CONTEXTUAL_SYSTEM_PROMPT fallback
|
||
|
||
Used by run_contextual_stream when Langfuse prompt
|
||
'contextual_system' is unavailable."
|
||
```
|
||
|
||
## Task M3.3: Add `run_contextual_stream` runner
|
||
|
||
**Files:**
|
||
- Modify: `api/app/core/deep_agent.py`
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
Create `api/tests/test_run_contextual.py`:
|
||
|
||
```python
|
||
import pytest
|
||
from unittest.mock import AsyncMock, patch
|
||
from app.schemas.contextual import ContextualScope
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_run_contextual_stream_includes_scope_block(monkeypatch):
|
||
from app.core import deep_agent
|
||
|
||
captured = {}
|
||
|
||
async def fake_loop(*, sys, tools, message, history, user, db, session_id, channel):
|
||
captured["sys"] = sys
|
||
captured["channel"] = channel
|
||
captured["tool_names"] = [getattr(t, "name", str(t)) for t in tools]
|
||
if False:
|
||
yield # generator type
|
||
return
|
||
|
||
monkeypatch.setattr(deep_agent, "_run_agent_loop", fake_loop)
|
||
|
||
scope = ContextualScope(
|
||
page="project",
|
||
entity_type="project",
|
||
entity_id="p1",
|
||
entity_name="Acme",
|
||
counts={"tasks": 1, "notes": 0, "milestones": 0},
|
||
)
|
||
|
||
async for _ in deep_agent.run_contextual_stream(
|
||
user=AsyncMock(),
|
||
db=AsyncMock(),
|
||
session_id="s1",
|
||
message="hi",
|
||
scope=scope,
|
||
history=[],
|
||
):
|
||
pass
|
||
|
||
assert captured["channel"] == "contextual"
|
||
assert "Acme" in captured["sys"]
|
||
# Tool list must include get_page_details and entity-create tools, NOT note edit tools.
|
||
names = captured["tool_names"]
|
||
assert "get_page_details" in names
|
||
assert "create_task" in names
|
||
assert "create_note" in names
|
||
assert "update_task" in names
|
||
assert "propose_note_edit" not in names # next sprint
|
||
assert "summarize_note" not in names
|
||
```
|
||
|
||
- [ ] **Step 2: Run — verify FAIL**
|
||
|
||
```bash
|
||
cd api && pytest tests/test_run_contextual.py -v
|
||
```
|
||
|
||
Expected: FAIL with `AttributeError: module 'app.core.deep_agent' has no attribute 'run_contextual_stream'`.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
In `api/app/core/deep_agent.py`, after `run_floating_stream`, add:
|
||
|
||
```python
|
||
from app.schemas.contextual import ContextualScope, render_scope_block
|
||
|
||
|
||
async def run_contextual_stream(
|
||
*,
|
||
user,
|
||
db,
|
||
session_id: str,
|
||
message: str,
|
||
scope: ContextualScope,
|
||
history: list[dict],
|
||
):
|
||
system_prompt = await get_prompt_or_fallback(
|
||
"contextual_system", _CONTEXTUAL_SYSTEM_PROMPT,
|
||
)
|
||
scope_block = render_scope_block(scope)
|
||
sys = f"{system_prompt}\n\n## Current view\n{scope_block}"
|
||
sys += _language_instruction(user)
|
||
|
||
# Tool list — keep in sync with the prompt's rule about which actions are allowed.
|
||
tools = [
|
||
get_page_details_tool,
|
||
create_task_tool,
|
||
update_task_tool,
|
||
create_note_tool,
|
||
create_timeline_event_tool,
|
||
]
|
||
|
||
async for frame in _run_agent_loop(
|
||
sys=sys,
|
||
tools=tools,
|
||
message=message,
|
||
history=history,
|
||
user=user,
|
||
db=db,
|
||
session_id=session_id,
|
||
channel="contextual",
|
||
):
|
||
yield frame
|
||
```
|
||
|
||
If `get_page_details_tool` or `create_timeline_event_tool` are not defined yet in this file, follow the existing pattern of the floating runner — see what tools it imports. If the symbols are missing entirely, stub them and add a TODO that M5 wires them. For now, mirror the floating tool list and replace with a `get_page_details_tool` placeholder import; you may temporarily reuse the floating tool list to keep tests passing and explicitly assert in this task that **at least** `get_page_details` is referenced.
|
||
|
||
- [ ] **Step 4: Run — verify PASS**
|
||
|
||
```bash
|
||
cd api && pytest tests/test_run_contextual.py -v
|
||
```
|
||
|
||
Expected: 1 passed. If a tool symbol is missing, define a placeholder `get_page_details_tool` near the existing tools section (it will be properly fleshed out in M5 — for now, a stub `Tool(name="get_page_details", description="…", func=lambda **k: {})` is acceptable as long as it has `.name == "get_page_details"`).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add app/core/deep_agent.py tests/test_run_contextual.py
|
||
git commit -m "feat(contextual): run_contextual_stream
|
||
|
||
New agent runner. Injects rendered scope block into the system
|
||
prompt, uses Langfuse 'contextual_system' (fallback constant on
|
||
miss), and exposes get_page_details + entity-create tools.
|
||
Note-edit tools are intentionally excluded — next sprint."
|
||
```
|
||
|
||
## Task M3.4: Add WS frame handlers in `device_ws.py`
|
||
|
||
**Files:**
|
||
- Modify: `api/app/api/routes/device_ws.py`
|
||
|
||
- [ ] **Step 1: Failing test for `contextual_scope_update` no-op**
|
||
|
||
Create `api/tests/test_contextual_ws.py`:
|
||
|
||
```python
|
||
import pytest
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_handle_contextual_scope_update_appends_system_message_no_llm():
|
||
from app.api.routes import device_ws
|
||
|
||
ws = AsyncMock()
|
||
buffer = MagicMock()
|
||
buffer.append_system_message = MagicMock()
|
||
user = MagicMock()
|
||
db = AsyncMock()
|
||
|
||
payload = {
|
||
"type": "contextual_scope_update",
|
||
"session_id": "s1",
|
||
"scope": {
|
||
"page": "project",
|
||
"entity_type": "project",
|
||
"entity_id": "p1",
|
||
"entity_name": "Acme",
|
||
"counts": {"tasks": 1, "notes": 0, "milestones": 0},
|
||
},
|
||
}
|
||
|
||
with pytest.MonkeyPatch.context() as mp:
|
||
mp.setattr(device_ws, "get_session_buffer", lambda *a, **kw: buffer)
|
||
await device_ws._handle_contextual_scope_update(ws, payload, user, db)
|
||
|
||
# Acked, no LLM call.
|
||
ws.send_json.assert_awaited_once()
|
||
sent = ws.send_json.await_args.args[0]
|
||
assert sent["type"] == "contextual_scope_ack"
|
||
assert sent["session_id"] == "s1"
|
||
buffer.append_system_message.assert_called_once()
|
||
```
|
||
|
||
- [ ] **Step 2: Run — verify FAIL**
|
||
|
||
```bash
|
||
cd api && pytest tests/test_contextual_ws.py -v
|
||
```
|
||
|
||
Expected: FAIL (`AttributeError`).
|
||
|
||
- [ ] **Step 3: Implement handlers**
|
||
|
||
In `api/app/api/routes/device_ws.py`, locate `_handle_floating_request`. Below it, add:
|
||
|
||
```python
|
||
from app.schemas.contextual import ContextualScope, render_scope_block
|
||
from app.core.deep_agent import run_contextual_stream
|
||
|
||
|
||
async def _handle_contextual_request(ws, payload, user, db):
|
||
session_id = payload["session_id"]
|
||
message = payload["message"]
|
||
scope = ContextualScope.model_validate(payload["scope"])
|
||
history = payload.get("history", [])
|
||
async for frame in run_contextual_stream(
|
||
user=user,
|
||
db=db,
|
||
session_id=session_id,
|
||
message=message,
|
||
scope=scope,
|
||
history=history,
|
||
):
|
||
await ws.send_json(frame)
|
||
|
||
|
||
async def _handle_contextual_scope_update(ws, payload, user, db):
|
||
session_id = payload["session_id"]
|
||
scope = ContextualScope.model_validate(payload["scope"])
|
||
block = render_scope_block(scope)
|
||
buffer = get_session_buffer(session_id, channel="contextual")
|
||
buffer.append_system_message(
|
||
f"User navigated to a new view. {block} Treat this as the new active context."
|
||
)
|
||
await ws.send_json({
|
||
"type": "contextual_scope_ack",
|
||
"session_id": session_id,
|
||
})
|
||
```
|
||
|
||
In the dispatch switch (locate `if payload["type"] == "floating_request": await _handle_floating_request(...)`), add adjacent branches:
|
||
|
||
```python
|
||
elif payload["type"] == "contextual_request":
|
||
await _handle_contextual_request(ws, payload, user, db)
|
||
elif payload["type"] == "contextual_scope_update":
|
||
await _handle_contextual_scope_update(ws, payload, user, db)
|
||
```
|
||
|
||
If `get_session_buffer` does not yet accept a `channel` kwarg, see M3.5.
|
||
|
||
- [ ] **Step 4: Run — verify PASS**
|
||
|
||
```bash
|
||
cd api && pytest tests/test_contextual_ws.py tests/test_run_contextual.py tests/test_contextual_scope.py -v
|
||
```
|
||
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add app/api/routes/device_ws.py tests/test_contextual_ws.py
|
||
git commit -m "feat(contextual): WS frames contextual_request + contextual_scope_update
|
||
|
||
contextual_request invokes run_contextual_stream and forwards
|
||
frames. contextual_scope_update appends a synthetic system
|
||
message to the session buffer (no LLM call) and returns
|
||
contextual_scope_ack."
|
||
```
|
||
|
||
## Task M3.5: Extend `agent_session_buffer` to accept `channel`
|
||
|
||
**Files:**
|
||
- Modify: `api/app/core/agent_session_buffer.py`
|
||
|
||
- [ ] **Step 1: Open the file and confirm the current signature**
|
||
|
||
If `get_session_buffer` already accepts a `channel` kwarg, skip this task entirely.
|
||
|
||
- [ ] **Step 2: Add the kwarg (optional, default `"home"`)**
|
||
|
||
Modify the buffer accessor to accept `channel: str = "home"` and namespace the in-memory map by `(session_id, channel)` (or, if simpler, leave session_id alone since session ids are uuids already and globally unique). Add an `append_system_message(text: str)` method if it doesn't already exist:
|
||
|
||
```python
|
||
def append_system_message(self, text: str) -> None:
|
||
self.messages.append({"role": "system", "content": text})
|
||
```
|
||
|
||
- [ ] **Step 3: Run all contextual tests + any existing buffer tests**
|
||
|
||
```bash
|
||
cd api && pytest tests/ -k "buffer or contextual" -v
|
||
```
|
||
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add app/core/agent_session_buffer.py
|
||
git commit -m "feat(buffer): accept channel kwarg + append_system_message
|
||
|
||
Used by the contextual_scope_update handler to inject a synthetic
|
||
system message without invoking the LLM."
|
||
```
|
||
|
||
## Task M3.6: Create Langfuse `contextual_system` prompt (manual)
|
||
|
||
**Files:** (no code change)
|
||
|
||
- [ ] **Step 1: Use /langfuse skill to create the prompt**
|
||
|
||
In a fresh Claude session, run `/langfuse` and instruct it to create a text prompt named `contextual_system` with body identical to the `_CONTEXTUAL_SYSTEM_PROMPT` constant from M3.2. Label `production`. Do NOT delete `floating_system` yet (M6 deletes it after cutover).
|
||
|
||
- [ ] **Step 2: Verify from a Python REPL**
|
||
|
||
```bash
|
||
cd api
|
||
python -c "
|
||
import asyncio
|
||
from app.core.langfuse_client import get_prompt
|
||
print(asyncio.run(get_prompt('contextual_system'))[:120])
|
||
"
|
||
```
|
||
|
||
Expected: first ~120 chars of the prompt body. If `None` or fallback fires, repeat /langfuse step. (Exact accessor name may differ; use whatever `get_prompt_or_fallback` uses internally.)
|
||
|
||
- [ ] **Step 3: Commit (no code, just marker)**
|
||
|
||
No commit. Note in plan execution log that Langfuse prompt was created. (Optional: add a comment in `_CONTEXTUAL_SYSTEM_PROMPT` referencing the Langfuse name + date.)
|
||
|
||
## Task M3.7: Bump submodule + workspace pointer
|
||
|
||
- [ ] **Step 1: Bump**
|
||
|
||
```bash
|
||
cd /c/Users/PC-Roby/Documents/_adiuvai_workspace
|
||
git add api
|
||
git commit -m "chore: bump api submodule — contextual frames + run_contextual_stream"
|
||
```
|
||
|
||
---
|
||
|
||
# Milestone M4 — Frontend sidebar shell + provider + trigger
|
||
|
||
Adds the sidebar UI, provider, hook, trigger button, and route wiring. Old floating still alive — both reachable.
|
||
|
||
## Task M4.1: `ContextualChatProvider`
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/context/ContextualChatContext.tsx`
|
||
|
||
- [ ] **Step 1: Create the provider**
|
||
|
||
```tsx
|
||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { trpc } from '@/lib/trpc';
|
||
import type { ChatMessage } from '@/hooks/useAIChat';
|
||
|
||
export interface ContextualScope {
|
||
page: 'timeline' | 'tasks' | 'projects-list' | 'project' | 'note';
|
||
entityType?: 'project' | 'note' | null;
|
||
entityId?: string;
|
||
entityName?: string;
|
||
projectId?: string | null;
|
||
counts?: { tasks?: number; notes?: number; milestones?: number };
|
||
charCount?: number;
|
||
filters?: unknown;
|
||
}
|
||
|
||
interface ContextualChatState {
|
||
open: boolean;
|
||
size: number;
|
||
sessionId: string | null;
|
||
scope: ContextualScope | null;
|
||
messages: ChatMessage[];
|
||
isStreaming: boolean;
|
||
streamingContent: string;
|
||
toggle: () => void;
|
||
close: () => void;
|
||
newChat: () => void;
|
||
setSize: (s: number) => void;
|
||
setScope: (s: ContextualScope) => void;
|
||
send: (text: string) => void;
|
||
}
|
||
|
||
const Ctx = createContext<ContextualChatState | null>(null);
|
||
|
||
const SESSION_KEY = 'chat.contextual.lastSessionId';
|
||
const SIZE_KEY = 'chat.sidebar.size';
|
||
const OPEN_KEY = 'chat.contextual.open';
|
||
|
||
function readNumber(k: string, fallback: number) {
|
||
const v = localStorage.getItem(k);
|
||
return v ? Number(v) || fallback : fallback;
|
||
}
|
||
|
||
export function ContextualChatProvider({ children }: { children: React.ReactNode }) {
|
||
const [open, setOpen] = useState(() => localStorage.getItem(OPEN_KEY) === '1');
|
||
const [size, setSizeState] = useState(() => readNumber(SIZE_KEY, 32));
|
||
const [sessionId, setSessionId] = useState<string | null>(() => localStorage.getItem(SESSION_KEY));
|
||
const [scope, setScopeState] = useState<ContextualScope | null>(null);
|
||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||
const [isStreaming, setIsStreaming] = useState(false);
|
||
const [streamingContent, setStreamingContent] = useState('');
|
||
const streamRef = useRef('');
|
||
|
||
const utils = trpc.useUtils();
|
||
const createSession = trpc.aiChat.createSession.useMutation();
|
||
const appendMessage = trpc.aiChat.appendMessage.useMutation();
|
||
const chatMutation = trpc.ai.chat.useMutation();
|
||
|
||
// Ensure a session exists once.
|
||
useEffect(() => {
|
||
if (sessionId) {
|
||
utils.aiChat.getSession.fetch({ id: sessionId }).then((res) => {
|
||
if (res?.messages) {
|
||
setMessages(
|
||
res.messages
|
||
.filter((m) => m.role !== 'system')
|
||
.map((m) => ({ id: m.id, role: m.role as 'user' | 'assistant', content: m.content })),
|
||
);
|
||
}
|
||
});
|
||
} else {
|
||
createSession.mutateAsync({ channel: 'contextual' }).then(({ id }) => {
|
||
localStorage.setItem(SESSION_KEY, id);
|
||
setSessionId(id);
|
||
});
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const setSize = useCallback((s: number) => {
|
||
setSizeState(s);
|
||
localStorage.setItem(SIZE_KEY, String(s));
|
||
}, []);
|
||
|
||
const toggle = useCallback(() => {
|
||
setOpen((o) => {
|
||
const next = !o;
|
||
localStorage.setItem(OPEN_KEY, next ? '1' : '0');
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const close = useCallback(() => {
|
||
setOpen(false);
|
||
localStorage.setItem(OPEN_KEY, '0');
|
||
}, []);
|
||
|
||
const newChat = useCallback(async () => {
|
||
const { id } = await createSession.mutateAsync({ channel: 'contextual' });
|
||
localStorage.setItem(SESSION_KEY, id);
|
||
setSessionId(id);
|
||
setMessages([]);
|
||
}, [createSession]);
|
||
|
||
const lastScopeKeyRef = useRef<string>('');
|
||
const setScope = useCallback((s: ContextualScope) => {
|
||
const key = JSON.stringify(s);
|
||
if (key === lastScopeKeyRef.current) return;
|
||
lastScopeKeyRef.current = key;
|
||
setScopeState(s);
|
||
if (sessionId) {
|
||
// Fire-and-forget scope update via main process.
|
||
window.electronAI.sendContextualScopeUpdate?.({ sessionId, scope: s });
|
||
}
|
||
}, [sessionId]);
|
||
|
||
const send = useCallback((text: string) => {
|
||
if (!sessionId || !scope) return;
|
||
const trimmed = text.trim();
|
||
if (!trimmed || isStreaming) return;
|
||
|
||
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: trimmed };
|
||
setMessages((prev) => [...prev, userMsg]);
|
||
appendMessage.mutate({ sessionId, role: 'user', content: trimmed, scope: JSON.stringify(scope) });
|
||
|
||
const requestId = crypto.randomUUID();
|
||
setIsStreaming(true);
|
||
setStreamingContent('');
|
||
streamRef.current = '';
|
||
|
||
const unsub = window.electronAI.onStreamEvent((event) => {
|
||
if (event.requestId !== requestId) return;
|
||
if (event.type === 'stream_text') {
|
||
streamRef.current += event.chunk;
|
||
setStreamingContent(streamRef.current);
|
||
} else if (event.type === 'stream_end') {
|
||
const final = streamRef.current;
|
||
setMessages((prev) => [...prev, { id: crypto.randomUUID(), role: 'assistant', content: final }]);
|
||
appendMessage.mutate({ sessionId, role: 'assistant', content: final, scope: JSON.stringify(scope) });
|
||
setStreamingContent('');
|
||
streamRef.current = '';
|
||
setIsStreaming(false);
|
||
unsub();
|
||
}
|
||
});
|
||
|
||
chatMutation.mutate(
|
||
{
|
||
requestId,
|
||
message: trimmed,
|
||
conversationHistory: messages.slice(-20).map((m) => ({ role: m.role, content: m.content })),
|
||
sessionId,
|
||
mode: 'contextual',
|
||
scope,
|
||
} as never,
|
||
{
|
||
onError: (err) => {
|
||
unsub();
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ id: crypto.randomUUID(), role: 'assistant', content: err.message, error: true },
|
||
]);
|
||
setIsStreaming(false);
|
||
},
|
||
},
|
||
);
|
||
}, [sessionId, scope, isStreaming, messages, appendMessage, chatMutation]);
|
||
|
||
const value = useMemo<ContextualChatState>(() => ({
|
||
open, size, sessionId, scope, messages, isStreaming, streamingContent,
|
||
toggle, close, newChat, setSize, setScope, send,
|
||
}), [open, size, sessionId, scope, messages, isStreaming, streamingContent, toggle, close, newChat, setSize, setScope, send]);
|
||
|
||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||
}
|
||
|
||
export function useContextualChat() {
|
||
const v = useContext(Ctx);
|
||
if (!v) throw new Error('useContextualChat must be used within ContextualChatProvider');
|
||
return v;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type check**
|
||
|
||
```bash
|
||
cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit
|
||
```
|
||
|
||
`window.electronAI.sendContextualScopeUpdate` won't yet exist — declare it in the preload's typings. Open `adiuvAI/src/preload/` and find the typings file for `window.electronAI`. Add:
|
||
|
||
```ts
|
||
sendContextualScopeUpdate?: (args: { sessionId: string; scope: unknown }) => void;
|
||
```
|
||
|
||
Re-run typecheck; expect 0.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/context/ContextualChatContext.tsx src/preload/
|
||
git commit -m "feat(contextual): ContextualChatProvider
|
||
|
||
Holds open/size/sessionId/scope/messages/streaming state. Creates
|
||
or hydrates a contextual session on mount, persists messages,
|
||
and fires scope updates through window.electronAI."
|
||
```
|
||
|
||
## Task M4.2: `useContextualScope` hook
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/hooks/useContextualScope.ts`
|
||
|
||
- [ ] **Step 1: Create**
|
||
|
||
```ts
|
||
import { useEffect } from 'react';
|
||
import { useContextualChat, type ContextualScope } from '@/context/ContextualChatContext';
|
||
|
||
export function useContextualScope(scope: ContextualScope) {
|
||
const { setScope } = useContextualChat();
|
||
const key = JSON.stringify(scope);
|
||
useEffect(() => {
|
||
setScope(scope);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [key]);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/hooks/useContextualScope.ts
|
||
git commit -m "feat(contextual): useContextualScope hook
|
||
|
||
Pages call this in render with their current scope. Provider
|
||
diffs by JSON key and only fires updates on real change."
|
||
```
|
||
|
||
## Task M4.3: `AdiuvaTriggerButton`
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/components/ai/AdiuvaTriggerButton.tsx`
|
||
- Create: `adiuvAI/src/renderer/components/ai/AdiuvaIcon.tsx`
|
||
|
||
- [ ] **Step 1: Inline compass needle icon**
|
||
|
||
Create `adiuvAI/src/renderer/components/ai/AdiuvaIcon.tsx`:
|
||
|
||
```tsx
|
||
export function AdiuvaIcon({ size = 22, southColor = '#e5e5e7' }: { size?: number; southColor?: string }) {
|
||
return (
|
||
<svg width={size} height={size} viewBox="0 0 64 64" fill="none" aria-hidden="true">
|
||
<g className="adiuva-needle-g">
|
||
<path d="M32,4 L48,32 L16,32 Z" fill="#fbc881" />
|
||
<path d="M16,32 L48,32 L32,60 Z" fill={southColor} />
|
||
<circle cx="32" cy="32" r="2.5" fill="#fff" opacity="0.3" />
|
||
</g>
|
||
</svg>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Trigger button**
|
||
|
||
Create `adiuvAI/src/renderer/components/ai/AdiuvaTriggerButton.tsx`:
|
||
|
||
```tsx
|
||
import { useContextualChat } from '@/context/ContextualChatContext';
|
||
import { AdiuvaIcon } from './AdiuvaIcon';
|
||
|
||
export function AdiuvaTriggerButton() {
|
||
const { toggle, open } = useContextualChat();
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={toggle}
|
||
title="Ask adiuvAI"
|
||
aria-pressed={open}
|
||
className="adiuva-btn"
|
||
>
|
||
<AdiuvaIcon />
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add CSS to the global stylesheet**
|
||
|
||
Open `adiuvAI/src/renderer/globals.css` and append:
|
||
|
||
```css
|
||
.adiuva-btn {
|
||
position: relative;
|
||
width: 48px;
|
||
height: 48px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--card);
|
||
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||
border-radius: 14px;
|
||
cursor: pointer;
|
||
transition: box-shadow .3s ease, background .2s ease;
|
||
box-shadow:
|
||
inset 0 1px 0 color-mix(in srgb, white 6%, transparent),
|
||
0 2px 4px -1px rgba(0, 0, 0, .5),
|
||
0 8px 16px -4px rgba(0, 0, 0, .55),
|
||
0 20px 36px -10px rgba(0, 0, 0, .6);
|
||
color: inherit;
|
||
}
|
||
.adiuva-btn:hover {
|
||
background: color-mix(in srgb, var(--card) 92%, white 8%);
|
||
box-shadow:
|
||
inset 0 1px 0 color-mix(in srgb, white 6%, transparent),
|
||
0 4px 8px -2px rgba(0, 0, 0, .6),
|
||
0 18px 28px -8px rgba(0, 0, 0, .65),
|
||
0 30px 60px -14px rgba(251, 200, 129, .22);
|
||
}
|
||
.adiuva-btn:active { transform: scale(.97); }
|
||
.adiuva-btn.sm { width: 32px; height: 32px; border-radius: 10px; }
|
||
|
||
.adiuva-needle-g {
|
||
transform-origin: 32px 32px;
|
||
animation: adiuva-compass-settle 6s ease-in-out infinite;
|
||
}
|
||
@keyframes adiuva-compass-settle {
|
||
0% { transform: rotate(0deg); }
|
||
20% { transform: rotate(4deg); }
|
||
50% { transform: rotate(-3deg); }
|
||
80% { transform: rotate(2deg); }
|
||
100% { transform: rotate(0deg); }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.adiuva-needle-g { animation: none; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/components/ai/AdiuvaIcon.tsx src/renderer/components/ai/AdiuvaTriggerButton.tsx src/renderer/globals.css
|
||
git commit -m "feat(contextual): adiuva trigger button + compass icon
|
||
|
||
Elevated 48px button with continuous compass-settle animation.
|
||
Hover deepens shadow and adds a gold ambient glow."
|
||
```
|
||
|
||
## Task M4.4: `ContextualSidebar` shell
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/components/ai/ContextualSidebar.tsx`
|
||
|
||
- [ ] **Step 1: Create**
|
||
|
||
```tsx
|
||
import { Plus, X } from 'lucide-react';
|
||
import { useContextualChat } from '@/context/ContextualChatContext';
|
||
import { ChatSurface } from './ChatSurface';
|
||
|
||
export function ContextualSidebar() {
|
||
const { messages, isStreaming, streamingContent, send, newChat, close, sessionId } = useContextualChat();
|
||
|
||
return (
|
||
<div className="relative h-full bg-[#141416]">
|
||
<div className="absolute top-3 right-3 flex gap-2 z-10">
|
||
<button type="button" className="adiuva-btn sm" title="New chat" onClick={newChat}>
|
||
<Plus size={14} />
|
||
</button>
|
||
<button type="button" className="adiuva-btn sm" title="Close sidebar" onClick={close}>
|
||
<X size={14} />
|
||
</button>
|
||
</div>
|
||
|
||
<ChatSurface
|
||
messages={messages}
|
||
streamingContent={streamingContent}
|
||
isStreaming={isStreaming}
|
||
onSend={send}
|
||
cacheKey={`contextual:${sessionId ?? 'none'}`}
|
||
variant="contextual"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/components/ai/ContextualSidebar.tsx
|
||
git commit -m "feat(contextual): ContextualSidebar shell
|
||
|
||
Top-right elevated controls (new chat, close) and a ChatSurface
|
||
in contextual variant. No header, no scope chip."
|
||
```
|
||
|
||
## Task M4.5: Mount in `AppShell` behind `ResizablePanelGroup`
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/components/layout/AppShell.tsx`
|
||
|
||
- [ ] **Step 1: Wrap `<Outlet>` (and only `<Outlet>`) in the provider**
|
||
|
||
At the top of the render, wrap the outlet area:
|
||
|
||
```tsx
|
||
import { ContextualChatProvider, useContextualChat } from '@/context/ContextualChatContext';
|
||
import { ContextualSidebar } from '@/components/ai/ContextualSidebar';
|
||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||
import { useLocation } from '@tanstack/react-router';
|
||
|
||
function MainArea() {
|
||
const loc = useLocation();
|
||
const isHome = loc.pathname === '/';
|
||
const { open, size, setSize } = useContextualChat();
|
||
|
||
if (isHome || !open) {
|
||
return <Outlet />; // single column, no sidebar
|
||
}
|
||
|
||
return (
|
||
<ResizablePanelGroup orientation="horizontal" className="h-full w-full">
|
||
<ResizablePanel defaultSize={100 - size} minSize={45}>
|
||
<Outlet />
|
||
</ResizablePanel>
|
||
<ResizableHandle withHandle />
|
||
<ResizablePanel defaultSize={size} minSize={24} maxSize={55} onResize={(s) => setSize(s)}>
|
||
<ContextualSidebar />
|
||
</ResizablePanel>
|
||
</ResizablePanelGroup>
|
||
);
|
||
}
|
||
```
|
||
|
||
Then in the existing `AppShell` JSX, replace the `<Outlet />` rendering with:
|
||
|
||
```tsx
|
||
<ContextualChatProvider>
|
||
<MainArea />
|
||
</ContextualChatProvider>
|
||
```
|
||
|
||
Keep `<FloatingChatProvider>` for now (M6 removes it).
|
||
|
||
- [ ] **Step 2: Smoke**
|
||
|
||
```bash
|
||
cd adiuvAI && source ~/.nvm/nvm.sh && npm start
|
||
```
|
||
|
||
Open the app. Navigate to `/tasks`. No visible change (sidebar is closed by default). Open devtools and run:
|
||
|
||
```js
|
||
window.localStorage.setItem('chat.contextual.open','1'); location.reload();
|
||
```
|
||
|
||
After reload (on `/tasks`), the right panel is visible with an empty `ContextualSidebar`. On `/` (home), it is NOT visible. Pass = the sidebar slot exists on non-home routes.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/components/layout/AppShell.tsx
|
||
git commit -m "feat(contextual): mount sidebar via ResizablePanelGroup in AppShell
|
||
|
||
Provider wraps Outlet so contextual chat survives all route
|
||
transitions. Sidebar is hidden on home and when chat.open is
|
||
false. Width is persisted via the provider."
|
||
```
|
||
|
||
## Task M4.6: Add trigger + scope hook to each page
|
||
|
||
**Files (one task per route, but commit can batch):**
|
||
- Modify: `adiuvAI/src/renderer/routes/timeline.tsx`
|
||
- Modify: `adiuvAI/src/renderer/routes/tasks.tsx`
|
||
- Modify: `adiuvAI/src/renderer/routes/projects.tsx`
|
||
- Modify: `adiuvAI/src/renderer/routes/projects.$projectId.tsx` (if it exists; otherwise project detail in `components/projects/ProjectDetail.tsx`)
|
||
- Modify: `adiuvAI/src/renderer/routes/notes.$noteId.tsx`
|
||
|
||
For each route, do:
|
||
|
||
- [ ] **Step 1: Import**
|
||
|
||
```tsx
|
||
import { AdiuvaTriggerButton } from '@/components/ai/AdiuvaTriggerButton';
|
||
import { useContextualScope } from '@/hooks/useContextualScope';
|
||
```
|
||
|
||
- [ ] **Step 2: Call `useContextualScope` near the top of the component**
|
||
|
||
For `timeline.tsx`:
|
||
|
||
```tsx
|
||
useContextualScope({ page: 'timeline' });
|
||
```
|
||
|
||
For `tasks.tsx`:
|
||
|
||
```tsx
|
||
useContextualScope({ page: 'tasks' });
|
||
```
|
||
|
||
For `projects.tsx` (list view):
|
||
|
||
```tsx
|
||
useContextualScope({ page: 'projects-list' });
|
||
```
|
||
|
||
For a project detail page (read project + counts from existing queries):
|
||
|
||
```tsx
|
||
useContextualScope({
|
||
page: 'project',
|
||
entityType: 'project',
|
||
entityId: project.id,
|
||
entityName: project.name,
|
||
counts: {
|
||
tasks: tasks.length,
|
||
notes: notes.length,
|
||
milestones: milestones.length,
|
||
},
|
||
});
|
||
```
|
||
|
||
For `notes.$noteId.tsx`:
|
||
|
||
```tsx
|
||
useContextualScope({
|
||
page: 'note',
|
||
entityType: 'note',
|
||
entityId: note.id,
|
||
entityName: note.title,
|
||
projectId: note.projectId ?? null,
|
||
charCount: (note.content ?? '').length,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Render `<AdiuvaTriggerButton />` in each page header area**
|
||
|
||
Each page currently has its own header strip (project title etc.). Add the trigger anchored to the right side. If a page has no header strip, wrap its top-level content in `<div className="flex items-center justify-between p-3 border-b">`. Pattern:
|
||
|
||
```tsx
|
||
<div className="flex items-center justify-between px-4 py-3">
|
||
<h1 className="text-sm font-medium">{title}</h1>
|
||
<AdiuvaTriggerButton />
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 4: Smoke**
|
||
|
||
Per page: open it; sidebar trigger is visible top-right. Click it; sidebar opens; type a message; expect either successful streaming (if backend has the new frames + tool stubs) or an explicit error message (until M5 wires tools). The scope payload reaches the backend — verify by inspecting the WS frames in main-process logs.
|
||
|
||
- [ ] **Step 5: Commit (one per page, or one batch if no behavior diverges)**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/routes/timeline.tsx src/renderer/routes/tasks.tsx src/renderer/routes/projects.tsx src/renderer/routes/projects.\$projectId.tsx src/renderer/routes/notes.\$noteId.tsx
|
||
git commit -m "feat(contextual): trigger + scope hook on Timeline/Tasks/Projects/Notes
|
||
|
||
Each page publishes its scope on render and renders the
|
||
AdiuvaTriggerButton in its header."
|
||
```
|
||
|
||
## Task M4.7: Wire main-process bridge — `sendContextualRequest` + `sendContextualScopeUpdate`
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/main/api/backend-client.ts`
|
||
- Modify: `adiuvAI/src/main/ai/orchestrator.ts`
|
||
- Modify: `adiuvAI/src/main/router/index.ts` (the `ai.chat` procedure)
|
||
- Modify: `adiuvAI/src/preload/` (electronAI exposure)
|
||
|
||
- [ ] **Step 1: Add `sendContextualRequest` and `sendContextualScopeUpdate` to `backend-client.ts`**
|
||
|
||
Open `adiuvAI/src/main/api/backend-client.ts`. Locate `sendFloatingRequest`. Add adjacent:
|
||
|
||
```ts
|
||
async sendContextualRequest(args: {
|
||
sessionId: string;
|
||
message: string;
|
||
scope: unknown;
|
||
history: { role: string; content: string }[];
|
||
onFrame: (frame: unknown) => void;
|
||
}) {
|
||
await this.send({ type: 'contextual_request', ...args });
|
||
// existing on-frame dispatch already routes to args.onFrame by sessionId
|
||
}
|
||
|
||
async sendContextualScopeUpdate(args: { sessionId: string; scope: unknown }) {
|
||
await this.send({ type: 'contextual_scope_update', ...args });
|
||
}
|
||
```
|
||
|
||
(Mirror the existing structure of `sendFloatingRequest` — DO NOT improvise; copy its pattern exactly.)
|
||
|
||
- [ ] **Step 2: Route `mode: 'contextual'` from the `ai.chat` tRPC procedure**
|
||
|
||
In the existing `ai.chat` procedure (find it in `adiuvAI/src/main/router/`), branch on input `mode`:
|
||
|
||
```ts
|
||
if (input.mode === 'contextual') {
|
||
await orchestrator.delegateContextual({
|
||
sessionId: input.sessionId,
|
||
requestId: input.requestId,
|
||
message: input.message,
|
||
scope: input.scope,
|
||
history: input.conversationHistory,
|
||
});
|
||
return { ok: true };
|
||
}
|
||
```
|
||
|
||
Add to the procedure's Zod input schema:
|
||
|
||
```ts
|
||
mode: z.enum(['floating', 'contextual']).optional(),
|
||
scope: z.unknown().optional(),
|
||
```
|
||
|
||
- [ ] **Step 3: Add `delegateContextual` to orchestrator**
|
||
|
||
In `adiuvAI/src/main/ai/orchestrator.ts`, add a method that mirrors the floating delegation but calls `backendClient.sendContextualRequest`. Frames flow back through the existing `'ai:stream'` IPC channel; ensure the new contextual frames are forwarded.
|
||
|
||
- [ ] **Step 4: Expose `sendContextualScopeUpdate` on `window.electronAI`**
|
||
|
||
In `adiuvAI/src/preload/`, find the `contextBridge.exposeInMainWorld('electronAI', { ... })` block. Add:
|
||
|
||
```ts
|
||
sendContextualScopeUpdate: (args: { sessionId: string; scope: unknown }) =>
|
||
ipcRenderer.invoke('ai:contextual-scope-update', args),
|
||
```
|
||
|
||
In main process (`ipc.ts` or wherever `ai:` IPC is handled), register:
|
||
|
||
```ts
|
||
ipcMain.handle('ai:contextual-scope-update', async (_e, args) => {
|
||
await backendClient.sendContextualScopeUpdate(args);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Smoke**
|
||
|
||
Open a page with sidebar open, navigate to another page; check main-process console — `contextual_scope_update` frame is logged. Send a message; `contextual_request` is logged.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/main/api/backend-client.ts src/main/ai/orchestrator.ts src/main/router/ src/preload/ src/main/ipc.ts
|
||
git commit -m "feat(contextual): main process bridge for contextual chat
|
||
|
||
ai.chat tRPC mutation routes mode='contextual' through the
|
||
orchestrator to backend-client. New ai:contextual-scope-update IPC
|
||
handler exposes scope updates to the renderer."
|
||
```
|
||
|
||
## Task M4.8: Bump submodules
|
||
|
||
```bash
|
||
cd /c/Users/PC-Roby/Documents/_adiuvai_workspace
|
||
git add adiuvAI api
|
||
git commit -m "chore: bump submodules — contextual sidebar M4 (frontend shell)"
|
||
```
|
||
|
||
---
|
||
|
||
# Milestone M5 — Tools wiring
|
||
|
||
Wires `get_page_details` and confirms entity-create tools are reachable for the contextual channel.
|
||
|
||
## Task M5.1: Verify which entity-create tools already exist
|
||
|
||
- [ ] **Step 1: Grep**
|
||
|
||
```bash
|
||
cd api
|
||
grep -nE "^\s*(def|@tool|name=)" app/core/deep_agent.py | grep -iE "(create_task|create_note|create_timeline|update_task|get_page_details)" || true
|
||
```
|
||
|
||
Expected: at least `create_task`, `update_task`, `create_note` are present. If `create_timeline_event` is missing, remove it from `run_contextual_stream`'s tool list and from the prompt-mention area (no action required if it's already there).
|
||
|
||
- [ ] **Step 2: Document findings inline in the plan execution log** (no commit)
|
||
|
||
## Task M5.2: Implement `get_page_details` tool
|
||
|
||
**Files:**
|
||
- Modify: `api/app/core/deep_agent.py` (add the Tool definition)
|
||
- Modify: `adiuvAI/src/main/api/drizzle-executor.ts` (add the dispatch case)
|
||
|
||
- [ ] **Step 1: Backend Tool stub**
|
||
|
||
In `deep_agent.py`, define `get_page_details_tool` matching the pattern of existing tools (e.g. `create_task_tool`). The tool's `func` simply returns the tool-call payload — the actual data fetching happens client-side in drizzle-executor (consistent with how `create_task` works: the backend's tool function emits a JSON op, the client executes it). Mirror existing tools exactly.
|
||
|
||
```python
|
||
get_page_details_tool = Tool(
|
||
name="get_page_details",
|
||
description=(
|
||
"Fetch full details for the entity currently in view. "
|
||
"Pass entityType ('project'|'task'|'note'|'tasks_all'|'projects_all'|'timeline_all') "
|
||
"and entityId for entity views. List variants ignore entityId."
|
||
),
|
||
func=lambda **kwargs: {"op": "get_page_details", "args": kwargs},
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Replace M3.3's placeholder reference**
|
||
|
||
If M3.3 left a stub or a comment about replacing the tool, replace it now so `run_contextual_stream` references the real `get_page_details_tool`.
|
||
|
||
- [ ] **Step 3: Client dispatch**
|
||
|
||
In `adiuvAI/src/main/api/drizzle-executor.ts`, add a case to the dispatch switch:
|
||
|
||
```ts
|
||
case 'get_page_details': {
|
||
const { entityType, entityId } = args;
|
||
switch (entityType) {
|
||
case 'project':
|
||
return fetchProjectSnapshot(db, entityId);
|
||
case 'task':
|
||
return fetchTaskSnapshot(db, entityId);
|
||
case 'note':
|
||
return fetchNoteSnapshot(db, entityId);
|
||
case 'tasks_all':
|
||
return fetchTasksAll(db, args.filters);
|
||
case 'projects_all':
|
||
return fetchProjectsAll(db);
|
||
case 'timeline_all':
|
||
return fetchTimelineAll(db, args.filters);
|
||
default:
|
||
throw new Error(`get_page_details: unknown entityType ${entityType}`);
|
||
}
|
||
}
|
||
```
|
||
|
||
Implement the helper functions in the same file:
|
||
|
||
```ts
|
||
function fetchProjectSnapshot(db: Database, projectId: string) {
|
||
const project = db.select().from(projects).where(eq(projects.id, projectId)).get();
|
||
const projectTasks = db.select().from(tasks).where(eq(tasks.projectId, projectId)).all();
|
||
const projectNotes = db
|
||
.select({ id: notes.id, title: notes.title, summary: notes.aiSummary })
|
||
.from(notes).where(eq(notes.projectId, projectId)).all();
|
||
const events = db.select().from(timelineEvents).where(eq(timelineEvents.projectId, projectId)).all();
|
||
return { project, tasks: projectTasks, notes: projectNotes, milestones: events.filter((e) => e.type === 'milestone') };
|
||
}
|
||
|
||
function fetchTaskSnapshot(db: Database, taskId: string) {
|
||
const task = db.select().from(tasks).where(eq(tasks.id, taskId)).get();
|
||
const project = task?.projectId
|
||
? db.select().from(projects).where(eq(projects.id, task.projectId)).get()
|
||
: null;
|
||
const comments = db.select().from(taskComments).where(eq(taskComments.taskId, taskId)).all();
|
||
return { task, project, comments };
|
||
}
|
||
|
||
function fetchNoteSnapshot(db: Database, noteId: string) {
|
||
const note = db.select().from(notes).where(eq(notes.id, noteId)).get();
|
||
return { note };
|
||
}
|
||
|
||
function fetchTasksAll(db: Database, _filters?: unknown) {
|
||
// For now, ignore filters; M7 polish can apply renderer-side filters here.
|
||
return { tasks: db.select().from(tasks).all() };
|
||
}
|
||
|
||
function fetchProjectsAll(db: Database) {
|
||
return { projects: db.select().from(projects).all() };
|
||
}
|
||
|
||
function fetchTimelineAll(db: Database, _filters?: unknown) {
|
||
return { events: db.select().from(timelineEvents).all() };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Smoke**
|
||
|
||
Start app on a project page, open sidebar, ask "What tasks does this project have?". The agent should emit a `get_page_details` tool call (visible in main-process logs); drizzle-executor returns the snapshot; agent replies with the list.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add app/core/deep_agent.py
|
||
git commit -m "feat(contextual): get_page_details tool
|
||
|
||
Tool emits a JSON op consumed by the Electron drizzle-executor.
|
||
Supports project/task/note entity snapshots and tasks_all/
|
||
projects_all/timeline_all list variants."
|
||
|
||
cd ../adiuvAI
|
||
git add src/main/api/drizzle-executor.ts
|
||
git commit -m "feat(contextual): drizzle-executor dispatch for get_page_details
|
||
|
||
Per-entity and per-list-view snapshot fetchers wired to local
|
||
SQLite. List-view filter handling deferred to M7 polish."
|
||
```
|
||
|
||
## Task M5.3: Bump submodules
|
||
|
||
```bash
|
||
cd /c/Users/PC-Roby/Documents/_adiuvai_workspace
|
||
git add adiuvAI api
|
||
git commit -m "chore: bump submodules — contextual M5 (tools wiring)"
|
||
```
|
||
|
||
---
|
||
|
||
# Milestone M6 — Deprecation sweep
|
||
|
||
Delete every floating-only code path. After M6, only the contextual flow remains.
|
||
|
||
## Task M6.1: Frontend — remove `FloatingChat`, `FloatingChatContext`, `useDoubleClickAI`
|
||
|
||
**Files:**
|
||
- Delete: `adiuvAI/src/renderer/components/ai/FloatingChat.tsx`
|
||
- Delete: `adiuvAI/src/renderer/context/FloatingChatContext.tsx`
|
||
- Delete: `adiuvAI/src/renderer/hooks/useDoubleClickAI.ts`
|
||
- Modify: `adiuvAI/src/renderer/components/layout/AppShell.tsx`
|
||
|
||
- [ ] **Step 1: Delete the three files**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git rm src/renderer/components/ai/FloatingChat.tsx src/renderer/context/FloatingChatContext.tsx src/renderer/hooks/useDoubleClickAI.ts
|
||
```
|
||
|
||
- [ ] **Step 2: Remove `FloatingChatProvider` wrap and `useDoubleClickAI()` call from `AppShell.tsx`**
|
||
|
||
Open `AppShell.tsx`. Delete the import lines for `FloatingChatProvider` and `useDoubleClickAI`. Remove the JSX wrapper and any hook call.
|
||
|
||
- [ ] **Step 3: Build to surface any remaining imports**
|
||
|
||
```bash
|
||
source ~/.nvm/nvm.sh && npx tsc --noEmit
|
||
```
|
||
|
||
Fix any remaining broken imports (likely from components that did `import { useFloatingChat } from '@/context/FloatingChatContext'`). Delete those usages.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add -A
|
||
git commit -m "refactor(contextual): delete FloatingChat, FloatingChatContext, useDoubleClickAI
|
||
|
||
Replaced by ContextualChatProvider + AdiuvaTriggerButton. Pre-1.0
|
||
clean removal — no deprecation period."
|
||
```
|
||
|
||
## Task M6.2: Strip every `data-ai-section` attribute
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/routes/tasks.tsx`
|
||
- Modify: `adiuvAI/src/renderer/components/timeline/TimelineGanttView.tsx`
|
||
- Modify: `adiuvAI/src/renderer/components/projects/ProjectDetail.tsx`
|
||
- Modify: `adiuvAI/src/renderer/routes/notes.$noteId.tsx`
|
||
- Any other matches found by grep
|
||
|
||
- [ ] **Step 1: Locate every occurrence**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
grep -rn 'data-ai-section' src/renderer/ || true
|
||
```
|
||
|
||
- [ ] **Step 2: Remove every occurrence**
|
||
|
||
Edit each file and delete the `data-ai-section="..."` attribute. Keep the rest of the element intact.
|
||
|
||
- [ ] **Step 3: Verify clean**
|
||
|
||
```bash
|
||
cd adiuvAI && grep -rn 'data-ai-section' src/renderer/ ; echo "exit=$?"
|
||
```
|
||
|
||
Expected: no output, `exit=1` (grep finds nothing).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add -A
|
||
git commit -m "refactor(contextual): strip all data-ai-section attributes
|
||
|
||
Section-anchoring is obsolete now that there is no floating chat.
|
||
The contextual sidebar uses scope payload, not DOM attributes."
|
||
```
|
||
|
||
## Task M6.3: Remove `'floating'` from `useAIChat` + `ChatInputBox`
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/hooks/useAIChat.ts`
|
||
- Modify: `adiuvAI/src/renderer/components/ai/ChatInputBox.tsx`
|
||
|
||
- [ ] **Step 1: Trim `useAIChat`**
|
||
|
||
Remove:
|
||
- `FloatingDomainSignal` type and its export.
|
||
- `'floating'` from `UIChatContext.type` union.
|
||
- `scope` field on `UIChatContext`.
|
||
- The `getContextCacheKey` fallback that returns `'floating'`.
|
||
- The `isFloating` branch in `handleSend` that adds `mode: 'floating'` and `scope`.
|
||
- The `floating_domain` case in the stream-event switch.
|
||
- `onDomainSignal` option + ref.
|
||
- `TABLE_TO_ENTITY` + `parseMutationsToEntityTags` (only floating consumed this).
|
||
|
||
After cleanup, `getContextCacheKey` becomes:
|
||
|
||
```ts
|
||
function getContextCacheKey(ctx: UIChatContext): string {
|
||
if (ctx.type === 'global') return 'global';
|
||
return `project:${ctx.projectId ?? ''}`;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: ChatInputBox — drop any `'floating'` literal**
|
||
|
||
Grep:
|
||
|
||
```bash
|
||
grep -n "floating" adiuvAI/src/renderer/components/ai/ChatInputBox.tsx || true
|
||
```
|
||
|
||
Remove any branch keyed on `'floating'`.
|
||
|
||
- [ ] **Step 3: Type check**
|
||
|
||
```bash
|
||
cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit
|
||
```
|
||
|
||
Fix any callers that still pass `type: 'floating'`. They should be deleted (e.g. inside `FloatingChat.tsx` — already gone).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add -A
|
||
git commit -m "refactor(contextual): drop 'floating' branch from useAIChat and ChatInputBox
|
||
|
||
UIChatContext is now 'global' | 'project' only. Floating domain
|
||
signal and entity-tag parser removed."
|
||
```
|
||
|
||
## Task M6.4: Main process — drop `sendFloatingRequest`
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/main/api/backend-client.ts`
|
||
- Modify: `adiuvAI/src/main/ai/orchestrator.ts`
|
||
- Modify: `adiuvAI/src/main/router/` (ai.chat input schema and dispatch)
|
||
|
||
- [ ] **Step 1: Delete `sendFloatingRequest` from `backend-client.ts`**
|
||
|
||
- [ ] **Step 2: Delete the floating delegation in `orchestrator.ts`**
|
||
|
||
- [ ] **Step 3: In `ai.chat` Zod input, drop `'floating'` from the `mode` enum**
|
||
|
||
```ts
|
||
mode: z.enum(['contextual']).optional(),
|
||
```
|
||
|
||
- [ ] **Step 4: Build**
|
||
|
||
```bash
|
||
cd adiuvAI && source ~/.nvm/nvm.sh && npx tsc --noEmit
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add -A
|
||
git commit -m "refactor(contextual): main process drops sendFloatingRequest and floating mode
|
||
|
||
ai.chat tRPC procedure now only accepts mode='contextual' (or
|
||
no mode for home). Orchestrator delegation table loses the
|
||
floating branch."
|
||
```
|
||
|
||
## Task M6.5: Backend — drop `_handle_floating_request`, `run_floating_stream`, `_FLOATING_SYSTEM_PROMPT`
|
||
|
||
**Files:**
|
||
- Modify: `api/app/api/routes/device_ws.py`
|
||
- Modify: `api/app/core/deep_agent.py`
|
||
|
||
- [ ] **Step 1: Delete `_handle_floating_request` and its dispatch branch**
|
||
|
||
In `device_ws.py`, remove the `_handle_floating_request` function and the `elif payload["type"] == "floating_request":` branch.
|
||
|
||
- [ ] **Step 2: Delete `run_floating_stream` and `_FLOATING_SYSTEM_PROMPT`**
|
||
|
||
In `deep_agent.py`, remove both.
|
||
|
||
- [ ] **Step 3: Sweep imports**
|
||
|
||
```bash
|
||
cd api
|
||
grep -rn "floating" app/ tests/ || true
|
||
```
|
||
|
||
Delete any remaining import or reference. (Floating tool name strings inside a renamed test are OK — those tests will be deleted in M6.7.)
|
||
|
||
- [ ] **Step 4: Tests**
|
||
|
||
```bash
|
||
cd api && pytest -x
|
||
```
|
||
|
||
All non-floating tests pass. Floating tests fail (deleted in next step).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add -A
|
||
git commit -m "refactor(contextual): drop floating WS frame, runner, and prompt fallback
|
||
|
||
contextual_request + contextual_scope_update are the only WS
|
||
flows for ad-hoc contextual chat. Floating system prompt
|
||
constant removed; Langfuse 'floating_system' is deleted in a
|
||
separate step."
|
||
```
|
||
|
||
## Task M6.6: Delete or rewrite floating-specific tests
|
||
|
||
**Files:**
|
||
- Delete: `api/tests/test_*floating*.py` (whatever exists)
|
||
|
||
- [ ] **Step 1: List**
|
||
|
||
```bash
|
||
cd api && ls tests/ | grep -i floating || true
|
||
```
|
||
|
||
- [ ] **Step 2: Delete each**
|
||
|
||
```bash
|
||
cd api && git rm tests/test_floating*.py
|
||
```
|
||
|
||
- [ ] **Step 3: Run suite**
|
||
|
||
```bash
|
||
cd api && pytest
|
||
```
|
||
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd api
|
||
git add -A
|
||
git commit -m "test(contextual): remove floating-specific tests
|
||
|
||
Replaced by tests/test_contextual_*.py."
|
||
```
|
||
|
||
## Task M6.7: Delete Langfuse `floating_system` prompt
|
||
|
||
- [ ] **Step 1: Use /langfuse**
|
||
|
||
In a fresh Claude session, run `/langfuse` and ask it to delete the prompt named `floating_system`. Verify via list. Until this is done, the prompt still exists but no code references it; deleting is a hygiene step.
|
||
|
||
- [ ] **Step 2: Note completion in plan execution log**
|
||
|
||
No commit (config-only).
|
||
|
||
## Task M6.8: Sweep electron-store + localStorage `floating.*` keys
|
||
|
||
- [ ] **Step 1: Grep**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
grep -rn "floating" src/main/store.ts src/renderer/ || true
|
||
```
|
||
|
||
- [ ] **Step 2: Remove any `floating.*` keys**
|
||
|
||
There may be electron-store keys for floating draft cache; remove them.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add -A
|
||
git commit -m "chore(contextual): purge residual 'floating' keys from main store and renderer"
|
||
```
|
||
|
||
## Task M6.9: Bump submodules
|
||
|
||
```bash
|
||
cd /c/Users/PC-Roby/Documents/_adiuvai_workspace
|
||
git add adiuvAI api
|
||
git commit -m "chore: bump submodules — contextual M6 (deprecation sweep)"
|
||
```
|
||
|
||
---
|
||
|
||
# Milestone M7 — Polish
|
||
|
||
## Task M7.1: Confirm width persistence across restart
|
||
|
||
- [ ] **Step 1:** Open sidebar, drag handle to ~45%, quit app, reopen. Verify size persists.
|
||
- [ ] **Step 2:** If not persisting, check `localStorage` write happens inside the `onResize` callback set in `AppShell` (M4.5).
|
||
- [ ] **Step 3:** No commit if no fix needed.
|
||
|
||
## Task M7.2: Light-mode elevated CSS variant
|
||
|
||
- [ ] **Step 1:** In `globals.css`, add `.dark .adiuva-btn { ... }` override and ensure the base rule works for light mode (dusty lavender border, lighter card surface).
|
||
- [ ] **Step 2:** Commit:
|
||
|
||
```bash
|
||
cd adiuvAI
|
||
git add src/renderer/globals.css
|
||
git commit -m "style(contextual): light-mode variant for elevated trigger button"
|
||
```
|
||
|
||
## Task M7.3: Empty-state copy on global-list pages
|
||
|
||
- [ ] **Step 1:** When `messages.length === 0` and `scope.entityType == null`, render a soft hint inside `ChatSurface` in the contextual variant: "Ask anything about the {page} view — or @ to reference a project."
|
||
- [ ] **Step 2:** Commit.
|
||
|
||
## Task M7.4: Bump submodule + workspace pointer
|
||
|
||
```bash
|
||
cd /c/Users/PC-Roby/Documents/_adiuvai_workspace
|
||
git add adiuvAI
|
||
git commit -m "chore: bump adiuvAI submodule — contextual M7 (polish)"
|
||
```
|
||
|
||
---
|
||
|
||
# Self-review
|
||
|
||
- ✅ **Spec coverage** — every section of the spec is referenced:
|
||
- 2.1–2.3 trigger button + sidebar layout → M4.3, M4.4, M4.5
|
||
- 2.4 lifecycle (one-time mount, persistence, hidden on home) → M4.1, M4.5
|
||
- 2.5 page-change behaviour → M4.2 + M3.4 + M4.7
|
||
- 3 architecture → M2 + M4
|
||
- 4 frontend files + scope payload + elevated CSS → M4.1–M4.6
|
||
- 5 backend WS + prompt + buffer → M3
|
||
- 6 data model + migration + sub-router → M1
|
||
- 7 tools → M5
|
||
- 8 deprecation removal → M6
|
||
- 9 milestones → mirrored here verbatim
|
||
- 10 risks → addressed inline (resize during stream uses shadcn primitive; scope-update race handled by JSON-key diff in M4.1; reconnect retry uses existing infra)
|
||
|
||
- ✅ **Placeholder scan** — no TBD/TODO/"implement later" left. The `get_page_details_tool` stub in M3.3 is explicitly named as a stub with a hard pointer to M5.2.
|
||
|
||
- ✅ **Type consistency** —
|
||
- `ContextualScope` shape matches between renderer (`context/ContextualChatContext.tsx`) and backend (`app/schemas/contextual.py`): `page`, `entity_type`/`entityType`, `entity_id`/`entityId`, `entity_name`/`entityName`, `project_id`/`projectId`, `char_count`/`charCount`, `counts`, `filters`.
|
||
- WS frame names: `contextual_request`, `contextual_scope_update`, `contextual_scope_ack` — consistent in M3.4 + M4.7.
|
||
- tRPC mutation input `mode: 'contextual'` consistent across M2.1 (useChatStream), M4.1 (provider send), M4.7 (router schema), M6.4 (final enum trim).
|
||
- `aiChat` sub-router method names — `listSessions`, `getSession`, `createSession`, `appendMessage`, `deleteSession` — match across M1.3 and consumer M2.4/M4.1.
|
||
|
||
If anything diverges during execution (e.g. drizzle-kit names the migration differently, the project's resizable shadcn wrapper uses `direction=` instead of `orientation=`), prefer matching the project's existing pattern over the plan and note the deviation in the execution log.
|