Skip to main content

HoopAI AI Chat Widget Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Replace the broken VoiceGlow/ConvoCore widget with a fully custom Claude Haiku 4.5 chat panel backed by a Cloudflare Worker proxy that keeps all API keys off the client. Architecture: A Cloudflare Worker (worker/src/index.js) receives chat requests from the browser, runs an agentic tool loop (web search, support ticket creation), then streams the final Claude response back via SSE. The browser widget (theme/chat-widget.js) is a self-contained IIFE that renders a right-side panel matching Mintlify’s native AI drawer — slides in and pushes page content left. Tech Stack: Cloudflare Workers, Wrangler CLI v3, OpenRouter API (anthropic/claude-haiku-4-5), Vanilla JS (ES5-compatible IIFE), CSS custom properties (--ds-* tokens already in theme/custom.css).

File Map

FileActionResponsibility
worker/package.jsonCreateWrangler dev dependency
worker/wrangler.tomlCreateWorker name, compat date, var stubs
worker/src/index.jsCreateCORS, routing, agentic loop, tool execution, SSE streaming
theme/chat-widget.jsRewritePanel UI, streaming client, Ask AI button injections
theme/custom.cssModifyPanel layout, push-content rule, message bubbles, dark mode
docs.jsonVerify/fixConfirm JS path is "theme/chat-widget.js" (not bare "chat-widget.js")

Task 1: Worker — Project Scaffold

Files:
  • Create: worker/package.json
  • Create: worker/wrangler.toml
  • Create: worker/src/index.js (skeleton only)
  • Step 1.1 — Create worker/package.json
{
  "name": "hoopai-chat-api",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy"
  },
  "devDependencies": {
    "wrangler": "^3.99.0"
  }
}
  • Step 1.2 — Create worker/wrangler.toml
name = "hoopai-chat-api"
main = "src/index.js"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

# Allowed origins stored as plain var (not secret — not sensitive)
[vars]
ALLOWED_ORIGINS = "https://help.hoopai.com,http://localhost:3000"

# Secrets set via: wrangler secret put OPENROUTER_API_KEY
# Secrets set via: wrangler secret put BRAVE_SEARCH_API_KEY  (optional)
# Secrets set via: wrangler secret put SUPPORT_WEBHOOK_URL   (optional)
  • Step 1.3 — Create skeleton worker/src/index.js
export default {
  async fetch(request, env) {
    return new Response('hoopai-chat-api ok', { status: 200 });
  }
};
  • Step 1.4 — Install Wrangler and verify scaffold
cd worker
npm install
npx wrangler --version
Expected output: 3.x.x (Wrangler version number)
  • Step 1.5 — Commit scaffold
cd ..
git add worker/
git commit -m "feat: scaffold Cloudflare Worker for AI chat proxy"

Task 2: Worker — CORS + Routing + OpenRouter Streaming Proxy

Files:
  • Modify: worker/src/index.js (replace skeleton with full implementation)
  • Step 2.1 — Smoke-test the current skeleton locally
cd worker
CLOUDFLARE_API_TOKEN=cfk_wrWstQqIhihprafWZ4hoTLmTFla3nZZmtyhpJ2kyb70eecc0 npx wrangler dev --port 8787
In a second terminal:
curl http://localhost:8787/
Expected: hoopai-chat-api ok Stop the dev server (Ctrl+C).
  • Step 2.2 — Replace worker/src/index.js with full implementation
Write the complete file. This handles CORS, routing, the agentic tool loop, web search, support ticket, and SSE streaming all in one module:
/**
 * hoopai-chat-api — Cloudflare Worker
 * Proxies OpenRouter (Claude Haiku 4.5) with tool support.
 * Secrets: OPENROUTER_API_KEY, BRAVE_SEARCH_API_KEY, SUPPORT_WEBHOOK_URL
 */

const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
const MODEL = 'anthropic/claude-haiku-4-5';
const MAX_TOOL_ITERATIONS = 4;

const TOOL_DEFINITIONS = [
  {
    type: 'function',
    function: {
      name: 'web_search',
      description:
        'Search the web for current information. Use when the user asks about something not in your training or that may have changed recently.',
      parameters: {
        type: 'object',
        properties: {
          query: { type: 'string', description: 'The search query' }
        },
        required: ['query']
      }
    }
  },
  {
    type: 'function',
    function: {
      name: 'create_support_ticket',
      description:
        'Create a HoopAI support ticket when the user has an unresolved issue that needs human assistance.',
      parameters: {
        type: 'object',
        properties: {
          subject: { type: 'string', description: 'One-line summary of the issue' },
          description: { type: 'string', description: 'Full description of the problem' },
          email: { type: 'string', description: "User's email address, if they provided it" }
        },
        required: ['subject', 'description']
      }
    }
  }
];

/* ── CORS ─────────────────────────────────────────────────── */

function getAllowedOrigins(env) {
  return (env.ALLOWED_ORIGINS || 'https://help.hoopai.com,http://localhost:3000').split(',');
}

function corsHeaders(origin) {
  return {
    'Access-Control-Allow-Origin': origin,
    'Access-Control-Allow-Methods': 'POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type',
    'Access-Control-Max-Age': '86400'
  };
}

/* ── System prompt ────────────────────────────────────────── */

function buildSystemPrompt(pageContext) {
  const page = pageContext || {};
  return [
    'You are a helpful support assistant for HoopAI — an all-in-one CRM and marketing platform.',
    'You are embedded in the HoopAI Help Center documentation site.',
    page.title ? `The user is currently reading: "${page.title}" (${page.url})` : '',
    '',
    'Guidelines:',
    '- Answer questions about HoopAI features directly and concisely.',
    '- When asked about something that may have changed or is not in your training, use web_search.',
    '- When a user has an unresolved issue needing human help, offer to use create_support_ticket.',
    '- Never mention GoHighLevel, HighLevel, GHL, Marketing Muse, or WhoPayI.',
    '- Always say "HoopAI" or "Hoop" — never the white-label vendor names.',
    '- The mobile app is called LeadConnector (not HoopAI) — important for App Store searches.',
    '- Be direct and concise. No filler phrases like "Great question!" or "Certainly!".'
  ].filter(Boolean).join('\n');
}

/* ── OpenRouter call ──────────────────────────────────────── */

async function callOpenRouter(messages, env, stream) {
  const body = {
    model: MODEL,
    messages,
    tools: TOOL_DEFINITIONS,
    tool_choice: 'auto',
    stream
  };

  const res = await fetch(OPENROUTER_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
      'Content-Type': 'application/json',
      'HTTP-Referer': 'https://help.hoopai.com',
      'X-Title': 'HoopAI Help Center'
    },
    body: JSON.stringify(body)
  });

  if (!res.ok) {
    const err = await res.text();
    throw new Error(`OpenRouter error ${res.status}: ${err}`);
  }

  return res;
}

