Skip to content

feat(floating-chat): global floating chat popover with full /chat parity#38

Merged
jmagar merged 10 commits into
mainfrom
feat/lab-gych
Apr 27, 2026
Merged

feat(floating-chat): global floating chat popover with full /chat parity#38
jmagar merged 10 commits into
mainfrom
feat/lab-gych

Conversation

@jmagar
Copy link
Copy Markdown
Owner

@jmagar jmagar commented Apr 27, 2026

Summary

Implements a global floating chat surface available on every admin page — a fixed pill FAB that opens a draggable/resizable popover backed by shared session state with the /chat route.

Phase 1: Session Context Extraction (lab-gych.1)

  • ChatSessionProvider with 4-context re-render isolation (Data / Actions / Connection / Stream)
  • Lazy SSE stream: starts only on first FAB click — non-chat pages pay zero SSE cost
  • createSession mutex via isCreatingRef to prevent duplicate sessions
  • selectedRunId seeded from localStorage with regex validation
  • lib/acp/fetch.ts: standalone ACP fetcher utility (re-derives credentials per call)

Phase 2: FAB + Popover Shell (lab-gych.2)

  • FloatingChatFab: fixed pill (bottom-right), Cmd/Ctrl+/ hotkey, CSS-hidden on /chat
  • FloatingChatPopover: draggable (DOM ref + rAF, no React state), resizable (bottom-right handle), viewport hard-clamp, localStorage persistence, gear config panel (4 toggles), mobile Sheet, focus trap
  • AdminLayoutClient: 'use client' wrapper to keep layout.tsx as server component
  • Layout updated to host ChatSessionProvider + FAB + Popover

Phase 3: Full Parity Wiring (lab-gych.3)

  • FloatingChatShell: consumes all 4 contexts, React.memo wrapper
  • sendPrompt wires pageContext: when config toggle enabled and context non-null, includes as separate field in request body
  • Lazy mount: FloatingChatShell mounts on first FAB click, stays mounted permanently

Phase 4: Design System Sandbox (lab-gych.4)

  • FloatingChatSection added to /design-system after CommandPaletteSection
  • Demonstrates FAB states (default, badge, active, connecting, error), popover states (default, gear open, compact), persistence schema, hotkey reference, mobile sheet note

Phase 5: ACP Gateway pageContext (lab-gych.5)

  • POST /acp/sessions/{id}/prompt accepts optional pageContext: { route, entityType?, entityId? }
  • Server assembles compact prefix: [context: page=gateways entity=gateway/abc123]
  • Hard 30-token budget; truncates entityId first, then entityType, never route
  • Sanitization: NFKC-safe allowlist [a-zA-Z0-9/_-], max 32 chars/field, deny-list check
  • Validation failure silently skips injection — never errors the request
  • Every injection logged at INFO with session_id, route, estimated_tokens

Files Changed

New files (TypeScript):

  • apps/gateway-admin/lib/acp/fetch.ts
  • apps/gateway-admin/lib/chat/chat-session-provider.tsx
  • apps/gateway-admin/components/floating-chat-fab.tsx
  • apps/gateway-admin/components/floating-chat-popover.tsx
  • apps/gateway-admin/components/admin-layout-client.tsx
  • apps/gateway-admin/components/floating-chat-shell.tsx
  • apps/gateway-admin/components/design-system/floating-chat-section.tsx

Modified files:

  • apps/gateway-admin/app/(admin)/layout.tsx — wrap children in AdminLayoutClient
  • apps/gateway-admin/components/design-system/design-system-shell.tsx — add FloatingChatSection
  • crates/lab/src/api/services/acp.rs — pageContext support in prompt handler

Test Plan

  • TypeScript: pnpm tsc --noEmit — 0 new errors in new files
  • Rust: cargo clippy --workspace --all-features — 0 errors/warnings
  • Manual: FAB appears bottom-right on all admin pages except /chat
  • Manual: Cmd/Ctrl+/ toggles popover open/closed
  • Manual: Popover is draggable and resizable, clamps to viewport
  • Manual: Position/size/open state persists across page navigations
  • Manual: First FAB click starts SSE stream; non-open pages have no SSE cost
  • Manual: Mobile (< 768px) renders as full-screen Sheet
  • Manual: /design-system shows Floating Chat section with all demos
  • Manual: POST /acp/sessions/{id}/prompt with pageContext injects compact prefix

Beads

  • lab-gych.1: Session Context Extraction ✅
  • lab-gych.2: FAB + Popover Shell ✅
  • lab-gych.3: Full Parity Wiring ✅
  • lab-gych.4: Design System Sandbox ✅
  • lab-gych.5: ACP Gateway pageContext ✅

Summary by cubic

Adds a global floating chat popover on every admin page with full /chat parity and shared session state. SSE now starts whenever a session is selected, and ACP is hardened (SSE ticket required; 64k prompt cap).

  • New Features

    • Floating pill FAB (bottom-right) with Cmd/Ctrl+/; CSS-hidden on /chat.
    • Draggable/resizable popover with viewport clamp; persists open/position/size; mobile renders as a full‑screen Sheet; simplified gear with a single “Send page context” toggle.
    • ChatSessionProvider (4-context split) with shared runs/messages; SSE opens when selectedRunId is set; PageContextSync auto-syncs the current route into pageContext, and it’s only sent when the gear toggle is on.
    • ACP API: POST /acp/sessions/{id}/prompt accepts pageContext; server builds a compact prefix (30‑token budget); 64k‑char prompt cap; events SSE requires a subscribe ticket (401 otherwise); registry caps subscribers/session at 32.
    • Design system: Floating Chat section showing FAB/popover states and the persistence schema.
  • Refactors

    • Removed lazy-SSE and first-open shell gating; FloatingChatShell is always mounted inside the popover, and the stream lifecycle is tied to selectedRunId.
    • Resize now uses requestAnimationFrame with direct DOM writes during drag; commits on pointer up.
    • Introduced shared LRU session event caches (last 10 sessions) consumed by all SSE readers.
    • Consolidated ACP normalizers and adopted createAcpFetcher across provider/controller.
    • Stream/UI improvements: batched SSE event updates and O(1) last-status scan; fixed focus trap inert logic; wired selectAgent; cross-validates selectedRunId in refreshSessions.

Written for commit 264ba83. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • New Features
    • Added floating chat interface with keyboard shortcut support (Cmd/Ctrl + /)
    • Chat supports dragging, resizing, and persistent layout configuration
    • Automatic page context sync sends current route information to chat sessions
    • Visual connection indicators (error state, streaming animation, unread badges)
    • Mobile-optimized sheet mode for responsive viewing
    • Settings panel for toggling context sharing preferences

jmagar added 5 commits April 27, 2026 08:35
…lity

Create ChatSessionProvider with 4-context re-render isolation:
- ChatSessionDataContext: runs, selectedRun, providers, pageContext
- ChatSessionActionsContext: stable callbacks (createSession, selectRun, etc.)
- ChatSessionConnectionContext: connectionState, sessionStatus
- ChatSessionStreamContext: messages, events, activity

Lazy SSE stream: starts only on first FAB click via onFirstOpenRef callback.
Users who never open chat pay zero SSE cost.

createSession mutex: prevents duplicate session creation.
selectedRunId seeded from localStorage with regex validation.
providers setter uses bail-out comparison to prevent unnecessary re-renders.
Auto-bootstrap gated behind streamEnabled.

Also adds lib/acp/fetch.ts: standalone ACP fetcher that re-derives
acpBase and requestCredentials per call (not at module eval time).
…stem prefix

POST /acp/sessions/{id}/prompt now accepts optional pageContext field:
{ prompt: string, pageContext?: { route, entityType?, entityId? } }

When pageContext is present, server assembles a compact context prefix
and prepends it to the prompt before forwarding to the LLM:
  [context: page={route}]
  [context: page={route} entity={type}/{id}]

Token safeguards:
- Off by default: absent pageContext = zero injection, zero token cost
- Server-side assembly only: client sends structured data
- Hard token budget: 30 tokens (~120 chars); truncates entityId first,
  then entityType, never route
- No verbose markdown: single compact line
- NFKC-safe strip: chars filtered to [a-zA-Z0-9/_-], max 32 chars/field
- Deny-list: system, ignore, override, admin, instruction, assistant,
  prompt — any match skips injection, never errors the request
- Logged: every injection emits surface=api, session_id, route,
  estimated_tokens at INFO level

