# 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 (`<ChatSurface>`) 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 `<ChatSurface variant="contextual">`, 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 `<ChatSurface variant="home">`. 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 `<Outlet>` in `<ContextualChatProvider>`. When the current route is **not** home, render the outlet inside a `ResizablePanelGroup` with a second `ResizablePanel` for `<ContextualSidebar>` shown only when `chat.open === true`. On home, render the outlet directly (the full-width home `AIChatPanel` owns its own layout). Remove `<FloatingChatProvider>` wrap and `useDoubleClickAI()` call.
- Each route gains a `<AdiuvaTriggerButton>` 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).
