An assistant has two homes. On its own route the conversation is the page: it fills the content card, and
its transcript is the main landmark. On every other route it is a companion beside the page, in
AppLayout.SidePanel, the complementary landmark. This recipe renders
one conversation in both, keeps it in one store above the shell, and runs a View Transition when a
navigation crosses between them, so the page folds into the panel with the reply still streaming.
Start a conversation, then click Endpoints. Click Assistant to bring it back.
Desktop viewport
Tablet viewport
Mobile viewport
note
The demo fakes its router, because a preview is a document inside this site's own router and React
Router permits one router per tree. It keeps a pathname in state and calls
document.startViewTransition itself on a crossing, which is the one call React Router's
viewTransition makes for you. The pieces below are the real ones; only the shell's links differ.
Why the aside never becomes the page
The tempting shape is one panel that grows to fill the row on the assistant's route. Avoid it. An
<aside> is the complementary landmark, and ARIA in HTML does not
let it take role="main". A screen reader user on the assistant's route would find the page in the
complementary landmark and nothing in main. The landmark follows the route instead:
On /assistant, AssistantPage renders inside the shell's AppLayout.Content: the toolbar, then the
transcript and the composer inside AppLayout.Body, which is
main. The body stays the only scroll region, with the composer stuck to its bottom edge, so the skip
link lands on the element that scrolls and Page Down moves the transcript.
Everywhere else, AssistantPanel renders AppLayout.SidePanel after the content card, with the same
transcript and composer inside it.
The panel does not render on the assistant's route, and the page does not render anywhere else. One
instance of the conversation is on screen at a time.
What persists is the conversation, not the DOM. AssistantConversationProvider sits above the shell and
owns the messages, the composer's draft, and the reply in flight, on a timer the provider holds. A route
change unmounts one home and mounts the other. Both read the same store, so the transcript, the half-typed
draft, and the streaming reply are all there in the new home, and the View Transition hides the remount.
Put the provider above every shell the conversation must survive. A dashboard whose settings zone renders
a shell of its own loses an in-memory conversation at that crossing unless the provider sits above both.
Anatomy
app/routes/layouts/app.tsx the layout route that owns the shell├── AssistantConversationProvider the one store: messages, draft, the reply in flight└── AppLayout.SidePanelRoot open state, read from the cookie in the loader └── AssistantSidebarRoot Sidebar.Root that prefers the icon rail while the panel needs the lane └── AppLayout.Workspace ├── Sidebar.Nav <Link viewTransition={crossesAssistant(pathname, to)}> ├── AppLayout.Content named on /assistant │ └── <Outlet /> │ └── AssistantPage /assistant: the toolbar, main, the composer └── AssistantPanel every other route: the aside, named while open
The route decides
The shell reads the pathname once and makes three decisions from it: whether the content card is named,
whether the panel renders, and which links run the transition. Here is a react-router 7 layout route,
complete as written:
Sidebar.Tooltip and the panel's tooltips need a
TooltipProvider ancestor, so mount one at the app root,
above this layout. Tooltip.Root throws without it.
The assistant's route module renders the page and nothing else. A product page renders
AppLayout.SidePanelTrigger in its AppLayout.HeaderActions, the way the
side panel demo does:
1// app/routes/assistant.tsx2import { AssistantPage } from "~/features/assistant-across-routes/assistant-across-routes";34export default function AssistantRoute() {⋯5 return <AssistantPage />;6}
Three rules keep the transition running:
Name exactly one element per state. The content card carries assistantViewTransitionClassName
only on the assistant's route, and the panel carries it only while open. If two elements share a
view-transition-name in one state, the browser skips the whole transition and logs nothing.
isAssistantPath reads the pathname the way the router matches it, decoded and case-insensitive, so
/Assistant never renders the page and the panel together.
Do not render the panel on the assistant's route. A closed panel is still an <aside> with the
conversation mounted inside it, rendered twice for nothing. An open one would name the conversation
twice.
Keep AppLayout.SidePanelRoot above both, and above the rail. It is renderless, and AssistantPage
calls useSidePanel to open the companion on the first message, so
the conversation comes along when the reader leaves. AssistantSidebarRoot reads the same state to yield
the rail's width to the panel (see Tablet and phone), so it sits below the root and
above the shell.
The panel mounts open when the reader leaves the assistant's route, so its own 200ms slide never runs:
there is no closed state to slide from. The morph is the only animation. The same holds in reverse: the
panel unmounts, and the page is named. Nothing merges transition-none.
The link
Pass viewTransition on a crossing and on nothing else:
crossesAssistant is true when one side of the navigation is the assistant's route and the other is not.
A navigation between two product pages is the most frequent thing a reader does in a dashboard, many
times a day, and it gets no animation at all: the page swaps in one frame, as it does today. The crossing
is occasional, so it gets the standard animation. Nothing here is rare enough to earn more.
useViewTransitionState is not needed. The names are static, and a link runs the transition only on a
crossing. History is the one exception: React Router remembers which entries arrived with
viewTransition and runs the transition again on Back and Forward to them. Between two product pages
that is a 300ms crossfade of the page with no morph, because both states name the panel in the same
place.
Duration
The crossing is a page transition, so it runs for 300ms: the floor of the band for page transitions in
the duration guidelines Emil Kowalski publishes for interface motion, and the ceiling those guidelines
set for any UI animation.
Element type
Duration
Micro-interactions
100–150ms
Standard UI (tooltips, dropdowns)
150–250ms
Modals, drawers
200–300ms
Page transitions
300–400ms
The browser's default is 250ms with ease. The stylesheet below sets the group named assistant and the
root crossfade to 300ms on mantle's --ease-out-strong, the curve the panel's own slide uses. Under
prefers-reduced-motion: reduce every View Transition is a cut, the same as every motion-reduce: rule
in mantle. Import it from your app's stylesheet:
1/*2 * The assistant's view transition is a page transition, so it runs for 300ms:3 * the floor of the 300–400ms band for page transitions, and the ceiling for a4 * UI animation. The group is the element named `assistant`; `root` is the rest5 * of the page, which crossfades underneath it on the same clock.6 */7::view-transition-group(assistant),8::view-transition-old(assistant),9::view-transition-new(assistant),10::view-transition-old(root),11::view-transition-new(root) {⋯12 animation-duration: 300ms;13 animation-timing-function: var(--ease-out-strong);14}1516/* A cut, the same as every `motion-reduce:` rule in mantle. */17@media (prefers-reduced-motion: reduce) {⋯18 ::view-transition-group(*),19 ::view-transition-old(*),20 ::view-transition-new(*) {⋯21 animation: none;22 }23}
Server rendering
Both halves of the state are available on the server, so the first paint is right and nothing corrects
it:
Which home. The URL. isAssistantPath(pathname) is the same on the server and the client.
Whether the panel is open. The side panel cookie. Read it in the loader with
extractSidePanelStateCookie, pass it to defaultOpen, and write it back from onOpenChange with
serializeSidePanelStateCookie, as
Persisting the side panel to a cookie
shows.
A document load runs no View Transition; the browser only snapshots a same-document navigation. The
panel's hydration gate covers the one case left: a cookie the client reads differently from the server
snaps instead of sliding.
The conversation itself is data, and the demo's store is in memory. A real assistant keeps the active
conversation on the server and puts its id in the URL or a cookie, so a reload hydrates the same
transcript.
Tablet and phone
Three widths, one <aside>. The panel keeps its DOM across all of them, so the conversation never
remounts, and the crossing still morphs: the page shrinks into whichever shape the panel takes.
Beside the page, with the rail yielding. Between the lane an icon rail needs (46.75rem) and the lane
an expanded rail needs (56.5rem), an expanded rail leaves the row too narrow for the panel, and the panel
floats over the page. AssistantSidebarRoot prefers the icon rail there: when the panel opens, or the
window narrows below the lane while it is open, the rail collapses and the panel sits beside the page.
The reader can expand the rail again with Sidebar.Trigger, and the panel floats. When the panel closes
or the window widens, a rail the panel collapsed expands again, and a rail the reader toggled keeps their
choice. onOpenChange reports the reader's toggles only, so the sidebar cookie keeps their preference.
Floating, below mobileBreakpoint. The panel is an overlay the reader can also dismiss with
Escape while focus is inside. The page beneath stays usable. A drag on the panel's header toward the
end edge swipes it away at every width, beside the page too.
Covering, one breakpoint lower. The panel spans the row from gutter to gutter and turns modal: the
card and the rail go inert, a focus trap wraps Tab inside it, and Escape closes it. Nothing beneath
it is clickable or read.
Match mobileBreakpoint to Sidebar.Root's, so the rail and the panel change presentation at one width.
The demo narrows both to md; most apps keep the lg default.
The files
Two files to copy, and the stylesheet above. assistant-across-routes.tsx is the two homes, the rail
that yields to the panel, and the two path helpers; nothing in it touches the router.
assistant-conversation.tsx is the store and the two pieces of chat UI; nothing in it knows where it
renders.
1import { AppLayout, useSidePanel } from "@ngrok/mantle/app-layout";2import { IconButton } from "@ngrok/mantle/button";3import { cx } from "@ngrok/mantle/cx";4import { useMatchesMediaQuery } from "@ngrok/mantle/hooks";5import { Sidebar } from "@ngrok/mantle/sidebar";6import { Tooltip } from "@ngrok/mantle/tooltip";7import { PencilSimpleLineIcon } from "@phosphor-icons/react/PencilSimpleLine";8import { XIcon } from "@phosphor-icons/react/X";9import type { ComponentProps } from "react";10import { useEffect, useRef, useState } from "react";11import {12 AssistantComposer,13 AssistantTranscript,14 useAssistantConversation,15} from "~/features/assistant-across-routes/assistant-conversation";1617/** The assistant's own route, where the conversation is the page. */18const assistantPath = "/assistant";1920/**21 * Reads a pathname the way React Router matches one: decoded, lowercase, and22 * with no trailing slash. A malformed escape keeps the raw pathname, which is23 * what the router does too.24 */25function normalizePathname(pathname: string): string {⋯26 let decoded = pathname;27 try {⋯28 decoded = decodeURIComponent(pathname);29 } catch {⋯30 // a malformed escape: the raw pathname stands31 }32 return decoded.toLowerCase().replace(/\/+$/, "") || "/";33}3435/**36 * Returns true on the assistant's route and on its children, such as a37 * conversation's own URL. A sibling route that shares the prefix is a38 * different page. The comparison matches the router's: `/Assistant` and39 * `/assi%73tant` are the assistant's route, so the shell never renders the40 * page and the panel together.41 *42 * @example43 * ```ts44 * isAssistantPath("/assistant"); // true45 * isAssistantPath("/assistant/c_123"); // true46 * isAssistantPath("/Assistant/"); // true47 * isAssistantPath("/assistants"); // false48 * ```49 */50function isAssistantPath(pathname: string): boolean {⋯51 const normalized = normalizePathname(pathname);52 return normalized === assistantPath || normalized.startsWith(`${assistantPath}/`);53}5455/**56 * Returns true when a navigation enters or leaves the assistant's route. Those57 * are the only navigations that move the conversation between `main` and the58 * panel, so they are the only ones that run the view transition.59 *60 * @example61 * ```ts62 * crossesAssistant("/assistant", "/endpoints"); // true63 * crossesAssistant("/endpoints", "/domains"); // false64 * ```65 */66function crossesAssistant(from: string, to: string): boolean {⋯67 return isAssistantPath(from) !== isAssistantPath(to);68}6970/**71 * The `view-transition-name` the assistant's two homes share, as a class.72 * `AppLayout.Content` carries it on the assistant route and the open panel73 * carries it on every other route, so each state names exactly one element.74 * A name on two elements at once makes the browser skip the whole transition.75 * `assistant-view-transition.css` times the group by this name.76 */77const assistantViewTransitionClassName = "[view-transition-name:assistant]";7879/** The control that starts a new conversation, in both headers. */80function NewConversationButton() {⋯81 const { reset } = useAssistantConversation();8283 return (84 <Tooltip.Root>⋯85 <Tooltip.Trigger asChild>⋯86 <IconButton⋯87 type="button"88 appearance="ghost"89 intent="neutral"90 size="sm"91 icon={<PencilSimpleLineIcon />}92 label="New conversation"93 onClick={reset}94 />95 </Tooltip.Trigger>96 <Tooltip.Content>New conversation</Tooltip.Content>97 </Tooltip.Root>98 );99}100101/**102 * The assistant's route: the conversation as the page. Renders the toolbar,103 * the `main` body, and the composer pinned below it, inside the shell's104 * `AppLayout.Content`. The first message opens the companion, so the105 * conversation comes along when the reader leaves.106 *107 * @example108 * ```tsx109 * // app/routes/assistant.tsx110 * export default function AssistantRoute() {111 * return <AssistantPage />;112 * }113 * ```114 */115function AssistantPage() {⋯116 const { setOpen } = useSidePanel();117 const openCompanion = () => setOpen(true);118119 return (120 <>⋯121 <AppLayout.Header>⋯122 <AppLayout.HeaderStart>⋯123 <Sidebar.Trigger />124 </AppLayout.HeaderStart>125 <AppLayout.HeaderContent>⋯126 <h1 className="text-strong text-sm font-medium">Assistant</h1>127 </AppLayout.HeaderContent>128 <AppLayout.HeaderActions>⋯129 <NewConversationButton />130 </AppLayout.HeaderActions>131 </AppLayout.Header>132 <AppLayout.Body>⋯133 {/* Why one scroll region: the skip link lands on `main`, so `main` must be134 the element that scrolls. The composer sticks to its bottom edge. */}135 <div className="flex min-h-full flex-col">⋯136 <AssistantTranscript onSend={openCompanion} />137 <div className="bg-card sticky bottom-0 mt-auto shrink-0 px-3 pt-2 pb-3">⋯138 <AssistantComposer className="mx-auto w-full max-w-xl" onSend={openCompanion} />139 </div>140 </div>141 </AppLayout.Body>142 </>143 );144}145146/**147 * The assistant's panel: the same conversation beside a page. Render it as the148 * last child of `AppLayout.Workspace` on every route but the assistant's. On149 * that route the page is the home, and an `<aside>` beside it would name the150 * conversation twice.151 *152 * @example153 * ```tsx154 * <AppLayout.Workspace>155 * <Sidebar.Nav aria-label="Main">…</Sidebar.Nav>156 * <AppLayout.Content className={cx(onAssistantPage && assistantViewTransitionClassName)}>157 * <Outlet />158 * </AppLayout.Content>159 * {!onAssistantPage && <AssistantPanel />}160 * </AppLayout.Workspace>161 * ```162 */163function AssistantPanel({⋯164 mobileBreakpoint,165}: {⋯166 /** Forwarded to `AppLayout.SidePanel`. Match it to `Sidebar.Root`'s. */167 mobileBreakpoint?: ComponentProps<typeof AppLayout.SidePanel>["mobileBreakpoint"];168}) {⋯169 const { open } = useSidePanel();170171 return (172 <AppLayout.SidePanel⋯173 aria-label="Assistant"174 mobileBreakpoint={mobileBreakpoint}175 // Why only when open: a closed panel is a zero-width box, and a morph176 // from the page into it reads as the conversation folding into nothing.177 className={cx(open && assistantViewTransitionClassName)}178 >179 <AppLayout.SidePanelHeader>⋯180 <h2 className="text-strong min-w-0 flex-1 truncate text-sm font-medium">Assistant</h2>181 <NewConversationButton />182 <AppLayout.SidePanelTrigger icon={<XIcon />} label="Close the assistant" />183 </AppLayout.SidePanelHeader>184 <AppLayout.SidePanelBody>⋯185 <AssistantTranscript />186 </AppLayout.SidePanelBody>187 <AppLayout.SidePanelFooter>⋯188 <AssistantComposer />189 </AppLayout.SidePanelFooter>190 </AppLayout.SidePanel>191 );192}193194/**195 * The props of `AssistantSidebarRoot`: `Sidebar.Root`'s, except `open`, which196 * the part derives.197 */198type AssistantSidebarRootProps = Omit<ComponentProps<typeof Sidebar.Root>, "open"> & {⋯199 /**200 * Whether `AssistantPanel` is in the row. On the assistant's route it is201 * not, and the rail keeps its width.202 */203 panelMounted: boolean;204};205206/**207 * The `Sidebar.Root` of a shell with the assistant beside the page. The rail208 * prefers its icon rail while the panel needs the width: when the panel opens,209 * or the window narrows below the lane an expanded rail needs while the panel210 * is open, the rail collapses, so the panel sits beside the page instead of211 * floating over it. The reader can expand it again with `Sidebar.Trigger`, and212 * the panel floats. When the panel closes or the window widens, a rail the213 * panel collapsed expands again, and a rail the reader toggled keeps their214 * choice. `onOpenChange` reports the reader's toggles only, so the sidebar215 * cookie keeps their preference.216 *217 * @example218 * ```tsx219 * <AppLayout.SidePanelRoot defaultOpen={loaderData.sidePanelOpen}>220 * <AssistantSidebarRoot panelMounted={!onAssistantPage} defaultOpen={loaderData.sidebarOpen}>221 * <AppLayout.Root className="fixed inset-0">…</AppLayout.Root>222 * </AssistantSidebarRoot>223 * </AppLayout.SidePanelRoot>224 * ```225 */226function AssistantSidebarRoot({⋯227 children,228 defaultOpen = true,229 onOpenChange,230 panelMounted,231 ...props232}: AssistantSidebarRootProps) {⋯233 const [railOpen, setRailOpen] = useState(defaultOpen);234 const { open: panelOpen } = useSidePanel();235 // Why 56.5rem: it is `--container-side-panel-lane`, the narrowest row that236 // holds the expanded rail, the page, and the panel. Below it the row is the237 // viewport's width, so the viewport query is the row's.238 const panelNeedsTheLane = useMatchesMediaQuery("(width < 56.5rem)");239 const panelNeedsTheRail = panelMounted && panelOpen && panelNeedsTheLane;240 const panelNeededTheRail = useRef(false);241 // Why a ref: only a collapse the panel caused expands again, and a toggle242 // by the reader in between cancels that.243 const collapsedByPanel = useRef(false);244245 useEffect(() => {⋯246 const panelTookTheWidth = panelNeedsTheRail && !panelNeededTheRail.current;247 const panelGaveItBack = !panelNeedsTheRail && panelNeededTheRail.current;248 panelNeededTheRail.current = panelNeedsTheRail;249 if (panelTookTheWidth && railOpen) {⋯250 collapsedByPanel.current = true;251 setRailOpen(false);252 }253 if (panelGaveItBack && collapsedByPanel.current) {⋯254 collapsedByPanel.current = false;255 setRailOpen(true);256 }257 }, [panelNeedsTheRail, railOpen]);258259 return (260 <Sidebar.Root⋯261 {...props}262 open={railOpen}263 onOpenChange={(open) => {⋯264 collapsedByPanel.current = false;265 setRailOpen(open);266 onOpenChange?.(open);267 }}268 >269 {children}270 </Sidebar.Root>271 );272}273274export {275 //,276 AssistantPage,277 AssistantPanel,278 AssistantSidebarRoot,279 assistantPath,280 assistantViewTransitionClassName,281 crossesAssistant,282 isAssistantPath,283};
1import { Button, IconButton } from "@ngrok/mantle/button";2import { cx } from "@ngrok/mantle/cx";3import { Icon } from "@ngrok/mantle/icon";4import { LiveRegion } from "@ngrok/mantle/live-region";5import { TextArea } from "@ngrok/mantle/text-area";6import { ArrowUpIcon } from "@phosphor-icons/react/ArrowUp";7import { BroadcastIcon } from "@phosphor-icons/react/Broadcast";8import { GaugeIcon } from "@phosphor-icons/react/Gauge";9import { GlobeHemisphereWestIcon } from "@phosphor-icons/react/GlobeHemisphereWest";10import { ShieldCheckIcon } from "@phosphor-icons/react/ShieldCheck";11import { SparkleIcon } from "@phosphor-icons/react/Sparkle";12import {13 createContext,14 useCallback,15 useContext,16 useEffect,17 useMemo,18 useRef,19 useState,20 type ReactNode,21} from "react";2223/** One turn of the conversation. */24type AssistantMessage = {⋯25 id: string;26 role: "user" | "assistant";27 text: string;28};2930/**31 * The conversation the assistant's two homes share. The page on the assistant32 * route and the side panel on every other route both render it, so it lives33 * above both and nothing in it belongs to one host.34 */35type AssistantConversation = {⋯36 messages: ReadonlyArray<AssistantMessage>;37 /** The composer's unsent text. Held here so a route change keeps it. */38 draft: string;39 /** True from a send until the reply has arrived in full. */40 isReplying: boolean;41 setDraft: (draft: string) => void;42 /**43 * Appends the user's message and starts the reply. Returns `false`, and44 * changes nothing, for a blank message or while a reply is in flight.45 */46 send: (text: string) => boolean;47 /** Clears the conversation and stops a reply in flight. */48 reset: () => void;49};5051const AssistantConversationContext = createContext<AssistantConversation | null>(null);5253/**54 * Reads the conversation. Throws outside `AssistantConversationProvider`.55 *56 * @example57 * ```tsx58 * const { messages, send } = useAssistantConversation();59 * ```60 */61function useAssistantConversation(): AssistantConversation {⋯62 const conversation = useContext(AssistantConversationContext);63 if (conversation == null) {⋯64 throw new Error("useAssistantConversation must be used inside AssistantConversationProvider.");65 }66 return conversation;67}6869/** The canned replies this demo streams. A real assistant streams from a server. */70const scriptedReplies = [⋯71 "Here is the shape. A Traffic Policy on the inbound phase with an oauth action in front of your API, scoped to the endpoint you named. Every request then carries a verified identity before it reaches your upstream, and the policy is the only thing you deploy.",72 "That endpoint already has a policy with one rule. Adding a rate-limit action after the oauth action keeps the identity check first, so an anonymous burst is rejected before it counts against anyone's quota.",73 "You can apply it from the endpoint's Traffic Policy tab. The editor validates the YAML as you type, and the apply is atomic: the old policy serves until the new one is live.",74] as const;7576/** The gap between two revealed words, in milliseconds. */77const revealIntervalMs = 45;7879/** The visible wait beside the pulsing dot, and the first thing the live region announces after a send. */80const pendingLabel = "Working through it…";8182/**83 * The text the transcript's live region carries: the wait while a reply84 * streams, then the whole reply once it has landed, and nothing between turns.85 * The region reads the reply once, not a word at a time: a live region86 * re-announces on every change, and a stream of single words would interrupt87 * the reader fifty times.88 */89function announcementFor({⋯90 messages,91 isReplying,92}: {⋯93 messages: ReadonlyArray<AssistantMessage>;94 isReplying: boolean;95}): string {⋯96 if (isReplying) {⋯97 return pendingLabel;98 }99 const lastMessage = messages[messages.length - 1];100 if (lastMessage?.role === "assistant") {⋯101 return lastMessage.text;102 }103 return "";104}105106/**107 * Owns the conversation: the messages, the composer's draft, and the reply in108 * flight. Render it once, above the shell, so the assistant's page and its side109 * panel read one conversation and a route change never restarts a reply.110 *111 * The reply streams word by word on a timer this provider holds. Nothing in a112 * host component owns the timer, which is what lets the host unmount mid-reply.113 *114 * @example115 * ```tsx116 * <AssistantConversationProvider>117 * <Sidebar.Root>118 * <AppLayout.Root className="fixed inset-0">…</AppLayout.Root>119 * </Sidebar.Root>120 * </AssistantConversationProvider>121 * ```122 */123function AssistantConversationProvider({ children }: { children: ReactNode }) {⋯124 const [messages, setMessages] = useState<ReadonlyArray<AssistantMessage>>([]);125 const [draft, setDraft] = useState("");126 const [isReplying, setIsReplying] = useState(false);127 const replyTimer = useRef<number | null>(null);128 const replyCount = useRef(0);129130 const stopReply = useCallback(() => {⋯131 if (replyTimer.current != null) {⋯132 window.clearTimeout(replyTimer.current);133 replyTimer.current = null;134 }135 setIsReplying(false);136 }, []);137138 useEffect(() => stopReply, [stopReply]);139140 const send = useCallback((text: string): boolean => {⋯141 const trimmed = text.trim();142 if (trimmed === "" || replyTimer.current != null) {⋯143 return false;144 }145 const turn = replyCount.current;146 replyCount.current += 1;147 const replyId = `assistant-${turn}`;148 const reply = scriptedReplies[turn % scriptedReplies.length] ?? scriptedReplies[0];149 const words = reply.split(" ");150 setMessages((current) => [⋯151 ...current,152 { id: `user-${turn}`, role: "user", text: trimmed },153 { id: replyId, role: "assistant", text: "" },154 ]);155 setIsReplying(true);156157 const reveal = (count: number) => {⋯158 const revealed = words.slice(0, count).join(" ");159 setMessages((current) =>160 current.map((message) =>161 message.id === replyId ? { ...message, text: revealed } : message,162 ),163 );164 if (count === words.length) {⋯165 replyTimer.current = null;166 setIsReplying(false);167 return;168 }169 replyTimer.current = window.setTimeout(() => reveal(count + 1), revealIntervalMs);170 };171 replyTimer.current = window.setTimeout(() => reveal(1), revealIntervalMs * 8);172 return true;173 }, []);174175 const reset = useCallback(() => {⋯176 stopReply();177 setMessages([]);178 setDraft("");179 }, [stopReply]);180181 const conversation = useMemo<AssistantConversation>(182 () => ({ messages, draft, isReplying, setDraft, send, reset }),183 [messages, draft, isReplying, send, reset],184 );185186 return (187 <AssistantConversationContext.Provider value={conversation}>⋯188 {children}189 </AssistantConversationContext.Provider>190 );191}192193/** The prompts the empty state offers, each with the icon on its card. */194const starterPrompts = [⋯195 { prompt: "Route and authenticate API traffic", icon: <ShieldCheckIcon /> },196 { prompt: "Rate limit an endpoint by caller", icon: <GaugeIcon /> },197 { prompt: "Add a custom domain to an endpoint", icon: <GlobeHemisphereWestIcon /> },198 { prompt: "Share a local API without deploying", icon: <BroadcastIcon /> },199] as const;200201/**202 * Scrolls the nearest scrollable ancestor to its end. The host owns the scroll203 * region, and a composer stuck to its bottom edge covers a sentinel scrolled204 * into view, so the transcript moves the region itself.205 */206function scrollHostToEnd(node: HTMLElement) {⋯207 let host = node.parentElement;208 while (host != null) {⋯209 const { overflowY } = getComputedStyle(host);210 if (overflowY === "auto" || overflowY === "scroll") {⋯211 host.scrollTop = host.scrollHeight;212 return;213 }214 host = host.parentElement;215 }216}217218/**219 * The messages, or the starter deck before there are any. Sized with container220 * queries, so one component is right at full-page width and in a 336px panel.221 * Render it inside the host's scroll region: `AppLayout.Body` on the assistant222 * route, `AppLayout.SidePanelBody` in the panel.223 *224 * A live region mounted beside the transcript announces the wait on a send225 * and the whole reply once it lands, so a reader who stays in the composer226 * hears the answer. The visible paragraph that streams the reply is not a live227 * region: it would re-announce on every word.228 *229 * @example230 * ```tsx231 * <AppLayout.Body>232 * <AssistantTranscript onSend={() => setOpen(true)} />233 * </AppLayout.Body>234 * ```235 */236function AssistantTranscript({⋯237 onSend,238}: {⋯239 /** Runs after a starter prompt is accepted. The assistant's page opens the companion here. */240 onSend?: () => void;241}) {⋯242 const { messages, isReplying, send } = useAssistantConversation();243 const endRef = useRef<HTMLDivElement>(null);244245 // Why an effect: the newest text must stay in view while a reply grows. The246 // scroll region is the host's, so the sentinel finds it and scrolls it.247 useEffect(() => {⋯248 if (messages.length > 0 && endRef.current != null) {⋯249 scrollHostToEnd(endRef.current);250 }251 }, [messages]);252253 const choose = (prompt: string) => {⋯254 if (send(prompt)) {⋯255 onSend?.();256 }257 };258259 // Why mounted before the first send: a live region announces a change, not260 // its first text, so the region that announces the wait must already be there.261 // Both branches put it first in a fragment, so the first send swaps the deck262 // for the list and keeps the region.263 const announcer = <LiveRegion>{announcementFor({ messages, isReplying })}</LiveRegion>;264265 if (messages.length === 0) {⋯266 return (267 <>⋯268 {announcer}269 <div className="@container flex min-h-full flex-1 flex-col items-center justify-center gap-4 p-4 @2xl:gap-6 @2xl:p-6">⋯270 <span className="flex size-7 items-center justify-center rounded-lg bg-emerald-600/10 text-emerald-600 @2xl:size-9">⋯271 <Icon svg={<SparkleIcon />} className="size-4 @2xl:size-5" />272 </span>273 <div className="flex flex-col items-center gap-2 text-center">⋯274 <h2 className="text-strong text-lg font-medium text-balance @2xl:text-2xl">⋯275 What should we do today?276 </h2>277 <p className="text-body max-w-lg text-xs text-pretty @2xl:text-sm">⋯278 Pick a starting point and the assistant writes the Traffic Policy and gets your279 endpoints running.280 </p>281 </div>282 <div className="grid w-full max-w-md gap-2 @2xl:max-w-2xl @2xl:grid-cols-2 @2xl:gap-3">⋯283 {starterPrompts.map(({ prompt, icon }) => (284 <Button⋯285 key={prompt}286 type="button"287 appearance="outlined"288 intent="neutral"289 className="h-auto justify-start gap-3 px-3 py-2.5 text-left whitespace-normal @2xl:py-3"290 onClick={() => choose(prompt)}291 >292 <span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-emerald-600/10 text-emerald-600">⋯293 <Icon svg={icon} className="size-4" />294 </span>295 {prompt}296 </Button>297 ))}298 </div>299 </div>300 </>301 );302 }303304 const lastMessage = messages[messages.length - 1];305306 return (307 <>⋯308 {announcer}309 <div className="@container mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 @2xl:p-6">⋯310 {messages.map((message) =>311 message.role === "user" ? (312 <div key={message.id} className="flex justify-end">⋯313 <p className="bg-neutral-500/8 text-strong max-w-[85%] rounded-2xl px-3.5 py-2 text-sm wrap-break-word whitespace-pre-wrap">⋯314 {message.text}315 </p>316 </div>317 ) : (318 <div key={message.id} className="text-body flex flex-col gap-2 text-sm">⋯319 {message.text === "" && isReplying && lastMessage?.id === message.id ? (320 <p className="text-muted flex items-center gap-2">⋯321 <span⋯322 aria-hidden323 className="bg-muted size-1.5 animate-pulse rounded-full motion-reduce:animate-none"324 />325 {/* Why a wrapper: the dot is a permanent sibling, and a translation326 engine reparents bare text. The swap to the first word would then327 remove a node React no longer owns. */}328 <span>{pendingLabel}</span>329 </p>330 ) : (331 <p>{message.text}</p>332 )}333 </div>334 ),335 )}336 <div ref={endRef} />337 </div>338 </>339 );340}341342/**343 * The message box. Enter sends and Shift+Enter adds a line, which is what a chat344 * does and a bare textarea does not. The draft lives in the conversation, so345 * the box a route change unmounts and the box it mounts show the same text.346 *347 * @example348 * ```tsx349 * <AppLayout.SidePanelFooter>350 * <AssistantComposer />351 * </AppLayout.SidePanelFooter>352 * ```353 */354function AssistantComposer({⋯355 className,356 onSend,357}: {⋯358 /** Merged onto the frame, for the host's measure. */359 className?: string;360 /** Runs after the conversation accepts a message. The assistant's page opens the companion here. */361 onSend?: () => void;362}) {⋯363 const { draft, setDraft, send } = useAssistantConversation();364365 // Why the guard: a send the conversation rejects, such as one during a reply,366 // must keep the draft, or the text is gone with nothing to show for it.367 const submit = () => {⋯368 if (!send(draft)) {⋯369 return;370 }371 setDraft("");372 onSend?.();373 };374375 return (376 <div⋯377 className={cx(378 "border-form bg-form flex items-end gap-2 rounded-[1.25rem] border p-2 shadow-sm",379 "focus-within:border-accent-600 focus-within:ring-focus-accent focus-within:ring-4",380 className,381 )}382 >383 <TextArea⋯384 aria-label="Message the assistant"385 className="min-h-20 resize-none border-0 bg-transparent px-2 py-1.5 shadow-none focus-visible:ring-0"386 placeholder="Ask about endpoints, domains, or Traffic Policy…"387 rows={3}388 value={draft}389 onChange={(event) => setDraft(event.target.value)}390 onKeyDown={(event) => {⋯391 // Why isComposing: an input method editor confirms a candidate with392 // Enter, and that Enter is not a send.393 if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) {⋯394 return;395 }396 event.preventDefault();397 submit();398 }}399 />400 {/* Never disabled: a greyed button on an empty box reads as broken at the401 moment the box invites a first message. `submit` ignores a blank draft. */}402 <IconButton⋯403 type="button"404 appearance="filled"405 intent="neutral"406 size="sm"407 className="size-8 shrink-0 rounded-full"408 icon={<ArrowUpIcon />}409 label="Send message"410 onClick={submit}411 />412 </div>413 );414}415416export {417 //,418 AssistantComposer,419 AssistantConversationProvider,420 AssistantTranscript,421 useAssistantConversation,422};423export type {424 //,425 AssistantConversation,426 AssistantMessage,427};
Testing it
The branches that matter need no router: the path helpers are pure, the store is a provider, and each
home renders under AppLayout.SidePanelRoot alone. This is the file's test, verbatim. It runs in this
repo:
1// @vitest-environment happy-dom2import { AppLayout } from "@ngrok/mantle/app-layout";3import { Sidebar } from "@ngrok/mantle/sidebar";4import { TooltipProvider } from "@ngrok/mantle/tooltip";5import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";6import { userEvent } from "@testing-library/user-event";7import { afterEach, describe, expect, it, vi } from "vitest";8import {9 AssistantPage,10 AssistantPanel,11 assistantViewTransitionClassName,12 crossesAssistant,13 isAssistantPath,14} from "~/features/assistant-across-routes/assistant-across-routes";15import {16 AssistantComposer,17 AssistantConversationProvider,18 AssistantTranscript,19} from "~/features/assistant-across-routes/assistant-conversation";2021afterEach(() => {⋯22 cleanup();23 vi.useRealTimers();24});2526const composerName = "Message the assistant";27const firstStarter = "Route and authenticate API traffic";2829/** A user on a fake clock, for a reply in flight. */30function setupUserOnFakeClock() {⋯31 // Why shouldAdvanceTime: user-event drains its actions with a `setTimeout(0)`32 // that a frozen vitest clock never fires, so every `user.*` call would hang.33 vi.useFakeTimers({ shouldAdvanceTime: true });34 return userEvent.setup({ advanceTimers: vi.advanceTimersByTime });35}3637describe("isAssistantPath", () => {⋯38 it.each([⋯39 ["/assistant", true],40 ["/assistant/c_123", true],41 // The router matches case-insensitively and decodes the pathname.42 ["/Assistant/", true],43 ["/assi%73tant", true],44 ["/assistants", false],45 ["/endpoints", false],46 ])("%s → %s", (pathname, expected) => {⋯47 expect(isAssistantPath(pathname)).toBe(expected);48 });49});5051describe("crossesAssistant", () => {⋯52 it.each([⋯53 ["/assistant", "/endpoints", true],54 ["/endpoints", "/assistant", true],55 ["/endpoints", "/domains", false],56 ["/assistant", "/assistant/c_123", false],57 ])("%s → %s crosses: %s", (from, to, expected) => {⋯58 expect(crossesAssistant(from, to)).toBe(expected);59 });60});6162/** One conversation with the transcript and the composer as removable hosts. */63function Hosts({⋯64 showTranscript,65 showComposer,66}: {⋯67 showTranscript: boolean;68 showComposer: boolean;69}) {⋯70 return (71 <AssistantConversationProvider>⋯72 {showTranscript && <AssistantTranscript />}73 {showComposer && <AssistantComposer />}74 </AssistantConversationProvider>75 );76}7778describe("AssistantConversationProvider", () => {⋯79 it("keeps a reply streaming while the transcript unmounts and remounts", async () => {⋯80 const user = setupUserOnFakeClock();81 const { rerender } = render(<Hosts showTranscript showComposer={false} />);8283 await user.click(screen.getByRole("button", { name: firstStarter }));84 rerender(<Hosts showTranscript={false} showComposer={false} />);85 await act(async () => {⋯86 await vi.advanceTimersByTimeAsync(10_000);87 });88 rerender(<Hosts showTranscript showComposer={false} />);8990 expect(screen.queryByText(/the only thing you deploy\.$/, { selector: "p" })).not.toBeNull();91 expect(screen.queryByText("Working through it…")).toBeNull();92 });9394 it("announces the whole reply once it lands, not each streamed word", async () => {⋯95 const user = setupUserOnFakeClock();96 render(<Hosts showTranscript showComposer={false} />);97 const status = screen.getByRole("status");9899 await user.click(screen.getByRole("button", { name: firstStarter }));100 expect(status.textContent).toBe("Working through it…");101 // Why 600ms: the first word lands at 360ms and the rest follow every 45ms,102 // so the visible reply has several words while the region still says to wait.103 await act(async () => {⋯104 await vi.advanceTimersByTimeAsync(600);105 });106 expect(screen.queryByText(/^Here is the shape\./, { selector: "p" })).not.toBeNull();107 expect(status.textContent).toBe("Working through it…");108109 await act(async () => {⋯110 await vi.advanceTimersByTimeAsync(10_000);111 });112 expect(status.textContent).toMatch(/the only thing you deploy\.$/);113 });114115 it("keeps the draft while the composer unmounts and remounts", async () => {⋯116 const user = userEvent.setup();117 const { rerender } = render(<Hosts showTranscript={false} showComposer />);118119 await user.type(screen.getByRole("textbox", { name: composerName }), "hello");120 rerender(<Hosts showTranscript={false} showComposer={false} />);121 rerender(<Hosts showTranscript={false} showComposer />);122123 expect(screen.queryByDisplayValue("hello")).not.toBeNull();124 });125});126127describe("AssistantComposer", () => {⋯128 it("keeps the draft when a send during a reply is rejected", async () => {⋯129 const user = setupUserOnFakeClock();130 render(<Hosts showTranscript showComposer />);131132 await user.click(screen.getByRole("button", { name: firstStarter }));133 await user.type(screen.getByRole("textbox", { name: composerName }), "second{Enter}");134135 expect(screen.queryByDisplayValue("second")).not.toBeNull();136 expect(screen.queryByText("second", { selector: "p" })).toBeNull();137 });138139 it("ignores the Enter that confirms an input method editor candidate", async () => {⋯140 const user = userEvent.setup();141 render(<Hosts showTranscript showComposer />);142 const textbox = screen.getByRole("textbox", { name: composerName });143144 await user.type(textbox, "こんにちは");145 fireEvent.keyDown(textbox, { key: "Enter", isComposing: true });146147 expect(screen.queryByDisplayValue("こんにちは")).not.toBeNull();148 expect(screen.queryByText("こんにちは", { selector: "p" })).toBeNull();149 });150});151152describe("AssistantPage", () => {⋯153 function renderPage() {⋯154 render(155 <TooltipProvider>⋯156 <AssistantConversationProvider>⋯157 <Sidebar.Root>⋯158 <AppLayout.SidePanelRoot>⋯159 <AppLayout.SidePanelTrigger label="Toggle the assistant" />160 <AssistantPage />161 </AppLayout.SidePanelRoot>162 </Sidebar.Root>163 </AssistantConversationProvider>164 </TooltipProvider>,165 );166 return screen.getByRole("button", { name: "Toggle the assistant" });167 }168169 it("opens the companion on the first typed message", async () => {⋯170 const user = userEvent.setup();171 const trigger = renderPage();172 expect(trigger.getAttribute("aria-expanded")).toBe("false");173174 await user.type(screen.getByRole("textbox", { name: composerName }), "hello{Enter}");175176 expect(trigger.getAttribute("aria-expanded")).toBe("true");177 });178179 it("opens the companion on a starter prompt", async () => {⋯180 const user = userEvent.setup();181 const trigger = renderPage();182183 await user.click(screen.getByRole("button", { name: firstStarter }));184185 expect(trigger.getAttribute("aria-expanded")).toBe("true");186 });187});188189describe("AssistantPanel", () => {⋯190 function renderPanel({ open }: { open: boolean }) {⋯191 render(192 <TooltipProvider>⋯193 <AssistantConversationProvider>⋯194 <AppLayout.SidePanelRoot defaultOpen={open}>⋯195 <AssistantPanel />196 </AppLayout.SidePanelRoot>197 </AssistantConversationProvider>198 </TooltipProvider>,199 );200 return screen.getByRole("complementary", { name: "Assistant" });201 }202203 // Why a class assertion: the name is a contract that crosses files. The204 // assistant route puts the same class on `AppLayout.Content`, and205 // `assistant-view-transition.css` times `::view-transition-group(assistant)`.206 it("names the open panel for the view transition", () => {⋯207 const panel = renderPanel({ open: true });208 expect(panel.classList.contains(assistantViewTransitionClassName)).toBe(true);209 });210211 // Why assert the absence: a named closed panel beside a named page is the212 // one duplicate that makes the browser skip the transition.213 it("leaves the closed panel unnamed", () => {⋯214 const panel = renderPanel({ open: false });215 expect(panel.classList.contains(assistantViewTransitionClassName)).toBe(false);216 });217});
The demo's own test adds the two things only a shell can show: the conversation is in main on the
assistant's route and in the complementary landmark everywhere else, and startViewTransition runs
once per crossing and never between two product pages.