Validation failure silently skips injection — never errors the request.
FloatingChatFab:
- Fixed pill (bottom-right, z-40), rounded-full Aurora-styled
- Cmd/Ctrl+/ hotkey toggle, skips inputs and contentEditable
- CSS-hidden (visibility:hidden) on /chat route — NOT unmounted
- Ambient connection indicator (connecting = pulsing ring, error = amber dot)
- Streaming pulse animation (respects prefers-reduced-motion)
- Modal stack guard via openModals RefObject

FloatingChatPopover:
- Default 420x600, min 320x420, max 800x900
- Drag via DOM ref + rAF (no React state during drag), pointer capture
- Resize via bottom-right corner handle with pointer capture
- Viewport hard-clamp on drag commit and window resize (debounced 100ms)
- Gear config panel: 4 toggles (persistOpen, persistPosition, persistSize,
  sendPageContext) — persistence via labby:floating-chat:state localStorage key
- Accessibility: role=dialog, aria-modal, aria-labelledby, tabIndex=-1,
  focus trap, Escape closes, focus returns, HTML inert on app root
- Mobile: renders as full-screen Sheet (< 768px)
- CSS-hidden (not unmounted) on /chat route for both FAB and popover

AdminLayoutClient:
- 'use client' wrapper component preserving server component layout.tsx
- Hosts ChatSessionProvider, FAB, Popover
- Wires onFirstOpenRef for lazy SSE stream activation
- Persists open state to localStorage

Layout updated to use AdminLayoutClient.
FloatingChatShell consumes all 4 ChatSession contexts:
- Data: runs, selectedRun, agents, projects, pageContext
- Actions: createSession, selectRun, refreshSessions
- Connection: connectionState (for future connection indicator)
- Stream: messages (subscribed via StreamContext only)

React.memo wrapper: shell only re-renders when messages changes
during streaming — header/input don't re-render on token stream.

sendPrompt wires pageContext: when config.sendPageContext === true
AND pageContext !== null, includes pageContext as separate field in
request body: { prompt, pageContext: { route, entityType?, entityId? } }

Lazy mount pattern in AdminLayoutClient:
- shellMounted state starts false
- Set to true on first FAB click (alongside stream enable)
- FloatingChatShell mounts permanently after first click
- Popover container controls visibility via CSS, not mount state
Adds FloatingChatSection to /design-system sandbox with:
- FAB states: default, with-badge (count=3), active/open, connecting
  (pulsing ring), error (amber dot)
- Popover states: default, gear config open (4 toggles), compact (min size)
- Persistence schema: localStorage key + shape as code block
- Hotkey reference: ⌘/ (Ctrl+/ on Windows) with KbdGroup/Kbd primitives
- Mobile Sheet note: < 768px uses bottom Sheet, no drag/resize

All demos use local fake state — no live backend required.
Placed after CommandPaletteSection in Application Patterns group.
Copilot AI review requested due to automatic review settings April 27, 2026 12:49
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Introduces a comprehensive floating chat UI system for the gateway admin, including draggable and resizable popover components, chat session management with SSE streaming, persistent UI state, page context synchronization, and backend enhancements for structured page context parameter handling and session subscriber limits.

Changes

