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