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>
22 KiB
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
- Replace the double-click "floating chat" with an explicit adiuva trigger button in the top-right of Timeline, Tasks, Projects and Notes pages.
- Clicking the trigger opens a right-side, resizable sidebar with the same chat UI as the home chat.
- The sidebar persists across route changes (no remount, no history reload). A silent system message informs the agent each time the user navigates.
- The agent receives a base context payload every turn (
page,entityType,entityId,entityName,counts) and can pull full details on demand via a singleget_page_detailstool. - Chat history survives application restart (SQLite-backed sessions, same model for home and contextual).
- 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 (6scompass-settlekeyframes, ported fromlogo-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-widthAIChatPanel.
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). - 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_updateWS frame to the backend. - Backend appends a
systemmessage 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:
ContextualChatProvideris 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 byAIChatPanel(home) andContextualSidebar. - 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 newuseChatStream).'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 aResizablePanelGroupwith a secondResizablePanelfor<ContextualSidebar>shown only whenchat.open === true. On home, render the outlet directly (the full-width homeAIChatPanelowns its own layout). Remove<FloatingChatProvider>wrap anduseDoubleClickAI()call.- Each route gains a
<AdiuvaTriggerButton>in the page header area and callsuseContextualScope(scope)in render:- routes/timeline.tsx
- routes/tasks.tsx
- routes/projects.tsx and
routes/projects.$projectId.tsx - routes/notes.$noteId.tsx
4.3 Scope payload shape
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)
.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)
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_systemin Langfuse (do not renamefloating_systemyet — coexist during the rollout). - After cutover (milestone M6), delete
floating_systemfrom Langfuse. - Fallback constant
_CONTEXTUAL_SYSTEM_PROMPTlives indeep_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:
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:
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:
- On
ContextualChatProvidermount: read electron-storechat.contextual.lastSessionId. If set,getSession→ hydrate. Else create on first send. - Each user send + assistant complete →
appendMessage. - "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. 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 (
tasks-overview,tasks-list) - components/timeline/TimelineGanttView.tsx:95-110
- components/projects/ProjectDetail.tsx:62-100
- routes/notes.$noteId.tsx:44-50
- Final grep sweep before merge — fail PR if any remain.
- routes/tasks.tsx:16-25 (
- 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— removesendFloatingRequest(); addsendContextualRequest()+sendContextualScopeUpdate(). - EDIT
ai/orchestrator.ts— remove floating delegation; add contextual delegation. - EDIT
db/schema.ts— addaiChatSessions+aiChatMessages. New migration0011_ai_chat_history.sql. - EDIT
router/index.ts— addaiChatsub-router.
Backend (api/app/):
- EDIT
api/routes/device_ws.py— delete_handle_floating_request(line 292) and its dispatch entry; add_handle_contextual_requestand_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.
- DELETE
- EDIT
core/agent_session_buffer.py— acceptchannel="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_updateno LLM call, system message appended.- Scope block rendering for each
entityType. - Tool list contains
get_page_detailsand 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_disconnectedcovers this. Client retries with samesessionId, replays history from SQLite. - Race: nav while assistant streaming. Provider serializes outbound frames —
scope_updateis queued and applied after stream completes. - Resize during stream: shadcn
ResizablePanelre-renders cheap, no observable impact. - Migration rollout: Langfuse
contextual_systemmust 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).