Cohort / File(s) Summary
Admin Layout Wrapper
apps/gateway-admin/app/(admin)/layout.tsx
Admin layout now renders children within new AdminLayoutClient component instead of directly.
Floating Chat UI Components
apps/gateway-admin/components/floating-chat-fab.tsx, apps/gateway-admin/components/floating-chat-popover.tsx, apps/gateway-admin/components/floating-chat-shell.tsx, apps/gateway-admin/components/admin-layout-client.tsx
New UI layer for floating chat: FAB with keyboard shortcuts and connection state indicators, popover with draggable header and resizable corners with localStorage persistence, shell wiring chat actions/streams, and client wrapper managing session provider initialization and page context sync.
Page Context Integration
apps/gateway-admin/components/page-context-sync.tsx, apps/gateway-admin/lib/chat/use-page-context-sync.ts
New hook and component to synchronize current route pathname into chat session context, enabling route-aware prompt augmentation.
Chat Session Management
apps/gateway-admin/lib/chat/chat-session-provider.tsx, apps/gateway-admin/lib/chat/session-event-cache.ts, apps/gateway-admin/lib/chat/use-session-events.ts
Provider exposing session data/actions/connection/stream contexts with SSE event streaming, LRU-evicted in-memory event cache keyed by session ID, and event accumulation logic for efficient state updates.
Chat Utilities & Normalizers
apps/gateway-admin/lib/chat/acp-normalizers.ts, apps/gateway-admin/lib/chat/use-chat-session-controller.ts, apps/gateway-admin/lib/acp/fetch.ts
Shared ACP payload normalization (session summaries, provider health, error handling), refactored session controller using centralized normalizers, and new ACP fetch utility with automatic auth header and content-type handling.
Design System Documentation
apps/gateway-admin/components/design-system/design-system-shell.tsx, apps/gateway-admin/components/design-system/floating-chat-section.tsx
Added floating chat pattern documentation and interactive demos to design system grid, including FAB variants and popover states.
Backend: ACP Service & Limits
crates/lab/src/api/services/acp.rs, crates/lab/src/dispatch/acp/catalog.rs
Added prompt character length limit (64k), optional structured page_context parameter in prompt requests, stricter SSE ticket validation, and per-session subscriber limit (32) enforcement.
Backend: Page Context Dispatch
crates/lab/src/dispatch/acp/page_context.rs, crates/lab/src/dispatch/acp/dispatch.rs, crates/lab/src/dispatch/acp.rs
New page context injection module with field sanitization, prompt prefix assembly, and logic to augment prompts with allowlisted route/entity information while preventing injection attacks; updated prompt dispatch to use injected context.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant UI as FloatingChatFab
    participant Pop as FloatingChatPopover
    participant Shell as FloatingChatShell
    participant Provider as ChatSessionProvider
    participant SSE as SSE Stream
    participant Backend as ACP Backend

    User->>UI: Click FAB or Cmd+/
    UI->>Pop: togglePopover (open=true)
    Pop->>Pop: Persist state to localStorage
    
    Note over Shell: Shell mounts within Provider
    Shell->>Provider: Request session data (runs, selected run)
    Provider-->>Shell: Return session state
    
    User->>Shell: Enter prompt text
    Shell->>Provider: sendPrompt(text) with pageContext
    Provider->>Backend: POST /sessions/{runId}/prompt<br/>(text + page_context)
    
    Backend->>Backend: Sanitize pageContext fields<br/>Inject context into prompt
    Backend->>Backend: Execute prompt dispatch
    
    Backend->>SSE: Create event stream
    SSE-->>Provider: Stream events (role, content, status)
    Provider->>Provider: Cache events in sessionEventCache
    Provider->>Shell: Update transcript & message thread
    Shell->>UI: Stream message appears in UI

    Note over Pop: Popover maintains draggable position<br/>and resizable size in localStorage
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 A chat floats in, both sleek and spry,
With dragging grace and resize's sigh,
The route whispers context so neat,
While SSE streams make sessions complete,
No injection tricks shall pass our guard! 💬✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.40% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main feature: a global floating chat popover that achieves parity with the /chat route's functionality.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/lab-gych

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e8c72c67f2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +212 to +215
const appRoot = document.querySelector('#__next') as HTMLElement | null
if (appRoot) {
appRoot.inert = true
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop inerting the root app container

When the popover opens, this sets #__next to inert. Because the floating chat panel is rendered inside that same root container, the dialog and all of its controls become non-interactive along with the rest of the app, so users can’t type, click, or focus reliably while chat is open. This should inert only non-dialog siblings (or use an overlay/portal approach), not the root element containing the dialog.

Useful? React with 👍 / 👎.

Comment on lines +133 to +136
const selectAgent = React.useCallback((_providerId: string) => {
// provider selection is handled by useChatSessionActions.selectAgent
// local agent selection just for visual state in this shell
}, [])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire agent picker to shared session actions

The floating shell passes a no-op handler to ChatInput for agent changes, so selecting a different agent in the picker never updates selectedProviderId. In multi-provider setups this means new sessions/prompts keep using the old provider despite the UI selection, which breaks the “/chat parity” behavior.

Useful? React with 👍 / 👎.

const onFirstOpenRef = React.useRef<(() => void) | null>(null)
const hasOpenedOnce = React.useRef(false)
// State-based lazy mount for FloatingChatShell
const [shellMounted, setShellMounted] = React.useState(false)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Initialize shell mount when open state is restored

The popover open state can restore to true from persisted storage, but shellMounted always starts false and is only set in handleToggle. On a reload with persisted-open enabled, users get an open popover without chat content/stream startup until they manually close and reopen it. shellMounted (and first-open bookkeeping) should be initialized from the restored open state.

Useful? React with 👍 / 👎.

Comment thread apps/gateway-admin/lib/chat/chat-session-provider.tsx Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a global, always-available floating chat surface to gateway-admin that shares session state with the existing /chat experience, plus server-side support for optionally injecting compact page context into ACP prompts.

Changes:

  • Introduces a shared ChatSessionProvider (split into 4 contexts) and an ACP fetch utility used by both /chat-style surfaces and the new floating chat.
  • Adds a floating FAB + draggable/resizable popover and a shell that wires existing chat UI components into the popover.
  • Extends the ACP prompt API to accept optional pageContext and prepends a compact context prefix when valid.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
crates/lab/src/api/services/acp.rs Adds optional pageContext to prompt handling with sanitization + prefix assembly and logging.
apps/gateway-admin/lib/acp/fetch.ts Adds a small ACP fetch helper to unify base URL/credentials/headers.
apps/gateway-admin/lib/chat/chat-session-provider.tsx Introduces a shared provider for sessions + lazy SSE stream management and shared caches.
apps/gateway-admin/components/admin-layout-client.tsx Wraps admin pages with the provider and mounts the FAB/popover/shell.
apps/gateway-admin/components/floating-chat-fab.tsx Implements the fixed pill FAB with hotkey and modal-stack guard integration.
apps/gateway-admin/components/floating-chat-popover.tsx Implements the draggable/resizable popover, persistence, mobile sheet, and focus trapping.
apps/gateway-admin/components/floating-chat-shell.tsx Wires the provider contexts into existing chat UI for popover parity.
apps/gateway-admin/components/design-system/floating-chat-section.tsx Adds a design-system sandbox section documenting/demonstrating the pattern.
apps/gateway-admin/components/design-system/design-system-shell.tsx Registers the new Floating Chat section in the design system page.
apps/gateway-admin/app/(admin)/layout.tsx Hosts the client wrapper in the admin layout without converting the layout to a client component.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/lab/src/api/services/acp.rs Outdated
Comment on lines +79 to +85
let route = sanitize_page_context_field(&ctx.route)?;

// Build prefix candidates, longest first, then trim to token budget
let prefix = match (&ctx.entity_type, &ctx.entity_id) {
(Some(et), Some(eid)) => {
let entity_type = sanitize_page_context_field(et)?;
let entity_id = sanitize_page_context_field(eid)?;
Comment thread crates/lab/src/api/services/acp.rs Outdated
Comment on lines +201 to +219
// Assemble the effective prompt text: optional context prefix + user prompt.
// Validation failure silently skips injection — never errors the request.
let prompt_text = if let Some(ctx) = &body.page_context {
match assemble_page_context_prefix(&session_id, ctx) {
Some(prefix) => format!("{}\n\n{}", prefix, body.prompt.trim()),
None => {
tracing::warn!(
surface = "api",
service = "acp",
action = "session.prompt",
session_id = %session_id,
"page context validation failed — injecting without context",
);
body.prompt.trim().to_string()
}
}
} else {
body.prompt.trim().to_string()
};
Comment on lines +39 to +44
const [config, setConfig] = React.useState<PersistConfig>({
persistOpen: true,
persistPosition: true,
persistSize: true,
sendPageContext: false,
})
Comment on lines +599 to +607
for (const event of consumed.events) {
lastSeqRef.current = event.seq
sessionLastSeqCache.set(selectedRunId, event.seq)
setEvents((current) => {
const next = appendSessionEvent(current, event)
sessionEventCache.set(selectedRunId, next)
return next
})
}
Comment thread apps/gateway-admin/lib/chat/chat-session-provider.tsx
Comment thread crates/lab/src/api/services/acp.rs Outdated
Comment on lines +61 to +64
// Deny-list check (case-insensitive)
let lower = stripped.to_lowercase();
for denied in PAGE_CONTEXT_DENY_LIST {
if lower.contains(denied) {
Comment thread crates/lab/src/api/services/acp.rs Outdated
Comment on lines +45 to +50
/// NFKC-normalizes (ASCII-safe: identity for ASCII input), strips to allowed characters,
/// truncates to 32 chars.
fn sanitize_page_context_field(value: &str) -> Option<String> {
// Strip to allowed characters (NFKC normalization is identity for our ASCII allow-list).
// For non-ASCII Unicode the filter naturally drops all non-ASCII chars, achieving
// the same safety goal as NFKC + strip without pulling in the unicode-normalization crate.
Comment on lines +211 to +215
// Inert the rest of the page
const appRoot = document.querySelector('#__next') as HTMLElement | null
if (appRoot) {
appRoot.inert = true
}
Comment thread apps/gateway-admin/components/admin-layout-client.tsx Outdated
Comment on lines +133 to +137
const selectAgent = React.useCallback((_providerId: string) => {
// provider selection is handled by useChatSessionActions.selectAgent
// local agent selection just for visual state in this shell
}, [])

jmagar added 5 commits April 27, 2026 08:58
- Wire setPageContext producer: add usePageContextSync hook and
  PageContextSync component, render inside ChatSessionProvider in
  AdminLayoutClient so every page propagates its route automatically

- Fix focus-trap inert selector: replace Pages Router #__next selector
  with App Router compatible body-children iteration, inert all direct
  body children except the panel itself to avoid blocking the overlay

- Wire selectAgent in FloatingChatShell: call useChatSessionActions()
  selectAgent directly instead of no-op local callback

- Simplify: remove redundant selectAgent wrapper closure, pass context
  action directly; rename inerter -> toRestore for clarity; trim
  what-comments from new files, keep only non-obvious constraint notes

Build: cargo build --workspace --all-features exit=0
Clippy: cargo clippy --workspace --all-features -D warnings exit=0
P1 (all 6 fixed):
- lab-gych.6: SSE 401 for missing ticket; remove String::new() fallback
- lab-gych.7: initialize shellMounted from same localStorage check as open
- lab-gych.8: pure setState updater; move side effects to useEffect([open])
- lab-gych.9: move sanitize/assemble page_context to dispatch/acp/page_context.rs;
  HTTP handler is now a thin shim; CLI/MCP get page_context support for free
- lab-gych.10: extract shared normalizers to lib/chat/acp-normalizers.ts;
  eliminate duplicate types/functions between chat-session-provider and controller
- lab-gych.11: create lib/chat/session-event-cache.ts; both SSE consumers
  import from single source, remove "must stay in sync" comment

P2 (all 9 fixed):
- lab-gych.12: batch setEvents per SSE chunk (O(n) → single call);
  reverse-scan resolveSessionStatusFromEvents (O(1) in common case)
- lab-gych.13: PROMPT_MAX_CHARS = 64_000 guard before processing
- lab-gych.14: patchPersistedState() with in-memory cache; exported from popover
- lab-gych.15: initialize config state from readPersistedState().config
- lab-gych.16: set Content-Type: application/json when body present in fetchAcp
- lab-gych.17: fix 4 type violations (timer | undefined, union narrowing,
  NonNullable<PageContext> field type)
- lab-gych.18: add sessionsLoaded guard to auto-bootstrap effect; AbortController
- lab-gych.19: remove "admin"/"prompt"/"system" from deny-list; keep only
  injection-specific terms; rely on character allowlist as primary safety
- lab-gych.20: remove ghost SettingsPanel state + render (no backend wiring)

P3 (4 of 5 addressed):
- lab-gych.21: split on separators before deny-list check in page_context.rs
- lab-gych.22: MAX_SUBSCRIBERS_PER_SESSION = 32 cap in registry.rs
- lab-gych.28: remove React.memo from FloatingChatShell (no stable props)
- lab-gych.29: skipped (triplication requires broader hook refactor)
- lab-gych.26: skipped (PersistConfig move is cross-cutting; low risk/reward)
- lab-gych.6 list_sessions: add TODO(phase-2) comment noting principal filter
  deferred until bearer auth is wired in middleware layer
- lab-gych.18: remove decorative AbortController (createSession does not accept
  a signal; guard was already a no-op); add NOTE(phase-2) for future wiring;
  sessionsLoaded guard still present as the actual fix
- Cross-validate selectedRunId against loaded runs in refreshSessions;
  stale server-deleted session IDs no longer block auto-bootstrap
- Fix misleading comment on isCreatingRef mutex guard

Resolves review thread PRRT_kwDOR8nC1M591X9O
Resolves review thread PRRT_kwDOR8nC1M591YUu
Resolves review thread PRRT_kwDOR8nC1M591YWB
…h.29)

- lab-gych.23: resize now uses rAF + direct DOM writes during pointermove,
  commits to React state only on pointerup (mirrors drag pattern)
- lab-gych.24: remove 4-state lazy-SSE protocol (streamEnabled, onFirstOpenRef,
  hasOpenedOnce, shellMounted); SSE opens whenever selectedRunId is non-null;
  FloatingChatShell always mounted inside popover
- lab-gych.25: remove PersistConfig persist toggles (persistOpen, persistPosition,
  persistSize); always persist all state; rename PersistConfig -> ChatConfig;
  keep sendPageContext as standalone boolean; simplify gear panel
- lab-gych.26: update all three importers (floating-chat-shell, admin-layout-client,
  design-system) to use ChatConfig (PersistConfig removal made type relocation moot)
- lab-gych.27: add LRU eviction to session-event-cache keeping last 10 sessions;
  replace raw Map exports with helper objects; both maps evicted together per key
- lab-gych.29: replace duplicate inline fetchAcp in use-chat-session-controller with
  createAcpFetcher() via stable ref; use fetchAcpRef.current in SSE effect in
  chat-session-provider (removes createAcpFetcher() call inside the effect)
@jmagar jmagar merged commit a522655 into main Apr 27, 2026
6 of 8 checks passed
jmagar added a commit that referenced this pull request May 2, 2026
* spike: validate rmcp AuthClient integration with StreamableHttpClientWorker

Task 0 (gating spike) for the upstream MCP OAuth PKCE plan. Confirms four
integration points against rmcp 1.4.0 before Task 2 commits to a design:

1. AuthClient<reqwest::Client> constructs cleanly over AuthorizationManager
   + InMemoryCredentialStore.
2. AuthClient auto-injects Authorization: Bearer <token> when the caller
   passes auth_token: None — its StreamableHttpClient impl calls
   auth_manager.get_access_token() and fills the slot before delegating.
3. rmcp does NOT automatically refresh on a 401 from the upstream.
   AuthorizationManager::get_access_token() only refreshes on the local
   clock (REFRESH_BUFFER_SECS = 30s). Refresh-on-401 is the caller's
   responsibility, so Task 2 must layer it on.
4. Spike runs against a wiremock AS+RS stub by default, and against a
   real OAuth-protected MCP upstream when SPIKE_REAL_AS_URL is set, so
   the operator can validate end-to-end interactively before Task 2
   starts.

Plan A (AuthClient as StreamableHttpClient) is confirmed; Plan B (custom
wrapper that calls get_access_token() pre-request) is strictly inferior
and not needed.

Findings duplicated inline at the top of the spike example AND in the
stub crates/lab/src/oauth/upstream/refresh.rs that Task 2 will replace.
The upstream/* files are intentionally NOT wired into oauth.rs yet —
they are exploratory docs.

* feat: add rmcp-backed upstream oauth manager with single-flight refresh and at-rest encryption

- store.rs: SqliteCredentialStore + SqliteStateStore implementing rmcp CredentialStore/StateStore traits
  - ChaCha20-Poly1305 encryption at rest; decryption failure → AuthorizationRequired
  - StateStore::load uses atomic DELETE…RETURNING (take_upstream_oauth_state); delete is no-op
  - Two-lifetime pattern ('life0: 'async_trait, Self: 'async_trait) matching async_trait expansion
- refresh.rs: RefreshLocks (DashMap per-(upstream,subject) Mutex) + refresh_if_stale()
  - Single-flight serialization prevents concurrent refresh storms
  - AuthError::AuthorizationRequired → OauthError::NeedsReauth
- manager.rs: UpstreamOauthManager orchestrates full authorization_code+PKCE flow
  - begin_authorization: discovers/caches AS metadata, enforces S256, saves PKCE state
  - complete_authorization_callback: exchanges code, persists encrypted credentials
  - clear_credentials: deletes tokens + evicts pending state
  - build_auth_client: creates fresh AuthClient from stored credentials with proactive refresh
  - Supports Preregistered and Dynamic registration; ClientMetadataDocument is TODO
- Cargo.toml: promote oauth2 from dev-dep to regular dep (needed for TokenResponse trait methods)

* feat(upstream-oauth): wire HTTP routes and AppState for upstream OAuth callback

Task 3: mount /v1/upstream-oauth/:name/{start,callback} routes in the axum
router, guarded by upstream_oauth.is_some(). Add upstream_oauth field to
AppState with with_upstream_oauth() builder. Routes are outside the bearer
middleware — browser redirects from the AS cannot carry Authorization headers.

* feat(upstream): wire per-(upstream,subject) AuthClient cache into UpstreamPool

Add oauth_managers field to UpstreamPool (DashMap keyed by upstream name),
with_oauth_managers() builder for injection at serve time, and OAuth branch
in connect_http_upstream: looks up manager, calls build_auth_client("default"),
wraps the AuthClient in StreamableHttpClientWorker. Non-OAuth path unchanged.
oauth_required error on NeedsReauth marks upstream unhealthy via discover_all.

* docs: add upstream oauth pkce gateway guidance and error kinds

* feat(upstream-oauth): enforce S256, issuer binding, canonical resource, CIMD

Completes Task 2 §6 spec-aligned invariants identified by plan audit:

- Must-Fix #1 S256 enforcement: verify_s256 now rejects AS metadata that
  omits `code_challenge_methods_supported` (previously warned and continued)
  or advertises only non-S256 methods. Both paths surface
  `oauth_unsupported_method`.

- Must-Fix #4 canonical resource indicator: UpstreamConfig gains
  `canonical_url()` applying RFC 3986 §6.2.2 normalization at
  validation time (lowercase scheme+host, strip default port,
  dot-segment removal, percent-encoding case). Manager uses the
  canonical form when constructing the AuthorizationManager so
  rmcp's `resource` parameter on authorize and token is byte-identical
  to the canonical upstream URL. Known gap: rmcp 1.4 does not re-emit
  `resource` on the refresh_token grant; documented in UPSTREAM.md.

- Must-Fix #5 issuer binding (scope 6b): verify_issuer_binding requires
  `metadata.issuer` to be present and enforces host-consistency across
  authorization_endpoint, token_endpoint, and registration_endpoint
  (when present). Cannot duplicate rmcp discovery to bind against the
  successful discovery URL, so the check is approximated via endpoint
  host consistency. Violations surface as `oauth_issuer_mismatch`.

- CIMD registration: ClientMetadataDocument strategy now constructs the
  OAuth client locally, using the metadata document URL as the
  client_id. No registration_endpoint call is issued.

- Must-Fix #6 reactive 401 (scope 6b): deferred. rmcp's
  StreamableHttpClientWorker hides the raw HTTP response, so a 401 on
  an MCP call surfaces as a generic transport error. Operators
  recover via `POST /v1/gateway/oauth/start`. Documented in UPSTREAM.md
  so the doc no longer promises retry semantics that code does not
  implement.

Also ships Task 2/3 scaffolding kept uncommitted in the worktree:
UpstreamOauthCredentialRow + UpstreamOauthStateRow (manual redacted
Debug), UpstreamOauthConfig + UpstreamOauthRegistration enum (CIMD,
Preregistered, Dynamic), and `oauth: None` test-fixture fill-ins.

Tests:
- crates/lab/tests/upstream_oauth.rs (8 tests)
  - canonical_url_strips_default_port_and_lowercases_host
  - missing_code_challenge_methods_returns_unsupported
  - plain_pkce_only_returns_unsupported
  - authorize_url_carries_canonical_resource_indicator
  - token_exchange_carries_canonical_resource_indicator
  - issuer_missing_returns_issuer_mismatch
  - issuer_endpoint_host_mismatch_returns_issuer_mismatch
  - cimd_registration_uses_metadata_url_as_client_id

* feat: wire subject-scoped upstream oauth cache

* feat: finish upstream oauth gateway wiring and verification

* fix: align upstream oauth HTTP surface with ERRORS.md spec and add dispatch telemetry

Add elapsed_ms field to all four OAuth handlers (start, status, clear, callback)
so every dispatch event includes surface/service/action/elapsed_ms per OBSERVABILITY.md.

Change clear handler confirmation_required response from 400 plain-text to 422 JSON
envelope (ToolError::Sdk) to match the documented spec in ERRORS.md. Update
accompanying test to assert 422 UNPROCESSABLE_ENTITY and JSON kind field.

Resolves review threads:
  PRRT_kwDOR8nC1M576Bjh (missing dispatch logs)

Note: router callback placement, HTML escaping (html_escape helper already present),
subject validation (subject sourced from JWT auth.sub not query param), and axum
path syntax were all already correctly implemented — no changes needed.

* fix: harden upstream oauth manager - encryption, TOCTOU, issuer binding, client secret

seal()/seal_with_aad() now return Result<_, EncryptionError> instead of panicking via
.expect(); store.rs propagates the error as AuthError::InternalError.

get_or_discover_metadata holds the write lock across discovery to eliminate the
read-lock-drop-write-lock TOCTOU race where two callers could both issue discovery.

verify_issuer_binding changed from host-only comparison to full origin comparison
(scheme + host + explicit port) so http/https scheme and port differences are caught.

resolve_client_config now returns OauthError::Internal when client_secret_env names
an env var that is not set or empty, instead of silently using an empty secret.

Resolves review threads:
  PRRT_kwDOR8nC1M576Bjk  (seal() panics - encryption.rs)
  PRRT_kwDOR8nC1M576Cmu  (seal() panics - encryption.rs)
  PRRT_kwDOR8nC1M576BYd  (refresh lock TOCTOU)
  PRRT_kwDOR8nC1M576Cmn  (metadata cache TOCTOU)
  PRRT_kwDOR8nC1M576ez2  (issuer binding host-only)
  PRRT_kwDOR8nC1M576Bjm  (missing client_secret env var silent)
  PRRT_kwDOR8nC1M576Cmp  (missing client_secret env var silent)

Note: extract_state_param None-guard (FIX E) and in-memory PKCE map TTL (FIX I)
were both already correctly implemented in this codebase — no changes needed.

* fix: enforce upstream config validation at startup and fix cleanup_expired predicate

UpstreamConfig::validate() is now called for each upstream in load_toml(), so invalid
configs (bad URL scheme, conflicting auth fields) are caught at startup rather than
at first OAuth flow attempt. validate() also now rejects non-http/https URL schemes.

cleanup_expired changes both DELETE predicates from < to <= so rows expiring exactly
at the current timestamp are cleaned up consistently with the rest of the expiry checks.

Resolves review threads:
  PRRT_kwDOR8nC1M576ez0  (validate() never called at startup)
  PRRT_kwDOR8nC1M576dqS  (config.rs critical - URL scheme not validated)
  PRRT_kwDOR8nC1M576dqQ  (cleanup_expired < vs <= off-by-one)

Note: OauthError::Internal display/kind strings were already aligned in types.rs
(Display prefix matches kind() return value) — no change needed.

* fix: redact token from spike error, wire gateway validation, correct docs

spike_rmcp_auth_client.rs: remove token value from bail! error message to prevent
leaking access tokens into log output (use placeholder instead).

gateway/config.rs validate_upstream: call upstream.validate() at the start so that
bearer_token_env + oauth mutual-exclusion and other config constraints are enforced
in the gateway dispatch layer, not only in the top-level config loader.

docs/CONFIG.md: fix clear endpoint URL example to include required upstream= param.

docs/GATEWAY.md:
- Update clear endpoint description to reflect 422 JSON response (not 400 plain-text)
  and document required upstream= query param
- Correct callback-security section: remove claim that handler re-validates
  upstream-vs-state-row in application code (enforcement is via SQL primary key)

docs/UPSTREAM.md: correct claim that OAuth upstreams participate in startup discovery;
they are excluded from discover_all and connected per-request, not pooled.

Resolves review threads:
  PRRT_kwDOR8nC1M576Bjb  (token in spike error)
  PRRT_kwDOR8nC1M576dqT  (gateway/config.rs critical - validate not called)
  PRRT_kwDOR8nC1M576dqW  (CONFIG.md clear URL missing upstream=)
  PRRT_kwDOR8nC1M576dqa  (GATEWAY.md:140 clear endpoint description)
  PRRT_kwDOR8nC1M576dqc  (GATEWAY.md:152 callback invariants not in code)
  PRRT_kwDOR8nC1M576dqe  (UPSTREAM.md:158 OAuth discovery claim inaccurate)

Note: with_oauth_managers() wiring (FIX G) was already correctly implemented via
with_oauth_client_cache() in cli/serve.rs. OAuth error kinds in ERRORS.md were
already documented. No changes needed for those items.

* fix(oauth): remove dead url_host fn, duplicate tool_error_from_oauth, stale comment

Resolves review threads #15, #3, #21.
- Remove #[allow(dead_code)] url_host() from manager.rs (thread 15)
- Remove duplicate pub tool_error_from_oauth from gateway/oauth.rs; the
  private copy in manager.rs is the only caller (thread 3)
- Drop unused OauthError import from oauth.rs
- Correct stale comment in spike_rmcp_auth_client.rs: mock returns 401
  to drive re-auth path, not 200 (thread 21)

* docs(oauth): align upstream OAuth docs with implementation

Resolves review threads #4, #17, #18, #19, #24, #25, #26, #27, #28,
#29, #30, #36, #37, #38.

UPSTREAM.md:
- Thread 4: OAuth upstreams are attempted at startup and fail unhealthy
  (not excluded entirely)
- Thread 18: Issuer binding checks origin (scheme+host+port), not just
  host; covers auth/token/revocation/userinfo endpoints
- Thread 19: Remove false LRU-cap claim; lock entries live for process
  lifetime
- Thread 24: Merged catalog is transport-neutral; OAuth upstreams appear
  in catalog but need HTTP session to initiate authorization
- Thread 28: POST /v1/gateway/oauth/start route references are correct
- Thread 38: Remove auto-delete claim on invalid_grant (not implemented)

GATEWAY.md:
- Thread 17: Pending state SQL key is (upstream_name, subject,
  csrf_token), not just (upstream_name, csrf_token)
- Thread 25: Reload eagerly evicts all OAuth AuthClient entries; remove
  false built_with_client_id eviction-on-mismatch claim
- Thread 26: Routes /v1/gateway/oauth/* are implemented as documented
- Thread 27: Callback is browser-facing; subject from session cookie,
  not from state parameter

ERRORS.md:
- Thread 30: Remove "(RFC 7636 absence implies plain-only)" — omission
  of code_challenge_methods_supported is not equivalent to plain-only
- Thread 36: oauth_issuer_mismatch triggers on missing issuer or
  endpoint/issuer origin mismatch, not direct discovered-URL equality
- Thread 37: Route references /v1/gateway/oauth/status and
  POST /v1/gateway/oauth/start are correct (no /v1/upstream-oauth/ routes)

* fix(oauth): evict build_locks, fix param attribution, https guard, TTL guard, epoch default

Resolves review threads #1, #2/#9, #16, #20, #31, #32, #35.

cache.rs (thread 1):
- evict_subject and evict_upstream now also remove entries from
  build_locks, preventing unbounded growth on long-running processes

config.rs (threads 2/9):
- validate_upstream maps ConfigError::InvalidUrl to param="url" instead
  of "bearer_token_env"; auth-conflict errors still map to bearer_token_env

manager.rs (thread 35):
- ClientMetadataDocument URL validation now enforces https scheme;
  http URLs are rejected with OauthError::Internal

store.rs (thread 16):
- token_received_at falls back to now_unix() instead of 0 (Unix epoch)
  when absent; prevents access_token_expires_at underflow for tokens
  that don't carry a received_at timestamp

sqlite.rs (threads 20, 31, 32):
- TTL guard now also rejects expires_at <= created_at (negative delta)
  to prevent integer underflow on malicious/clock-skewed input
- cleanup queries already used <=; threads 31/32 already resolved

* fix(oauth): stdio ordering, oauth URL guard, is_master gating

Resolves review threads #7, #8, #22.

serve.rs (thread 7):
- Compute stdio_mode before build_upstream_oauth_runtime; skip OAuth
  runtime init entirely in stdio mode so missing LAB_PUBLIC_URL /
  LAB_OAUTH_ENCRYPTION_KEY never fails a stdio serve

config.rs / gateway config.rs (thread 8):
- UpstreamConfig::validate now rejects oauth+no-url combinations with
  ConfigError::MissingOauthUrl; gateway config dispatch maps the new
  variant to param="url"

router.rs (thread 22):
- Gateway OAuth routes (/v1/gateway/oauth/*) and browser callback are
  now guarded by is_master; non-master nodes no longer mount them

* fix(oauth): redirect error kind, circuit breaker, tracing, DashMap clone, callback ext, reload reconcile

Resolves review threads #5, #6, #10, #11, #12, #13, #23, #50/#71.

upstream_oauth.rs (threads 5, 23):
- Callback: embed error_kind in redirect URL query params instead of
  x-lab-oauth-error-kind header (browsers silently discard headers on
  302 responses)
- Callback: extract AuthContext via Option<Extension<AuthContext>>
  instead of reconstructing Parts from empty request (which discarded
  middleware extensions); update callback_subject signature accordingly

pool.rs (threads 6, 11):
- subject_scoped_call_tool: add circuit breaker calls (record_success_for
  / record_failure_for) around the peer call
- subject_scoped_read_resource: add circuit breaker calls AND response
  size guard matching the non-scoped read_upstream_resource path
- subject_scoped_get_prompt: add circuit breaker calls

server.rs (thread 12):
- Subject-scoped dispatch path now emits tracing::info! on success and
  tracing::warn! on failure, matching the non-subject-scoped path

cache.rs (thread 13):
- get_or_build: clone DashMap Ref before the .await call on
  build_auth_client to avoid holding a DashMap read-lock across await
  (potential deadlock under contention)

manager.rs (thread 10):
- reload: reconcile upstream_oauth_managers after loading new config;
  remove managers for OAuth upstreams no longer present, warn about
  new OAuth upstreams that need restart to get a manager

Threads 50/71 (TokenRefreshFailed → NeedsReauth): already mapped
correctly in map_auth_error; no change needed.

* fix(oauth): dynamic registration once per upstream, not per call

Resolves review thread PRRT_kwDOR8nC1M579vdo

`configured_authorization_manager` was calling `register_client` on
every invocation (complete_authorization_callback, build_auth_client),
receiving a new AS-assigned client_id each time — mismatching the id
used to start the flow.

Fix: `resolve_client_config` for Dynamic now:
  1. Checks stored credential row (available after token exchange)
  2. Checks in-memory `dynamic_client_ids` cache (populated by begin_authorization)
  3. Only calls register_client on the very first invocation

`clear_credentials` evicts the in-memory cache entry so a fresh
registration is issued when re-authorizing after credential clearance.

* fix(gateway): exhaustive ConfigError match, no catch-all for param attribution

Resolves review thread PRRT_kwDOR8nC1M579vdf

The wildcard arm `_ => param: "bearer_token_env"` was too broad: any
future ConfigError variant would be misattributed to bearer_token_env.

Replace the catch-all with an exhaustive match over all three ConfigError
variants so the compiler enforces correct attribution if new variants are
added. ConflictingAuth → bearer_token_env; MissingOauthUrl + InvalidUrl → url.

* fix(oauth): persist dynamic client registrations to SQLite

Dynamic registration (RFC 7591) must survive server restarts. Replace
the in-memory DashMap cache with a durable `upstream_oauth_dynamic_clients`
SQLite table (WITHOUT ROWID, PK: upstream_name + subject).

- Add `upstream_oauth_dynamic_clients` table to `open_connection` schema
- Add `save_dynamic_client_registration` (UPSERT), `find_dynamic_client_registration`
  (SELECT), and `delete_dynamic_client_registration` (DELETE) to `SqliteStore`
- Add round-trip test covering save, upsert, find, delete, and subject isolation
- Remove `dynamic_client_ids: Arc<DashMap<...>>` field from `UpstreamOauthManager`
- `resolve_client_config` Dynamic branch now checks SQLite before calling
  `register_client`, then persists the assigned client_id immediately
- `clear_credentials` also deletes the dynamic client registration row

client_secret is not stored: `token_endpoint_auth_method: "none"` means the
AS issues public clients and must not return a client_secret.

* feat(gateway-admin): upstream OAuth connection UI

Adds an "Upstream Connections" section to the Gateways page with per-upstream
Connect/Disconnect cards. Cards poll auth status, open the authorization URL
in a new tab, and wait for the callback before clearing the connecting state.

- GET /v1/gateway/oauth/upstreams — lists upstreams with oauth: config
- upstream-oauth-card: badge (Connected/Expiring/Disconnected), Connect/Disconnect
- upstream-oauth-section: grid of cards, null when no oauth upstreams configured
- Fix redirect loop: restore allow_session_cookie guard on browser redirect
- Fix redirect loop: switch v1 + MCP auth middleware to route_layer so unmatched
  SPA paths (e.g. /gateways) are not intercepted by auth middleware

* style: rustfmt router.rs
jmagar added a commit that referenced this pull request May 2, 2026
…ity (#38)

* feat(lab-gych.1): ChatSessionProvider 4-context split + ACP fetch utility

Create ChatSessionProvider with 4-context re-render isolation:
- ChatSessionDataContext: runs, selectedRun, providers, pageContext
- ChatSessionActionsContext: stable callbacks (createSession, selectRun, etc.)
- ChatSessionConnectionContext: connectionState, sessionStatus
- ChatSessionStreamContext: messages, events, activity

Lazy SSE stream: starts only on first FAB click via onFirstOpenRef callback.
Users who never open chat pay zero SSE cost.

createSession mutex: prevents duplicate session creation.
selectedRunId seeded from localStorage with regex validation.
providers setter uses bail-out comparison to prevent unnecessary re-renders.
Auto-bootstrap gated behind streamEnabled.

Also adds lib/acp/fetch.ts: standalone ACP fetcher that re-derives
acpBase and requestCredentials per call (not at module eval time).

* feat(lab-gych.5): ACP gateway pageContext support — opt-in compact system prefix

POST /acp/sessions/{id}/prompt now accepts optional pageContext field:
{ prompt: string, pageContext?: { route, entityType?, entityId? } }

When pageContext is present, server assembles a compact context prefix
and prepends it to the prompt before forwarding to the LLM:
  [context: page={route}]
  [context: page={route} entity={type}/{id}]

Token safeguards:
- Off by default: absent pageContext = zero injection, zero token cost
- Server-side assembly only: client sends structured data
- Hard token budget: 30 tokens (~120 chars); truncates entityId first,
  then entityType, never route
- No verbose markdown: single compact line
- NFKC-safe strip: chars filtered to [a-zA-Z0-9/_-], max 32 chars/field
- Deny-list: system, ignore, override, admin, instruction, assistant,
  prompt — any match skips injection, never errors the request
- Logged: every injection emits surface=api, session_id, route,
  estimated_tokens at INFO level

Validation failure silently skips injection — never errors the request.

* feat(lab-gych.2): FAB + draggable/resizable popover shell with full UX

FloatingChatFab:
- Fixed pill (bottom-right, z-40), rounded-full Aurora-styled
- Cmd/Ctrl+/ hotkey toggle, skips inputs and contentEditable
- CSS-hidden (visibility:hidden) on /chat route — NOT unmounted
- Ambient connection indicator (connecting = pulsing ring, error = amber dot)
- Streaming pulse animation (respects prefers-reduced-motion)
- Modal stack guard via openModals RefObject

FloatingChatPopover:
- Default 420x600, min 320x420, max 800x900
- Drag via DOM ref + rAF (no React state during drag), pointer capture
- Resize via bottom-right corner handle with pointer capture
- Viewport hard-clamp on drag commit and window resize (debounced 100ms)
- Gear config panel: 4 toggles (persistOpen, persistPosition, persistSize,
  sendPageContext) — persistence via labby:floating-chat:state localStorage key
- Accessibility: role=dialog, aria-modal, aria-labelledby, tabIndex=-1,
  focus trap, Escape closes, focus returns, HTML inert on app root
- Mobile: renders as full-screen Sheet (< 768px)
- CSS-hidden (not unmounted) on /chat route for both FAB and popover

AdminLayoutClient:
- 'use client' wrapper component preserving server component layout.tsx
- Hosts ChatSessionProvider, FAB, Popover
- Wires onFirstOpenRef for lazy SSE stream activation
- Persists open state to localStorage

Layout updated to use AdminLayoutClient.

* feat(lab-gych.3): FloatingChatShell — full /chat parity in popover

FloatingChatShell consumes all 4 ChatSession contexts:
- Data: runs, selectedRun, agents, projects, pageContext
- Actions: createSession, selectRun, refreshSessions
- Connection: connectionState (for future connection indicator)
- Stream: messages (subscribed via StreamContext only)

React.memo wrapper: shell only re-renders when messages changes
during streaming — header/input don't re-render on token stream.

sendPrompt wires pageContext: when config.sendPageContext === true
AND pageContext !== null, includes pageContext as separate field in
request body: { prompt, pageContext: { route, entityType?, entityId? } }

Lazy mount pattern in AdminLayoutClient:
- shellMounted state starts false
- Set to true on first FAB click (alongside stream enable)
- FloatingChatShell mounts permanently after first click
- Popover container controls visibility via CSS, not mount state

* feat(lab-gych.4): FloatingChat design system section

Adds FloatingChatSection to /design-system sandbox with:
- FAB states: default, with-badge (count=3), active/open, connecting
  (pulsing ring), error (amber dot)
- Popover states: default, gear config open (4 toggles), compact (min size)
- Persistence schema: localStorage key + shape as code block
- Hotkey reference: ⌘/ (Ctrl+/ on Windows) with KbdGroup/Kbd primitives
- Mobile Sheet note: < 768px uses bottom Sheet, no drag/resize

All demos use local fake state — no live backend required.
Placed after CommandPaletteSection in Application Patterns group.

* verify: address gaps and simplify lab-gych implementation

- Wire setPageContext producer: add usePageContextSync hook and
  PageContextSync component, render inside ChatSessionProvider in
  AdminLayoutClient so every page propagates its route automatically

- Fix focus-trap inert selector: replace Pages Router #__next selector
  with App Router compatible body-children iteration, inert all direct
  body children except the panel itself to avoid blocking the overlay

- Wire selectAgent in FloatingChatShell: call useChatSessionActions()
  selectAgent directly instead of no-op local callback

- Simplify: remove redundant selectAgent wrapper closure, pass context
  action directly; rename inerter -> toRestore for clarity; trim
  what-comments from new files, keep only non-obvious constraint notes

Build: cargo build --workspace --all-features exit=0
Clippy: cargo clippy --workspace --all-features -D warnings exit=0

* fix: address lavra-review findings for lab-gych floating chat

P1 (all 6 fixed):
- lab-gych.6: SSE 401 for missing ticket; remove String::new() fallback
- lab-gych.7: initialize shellMounted from same localStorage check as open
- lab-gych.8: pure setState updater; move side effects to useEffect([open])
- lab-gych.9: move sanitize/assemble page_context to dispatch/acp/page_context.rs;
  HTTP handler is now a thin shim; CLI/MCP get page_context support for free
- lab-gych.10: extract shared normalizers to lib/chat/acp-normalizers.ts;
  eliminate duplicate types/functions between chat-session-provider and controller
- lab-gych.11: create lib/chat/session-event-cache.ts; both SSE consumers
  import from single source, remove "must stay in sync" comment

P2 (all 9 fixed):
- lab-gych.12: batch setEvents per SSE chunk (O(n) → single call);
  reverse-scan resolveSessionStatusFromEvents (O(1) in common case)
- lab-gych.13: PROMPT_MAX_CHARS = 64_000 guard before processing
- lab-gych.14: patchPersistedState() with in-memory cache; exported from popover
- lab-gych.15: initialize config state from readPersistedState().config
- lab-gych.16: set Content-Type: application/json when body present in fetchAcp
- lab-gych.17: fix 4 type violations (timer | undefined, union narrowing,
  NonNullable<PageContext> field type)
- lab-gych.18: add sessionsLoaded guard to auto-bootstrap effect; AbortController
- lab-gych.19: remove "admin"/"prompt"/"system" from deny-list; keep only
  injection-specific terms; rely on character allowlist as primary safety
- lab-gych.20: remove ghost SettingsPanel state + render (no backend wiring)

P3 (4 of 5 addressed):
- lab-gych.21: split on separators before deny-list check in page_context.rs
- lab-gych.22: MAX_SUBSCRIBERS_PER_SESSION = 32 cap in registry.rs
- lab-gych.28: remove React.memo from FloatingChatShell (no stable props)
- lab-gych.29: skipped (triplication requires broader hook refactor)
- lab-gych.26: skipped (PersistConfig move is cross-cutting; low risk/reward)

* fix: tighten lab-gych.6 and lab-gych.18 follow-ups

- lab-gych.6 list_sessions: add TODO(phase-2) comment noting principal filter
  deferred until bearer auth is wired in middleware layer
- lab-gych.18: remove decorative AbortController (createSession does not accept
  a signal; guard was already a no-op); add NOTE(phase-2) for future wiring;
  sessionsLoaded guard still present as the actual fix

* fix: address PR review comments on session management

- Cross-validate selectedRunId against loaded runs in refreshSessions;
  stale server-deleted session IDs no longer block auto-bootstrap
- Fix misleading comment on isCreatingRef mutex guard

Resolves review thread PRRT_kwDOR8nC1M591X9O
Resolves review thread PRRT_kwDOR8nC1M591YUu
Resolves review thread PRRT_kwDOR8nC1M591YWB

* fix: address all deferred P3 review findings (lab-gych.23-27, lab-gych.29)

- lab-gych.23: resize now uses rAF + direct DOM writes during pointermove,
  commits to React state only on pointerup (mirrors drag pattern)
- lab-gych.24: remove 4-state lazy-SSE protocol (streamEnabled, onFirstOpenRef,
  hasOpenedOnce, shellMounted); SSE opens whenever selectedRunId is non-null;
  FloatingChatShell always mounted inside popover
- lab-gych.25: remove PersistConfig persist toggles (persistOpen, persistPosition,
  persistSize); always persist all state; rename PersistConfig -> ChatConfig;
  keep sendPageContext as standalone boolean; simplify gear panel
- lab-gych.26: update all three importers (floating-chat-shell, admin-layout-client,
  design-system) to use ChatConfig (PersistConfig removal made type relocation moot)
- lab-gych.27: add LRU eviction to session-event-cache keeping last 10 sessions;
  replace raw Map exports with helper objects; both maps evicted together per key
- lab-gych.29: replace duplicate inline fetchAcp in use-chat-session-controller with
  createAcpFetcher() via stable ref; use fetchAcpRef.current in SSE effect in
  chat-session-provider (removes createAcpFetcher() call inside the effect)
jmagar added a commit that referenced this pull request May 2, 2026
…ity (#38)

* feat(lab-gych.1): ChatSessionProvider 4-context split + ACP fetch utility

Create ChatSessionProvider with 4-context re-render isolation:
- ChatSessionDataContext: runs, selectedRun, providers, pageContext
- ChatSessionActionsContext: stable callbacks (createSession, selectRun, etc.)
- ChatSessionConnectionContext: connectionState, sessionStatus
- ChatSessionStreamContext: messages, events, activity

Lazy SSE stream: starts only on first FAB click via onFirstOpenRef callback.
Users who never open chat pay zero SSE cost.

createSession mutex: prevents duplicate session creation.
selectedRunId seeded from localStorage with regex validation.
providers setter uses bail-out comparison to prevent unnecessary re-renders.
Auto-bootstrap gated behind streamEnabled.

Also adds lib/acp/fetch.ts: standalone ACP fetcher that re-derives
acpBase and requestCredentials per call (not at module eval time).

* feat(lab-gych.5): ACP gateway pageContext support — opt-in compact system prefix

POST /acp/sessions/{id}/prompt now accepts optional pageContext field:
{ prompt: string, pageContext?: { route, entityType?, entityId? } }

When pageContext is present, server assembles a compact context prefix
and prepends it to the prompt before forwarding to the LLM:
  [context: page={route}]
  [context: page={route} entity={type}/{id}]

Token safeguards:
- Off by default: absent pageContext = zero injection, zero token cost
- Server-side assembly only: client sends structured data
- Hard token budget: 30 tokens (~120 chars); truncates entityId first,
  then entityType, never route
- No verbose markdown: single compact line
- NFKC-safe strip: chars filtered to [a-zA-Z0-9/_-], max 32 chars/field
- Deny-list: system, ignore, override, admin, instruction, assistant,
  prompt — any match skips injection, never errors the request
- Logged: every injection emits surface=api, session_id, route,
  estimated_tokens at INFO level

Validation failure silently skips injection — never errors the request.

* feat(lab-gych.2): FAB + draggable/resizable popover shell with full UX

FloatingChatFab:
- Fixed pill (bottom-right, z-40), rounded-full Aurora-styled
- Cmd/Ctrl+/ hotkey toggle, skips inputs and contentEditable
- CSS-hidden (visibility:hidden) on /chat route — NOT unmounted
- Ambient connection indicator (connecting = pulsing ring, error = amber dot)
- Streaming pulse animation (respects prefers-reduced-motion)
- Modal stack guard via openModals RefObject

FloatingChatPopover:
- Default 420x600, min 320x420, max 800x900
- Drag via DOM ref + rAF (no React state during drag), pointer capture
- Resize via bottom-right corner handle with pointer capture
- Viewport hard-clamp on drag commit and window resize (debounced 100ms)
- Gear config panel: 4 toggles (persistOpen, persistPosition, persistSize,
  sendPageContext) — persistence via labby:floating-chat:state localStorage key
- Accessibility: role=dialog, aria-modal, aria-labelledby, tabIndex=-1,
  focus trap, Escape closes, focus returns, HTML inert on app root
- Mobile: renders as full-screen Sheet (< 768px)
- CSS-hidden (not unmounted) on /chat route for both FAB and popover

AdminLayoutClient:
- 'use client' wrapper component preserving server component layout.tsx
- Hosts ChatSessionProvider, FAB, Popover
- Wires onFirstOpenRef for lazy SSE stream activation
- Persists open state to localStorage

Layout updated to use AdminLayoutClient.

* feat(lab-gych.3): FloatingChatShell — full /chat parity in popover

FloatingChatShell consumes all 4 ChatSession contexts:
- Data: runs, selectedRun, agents, projects, pageContext
- Actions: createSession, selectRun, refreshSessions
- Connection: connectionState (for future connection indicator)
- Stream: messages (subscribed via StreamContext only)

React.memo wrapper: shell only re-renders when messages changes
during streaming — header/input don't re-render on token stream.

sendPrompt wires pageContext: when config.sendPageContext === true
AND pageContext !== null, includes pageContext as separate field in
request body: { prompt, pageContext: { route, entityType?, entityId? } }

Lazy mount pattern in AdminLayoutClient:
- shellMounted state starts false
- Set to true on first FAB click (alongside stream enable)
- FloatingChatShell mounts permanently after first click
- Popover container controls visibility via CSS, not mount state

* feat(lab-gych.4): FloatingChat design system section

Adds FloatingChatSection to /design-system sandbox with:
- FAB states: default, with-badge (count=3), active/open, connecting
  (pulsing ring), error (amber dot)
- Popover states: default, gear config open (4 toggles), compact (min size)
- Persistence schema: localStorage key + shape as code block
- Hotkey reference: ⌘/ (Ctrl+/ on Windows) with KbdGroup/Kbd primitives
- Mobile Sheet note: < 768px uses bottom Sheet, no drag/resize

All demos use local fake state — no live backend required.
Placed after CommandPaletteSection in Application Patterns group.

* verify: address gaps and simplify lab-gych implementation

- Wire setPageContext producer: add usePageContextSync hook and
  PageContextSync component, render inside ChatSessionProvider in
  AdminLayoutClient so every page propagates its route automatically

- Fix focus-trap inert selector: replace Pages Router #__next selector
  with App Router compatible body-children iteration, inert all direct
  body children except the panel itself to avoid blocking the overlay

- Wire selectAgent in FloatingChatShell: call useChatSessionActions()
  selectAgent directly instead of no-op local callback

- Simplify: remove redundant selectAgent wrapper closure, pass context
  action directly; rename inerter -> toRestore for clarity; trim
  what-comments from new files, keep only non-obvious constraint notes

Build: cargo build --workspace --all-features exit=0
Clippy: cargo clippy --workspace --all-features -D warnings exit=0

* fix: address lavra-review findings for lab-gych floating chat

P1 (all 6 fixed):
- lab-gych.6: SSE 401 for missing ticket; remove String::new() fallback
- lab-gych.7: initialize shellMounted from same localStorage check as open
- lab-gych.8: pure setState updater; move side effects to useEffect([open])
- lab-gych.9: move sanitize/assemble page_context to dispatch/acp/page_context.rs;
  HTTP handler is now a thin shim; CLI/MCP get page_context support for free
- lab-gych.10: extract shared normalizers to lib/chat/acp-normalizers.ts;
  eliminate duplicate types/functions between chat-session-provider and controller
- lab-gych.11: create lib/chat/session-event-cache.ts; both SSE consumers
  import from single source, remove "must stay in sync" comment

P2 (all 9 fixed):
- lab-gych.12: batch setEvents per SSE chunk (O(n) → single call);
  reverse-scan resolveSessionStatusFromEvents (O(1) in common case)
- lab-gych.13: PROMPT_MAX_CHARS = 64_000 guard before processing
- lab-gych.14: patchPersistedState() with in-memory cache; exported from popover
- lab-gych.15: initialize config state from readPersistedState().config
- lab-gych.16: set Content-Type: application/json when body present in fetchAcp
- lab-gych.17: fix 4 type violations (timer | undefined, union narrowing,
  NonNullable<PageContext> field type)
- lab-gych.18: add sessionsLoaded guard to auto-bootstrap effect; AbortController
- lab-gych.19: remove "admin"/"prompt"/"system" from deny-list; keep only
  injection-specific terms; rely on character allowlist as primary safety
- lab-gych.20: remove ghost SettingsPanel state + render (no backend wiring)

P3 (4 of 5 addressed):
- lab-gych.21: split on separators before deny-list check in page_context.rs
- lab-gych.22: MAX_SUBSCRIBERS_PER_SESSION = 32 cap in registry.rs
- lab-gych.28: remove React.memo from FloatingChatShell (no stable props)
- lab-gych.29: skipped (triplication requires broader hook refactor)
- lab-gych.26: skipped (PersistConfig move is cross-cutting; low risk/reward)

* fix: tighten lab-gych.6 and lab-gych.18 follow-ups

- lab-gych.6 list_sessions: add TODO(phase-2) comment noting principal filter
  deferred until bearer auth is wired in middleware layer
- lab-gych.18: remove decorative AbortController (createSession does not accept
  a signal; guard was already a no-op); add NOTE(phase-2) for future wiring;
  sessionsLoaded guard still present as the actual fix

* fix: address PR review comments on session management

- Cross-validate selectedRunId against loaded runs in refreshSessions;
  stale server-deleted session IDs no longer block auto-bootstrap
- Fix misleading comment on isCreatingRef mutex guard

Resolves review thread PRRT_kwDOR8nC1M591X9O
Resolves review thread PRRT_kwDOR8nC1M591YUu
Resolves review thread PRRT_kwDOR8nC1M591YWB

* fix: address all deferred P3 review findings (lab-gych.23-27, lab-gych.29)

- lab-gych.23: resize now uses rAF + direct DOM writes during pointermove,
  commits to React state only on pointerup (mirrors drag pattern)
- lab-gych.24: remove 4-state lazy-SSE protocol (streamEnabled, onFirstOpenRef,
  hasOpenedOnce, shellMounted); SSE opens whenever selectedRunId is non-null;
  FloatingChatShell always mounted inside popover
- lab-gych.25: remove PersistConfig persist toggles (persistOpen, persistPosition,
  persistSize); always persist all state; rename PersistConfig -> ChatConfig;
  keep sendPageContext as standalone boolean; simplify gear panel
- lab-gych.26: update all three importers (floating-chat-shell, admin-layout-client,
  design-system) to use ChatConfig (PersistConfig removal made type relocation moot)
- lab-gych.27: add LRU eviction to session-event-cache keeping last 10 sessions;
  replace raw Map exports with helper objects; both maps evicted together per key
- lab-gych.29: replace duplicate inline fetchAcp in use-chat-session-controller with
  createAcpFetcher() via stable ref; use fetchAcpRef.current in SSE effect in
  chat-session-provider (removes createAcpFetcher() call inside the effect)
@jmagar jmagar deleted the feat/lab-gych branch May 4, 2026 22:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants