diff --git a/docs/2026-05-14-contextual-sidebar-agent-design.md b/docs/2026-05-14-contextual-sidebar-agent-design.md new file mode 100644 index 0000000..89bf1b3 --- /dev/null +++ b/docs/2026-05-14-contextual-sidebar-agent-design.md @@ -0,0 +1,384 @@ +# Contextual Sidebar Agent — Design + +**Date:** 2026-05-14 +**Status:** Approved, ready for implementation plan +**Scope:** Replace the legacy floating-chat (double-click-on-entity) flow with a contextual right-side chat sidebar that survives navigation, anchors to the current page's entity, and shares chat surface code with the home chat. + +--- + +## 1. Goals + +1. Replace the double-click "floating chat" with an explicit **adiuva trigger button** in the top-right of Timeline, Tasks, Projects and Notes pages. +2. Clicking the trigger opens a **right-side, resizable sidebar** with the same chat UI as the home chat. +3. The sidebar **persists across route changes** (no remount, no history reload). A silent system message informs the agent each time the user navigates. +4. The agent receives a **base context payload** every turn (`page`, `entityType`, `entityId`, `entityName`, `counts`) and can pull full details on demand via a single `get_page_details` tool. +5. Chat history **survives application restart** (SQLite-backed sessions, same model for home and contextual). +6. **All deprecated code paths removed** from frontend, main process, backend, Langfuse and electron-store. Pre-1.0 clean refactor [[feedback_clean_refactor_dev]]. + +Out of scope (next sprint): note write/update/summarize tools. + +## 2. UX + +### 2.1 Trigger button + +- Bare elevated button anchored top-right of each page header. +- 48×48, 14px radius, dark surface (#1d1d20), multilayer shadow (Copilot-style elevation). +- Icon = `adiuvAI/assets/logo/logo-mark.svg` (compass needle, gold + light south for dark mode). Settles continuously (6s `compass-settle` keyframes, ported from `logo-mark.svg`). +- Hover: shadow deepens + subtle gold ambient glow halo behind the button. **No lift, no animation speedup.** +- Active: scale to 0.97. +- Pages with trigger: `/timeline`, `/tasks`, `/projects`, `/projects/$projectId`, `/notes/$noteId`. +- Home (`/`) does NOT mount the trigger — home keeps full-width `AIChatPanel`. + +### 2.2 Sidebar shell + +- Right side of `AppShell`, between the page and the screen edge. +- Resizable via shadcn `ResizablePanelGroup` + `ResizableHandle withHandle` (same control as task-brief, see [TaskCarousel.tsx:157-190](adiuvAI/src/renderer/components/brief/TaskCarousel.tsx#L157-L190)). +- Default size 32% of viewport. Min 24% (~320px on common widths). Max 55%. +- Width persisted as percentage in electron-store key `chat.sidebar.size`. + +### 2.3 Sidebar layout + +- **No header bar, no internal section dividers.** Pure chat surface. +- Two **elevated icon buttons floating top-right** (same recipe as trigger, 32×32 scale, 10px radius): `+` new chat, `✕` close. +- Messages scroll inside a single area. Bottom of the area is padded so that the last messages can scroll behind the input. +- Input is absolutely positioned at the bottom with a 64px gradient mask above it (`linear-gradient(to bottom, transparent → bg)`), so chat content fades visually under the translucent input box. +- Input box: `rgba(29,29,32,.85)`, `backdrop-filter: blur(14px)`, 16px radius, soft shadow — matches the home chat input treatment. +- No scope chip rendered. The user knows what page they are on; the agent sees the scope in the payload. + +### 2.4 Sidebar lifecycle + +- Mounted **once** at AppShell level inside `ContextualChatProvider`. Survives all route transitions. +- Default state: closed. +- Open state remembered for the current session (renderer memory); reset to closed on app restart. +- Sidebar is hidden on the home route regardless of open state. + +### 2.5 Page-change behaviour + +- Each page calls `useContextualScope(scope)` in its render. +- Provider diffs the new scope against the previous one. If they differ, it sends a `contextual_scope_update` WS frame to the backend. +- Backend appends a `system` message to the session buffer (`"User navigated to {scope}. Treat this as the new active context."`) and returns an ack. **No LLM call, no tokens.** +- User sees no visible divider in chat. The new context kicks in on the next user turn. + +## 3. Architecture + +``` +┌─ AppShell (mounted once) ─────────────────────────────────────┐ +│ │ +│ ResizablePanelGroup direction="horizontal" │ +│ ┌ Page (Outlet) ────────────┐ ┌ Sidebar (when !home) ──┐ │ +│ │ useContextualScope({...}) │ │ ChatSurface │ │ +│ │ Top-right trigger button │──│ + elevated controls │ │ +│ └───────────────────────────┘ └────────────────────────┘ │ +│ ContextualChatProvider │ +│ { open, size, sessionId, scope, │ +│ messages, isStreaming, send, toggle, │ +│ setScope, newChat } │ +└───────────────────────────────────────────────────────────────┘ + │ + ▼ WS /api/v1/device + contextual_request | contextual_scope_update + │ + ▼ + run_contextual_stream() + Langfuse: contextual_system + Tools: get_page_details, create_task, + create_note, update_task, + create_timeline_event +``` + +Invariants: + +- `ContextualChatProvider` is mounted once. Sidebar tree never unmounts on nav → no reload, full history kept in renderer state. +- The chat surface (``) is a shared component reused by `AIChatPanel` (home) and `ContextualSidebar`. +- Sidebar history lives in SQLite via two new tables; renderer hydrates from disk on app start. Same persistence model used by home chat after the refactor. +- Backend is stateless w.r.t. chat history — each request carries the history payload from the client. Agent session buffer is purely an in-memory short-cache. + +## 4. Frontend + +### 4.1 New files (`adiuvAI/src/renderer/`) + +| File | Purpose | +|---|---| +| `components/ai/ChatSurface.tsx` | Headless chat: messages list, streaming, markdown, input. Props: `{messages, onSend, isStreaming, variant: 'home' \| 'contextual'}` | +| `components/ai/ContextualSidebar.tsx` | Right sidebar shell: floating elevated controls, embeds ``, input fade. | +| `components/ai/AdiuvaTriggerButton.tsx` | 48px elevated trigger button with compass needle (`logo-mark.svg`). Reads `useContextualChat().toggle()`. | +| `context/ContextualChatContext.tsx` | Provider: `{open, size, sessionId, scope, messages, isStreaming, send, toggle, setScope, newChat, setSize}`. Hydrates session from SQLite on mount. | +| `hooks/useContextualScope.ts` | `useContextualScope(scope)` — page calls in render. Effect diffs scope; on change emits `contextual_scope_update`. | +| `hooks/useChatStream.ts` | Shared streaming engine extracted from `useAIChat.ts`. Consumed by both home and contextual. | + +### 4.2 Edits + +- `components/ai/AIChatPanel.tsx` — becomes a thin wrapper around ``. Keeps home-specific shell (suggestion chips, brief carousel hooks). All chat plumbing moves out. +- `hooks/useAIChat.ts` — `'home'` and `'global'` branches kept (via the new `useChatStream`). `'floating'` branch deleted. +- `components/ai/ChatInputBox.tsx` — `'floating'` cache key replaced by `'contextual'`. Old drafts are wiped (acceptable pre-1.0). +- `components/layout/AppShell.tsx` — wrap `` in ``. When the current route is **not** home, render the outlet inside a `ResizablePanelGroup` with a second `ResizablePanel` for `` shown only when `chat.open === true`. On home, render the outlet directly (the full-width home `AIChatPanel` owns its own layout). Remove `` wrap and `useDoubleClickAI()` call. +- Each route gains a `` in the page header area and calls `useContextualScope(scope)` in render: + - [routes/timeline.tsx](adiuvAI/src/renderer/routes/timeline.tsx) + - [routes/tasks.tsx](adiuvAI/src/renderer/routes/tasks.tsx) + - [routes/projects.tsx](adiuvAI/src/renderer/routes/projects.tsx) and `routes/projects.$projectId.tsx` + - [routes/notes.$noteId.tsx](adiuvAI/src/renderer/routes/notes.$noteId.tsx) + +### 4.3 Scope payload shape + +```ts +type ContextualScope = + | { page: 'timeline'; entityType: null } + | { page: 'tasks'; entityType: null } + | { page: 'projects-list'; entityType: null } + | { page: 'project'; + entityType: 'project'; + entityId: string; + entityName: string; + counts: { tasks: number; notes: number; milestones: number } } + | { page: 'note'; + entityType: 'note'; + entityId: string; + entityName: string; + projectId: string | null; + charCount: number }; +``` + +For global list pages (`tasks`, `projects-list`, `timeline`), the scope may also include the renderer-side active filters so that `get_page_details` can apply them. + +### 4.4 Elevated button recipe (Tailwind-compatible CSS, ported to component) + +```css +.adiuva-btn { + width: 48px; height: 48px; + background: #1d1d20; + border: 1px solid rgba(255,255,255,.06); + border-radius: 14px; + box-shadow: + 0 1px 0 rgba(255,255,255,.05) inset, + 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); + transition: box-shadow .3s ease, background .2s ease; +} +.adiuva-btn:hover { + background: #26262a; + box-shadow: + 0 1px 0 rgba(255,255,255,.06) inset, + 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; } +.needle-g { + transform-origin: 32px 32px; + animation: compass-settle 6s ease-in-out infinite; +} +@keyframes compass-settle { + 0% { transform: rotate(0deg); } + 20% { transform: rotate(4deg); } + 50% { transform: rotate(-3deg); } + 80% { transform: rotate(2deg); } + 100% { transform: rotate(0deg); } +} +``` + +Light-mode variant: bg `#ffffff`, border `#c8c3cd` (dusty lavender per brand), shadows softer; settle and halo unchanged. Light-mode colors confirmed against [adiuvAI/.claude/CLAUDE.md Design Context]. + +## 5. Backend + +### 5.1 WS frames (`api/app/api/routes/device_ws.py`) + +Dispatch table: + +- `home_request` — unchanged. +- `contextual_request` — **new**. `{ session_id, message, scope, history? }`. Handler: `_handle_contextual_request` → `run_contextual_stream`. +- `contextual_scope_update` — **new**. `{ session_id, scope }`. Handler: appends system message to session buffer, returns `{ type: 'contextual_scope_ack' }`. No LLM call. +- `brief_request`, `task_brief_request` — unchanged (out of scope). +- `floating_request` — **removed**. + +### 5.2 Runner (`api/app/core/deep_agent.py`) + +```python +async def run_contextual_stream(*, user, db, session_id, message, scope, history): + 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) + tools = [ + get_page_details_tool, + create_task_tool, + update_task_tool, + create_note_tool, + create_timeline_event_tool, + ] + yield from _run_agent_loop( + sys=sys, tools=tools, message=message, history=history, + user=user, db=db, session_id=session_id, channel="contextual", + ) +``` + +`_render_scope_block(scope)` produces a single-paragraph human-readable summary like: +`"User is viewing the project Acme Q3 launch. It has 12 tasks (8 completed), 4 notes, 3 milestones. Active milestone: Beta cut, overdue 2 days."` + +### 5.3 Langfuse prompt + +- Create a **new** prompt `contextual_system` in Langfuse (do not rename `floating_system` yet — coexist during the rollout). +- After cutover (milestone M6), delete `floating_system` from Langfuse. +- Fallback constant `_CONTEXTUAL_SYSTEM_PROMPT` lives in `deep_agent.py`. Body (text): + +``` +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. +``` + +Home prompt `home_system` is unchanged. + +### 5.4 Session buffer + +Extend `app/core/agent_session_buffer.py` to accept `channel="contextual"`. The buffer is in-memory only; durable history lives client-side (§6). + +## 6. Data model — client-side SQLite + +Two new tables in [adiuvAI/src/main/db/schema.ts](adiuvAI/src/main/db/schema.ts): + +```ts +export const aiChatSessions = sqliteTable("ai_chat_sessions", { + id: text("id").primaryKey(), + channel: text("channel").notNull(), // 'home' | 'contextual' + title: text("title"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + lastScope: text("last_scope"), // JSON ContextualScope | null +}); + +export const aiChatMessages = sqliteTable("ai_chat_messages", { + id: text("id").primaryKey(), + sessionId: text("session_id").notNull(), // logical FK, cascade in tRPC + role: text("role").notNull(), // 'user' | 'assistant' | 'system' + content: text("content").notNull(), + toolCalls: text("tool_calls"), // JSON | null + toolResults: text("tool_results"), // JSON | null + scope: text("scope"), // JSON snapshot at msg time | null + createdAt: integer("created_at").notNull(), +}); +``` + +Indexes: `(session_id, created_at)`, `(channel, updated_at DESC)`. New Drizzle migration `0011_ai_chat_history.sql`. + +New tRPC sub-router `aiChat` in [adiuvAI/src/main/router/index.ts](adiuvAI/src/main/router/index.ts): + +- `aiChat.listSessions({ channel })` +- `aiChat.getSession({ id })` → `{ session, messages }` +- `aiChat.createSession({ channel, initialScope? })` → `{ id }` +- `aiChat.appendMessage({ sessionId, role, content, toolCalls?, toolResults?, scope? })` +- `aiChat.deleteSession({ id })` + +Renderer flow: + +1. On `ContextualChatProvider` mount: read electron-store `chat.contextual.lastSessionId`. If set, `getSession` → hydrate. Else create on first send. +2. Each user send + assistant complete → `appendMessage`. +3. "New chat" button → `createSession`, swap stored sessionId. + +Home chat receives the same persistence model (key `chat.home.lastSessionId`). + +## 7. Tools + +| Tool | Args | Result | +|---|---|---| +| `get_page_details` | `{ entityType, entityId }` for entity views; `{ entityType }` for list views | Snapshot JSON (see below) | +| `create_task` | `{ title, dueAt?, projectId?, ... }` | `{ taskId }` | +| `update_task` | `{ taskId, patch }` | `{ ok: true }` | +| `create_note` | `{ title, body?, projectId? }` | `{ noteId }` | +| `create_timeline_event` | `{ type, title, dueAt, projectId?, deps? }` | `{ eventId }` (verify tool exists in current backend during M5; if missing, scope it in or remove from this list) | + +`get_page_details` is dispatched client-side via [drizzle-executor.ts](adiuvAI/src/main/api/drizzle-executor.ts). Supported scopes: + +- `entityType: 'project'` → `{ project, tasks[], notes[summary only], milestones[], comments[] }` +- `entityType: 'task'` → `{ task, project, comments[], deps[] }` +- `entityType: 'note'` → `{ note: { ..., body } }` +- `entityType: 'tasks_all'` → `{ tasks[] filtered by renderer view filters }` +- `entityType: 'projects_all'` → `{ projects[] }` +- `entityType: 'timeline_all'` → `{ events[] }` + +Default-projectId is **not** applied automatically by the executor. The prompt instructs the LLM to attach the current project when scope is a project; on global list pages the LLM is told to ask first. + +Note write/edit tools (`update_note`, `summarize_note`, `append_to_note`) are explicitly out of scope and **not** wired into `tools` for `run_contextual_stream`. + +## 8. Deprecation removal + +**Renderer (adiuvAI/src/renderer/)**: + +- DELETE `components/ai/FloatingChat.tsx` +- DELETE `context/FloatingChatContext.tsx` +- DELETE `hooks/useDoubleClickAI.ts` +- EDIT `components/layout/AppShell.tsx` — remove provider wrap and double-click hook. +- STRIP every `data-ai-section="..."` attribute. Known sites: + - [routes/tasks.tsx:16-25](adiuvAI/src/renderer/routes/tasks.tsx#L16-L25) (`tasks-overview`, `tasks-list`) + - [components/timeline/TimelineGanttView.tsx:95-110](adiuvAI/src/renderer/components/timeline/TimelineGanttView.tsx#L95-L110) + - [components/projects/ProjectDetail.tsx:62-100](adiuvAI/src/renderer/components/projects/ProjectDetail.tsx#L62-L100) + - [routes/notes.$noteId.tsx:44-50](adiuvAI/src/renderer/routes/notes.$noteId.tsx#L44-L50) + - Final grep sweep before merge — fail PR if any remain. +- EDIT `components/ai/ChatInputBox.tsx` — remove `'floating'` cache key. +- EDIT `hooks/useAIChat.ts` — remove `'floating'` branch. + +**Main process (adiuvAI/src/main/)**: + +- EDIT `api/backend-client.ts` — remove `sendFloatingRequest()`; add `sendContextualRequest()` + `sendContextualScopeUpdate()`. +- EDIT `ai/orchestrator.ts` — remove floating delegation; add contextual delegation. +- EDIT `db/schema.ts` — add `aiChatSessions` + `aiChatMessages`. New migration `0011_ai_chat_history.sql`. +- EDIT `router/index.ts` — add `aiChat` sub-router. + +**Backend (api/app/)**: + +- EDIT `api/routes/device_ws.py` — delete `_handle_floating_request` (line 292) and its dispatch entry; add `_handle_contextual_request` and `_handle_contextual_scope_update`. +- EDIT `core/deep_agent.py`: + - DELETE `run_floating_stream` (line 1460) and `_FLOATING_SYSTEM_PROMPT`. + - ADD `run_contextual_stream`, `_CONTEXTUAL_SYSTEM_PROMPT`, `_render_scope_block`. +- EDIT `core/agent_session_buffer.py` — accept `channel="contextual"`. + +**Langfuse**: + +- CREATE prompt `contextual_system` (body per §5.3). +- After M6 deploy verified: DELETE prompt `floating_system`. + +**Tests (api/tests/)**: + +- DELETE / RENAME `test_*floating*` to contextual equivalents. +- ADD tests: + - `contextual_scope_update` no LLM call, system message appended. + - Scope block rendering for each `entityType`. + - Tool list contains `get_page_details` and entity-create tools, NOT note edit tools. + - Default-project rule via prompt-driven smoke (mocked LLM). + +**Electron-store keys**: + +- ADD `chat.sidebar.size`, `chat.contextual.lastSessionId`, `chat.contextual.open`, `chat.home.lastSessionId`. +- REMOVE any `floating.*` keys present (sweep). + +## 9. Milestones (commit-per-step, see [[feedback_plan_workflow]]) + +| # | Title | What ships | Verifiable by | +|---|---|---|---| +| **M1** | DB + persistence foundation | Schema, migration, `aiChat` tRPC sub-router | tRPC devtools | +| **M2** | ChatSurface refactor | `ChatSurface`, `useChatStream` extracted; home rewired; floating still works | Home chat unchanged behavior | +| **M3** | Backend contextual frame + prompt | `contextual_request`, `contextual_scope_update`, `run_contextual_stream`, Langfuse `contextual_system`, fallback constant; old floating still alive | Backend tests, manual WS frame | +| **M4** | Frontend sidebar shell + provider | `ContextualChatProvider`, `ContextualSidebar`, `AdiuvaTriggerButton`, `useContextualScope`; trigger on all 4 page types; `ResizablePanelGroup` in AppShell | Open sidebar on each page, chat works, survives nav | +| **M5** | Tools wiring | `get_page_details` dispatcher in drizzle-executor for all scopes; entity-create tools confirmed reachable | Manual recap + create-task smoke | +| **M6** | Deprecation sweep | Delete `FloatingChat*`, `useDoubleClickAI`, all `data-ai-section`, `_handle_floating_request`, `run_floating_stream`, `_FLOATING_SYSTEM_PROMPT`, `sendFloatingRequest`, floating draft cache, floating electron-store keys, Langfuse `floating_system` | Grep sweep, app still boots | +| **M7** | Polish | New-chat button UX, light-mode elevated styling, width persistence verified across restart, empty-state copy on global-list pages | Manual UX pass | + +## 10. Risks + +- **WS reconnect mid-stream:** existing `_mark_runs_disconnected` covers this. Client retries with same `sessionId`, replays history from SQLite. +- **Race: nav while assistant streaming.** Provider serializes outbound frames — `scope_update` is queued and applied after stream completes. +- **Resize during stream:** shadcn `ResizablePanel` re-renders cheap, no observable impact. +- **Migration rollout:** Langfuse `contextual_system` must exist in production before backend code referencing it deploys. Coordinate prompt-create in M3 step. +- **Old session continuity:** floating chat had no persisted history, so there is nothing to migrate. Existing draft cache `'floating'` keys are wiped on first run (acceptable pre-1.0).