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

22 KiB
Raw Blame History

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).
  • 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:

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_requestnew. { session_id, message, scope, history? }. Handler: _handle_contextual_requestrun_contextual_stream.
  • contextual_scope_updatenew. { 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_requestremoved.

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_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:

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:

  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. 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/):

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