DEVELOPER REFERENCE — LIBRARY LOOT
Library Loot
Developer Reference
← Index

Source: components/loot/LootPanel.jsx

// src/components/loot/LootPanel.jsx
//
// LOOT chat panel. Slides up from bottom-right on desktop, fullscreen on
// mobile. Conversation history persists in sessionStorage so navigating
// between admin routes preserves context; closing the panel keeps the
// history. Hitting "Clear chat" wipes it.
//
// Created by Miguel Brown on 5/15/26.
// Copyright (c) 2026 Luckey Logic LLC. All rights reserved.

import React, { useEffect, useRef, useState } from 'react'

import { useAuth }                            from '../../context/AuthContext.jsx'

import { chatWithLoot }                       from '../../lib/loot/lootClient.js'
import {
  getOrCreateLootSessionId,
  logLootSession
}                                              from '../../lib/loot/lootLogger.js'

import LootMessage                            from './LootMessage.jsx'

import styles                                 from './LootPanel.module.css'

const HISTORY_KEY = 'll_loot_history_v1'

/** Pull the saved conversation; tolerate corrupt / missing storage. */
function loadHistory() {
  try {
    const raw = sessionStorage.getItem(HISTORY_KEY)
    if (!raw) return []
    const parsed = JSON.parse(raw)
    return Array.isArray(parsed) ? parsed : []
  } catch (e) {
    return []
  }
}

function saveHistory(history) {
  try {
    sessionStorage.setItem(HISTORY_KEY, JSON.stringify(history))
  } catch (e) {
    // Quota exceeded / private mode — best-effort, conversation just
    // won't survive a reload. Non-fatal.
  }
}

/**
 * LootPanel — chat surface for the LOOT assistant.
 *
 * @param {Object}   props
 * @param {Function} props.onClose - Called when the panel is dismissed.
 * @returns {JSX.Element}
 */
