Files
workspace/docs/2026-05-14-contextual-sidebar-agent-design.md
Roberto 782004916e docs: contextual sidebar agent design
Replace floating-chat (double-click) with adiuva trigger button + right-side
resizable sidebar that persists across navigation and shares the chat surface
with home chat. Includes deprecation sweep of floating_* code paths,
Langfuse prompt swap, and SQLite-backed chat history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:15:36 +02:00

385 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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