/* ── Tool execution ───────────────────────────────────────── */

async function executeWebSearch(query, env) {
  if (!env.BRAVE_SEARCH_API_KEY) {
    return { error: 'Web search not configured. Answer from your training data.' };
  }
  const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=5`;
  const res = await fetch(url, {
    headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip', 'X-Subscription-Token': env.BRAVE_SEARCH_API_KEY }
  });
  if (!res.ok) return { error: `Search failed: ${res.status}` };
  const data = await res.json();
  const results = (data.web?.results || []).slice(0, 5).map(r => ({
    title: r.title,
    url: r.url,
    snippet: r.description
  }));
  return { results };
}

async function executeCreateTicket(params, env) {
  if (!env.SUPPORT_WEBHOOK_URL) {
    return {
      success: false,
      message: 'Ticket system not configured yet. Please email support@hoopai.com directly.'
    };
  }
  const res = await fetch(env.SUPPORT_WEBHOOK_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ subject: params.subject, description: params.description, email: params.email || '' })
  });
  if (!res.ok) return { success: false, message: `Webhook error: ${res.status}` };
  return { success: true, message: 'Support ticket created. The team will respond within 24 hours.' };
}

async function executeTool(name, args, env) {
  if (name === 'web_search') return executeWebSearch(args.query, env);
  if (name === 'create_support_ticket') return executeCreateTicket(args, env);
  return { error: `Unknown tool: ${name}` };
}

/* ── Agentic loop → SSE stream ────────────────────────────── */

function sse(obj) {
  return `data: ${JSON.stringify(obj)}\n\n`;
}

async function runAndStream(initialMessages, env, origin) {
  const { readable, writable } = new TransformStream();
  const writer = writable.getWriter();
  const enc = new TextEncoder();
  const write = (obj) => writer.write(enc.encode(sse(obj)));

  (async () => {
    try {
      let messages = [...initialMessages];

      // Agentic loop: non-streaming calls to handle tool calls
      for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
        const res = await callOpenRouter(messages, env, false);
        const data = await res.json();
        const choice = data.choices?.[0];
        if (!choice) throw new Error('No choice returned from OpenRouter');

        if (choice.finish_reason === 'tool_calls' && choice.message?.tool_calls?.length) {
          // Append assistant message with tool calls
          messages.push({ role: 'assistant', content: null, tool_calls: choice.message.tool_calls });

          // Execute each tool call
          for (const tc of choice.message.tool_calls) {
            let args = {};
            try { args = JSON.parse(tc.function.arguments); } catch (_) {}

            await write({ type: 'tool_start', tool: tc.function.name, input: args });
            const result = await executeTool(tc.function.name, args, env);
            await write({ type: 'tool_end', tool: tc.function.name, result });

            messages.push({
              role: 'tool',
              tool_call_id: tc.id,
              content: JSON.stringify(result)
            });
          }
          // Loop again for the assistant's next response
        } else {
          // No tool calls — do a streaming final response
          const streamRes = await callOpenRouter(messages, env, true);
          const reader = streamRes.body.getReader();
          const dec = new TextDecoder();
          let buf = '';

          while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            buf += dec.decode(value, { stream: true });
            const lines = buf.split('\n');
            buf = lines.pop(); // keep incomplete line

            for (const line of lines) {
              if (!line.startsWith('data: ')) continue;
              const raw = line.slice(6).trim();
              if (raw === '[DONE]') break;
              try {
                const parsed = JSON.parse(raw);
                const delta = parsed.choices?.[0]?.delta?.content;
                if (delta) await write({ type: 'token', content: delta });
              } catch (_) {}
            }
          }
          break;
        }
      }

      await write({ type: 'done' });
    } catch (err) {
      await write({ type: 'error', message: err.message });
    } finally {
      writer.close();
    }
  })();

  return new Response(readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      ...corsHeaders(origin)
    }
  });
}

/* ── Main handler ─────────────────────────────────────────── */

export default {
  async fetch(request, env) {
    const origin = request.headers.get('Origin') || '';
    const allowed = getAllowedOrigins(env);

    // CORS preflight
    if (request.method === 'OPTIONS') {
      const isAllowed = allowed.includes(origin);
      return new Response(null, {
        status: 204,
        headers: isAllowed ? corsHeaders(origin) : {}
      });
    }

    // Block non-allowed origins
    if (!allowed.includes(origin)) {
      return new Response('Forbidden', { status: 403 });
    }

    const url = new URL(request.url);

    if (request.method === 'POST' && url.pathname === '/chat') {
      let body;
      try {
        body = await request.json();
      } catch (_) {
        return new Response('Invalid JSON', { status: 400, headers: corsHeaders(origin) });
      }

      const { messages = [], pageContext = {} } = body;
      if (!Array.isArray(messages) || messages.length === 0) {
        return new Response('messages array required', { status: 400, headers: corsHeaders(origin) });
      }

      const systemMsg = { role: 'system', content: buildSystemPrompt(pageContext) };
      const allMessages = [systemMsg, ...messages];

      return runAndStream(allMessages, env, origin);
    }

    return new Response('Not found', { status: 404, headers: corsHeaders(origin) });
  }
};
  • Step 2.3 — Start worker dev server and test CORS preflight
cd worker
CLOUDFLARE_API_TOKEN=cfk_wrWstQqIhihprafWZ4hoTLmTFla3nZZmtyhpJ2kyb70eecc0 npx wrangler dev --port 8787
In a second terminal, test CORS preflight:
curl -X OPTIONS http://localhost:8787/chat \
  -H "Origin: http://localhost:3000" \
  -H "Access-Control-Request-Method: POST" \
  -v 2>&1 | grep -E "HTTP|access-control|< "
Expected: HTTP/1.1 204 and access-control-allow-origin: http://localhost:3000
  • Step 2.4 — Test blocked origin
curl -X OPTIONS http://localhost:8787/chat \
  -H "Origin: https://evil.com" -v 2>&1 | grep "HTTP/"
Expected: HTTP/1.1 403
  • Step 2.5 — Test basic streaming (requires OPENROUTER_API_KEY as local secret)
Create worker/.dev.vars (gitignored, local only):
OPENROUTER_API_KEY=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
Note: .dev.vars is automatically gitignored by Wrangler — it’s the Wrangler equivalent of .env for local dev. Restart the dev server, then:
curl -X POST http://localhost:8787/chat \
  -H "Content-Type: application/json" \
  -H "Origin: http://localhost:3000" \
  -d '{"messages":[{"role":"user","content":"Say hello in 5 words"}],"pageContext":{"title":"Test","url":"http://localhost:3000"}}' \
  --no-buffer
Expected: a stream of data: {"type":"token","content":"..."} lines ending with data: {"type":"done"}
  • Step 2.6 — Commit worker implementation
cd ..
git add worker/src/index.js
git commit -m "feat: implement Cloudflare Worker chat proxy with agentic tool loop"

Task 3: Deploy Worker and Set Secrets

Files: No code changes — deployment and secret configuration only.
  • Step 3.1 — Deploy to Cloudflare
cd worker
CLOUDFLARE_API_TOKEN=cfk_wrWstQqIhihprafWZ4hoTLmTFla3nZZmtyhpJ2kyb70eecc0 npx wrangler deploy
Expected output (last line):
Published hoopai-chat-api (xx sec)
  https://hoopai-chat-api.<account-id>.workers.dev
Copy the worker URL — you will need it in Task 5.
  • Step 3.2 — Set the OpenRouter API key secret
CLOUDFLARE_API_TOKEN=cfk_wrWstQqIhihprafWZ4hoTLmTFla3nZZmtyhpJ2kyb70eecc0 \
  npx wrangler secret put OPENROUTER_API_KEY
When prompted, paste:
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
Expected: ✓ Success! Uploaded secret OPENROUTER_API_KEY
  • Step 3.3 — Verify deployed worker responds
Replace <WORKER_URL> with the URL from Step 3.1:
curl -X POST https://<WORKER_URL>/chat \
  -H "Content-Type: application/json" \
  -H "Origin: https://help.hoopai.com" \
  -d '{"messages":[{"role":"user","content":"Say hi in 3 words"}],"pageContext":{}}' \
  --no-buffer
Expected: streaming token events ending with {"type":"done"}
  • Step 3.4 — Commit deployment artifacts
cd ..
git add worker/
git commit -m "chore: add worker .dev.vars to gitignore note, deployment verified"
Note: Add worker/.dev.vars to .gitignore if not already covered by .env.* rule: Open .gitignore and add under the secrets section:
worker/.dev.vars
git add .gitignore
git commit -m "chore: gitignore worker/.dev.vars local secrets"

Task 4: Widget CSS — Panel Styles

Files:
  • Modify: theme/custom.css (append at end of file)
  • Step 4.1 — Append panel CSS to theme/custom.css
Read the current end of theme/custom.css to find the last line number, then append:
/* ═══════════════════════════════════════════════════════════
   HOOPAI AI CHAT PANEL
   Right-side drawer that pushes #content-side-layout left.
   All colours use --ds-* tokens → auto dark/light mode.
   ═══════════════════════════════════════════════════════════ */

/* Panel container */
#hoopai-panel {
  position: fixed;
  top: 0;
  right: 0;
  height: 100vh;
  width: 380px;
  background: var(--ds-surface-page);
  border-left: 1px solid var(--ds-border);
  display: flex;
  flex-direction: column;
  z-index: 50;
  transform: translateX(100%);
  transition: transform var(--ds-transition-slow), width var(--ds-transition-slow);
  box-shadow: var(--ds-shadow-lg);
  font-family: inherit;
}

#hoopai-panel.hoopai-open {
  transform: translateX(0);
}

#hoopai-panel.hoopai-expanded {
  width: 680px;
}

/* Push page content left when panel is open */
body.hoopai-panel-open #content-side-layout {
  margin-right: 380px !important;
  transition: margin-right var(--ds-transition-slow) !important;
}

body.hoopai-panel-open.hoopai-panel-expanded #content-side-layout {
  margin-right: 680px !important;
}

/* Panel header */
#hoopai-panel-header {
  display: flex;
  align-items: center;
  gap: var(--ds-space-sm);
  padding: var(--ds-space-md) var(--ds-space-lg);
  border-bottom: 1px solid var(--ds-border-subtle);
  flex-shrink: 0;
}

.hoopai-panel-title {
  font-size: var(--ds-font-sm);
  font-weight: var(--ds-weight-semibold);
  color: var(--ds-text);
  flex: 1;
}

.hoopai-panel-action-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border: none;
  background: transparent;
  border-radius: var(--ds-radius-sm);
  color: var(--ds-text-tertiary);
  cursor: pointer;
  transition: background var(--ds-transition), color var(--ds-transition);
  flex-shrink: 0;
}

.hoopai-panel-action-btn:hover {
  background: var(--ds-surface-hover);
  color: var(--ds-text-secondary);
}

/* Messages area */
#hoopai-panel-messages {
  flex: 1;
  overflow-y: auto;
  padding: var(--ds-space-lg);
  display: flex;
  flex-direction: column;
  gap: var(--ds-space-md);
  scroll-behavior: smooth;
}

/* Empty state */
.hoopai-empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--ds-space-lg);
  padding: var(--ds-space-4xl) var(--ds-space-lg) 0;
  text-align: center;
}

.hoopai-empty-icon {
  width: 40px;
  height: 40px;
  color: var(--ds-accent);
  opacity: 0.8;
}

.hoopai-empty-title {
  font-size: var(--ds-font-md);
  font-weight: var(--ds-weight-semibold);
  color: var(--ds-text);
  margin: 0;
}

.hoopai-empty-sub {
  font-size: var(--ds-font-sm);
  color: var(--ds-text-secondary);
  margin: 0;
  line-height: 1.5;
}

.hoopai-suggestions {
  display: flex;
  flex-direction: column;
  gap: var(--ds-space-sm);
  width: 100%;
  margin-top: var(--ds-space-sm);
}

.hoopai-suggestion-chip {
  text-align: left;
  padding: var(--ds-space-sm) var(--ds-space-md);
  border-radius: var(--ds-radius-md);
  border: 1px solid var(--ds-border);
  background: var(--ds-surface-raised);
  color: var(--ds-text-secondary);
  font-size: var(--ds-font-sm);
  cursor: pointer;
  transition: background var(--ds-transition), color var(--ds-transition), border-color var(--ds-transition);
  line-height: 1.4;
}

.hoopai-suggestion-chip:hover {
  background: var(--ds-accent-light);
  border-color: var(--ds-accent);
  color: var(--ds-text);
}

/* Message bubbles */
.hoopai-msg {
  display: flex;
  flex-direction: column;
  gap: var(--ds-space-xs);
  max-width: 90%;
}

.hoopai-msg-user {
  align-self: flex-end;
}

.hoopai-msg-assistant {
  align-self: flex-start;
}

.hoopai-msg-bubble {
  padding: var(--ds-space-sm) var(--ds-space-md);
  border-radius: var(--ds-radius-lg);
  font-size: var(--ds-font-sm);
  line-height: 1.6;
  white-space: pre-wrap;
  word-break: break-word;
}

.hoopai-msg-user .hoopai-msg-bubble {
  background: var(--ds-accent-light);
  color: var(--ds-text);
  border-bottom-right-radius: var(--ds-radius-sm);
}

.hoopai-msg-assistant .hoopai-msg-bubble {
  background: var(--ds-surface-raised);
  color: var(--ds-text);
  border-bottom-left-radius: var(--ds-radius-sm);
  box-shadow: var(--ds-shadow-sm);
}

/* Typing indicator */
.hoopai-typing {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 10px var(--ds-space-md);
  background: var(--ds-surface-raised);
  border-radius: var(--ds-radius-lg);
  border-bottom-left-radius: var(--ds-radius-sm);
  align-self: flex-start;
  box-shadow: var(--ds-shadow-sm);
}

.hoopai-typing-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ds-text-tertiary);
  animation: hoopai-pulse 1.2s infinite ease-in-out;
}

.hoopai-typing-dot:nth-child(2) { animation-delay: 0.2s; }
.hoopai-typing-dot:nth-child(3) { animation-delay: 0.4s; }

@keyframes hoopai-pulse {
  0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; }
  40% { transform: scale(1); opacity: 1; }
}

/* Tool call pills */
.hoopai-tool-pill {
  display: inline-flex;
  align-items: center;
  gap: var(--ds-space-xs);
  padding: 4px var(--ds-space-sm);
  border-radius: var(--ds-radius-full);
  background: var(--ds-surface-inset);
  border: 1px solid var(--ds-border-subtle);
  font-size: 11px;
  color: var(--ds-text-secondary);
  align-self: flex-start;
}

.hoopai-tool-pill.done {
  color: var(--ds-accent);
  border-color: var(--ds-accent-light);
  background: var(--ds-accent-light);
}

/* Page context pill (shown above input when page context is set) */
.hoopai-context-pill {
  display: flex;
  align-items: center;
  gap: var(--ds-space-xs);
  padding: 4px var(--ds-space-sm);
  border-radius: var(--ds-radius-full);
  background: var(--ds-surface-inset);
  border: 1px solid var(--ds-border-subtle);
  font-size: 11px;
  color: var(--ds-text-secondary);
  flex-shrink: 0;
  max-width: 100%;
  overflow: hidden;
}

.hoopai-context-pill span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Footer / input area */
#hoopai-panel-footer {
  padding: var(--ds-space-md) var(--ds-space-lg);
  border-top: 1px solid var(--ds-border-subtle);
  display: flex;
  flex-direction: column;
  gap: var(--ds-space-sm);
  flex-shrink: 0;
}

.hoopai-input-row {
  display: flex;
  align-items: flex-end;
  gap: var(--ds-space-sm);
  background: var(--ds-surface-raised);
  border: 1px solid var(--ds-border);
  border-radius: var(--ds-radius-lg);
  padding: var(--ds-space-sm) var(--ds-space-sm) var(--ds-space-sm) var(--ds-space-md);
  transition: border-color var(--ds-transition);
}

.hoopai-input-row:focus-within {
  border-color: var(--ds-accent);
}

#hoopai-input {
  flex: 1;
  background: transparent;
  border: none;
  outline: none;
  resize: none;
  font-family: inherit;
  font-size: var(--ds-font-sm);
  color: var(--ds-text);
  line-height: 1.5;
  max-height: 120px;
  overflow-y: auto;
  padding: 0;
  min-height: 22px;
}

#hoopai-input::placeholder {
  color: var(--ds-text-tertiary);
}

#hoopai-send-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: var(--ds-radius-sm);
  border: none;
  background: var(--ds-accent);
  color: #fff;
  cursor: pointer;
  flex-shrink: 0;
  transition: opacity var(--ds-transition);
}

#hoopai-send-btn:disabled {
  opacity: 0.4;
  cursor: default;
}

#hoopai-send-btn:not(:disabled):hover {
  opacity: 0.85;
}

.hoopai-footer-hint {
  font-size: 10px;
  color: var(--ds-text-tertiary);
  text-align: center;
}

/* Markdown-like styling inside assistant bubbles */
.hoopai-msg-assistant .hoopai-msg-bubble strong { font-weight: 600; }
.hoopai-msg-assistant .hoopai-msg-bubble code {
  font-family: monospace;
  font-size: 12px;
  background: var(--ds-surface-inset);
  padding: 1px 4px;
  border-radius: 3px;
}

/* Mobile: full-width panel, no content push */
@media (max-width: 768px) {
  #hoopai-panel {
    width: 100vw !important;
    border-left: none;
  }
  body.hoopai-panel-open #content-side-layout,
  body.hoopai-panel-open.hoopai-panel-expanded #content-side-layout {
    margin-right: 0 !important;
  }
}
  • Step 4.2 — Commit CSS
git add theme/custom.css
git commit -m "feat: add HoopAI chat panel CSS (right-side drawer with DS tokens)"

Task 5: Widget JS — Complete Rewrite

Files:
  • Rewrite: theme/chat-widget.js
This task replaces the entire VoiceGlow file with the custom widget. All existing Ask AI button injection points are preserved with identical IDs and behaviour — only toggleWidget() is replaced by togglePanel().
  • Step 5.1 — Replace theme/chat-widget.js with the full widget
/**
 * HoopAI Help Center — Custom AI Chat Widget
 * Loaded globally via docs.json "js" key.
 *
 * Replaces VoiceGlow/ConvoCore entirely.
 * Backs onto the Cloudflare Worker proxy (WORKER_URL below).
 *
 * Entry points:
 *  1. Navbar "Ask AI" button (desktop)       → #assistant-entry
 *  2. Mobile header icon                      → #assistant-entry-mobile
 *  3. Code block sparkle buttons              → data-ask-ai-injected
 *  4. Page-level "Ask AI" button              → #page-ask-ai-btn
 *  5. Keyboard shortcut Ctrl+I / Cmd+I
 */

(function () {
  'use strict';

  /* ── CONFIG ──────────────────────────────────────────────── */

  var WORKER_URL = 'https://hoopai-chat-api.REPLACE_WITH_ACCOUNT_ID.workers.dev';

  /* ── ICONS ───────────────────────────────────────────────── */

  var DS_SPARKLE =
    '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">' +
    '<path d="M8 1.5L9.7 6.3L14.5 8L9.7 9.7L8 14.5L6.3 9.7L1.5 8L6.3 6.3L8 1.5Z" ' +
    'stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>' +
    '<path d="M3 1L3.4 2.6L5 3L3.4 3.4L3 5L2.6 3.4L1 3L2.6 2.6L3 1Z" ' +
    'stroke="currentColor" stroke-width="0.75" stroke-linecap="round" stroke-linejoin="round"/>' +
    '</svg>';

  var ICON_EXPAND =
    '<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
    '<path d="M2 5L7 1L12 5M2 9L7 13L12 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' +
    '</svg>';

  var ICON_COLLAPSE =
    '<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
    '<path d="M2 3L7 7L12 3M2 11L7 7L12 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' +
    '</svg>';

  var ICON_CLEAR =
    '<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
    '<path d="M1.5 1.5L12.5 12.5M12.5 1.5L1.5 12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>' +
    '</svg>';

  var ICON_CLOSE =
    '<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
    '<path d="M2 2L12 12M12 2L2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>' +
    '</svg>';

  var ICON_SEND =
    '<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
    '<path d="M1 7h12M8 2l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' +
    '</svg>';

  var ICON_STOP =
    '<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">' +
    '<rect x="2" y="2" width="8" height="8" rx="1.5"/>' +
    '</svg>';

  /* ── STATE ───────────────────────────────────────────────── */

  var messages = [];          // { role: 'user'|'assistant', content: string }
  var isStreaming = false;
  var abortController = null;
  var isExpanded = false;
  var pageContext = {};       // { title, url }
  var pendingPreload = null;  // text to pre-fill input (from code block clicks)

  /* ── PANEL LIFECYCLE ─────────────────────────────────────── */

  function getPanel() { return document.getElementById('hoopai-panel'); }

  function createPanel() {
    if (document.getElementById('hoopai-panel')) return;

    var panel = document.createElement('div');
    panel.id = 'hoopai-panel';
    panel.setAttribute('role', 'dialog');
    panel.setAttribute('aria-label', 'Ask AI');
    panel.innerHTML = [
      '<div id="hoopai-panel-header">',
      '  <span style="color:var(--ds-accent)">' + DS_SPARKLE + '</span>',
      '  <span class="hoopai-panel-title">Ask AI</span>',
      '  <button class="hoopai-panel-action-btn" id="hoopai-expand-btn" aria-label="Expand panel" title="Expand">' + ICON_EXPAND + '</button>',
      '  <button class="hoopai-panel-action-btn" id="hoopai-clear-btn" aria-label="Clear conversation" title="Clear conversation">' + ICON_CLEAR + '</button>',
      '  <button class="hoopai-panel-action-btn" id="hoopai-close-btn" aria-label="Close panel" title="Close">' + ICON_CLOSE + '</button>',
      '</div>',
      '<div id="hoopai-panel-messages"></div>',
      '<div id="hoopai-panel-footer">',
      '  <div id="hoopai-context-area"></div>',
      '  <div class="hoopai-input-row">',
      '    <textarea id="hoopai-input" rows="1" placeholder="Ask anything about HoopAI…" aria-label="Message input"></textarea>',
      '    <button id="hoopai-send-btn" aria-label="Send message">' + ICON_SEND + '</button>',
      '  </div>',
      '  <p class="hoopai-footer-hint">Claude Haiku · May make mistakes</p>',
      '</div>'
    ].join('');

    document.body.appendChild(panel);
    bindPanelEvents();
    renderEmptyState();
  }

  function bindPanelEvents() {
    document.getElementById('hoopai-close-btn').addEventListener('click', closePanel);
    document.getElementById('hoopai-clear-btn').addEventListener('click', clearConversation);
    document.getElementById('hoopai-expand-btn').addEventListener('click', toggleExpand);

    var input = document.getElementById('hoopai-input');
    var sendBtn = document.getElementById('hoopai-send-btn');

    input.addEventListener('keydown', function (e) {
      if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        handleSend();
      }
    });

    input.addEventListener('input', function () {
      this.style.height = 'auto';
      this.style.height = Math.min(this.scrollHeight, 120) + 'px';
    });

    sendBtn.addEventListener('click', function () {
      if (isStreaming) {
        stopStreaming();
      } else {
        handleSend();
      }
    });
  }

  function openPanel() {
    createPanel();
    updatePageContext();
    var panel = getPanel();
    panel.classList.add('hoopai-open');
    document.body.classList.add('hoopai-panel-open');
    if (isExpanded) document.body.classList.add('hoopai-panel-expanded');

    if (pendingPreload) {
      var input = document.getElementById('hoopai-input');
      if (input) {
        input.value = pendingPreload;
        input.style.height = 'auto';
        input.style.height = Math.min(input.scrollHeight, 120) + 'px';
        input.focus();
      }
      pendingPreload = null;
    } else {
      setTimeout(function () {
        var input = document.getElementById('hoopai-input');
        if (input) input.focus();
      }, 300);
    }
  }

  function closePanel() {
    var panel = getPanel();
    if (panel) panel.classList.remove('hoopai-open');
    document.body.classList.remove('hoopai-panel-open', 'hoopai-panel-expanded');
    if (isStreaming) stopStreaming();
  }

  function togglePanel() {
    var panel = getPanel();
    var isOpen = panel && panel.classList.contains('hoopai-open');
    if (isOpen) { closePanel(); } else { openPanel(); }
  }

  function toggleExpand() {
    isExpanded = !isExpanded;
    var panel = getPanel();
    var btn = document.getElementById('hoopai-expand-btn');
    if (!panel || !btn) return;

    if (isExpanded) {
      panel.classList.add('hoopai-expanded');
      document.body.classList.add('hoopai-panel-expanded');
      btn.innerHTML = ICON_COLLAPSE;
      btn.title = 'Collapse panel';
    } else {
      panel.classList.remove('hoopai-expanded');
      document.body.classList.remove('hoopai-panel-expanded');
      btn.innerHTML = ICON_EXPAND;
      btn.title = 'Expand panel';
    }
  }

  function clearConversation() {
    messages = [];
    pageContext = {};
    var msgArea = document.getElementById('hoopai-panel-messages');
    var ctxArea = document.getElementById('hoopai-context-area');
    if (msgArea) msgArea.innerHTML = '';
    if (ctxArea) ctxArea.innerHTML = '';
    if (isStreaming) stopStreaming();
    renderEmptyState();
    updatePageContext();
  }

  /* ── PAGE CONTEXT ─────────────────────────────────────────── */

  function updatePageContext() {
    var titleEl = document.getElementById('page-title');
    var title = titleEl ? titleEl.textContent.trim() : document.title.replace(' - HoopAI Help Center', '').trim();
    var url = window.location.href;

    if (pageContext.url === url) return; // already set for this page
    pageContext = { title: title, url: url };

    var ctxArea = document.getElementById('hoopai-context-area');
    if (!ctxArea) return;
    ctxArea.innerHTML =
      '<div class="hoopai-context-pill">' +
      '<span style="flex-shrink:0">📄</span>' +
      '<span>Reading: ' + escapeHtml(title) + '</span>' +
      '</div>';
  }

  /* ── MESSAGE RENDERING ───────────────────────────────────── */

  function renderEmptyState() {
    var msgArea = document.getElementById('hoopai-panel-messages');
    if (!msgArea) return;

    var titleEl = document.getElementById('page-title');
    var pageTitle = titleEl ? titleEl.textContent.trim() : 'this page';

    var chips = getSuggestedPrompts(pageTitle);

    msgArea.innerHTML =
      '<div class="hoopai-empty-state">' +
      '  <svg class="hoopai-empty-icon" viewBox="0 0 40 40" fill="none">' +
      '    <path d="M20 4L24.3 15.7L36 20L24.3 24.3L20 36L15.7 24.3L4 20L15.7 15.7L20 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
      '    <path d="M8 4L8.8 7.2L12 8L8.8 8.8L8 12L7.2 8.8L4 8L7.2 7.2L8 4Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>' +
      '  </svg>' +
      '  <p class="hoopai-empty-title">Ask me anything about HoopAI</p>' +
      '  <p class="hoopai-empty-sub">I know the docs, I can search the web,<br>and I can create support tickets.</p>' +
      '  <div class="hoopai-suggestions">' +
      chips.map(function (c) {
        return '<button class="hoopai-suggestion-chip">' + escapeHtml(c) + '</button>';
      }).join('') +
      '  </div>' +
      '</div>';

    msgArea.querySelectorAll('.hoopai-suggestion-chip').forEach(function (btn) {
      btn.addEventListener('click', function () {
        var input = document.getElementById('hoopai-input');
        if (!input) return;
        input.value = btn.textContent;
        input.dispatchEvent(new Event('input'));
        handleSend();
      });
    });
  }

  function getSuggestedPrompts(pageTitle) {
    var generic = [
      'How do I get started with HoopAI?',
      'How do I set up automated follow-up messages?',
      'Where can I find my billing and subscription settings?'
    ];
    if (!pageTitle || pageTitle === 'this page') return generic;
    return [
      'What does ' + pageTitle + ' do?',
      'How do I set up ' + pageTitle + '?',
      'What are common issues with ' + pageTitle + '?'
    ];
  }

  function appendMessage(role, content) {
    var msgArea = document.getElementById('hoopai-panel-messages');
    if (!msgArea) return null;

    // Remove empty state
    var emptyState = msgArea.querySelector('.hoopai-empty-state');
    if (emptyState) emptyState.remove();

    var wrapper = document.createElement('div');
    wrapper.className = 'hoopai-msg hoopai-msg-' + role;

    var bubble = document.createElement('div');
    bubble.className = 'hoopai-msg-bubble';
    bubble.textContent = content || '';

    wrapper.appendChild(bubble);
    msgArea.appendChild(wrapper);
    msgArea.scrollTop = msgArea.scrollHeight;
    return bubble;
  }

  function showTypingIndicator() {
    var msgArea = document.getElementById('hoopai-panel-messages');
    if (!msgArea) return null;
    var el = document.createElement('div');
    el.className = 'hoopai-typing';
    el.id = 'hoopai-typing';
    el.innerHTML = '<div class="hoopai-typing-dot"></div><div class="hoopai-typing-dot"></div><div class="hoopai-typing-dot"></div>';
    msgArea.appendChild(el);
    msgArea.scrollTop = msgArea.scrollHeight;
    return el;
  }

  function removeTypingIndicator() {
    var el = document.getElementById('hoopai-typing');
    if (el) el.remove();
  }

  function appendToolPill(toolName) {
    var msgArea = document.getElementById('hoopai-panel-messages');
    if (!msgArea) return null;
    var labels = { web_search: '🔍 Searching the web\u2026', create_support_ticket: '🎫 Creating support ticket\u2026' };
    var el = document.createElement('div');
    el.className = 'hoopai-tool-pill';
    el.setAttribute('data-tool', toolName);
    el.textContent = labels[toolName] || '⚙️ Running ' + toolName + '\u2026';
    msgArea.appendChild(el);
    msgArea.scrollTop = msgArea.scrollHeight;
    return el;
  }

  function completeToolPill(toolName) {
    var msgArea = document.getElementById('hoopai-panel-messages');
    if (!msgArea) return;
    var labels = { web_search: '🔍 Web search complete', create_support_ticket: '✓ Support ticket created' };
    var el = msgArea.querySelector('[data-tool="' + toolName + '"]:not(.done)');
    if (el) {
      el.classList.add('done');
      el.textContent = labels[toolName] || '✓ ' + toolName + ' complete';
    }
  }

  /* ── STREAMING API CALL ──────────────────────────────────── */

  function handleSend() {
    var input = document.getElementById('hoopai-input');
    if (!input) return;
    var text = input.value.trim();
    if (!text || isStreaming) return;

    input.value = '';
    input.style.height = 'auto';

    messages.push({ role: 'user', content: text });
    appendMessage('user', text);
    streamResponse();
  }

  function streamResponse() {
    var sendBtn = document.getElementById('hoopai-send-btn');
    var input = document.getElementById('hoopai-input');

    isStreaming = true;
    abortController = new AbortController();
    if (sendBtn) { sendBtn.innerHTML = ICON_STOP; sendBtn.disabled = false; }
    if (input) input.disabled = true;

    var typingEl = showTypingIndicator();
    var assistantBubble = null;
    var fullContent = '';

    fetch(WORKER_URL + '/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ messages: messages.slice(), pageContext: pageContext }),
      signal: abortController.signal
    })
    .then(function (res) {
      if (!res.ok) throw new Error('Worker returned ' + res.status);
      var reader = res.body.getReader();
      var decoder = new TextDecoder();
      var buf = '';

      function read() {
        return reader.read().then(function (chunk) {
          if (chunk.done) {
            finishStreaming(fullContent, sendBtn, input);
            return;
          }
          buf += decoder.decode(chunk.value, { stream: true });
          var lines = buf.split('\n');
          buf = lines.pop();

          lines.forEach(function (line) {
            if (!line.startsWith('data: ')) return;
            var raw = line.slice(6).trim();
            try {
              var evt = JSON.parse(raw);
              handleStreamEvent(evt);
            } catch (_) {}
          });

          return read();
        });
      }

      function handleStreamEvent(evt) {
        if (evt.type === 'token') {
          if (typingEl) { typingEl.remove(); typingEl = null; }
          if (!assistantBubble) {
            assistantBubble = appendMessage('assistant', '');
          }
          fullContent += evt.content;
          if (assistantBubble) assistantBubble.textContent = fullContent;
          var msgArea = document.getElementById('hoopai-panel-messages');
          if (msgArea) msgArea.scrollTop = msgArea.scrollHeight;
        } else if (evt.type === 'tool_start') {
          if (typingEl) { typingEl.remove(); typingEl = null; }
          appendToolPill(evt.tool);
        } else if (evt.type === 'tool_end') {
          completeToolPill(evt.tool);
          typingEl = showTypingIndicator();
        } else if (evt.type === 'done') {
          finishStreaming(fullContent, sendBtn, input);
        } else if (evt.type === 'error') {
          removeTypingIndicator();
          appendMessage('assistant', 'Sorry, something went wrong: ' + (evt.message || 'Unknown error'));
          finishStreaming('', sendBtn, input);
        }
      }

      return read();
    })
    .catch(function (err) {
      if (err.name === 'AbortError') return;
      removeTypingIndicator();
      appendMessage('assistant', 'Connection error. Please check your network and try again.');
      finishStreaming('', sendBtn, input);
    });
  }

  function finishStreaming(content, sendBtn, input) {
    removeTypingIndicator();
    isStreaming = false;
    abortController = null;
    if (content) messages.push({ role: 'assistant', content: content });
    if (sendBtn) { sendBtn.innerHTML = ICON_SEND; sendBtn.disabled = false; }
    if (input) { input.disabled = false; input.focus(); }
  }

  function stopStreaming() {
    if (abortController) abortController.abort();
    removeTypingIndicator();
    isStreaming = false;
    abortController = null;
    var sendBtn = document.getElementById('hoopai-send-btn');
    var input = document.getElementById('hoopai-input');
    if (sendBtn) { sendBtn.innerHTML = ICON_SEND; sendBtn.disabled = false; }
    if (input) { input.disabled = false; input.focus(); }
  }

  /* ── HELPERS ─────────────────────────────────────────────── */

  function escapeHtml(str) {
    return String(str)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;');
  }

  function getNearestHeading(el) {
    var node = el;
    while (node && node !== document.body) {
      var sibling = node.previousElementSibling;
      while (sibling) {
        if (/^H[2-4]$/.test(sibling.tagName)) {
          return sibling.textContent.replace(/\s+/g, ' ').trim();
        }
        sibling = sibling.previousElementSibling;
      }
      node = node.parentElement;
    }
    return null;
  }

  /* ─────────────────────────────────────────────────────────────
   * ASK AI BUTTON INJECTIONS
   * Identical IDs to previous VoiceGlow widget — CSS in
   * custom.css already styles #assistant-entry etc.
   * ───────────────────────────────────────────────────────────── */

  function injectAskAIButton() {
    if (document.getElementById('assistant-entry')) return;
    var searchBar = document.getElementById('search-bar-entry');
    if (!searchBar) return;

    var btn = document.createElement('button');
    btn.type = 'button';
    btn.id = 'assistant-entry';
    btn.setAttribute('aria-label', 'Ask AI');
    btn.className = 'flex-none hidden lg:flex items-center justify-center px-2.5 gap-1.5 cursor-pointer transition-all duration-150';
    btn.innerHTML =
      '<span class="shrink-0" style="color:var(--ds-text-secondary)">' + DS_SPARKLE + '</span>' +
      '<span style="font-size:var(--ds-font-xs);font-weight:500;color:var(--ds-text-secondary)">Ask AI</span>';
    btn.addEventListener('click', function (e) {
      e.preventDefault();
      e.stopPropagation();
      togglePanel();
    });
    searchBar.parentElement.appendChild(btn);
  }

  function injectMobileAskAIButton() {
    if (document.getElementById('assistant-entry-mobile')) return;
    var mobileSearch = document.getElementById('search-bar-entry-mobile');
    if (!mobileSearch) return;

    var btn = document.createElement('button');
    btn.type = 'button';
    btn.id = 'assistant-entry-mobile';
    btn.setAttribute('aria-label', 'Ask AI');
    btn.className = 'w-8 h-8 flex items-center justify-center cursor-pointer';
    btn.style.color = 'var(--ds-text-secondary)';
    btn.innerHTML = DS_SPARKLE;
    btn.addEventListener('click', function (e) {
      e.preventDefault();
      e.stopPropagation();
      togglePanel();
    });
    mobileSearch.parentElement.insertBefore(btn, mobileSearch);
  }

  var ASK_AI_ATTR = 'data-ask-ai-injected';

  function injectCodeBlockAskAI() {
    document.querySelectorAll('.code-block:not([' + ASK_AI_ATTR + '])').forEach(function (block) {
      block.setAttribute(ASK_AI_ATTR, '1');
      var container = block.querySelector('[data-floating-buttons="true"]');
      if (!container) return;

      var codeEl = block.querySelector('code');
      var language = block.getAttribute('language') || (codeEl && codeEl.getAttribute('language')) || 'code';
      var codeText = codeEl ? codeEl.textContent.trim() : '';

      var wrapper = document.createElement('div');
      wrapper.className = 'z-10 relative select-none';

      var btn = document.createElement('button');
      btn.className = 'h-[26px] w-[26px] flex items-center justify-center rounded-md backdrop-blur peer group/ask-code';
      btn.setAttribute('aria-label', 'Ask AI about this code');
      btn.style.color = 'var(--ds-text-tertiary)';
      btn.innerHTML = DS_SPARKLE;

      var tooltip = document.createElement('div');
      tooltip.setAttribute('aria-hidden', 'true');
      tooltip.className = 'absolute -top-3 left-1/2 transform whitespace-nowrap -translate-x-1/2 -translate-y-1/2 peer-hover:opacity-100 opacity-0 text-tooltip-foreground rounded-lg px-1.5 py-0.5 text-xs bg-primary-dark';
      tooltip.textContent = 'Ask AI';

      wrapper.appendChild(btn);
      wrapper.appendChild(tooltip);
      container.appendChild(wrapper);

      btn.addEventListener('click', function (e) {
        e.preventDefault();
        e.stopPropagation();

        var pageTitleEl = document.getElementById('page-title');
        var pageTitle = (pageTitleEl ? pageTitleEl.textContent : document.title).trim();
        var url = window.location.href;
        var section = getNearestHeading(block);
        var snippet = codeText.length > 800 ? codeText.substring(0, 800) + '\u2026' : codeText;

        var msg =
          'I\u2019m reading the ' + language + ' code example' +
          (section ? ' in the \u201c' + section + '\u201d section' : '') +
          ' of \u201c' + pageTitle + '\u201d:\n' + url + '\n\n' +
          '```' + language + '\n' + snippet + '\n```\n\n' +
          'Can you help me understand how this works?';

        pendingPreload = msg;
        openPanel();
      });
    });
  }

  function injectPageAskAIButton() {
    if (document.getElementById('page-ask-ai-btn')) return;
    var pageTitle = document.getElementById('page-title');
    var contextMenu = document.getElementById('page-context-menu');
    if (!pageTitle || !contextMenu) return;

    var btn = document.createElement('button');
    btn.id = 'page-ask-ai-btn';
    btn.type = 'button';
    btn.className =
      'rounded-xl px-3 py-1.5 cursor-pointer ' +
      'text-gray-700 dark:text-gray-300 ' +
      'border border-gray-200 dark:border-white/[0.07] ' +
      'bg-background-light dark:bg-background-dark ' +
      'hover:bg-gray-600/5 dark:hover:bg-gray-200/5 ' +
      'transition-all duration-150';
    btn.innerHTML =
      '<div class="flex items-center gap-1.5 text-sm font-medium">' +
      DS_SPARKLE + '<span>Ask AI</span></div>';

    btn.addEventListener('click', function (e) {
      e.preventDefault();
      e.stopPropagation();
      var title = pageTitle.textContent.trim();
      var url = window.location.href;
      var msg =
        'I\u2019m reading \u201c' + title + '\u201d in the HoopAI Help Center.\n' +
        'Page: ' + url + '\n\nI have a question about this page \u2014 can you help?';
      pendingPreload = msg;
      openPanel();
    });

    var column = document.createElement('div');
    column.id = 'page-ask-ai-wrapper';
    column.className = 'flex flex-col items-end gap-1 shrink-0 ml-auto sm:flex hidden';
    contextMenu.parentElement.insertBefore(column, contextMenu);
    column.appendChild(btn);
    column.appendChild(contextMenu);
  }

  /* ── KEYBOARD SHORTCUT ───────────────────────────────────── */

  document.addEventListener('keydown', function (e) {
    if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
      e.preventDefault();
      e.stopPropagation();
      togglePanel();
    }
  });

  /* ── BOOTSTRAP ───────────────────────────────────────────── */

  function tryInject() {
    injectAskAIButton();
    injectMobileAskAIButton();
    injectCodeBlockAskAI();
    injectPageAskAIButton();
    var missingDesktop = !document.getElementById('assistant-entry');
    var missingMobile = !document.getElementById('assistant-entry-mobile');
    if (missingDesktop || missingMobile) {
      setTimeout(function () { injectAskAIButton(); injectMobileAskAIButton(); }, 500);
      setTimeout(function () { injectAskAIButton(); injectMobileAskAIButton(); }, 1500);
      setTimeout(function () { injectAskAIButton(); injectMobileAskAIButton(); }, 3000);
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', tryInject);
  } else {
    tryInject();
  }

  var observer = new MutationObserver(function () {
    if (!document.getElementById('assistant-entry')) injectAskAIButton();
    if (!document.getElementById('assistant-entry-mobile')) injectMobileAskAIButton();
    if (!document.getElementById('page-ask-ai-btn')) injectPageAskAIButton();
    injectCodeBlockAskAI();
  });
  observer.observe(document.documentElement, { childList: true, subtree: true });

})();
  • Step 5.2 — Commit widget
git add theme/chat-widget.js
git commit -m "feat: replace VoiceGlow with custom Claude Haiku chat panel"

Task 6: Wire Worker URL and Verify docs.json

Files:
  • Modify: theme/chat-widget.js (replace placeholder URL)
  • Verify: docs.json (ensure JS path is correct)
  • Step 6.1 — Replace the WORKER_URL placeholder
In theme/chat-widget.js, line 22, replace:
var WORKER_URL = 'https://hoopai-chat-api.REPLACE_WITH_ACCOUNT_ID.workers.dev';
With the actual URL from Task 3 Step 3.1, e.g.:
var WORKER_URL = 'https://hoopai-chat-api.abc123.workers.dev';
  • Step 6.2 — Verify docs.json JS reference
Check that docs.json line ~143 references the correct path. Open docs.json and look for the "js" array. It should read:
"js": [
  "theme/dropdown-icons.js",
  "theme/method-icons.js",
  "theme/chat-widget.js",
  "theme/sidebar-toggle.js",
  "theme/rtl-detect.js"
]
If any entries are missing the theme/ prefix (e.g. "chat-widget.js"), update them to include theme/:
# Check current value
grep -n '"chat-widget' docs.json
If the path is already "theme/chat-widget.js", no change needed. If it shows "chat-widget.js", update it in docs.json.
  • Step 6.3 — Commit URL + docs.json fix
git add theme/chat-widget.js docs.json
git commit -m "chore: wire Worker URL and fix docs.json JS path to theme/chat-widget.js"

Task 7: Integration Smoke Test

No file changes — verification only.
  • Step 7.1 — Kill any stale Node processes and start dev server
taskkill //F //IM node.exe
npx mintlify dev
Wait for localhost:3000 to be ready.
  • Step 7.2 — Open browser at localhost:3000 and verify Ask AI button appears
Navigate to http://localhost:3000. Confirm:
  • Navbar shows the sparkle “Ask AI” button to the right of the search bar
  • No JavaScript console errors
  • Step 7.3 — Open panel and verify layout
Click “Ask AI”. Confirm:
  • Panel slides in from the right
  • Page content shifts left
  • Empty state shows sparkle icon, title, and 3 suggestion chips
  • Dark/light mode matches site theme (toggle dark mode to verify)
  • Step 7.4 — Send a message and verify streaming
Type “What is HoopAI?” and press Enter. Confirm:
  • User bubble appears immediately
  • Typing indicator shows
  • Response streams in token by token
  • Send button shows stop icon during streaming
  • Input is disabled during streaming
  • After completion: send button resets, input re-enables
  • Step 7.5 — Test expand and clear
Click the expand button — panel widens to 680px, content shifts further left. Click clear — conversation resets to empty state. Press Ctrl+I — panel toggles closed/open.
  • Step 7.6 — Test code block Ask AI button
Navigate to a page with a code block (e.g. /api-reference/introduction). Confirm:
  • Sparkle icon appears in code block toolbar on hover
  • Clicking it opens the panel with the code pre-loaded in input
  • Step 7.7 — Commit final state if all checks pass
git add -A
git commit -m "feat: HoopAI AI chat widget complete — Claude Haiku via Cloudflare Worker"

Self-Review Checklist

  • CORS security — Worker blocks all origins except help.hoopai.com and localhost:3000
  • API key never in browserOPENROUTER_API_KEY is a Wrangler secret, not in any file
  • Streaming — SSE handled with ReadableStream + TransformStream in Worker, ReadableStream in widget
  • Tool loop — max 4 iterations prevents infinite loops; each tool fires tool_start / tool_end SSE events
  • Web search gracefully disabled — if BRAVE_SEARCH_API_KEY secret absent, returns fallback message (no crash)
  • Support ticket gracefully disabled — if SUPPORT_WEBHOOK_URL absent, returns helpful message
  • Dark mode — all panel CSS uses --ds-* tokens; no hardcoded colours
  • Mobile — panel goes full-width on ≤768px, no content push (avoids broken layout)
  • Existing Ask AI IDs preserved#assistant-entry, #assistant-entry-mobile, data-ask-ai-injected, #page-ask-ai-btn all identical
  • No VoiceGlow CDN callscdn.voiceglow.org script removed entirely
  • Branding — system prompt explicitly blocks GoHighLevel / Marketing Muse / WhoPayI
  • Keyboard shortcut — Ctrl+I / Cmd+I preserved
Last modified on April 16, 2026