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
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
) {
// 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;
const widgetRef = useRef(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 (
({
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 (
{children}
);
}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(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 (
{ticket?.title ?? 'Loading...'}
({
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 }}
/>
);
}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.