export default function LootPanel({ onClose }) {

  const { user, tenantClaim }     = useAuth()
  const [history,  setHistory]    = useState(loadHistory)
  const [draft,    setDraft]      = useState('')
  const [sending,  setSending]    = useState(false)
  const [error,    setError]      = useState(null)

  const scrollerRef = useRef(null)
  const inputRef    = useRef(null)

  // Per-tab LOOT session ID — generated once, reused across reloads
  // of the same tab so we keep appending to the same Firestore doc.
  const sessionIdRef       = useRef(getOrCreateLootSessionId())
  // Tracks whether we've already checked Firestore for the session
  // doc's existence (so we only do that getDoc once per mount). The
  // logger mutates this object.
  const sessionStartFlagRef = useRef({ checked: false, exists: false })

  // Auto-scroll to the bottom whenever the conversation grows.
  useEffect(() => {
    const el = scrollerRef.current
    if (el) el.scrollTop = el.scrollHeight
  }, [history, sending])

  // Persist on every history change.
  useEffect(() => { saveHistory(history) }, [history])

  // Focus the input on mount so the librarian can just start typing.
  useEffect(() => {
    inputRef.current?.focus()
  }, [])

  // Esc closes the panel.
  useEffect(() => {
    function onKey(e) {
      if (e.key === 'Escape') onClose()
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [onClose])

  // Mobile-only body-scroll lock. On desktop the panel is a corner
  // overlay and we want the admin page underneath to stay scrollable.
  // On mobile the panel goes fullscreen, so background scroll should
  // be frozen — without this, dragging on a chat message bleeds
  // through to the admin page underneath.
  //
  // `document.body.style.overflow = 'hidden'` alone is NOT enough on
  // iOS Safari — Safari ignores body overflow lock once a touch-drag
  // gesture starts. The robust pattern: snapshot scrollY, then apply
  // position:fixed + negative top + width:100% to body, which fully
  // freezes the page. On cleanup, restore the styles AND scroll back
  // to where the user was so closing LOOT doesn't jump them to the
  // top of the admin page.
  useEffect(() => {
    if (typeof window === 'undefined') return
    const isMobile = window.matchMedia('(max-width: 600px)').matches
    if (!isMobile) return

    const scrollY = window.scrollY
    const prev    = {
      position: document.body.style.position,
      top     : document.body.style.top,
      width   : document.body.style.width,
      overflow: document.body.style.overflow
    }
    document.body.style.position = 'fixed'
    document.body.style.top      = `-${scrollY}px`
    document.body.style.width    = '100%'
    document.body.style.overflow = 'hidden'

    return () => {
      document.body.style.position = prev.position
      document.body.style.top      = prev.top
      document.body.style.width    = prev.width
      document.body.style.overflow = prev.overflow
      window.scrollTo(0, scrollY)
    }
  }, [])

  // ── HANDLERS ────────────────────────────────────────────────────
  async function handleSend() {
    const text = draft.trim()
    if (!text || sending) return

    const userTurn = { role: 'user', text, timestampMs: Date.now() }
    const next     = [...history, userTurn]
    setHistory(next)
    setDraft('')
    setSending(true)
    setError(null)

    // Local accumulator of turns added during this exchange so we
    // can write the complete user → tool* → model bundle to
    // Firestore in one go at the end, without depending on React
    // state batching.
    const turnsAccum = [...next]

    try {
      // onToolCall fires once per tool the model invokes. We push a
      // 'tool' entry into history so LootMessage renders a chip
      // ("📖 Looking up ISBN 9780062074324") inline with the
      // conversation. Chips persist in sessionStorage with the rest
      // of the history so they survive a refresh — useful when
      // scrolling back later to see what LOOT actually did.
      const reply = await chatWithLoot(next, {
        onToolCall: ({ name, args }) => {
          const toolTurn = { role: 'tool', name, args, timestampMs: Date.now() }
          setHistory((prev) => [...prev, toolTurn])
          turnsAccum.push(toolTurn)
        }
      })
      const modelTurn = { role: 'model', text: reply, timestampMs: Date.now() }
      setHistory((prev) => [...prev, modelTurn])
      turnsAccum.push(modelTurn)

      // Persist this turn to Firestore (ITEM 9c.1a). Non-fatal —
      // chat keeps working if the log write fails.
      logLootSession({
        sessionId       : sessionIdRef.current,
        user,
        tenantClaim,
        audience        : 'admin',
        history         : turnsAccum,
        sessionStartFlag: sessionStartFlagRef.current
      })
    } catch (err) {
      // eslint-disable-next-line no-console
      console.error('[LOOT] chat error', err)
      setError(
        err?.message ||
        'LOOT couldn\'t reach the model. Check your connection and try again.'
      )
      // Roll everything added during this turn back so the user can
      // retry without re-typing. Find the index of the user message
      // we appended (`next`'s last entry was the user text); slice
      // history down to before that. Any tool chips added between
      // the user message and the failure get rolled back with it,
      // which is what we want — a half-finished tool chain on screen
      // would be confusing.
      const rollbackLen = next.length - 1
      setHistory((prev) => prev.slice(0, rollbackLen))
      setDraft(text)
    } finally {
      setSending(false)
      inputRef.current?.focus()
    }
  }

  function handleKeyDown(e) {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault()
      handleSend()
    }
  }

  function handleClear() {
    if (!history.length) return
    if (!window.confirm('Clear this LOOT conversation?')) return
    setHistory([])
    setError(null)
  }

  // ── RENDER ──────────────────────────────────────────────────────
  const firstName = (user?.displayName || '').trim().split(/\s+/)[0]
  const greeting  = firstName
    ? `Hey ${firstName} — what are we looting today?`
    : 'Hey — what are we looting today?'

  return (
    <>

      {/* Mobile-only blurred backdrop. Hidden on desktop via CSS.
          Clicking/tapping it closes the panel — standard modal UX.
          Visually softens the admin page behind LOOT on phones so the
          panel feels like its own surface rather than just a fullscreen
          overlay floating on top of busy content. */}
      <div
        className   ={styles.backdrop}
        onClick     ={onClose}
        aria-hidden ="true"
      />

    <aside className={styles.panel} aria-label="LOOT assistant">

      <header className={styles.header}>
        <div className={styles.headerTitle}>
          <span className={styles.glyph} aria-hidden="true">⚡</span>
          <div>
            <p className={styles.brand}>LOOT</p>
            <p className={styles.tag}>Admin assistant · Gemini 2.5 Flash</p>
          </div>
        </div>
        <div className={styles.headerActions}>
          <button
            type      ="button"
            className ={styles.iconBtn}
            onClick   ={handleClear}
            disabled  ={history.length === 0}
            title     ="Clear chat"
            aria-label="Clear chat"
          >
            Clear
          </button>
          <button
            type      ="button"
            className ={styles.iconBtn}
            onClick   ={onClose}
            title     ="Close LOOT"
            aria-label="Close LOOT"
          >
            ✕
          </button>
        </div>
      </header>

      <div className={styles.scroller} ref={scrollerRef}>
        {history.length === 0 ? (
          <div className={styles.empty}>
            <p className={styles.emptyTitle}>{greeting}</p>
            <p className={styles.emptyBody}>
              I can chat. Tools (book lookup, sponsor reply drafts) drop in
              the next build. Ask me anything about running this tenant —
              I'll help where I can.
            </p>
          </div>
        ) : (
          history.map((m, i) => (
            <LootMessage
              key  ={i}
              role ={m.role}
              text ={m.text}
              name ={m.name}
              args ={m.args}
            />
          ))
        )}

        {sending ? (
          <LootMessage role="model" text="…" pending />
        ) : null}
      </div>

      {error ? (
        <div className={styles.errorBar} role="alert">
          {error}
        </div>
      ) : null}

      <form
        className ={styles.inputBar}
        onSubmit  ={(e) => { e.preventDefault(); handleSend() }}
      >
        <textarea
          ref         ={inputRef}
          className   ={styles.input}
          value       ={draft}
          onChange    ={(e) => setDraft(e.target.value)}
          onKeyDown   ={handleKeyDown}
          // Keep this short — the textarea is narrow at panel widths
          // ~280-340px. Enter-to-send is the web convention, no need to
          // spell it out in the placeholder. Shift+Enter for newline is
          // discoverable on try.
          placeholder ="Ask LOOT…"
          rows        ={1}
          disabled    ={sending}
        />
        <button
          type      ="submit"
          className ={styles.sendBtn}
          disabled  ={sending || !draft.trim()}
          aria-label="Send"
        >
          {sending ? '…' : 'Send'}
        </button>
      </form>

    </aside>

    </>
  )
}