Skip to main content

SPA-aware Companion with Route-sensitive SurfaceContext

Overview

Keep the Companion widget alive across SPA route changes while ensuring each message carries the current page's surface_context. Uses setInputAugmenter to rewire context on navigation without destroying the widget or losing conversation history.

When to use

  • Next.js / React Router / Remix apps where the URL changes without a full page reload
  • Multi-page dashboards where the Companion should know which section the user is viewing
  • Entity-detail pages (tickets, invoices, agents) where each navigation changes the entity context

The key insight

The React wrapper mounts the widget once (empty useEffect deps). Callbacks are stored in refs — they always call the latest version without remounting. When the route changes, a second useEffect calls setInputAugmenter to explicitly rewire the context function.

Pattern: global widget with per-route entity context

// components/GlobalCompanion.tsx
'use client';

import { useRef, useEffect } from 'react';
import { usePathname, useParams } from 'next/navigation';
import { CompanionWidget, CompanionWidgetRef } from '@human/companion-widget/react';

const DEPLOYMENT_ID = process.env.NEXT_PUBLIC_COMPANION_DEPLOYMENT_ID!;

function getSurfaceContext(
  pathname: string,
  params: Record<string, string | string[]>
) {
  // Ticket detail page
  if (pathname.startsWith('/support/tickets/') && params.ticketId) {
    return {
      surface: 'customer-support',
      page: pathname,
      entity_type: 'support_ticket',
      entity_id: String(params.ticketId),
    };
  }

  // Invoice detail page
  if (pathname.startsWith('/billing/invoices/') && params.invoiceId) {
    return {
      surface: 'billing',
      page: pathname,
      entity_type: 'invoice',
      entity_id: String(params.invoiceId),
    };
  }

  // Default: just the page path
  return {
    surface: 'app',
    page: pathname,
    page_title: typeof document !== 'undefined' ? document.title : undefined,
  };
}

export function GlobalCompanion() {
  const pathname = usePathname();
  const params = useParams() as Record<string, string | string[]>;
  const widgetRef = useRef<CompanionWidgetRef>(null);

  // Rewire surface_context on every route change — zero conversation loss
  useEffect(() => {
    widgetRef.current?.setInputAugmenter(() => ({
      deployment_id: DEPLOYMENT_ID,
      surface_context: getSurfaceContext(pathname, params),
    }));
  }, [pathname, params]);

  return (
    <CompanionWidget
      ref={widgetRef}
      humanApiUrl={process.env.NEXT_PUBLIC_API_URL}
      agentsCallUrl="/api/companion/ask"
      buildAgentInput={() => ({
        deployment_id: DEPLOYMENT_ID,
        surface_context: getSurfaceContext(pathname, params),
      })}
      ui={{ theme: 'dark', position: 'bottom-right' }}
    />
  );
}
// app/layout.tsx
import { GlobalCompanion } from '@/components/GlobalCompanion';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <GlobalCompanion />
      </body>
    </html>
  );
}

Pattern: entity name from data fetch

If you fetch the entity (e.g., ticket title) asynchronously, update the augmenter after the fetch:

// app/support/tickets/[ticketId]/page.tsx
'use client';

import { useRef, useEffect, useState } from 'react';
import { CompanionWidget, CompanionWidgetRef } from '@human/companion-widget/react';

export function TicketPage({ ticketId }: { ticketId: string }) {
  const widgetRef = useRef<CompanionWidgetRef>(null);
  const [ticket, setTicket] = useState<{ title: string } | null>(null);

  // Fetch ticket
  useEffect(() => {
    fetch(`/api/tickets/${ticketId}`)
      .then((r) => r.json())
      .then(setTicket);
  }, [ticketId]);

  // Update augmenter when ticket title loads
  useEffect(() => {
    if (!ticket) return;
    widgetRef.current?.setInputAugmenter(() => ({
      deployment_id: 'dep_support',
      surface_context: {
        surface: 'customer-support',
        page: `/support/tickets/${ticketId}`,
        entity_type: 'support_ticket',
        entity_id: ticketId,
        entity_name: ticket.title,  // Now includes the loaded title
      },
    }));
  }, [ticket, ticketId]);

  return (
    <div>
      <h1>{ticket?.title ?? 'Loading...'}</h1>
      <CompanionWidget
        ref={widgetRef}
        humanApiUrl={process.env.NEXT_PUBLIC_API_URL}
        agentsCallUrl="/api/companion/ask"
        buildAgentInput={() => ({
          deployment_id: 'dep_support',
          surface_context: {
            surface: 'customer-support',
            page: `/support/tickets/${ticketId}`,
            entity_type: 'support_ticket',
            entity_id: ticketId,
            // entity_name is not available yet — will be set by setInputAugmenter
          },
        })}
        ui={{ theme: 'light', position: 'inline' }}
        style={{ height: 480 }}
      />
    </div>
  );
}

Why setInputAugmenter and not just a prop change

If you passed buildAgentInput as a regular prop, changing it would not cause the widget to reinit (by design — the wrapper ignores prop changes after mount). You'd need a different mechanism to propagate the change.

setInputAugmenter is the imperative escape hatch: it updates the widget's internal augmenter directly, bypassing React's prop reconciliation entirely. The widget picks it up immediately for the next message send.

See Also

← All patterns