import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useLocation } from "react-router-dom"; import { MessageCircleQuestion, X } from "lucide-react"; import { Sheet, SheetContent, SheetDescription, SheetTitle, } from "../ui/sheet.tsx"; import { useIsNarrow } from "./useIsNarrow.ts"; import { getAkashicPortalContainer, hasAkashicPortalHost } from "../../lib/getAkashicPortalContainer"; import { useOptionalVideoServiceContext } from "../../services/VideoService"; import "./SetupHelpWidget.css"; /* * `APP_ROOT_ID` AND `ASSISTANT_PANEL_WIDTH` WERE REMOVED HERE, 2026-09-09. Both were declared and * read by nothing, and both are the tail of the same removal: the panel used to INSET the page, which * meant finding the app root by id and writing a width onto it. Spencer took the inset out on the 8th * - "It should overlay the body instead of pushing it" - and the two constants that existed only to * serve it stayed behind. * * THE WIDTH ONE IS WORSE THAN DEAD, AND THAT IS THE PART WORTH RECORDING. The note at the removal * site says the constant is kept "because the panel's own width still reads it". It does not. The * panel writes `w-[22rem]` as a literal utility, so the file carried a comment asserting a consumer * that had already gone - which is how a dead constant survives a reader who checks. * * WHY THIS SHAPE KEEPS LANDING IN THIS COMPONENT, since it is now the fourth time: every change * here has been the removal of a BEHAVIOUR - the inset, the kind pill, an escalation conditional on a * prop no caller passed, and now these. Deleting the code that uses a thing does not flag the thing, * and a scoped typecheck is the only thing on this project that notices. When a behaviour comes out * of this file, grep what it consumed before committing, rather than after the gate says so. */ /** * The setup & help pull-down -, design `roles/org-admin/onboarding-guidance-widget-design.md`. * * Spencer, 2026-08-14: *"considering it takes you through multiple workspaces to onboard... it should * almost be persistent throughout all workspaces and views. Almost like a sticky fixed widget... a pull-down * sitting at the top right almost like a little header extension."* * * WHY A WIDGET AND NOT A PAGE: help is wanted wherever somebody happens to be stuck, and this frame sits * above every route, so the door is in the same place in every workspace. * * TOP RIGHT, NEVER BOTTOM RIGHT, and that is a collision avoided rather than a preference. The bottom * right already holds `duration: Infinity` toasts - the receptionist's late-appointment nag and the * scribe's stagnant-draft nag. Those are clinical and time-critical; this must never contend with them * for the same pixels. * * A FULLSCREEN VIDEO VISIT SITS OVER THE DESKTOP PANEL BY Z-ORDER (z-40 under the window). On a phone * the assistant is a modal Sheet, so this widget reads optional video state and dismisses that Sheet * while the call is not idle, then lets it open again after the call ends. * * THIS COMPONENT IS THE CONTAINER, and keeping it one was what made the assistant a later decision rather * than a rebuild. It renders a MODE and its `children` - it never reaches for its own content and has no * opinion about where the content came from. That is also why removing the onboarding checklist from this * surface changed the mount and the contract and left the shell alone. */ export type SetupHelpWidgetProps = { /** * The panel's content - the assistant. THIS is the container's seam, and it is the whole of it. * * There was a `guidance` prop beside it carrying a MODE, so the widget could render the onboarding * checklist's resting line or the help affordance. With the checklist gone there is one mode, nothing * branches on it, and a prop no code reads is a promise the next reader will try to keep. A future * mode arrives the way the assistant did: as different `children`. */ children?: React.ReactNode; /** Counts requests to open the panel, such as help requested from a held invitation. */ openRequests?: number; /** Told when such a request could not open the panel, so nobody is told it opened. */ onOpenRefused?: () => void; }; type PanelBounds = { top: number; right: number; width: number }; const panelStyle = ({ top, right, width }: PanelBounds) => ({ "--ahr-assistant-top": `${top}px`, "--ahr-assistant-right": `${right}px`, ...(width > 0 ? { "--ahr-assistant-max": `${width}px` } : {}), }) as React.CSSProperties; type HeaderTag = React.ElementType<{ className?: string; children?: React.ReactNode }>; /* One header for both widths; the phone passes the sheet's title parts so its dialog stays named. The title is the shared overlay title. */ const AssistantHeader = ({ Title, Description, onClose, }: { Title: HeaderTag; Description?: HeaderTag; onClose: () => void; }) => (
Akashic assistant {Description ? Help with Akashic setup and support :

Help with Akashic setup and support

}
); export const SetupHelpWidget = ({ children, openRequests = 0, onOpenRefused, }: SetupHelpWidgetProps) => { const narrow = useIsNarrow(); const [open, setOpen] = useState(false); const video = useOptionalVideoServiceContext(); const callLive = video != null && video.call.phase !== "idle"; const callLiveNow = useRef(callLive); callLiveNow.current = callLive; // A request from the page opens the panel as a press of its button would, and is refused the same way during a call. const openNow = useRef(open); openNow.current = open; useEffect(() => { if (openRequests === 0) return; if (!callLiveNow.current) setOpen(true); else if (!openNow.current) onOpenRefused?.(); // eslint-disable-next-line react-hooks/exhaustive-deps -- answered once per request }, [openRequests]); /** * - A PANEL THAT IGNORED EVERY WAY OF CLOSING A PANEL. * * MEASURED by claude-3 on the deployed build, Referrals workspace, at two widths: the open panel * occupied `x 11821534, y 55245`, and *"Add a referral that came in"* sits at `1315, 103` - inside * it. `document.elementFromPoint` at the button's own centre returned the panel's paragraph. * **The front desk's primary action was unclickable**, on the one page where it is time-sensitive * because somebody is holding a fax. Escape did nothing, an outside click did nothing; only pressing * Support again closed it, and a receptionist who does not think of that concludes the button is * broken. It cost several minutes of real work, which is what makes it a defect rather than a nit. * * AND THE OPEN QUESTION ON THE CARD - *"I could not determine what opened it. It was open when I * arrived and I had not clicked Support"* - HAS AN ANSWER, and it is the third fix below rather than * something sinister. **It does not open itself.** `open` starts false and nothing sets it. But this * widget is mounted in the frame ABOVE ``, so it does not unmount when the route changes: * open it on one page, navigate, and it is still open - now hanging over a page it was never opened * on, covering controls it knows nothing about. **Persistence looked like self-opening.** * * SO THE FIX IS DISMISSAL, NOT RELOCATION. settled where this lives - *"a small tab * attached to the top right... as basically part of the header element"* - and a pull-down attached to * the header necessarily overlaps the page under it. Moving it would trade a settled design for a * problem that only exists because it could not be dismissed. * * The phone branch needs none of this: it is a `Dialog`, which already closes on Escape and on an * outside click, and it is modal, so it never silently covers a control. */ const { pathname } = useLocation(); useEffect(() => { setOpen(false); }, [pathname]); useEffect(() => { if (narrow && callLive) setOpen(false); }, [narrow, callLive]); const pullDown = useRef(null); /** * - THE TRIGGER MOVES INTO THE HEADER ROW; THE PANEL DOES NOT. * * Spencer, 2026-08-24: *"Every workspace gets that now."* The Support control belongs in the * header's right-hand action group for every role, where the settled mockup draws it. * * A PORTAL RATHER THAN LIFTING STATE, and the reason is the lifecycle. This component owns `open`, the * Escape/outside-press/route-change dismissal and the panel itself. Handing a button up to `App` to pass * as `children` would split one lifecycle across three files and put the frame in charge of a state it has * no other reason to know about. * * AND IT KEEPS `top-full`. The panel still hangs off the header's own box, so the browser still * does the arithmetic - the thing settled and proved you cannot hardcode. Only the * TAB relocates; nothing acquires a pixel offset. * * The lookup is an effect rather than a render-time query because the header and this widget mount * in the same commit: at first render the node does not exist yet, and asking for it then would fall * back to the old position permanently. */ const [headerSlot, setHeaderSlot] = useState(null); /** * / Spencer 2026-08-25 - THE ASSISTANT MOVED AGAIN, OUT OF THE HEADER AND ONTO THE * BREADCRUMB ROW, in every workspace. * * *"Its currently wasted screen space, it frees up room in the header and since the Ai assistant is * a unique thing it makes sense to have it in its own unique location."* * * THE CRUMB BAND WINS ON A PROPERTY THE HEADER DOES NOT HAVE: it is empty **identically** on every * view, because a breadcrumb never grows to fill its row. The header's right-hand end holds a different * number of controls per role and per width. * * AND THE PANEL FOLLOWS THE TRIGGER - that is the half that is easy to get wrong. The pull-down * hangs `top-full` off whatever it is positioned inside; leaving it anchored to the header while the * button moved one band down would have opened the panel directly ON TOP of the button that opened it, * so pressing it again to close would land on the panel instead. Both are portaled into the crumb * slot, which is `relative` for exactly this reason. */ const [crumbSlot, setCrumbSlot] = useState(null); // The slots come and go without the pathname changing - the dashboard draws its crumb row only once // it has loaded - so they are looked up again whenever the page's elements change. useEffect(() => { const findSlots = () => { setHeaderSlot(document.querySelector("[data-akashic-header-actions]")); setCrumbSlot(document.querySelector("[data-akashic-crumb-actions]")); }; findSlots(); const observer = new MutationObserver(findSlots); observer.observe(document.body, { childList: true, subtree: true }); return () => observer.disconnect(); }, [pathname]); /* * The assistant is an icon alone at the right of the breadcrumb row below the header, never in the * header. Without a crumb row it renders in place, hanging just below * the header at the same spot. The control is never simply absent, and never in two places at once. */ /** * THE SECOND REF IS NOT OPTIONAL - WITHOUT IT THE BUTTON IS DEAD ON ARRIVAL. * * `contains` walks the DOM, and a portal moves the trigger OUT of the pull-down's node. So the * outside-press listener below would see a press on Support as a press outside the panel, close it in * the same gesture that opened it, and the control would read as broken rather than as closing. * own control â‘¢ - *"a relocation must not undo dismissal"* - is exactly this. */ const trigger = useRef(null); useEffect(() => { if (!open) return undefined; /* * NOT ON A PHONE - and this listener was closing the assistant the moment a patient touched its * own input. * * Spencer, 2026-08-25: *"When I click the input in the ai assistant modal the modal closes."* * * THE PHONE BRANCH IS A `Dialog`, which closes on Escape and on an outside press by itself - the * comment above this component already says so. But this listener runs for BOTH branches, and on the * phone neither `pullDown` nor `trigger` is attached to anything, so `contains` answers false for * every press in the document. Every press was an outside press, including the one on the input. * * It has been true since the phone branch was written; it only became visible when the icon moved * to the crumb row, somewhere a patient actually presses. A guard that cannot fire and a guard that * fires on everything look identical from the outside until somebody touches it. */ if (narrow) return undefined; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; /* * `pointerdown`, not `click`. A control that unmounts on `pointerdown` never receives the `click` * that follows, so a listener on `click` can be beaten by a re-render - and `pointerdown` is also * what a person means by "clicking away", which is the moment the panel should already be going. * * AND THE UNDERLYING CONTROL STILL GETS THE CLICK, which is better than an ordinary popover * manages. The container is `pointer-events-none` with each part opting back in, so a press outside * the pull-down was never on this widget at all: it lands on the page as it always would, and this * only closes what was covering it. */ const onPointerDown = (event: PointerEvent) => { const target = event.target as Node; // Both nodes, since moved the trigger into the header and out of the pull-down's subtree. if (pullDown.current?.contains(target) || trigger.current?.contains(target)) return; setOpen(false); }; document.addEventListener("keydown", onKeyDown); document.addEventListener("pointerdown", onPointerDown); return () => { document.removeEventListener("keydown", onKeyDown); document.removeEventListener("pointerdown", onPointerDown); }; }, [open, narrow]); // The strip starts under the crumb row (the header without one) and ends at the app's right edge, // so it stays inside the WordPress app rather than running to the viewport's edge. const [panelBounds, setPanelBounds] = useState(null); useLayoutEffect(() => { const anchor = crumbSlot?.parentElement ?? headerSlot?.closest("header") ?? null; if (!open || narrow || !anchor) { setPanelBounds(null); return undefined; } const app = hasAkashicPortalHost() ? getAkashicPortalContainer().parentElement : null; const measure = () => { const box = app && app !== document.body ? app.getBoundingClientRect() : null; setPanelBounds({ top: Math.max(0, Math.round(anchor.getBoundingClientRect().bottom)), right: box ? Math.max(0, Math.round(document.documentElement.clientWidth - box.right)) : 0, width: box ? Math.round(box.width) : 0, }); }; measure(); window.addEventListener("resize", measure); window.addEventListener("scroll", measure, true); return () => { window.removeEventListener("resize", measure); window.removeEventListener("scroll", measure, true); }; }, [open, narrow, crumbSlot, headerSlot]); /* * 2026-09-08 - THE PAGE IS NOT MOVED. Spencer: "It should overlay the body instead of * pushing it." * * This used to set `paddingRight: 22rem` on the app root for as long as the panel was open, so * asking for help reflowed the whole page underneath the question - columns narrowed, cards * rewrapped, and whatever the reader was pointing at moved. The panel is `fixed` with a shadow and * a border already; it is a layer, and a layer does not get to edit the document beneath it. * * The constant it used is GONE TOO. This line used to say it stayed "because the panel's own * width still reads it", and that was untrue when it was written - the panel writes the width as a * literal utility, so the comment vouched for a consumer that had already left. See the tombstone * at the top of this file. */ /** * PHONE: AN ICON THAT OPENS A DIALOG, NOT A PULL-DOWN. Spencer, 2026-08-14: *"Very few staff would be * using a phone as their device, most using laptop/desktop or tablet. For mobile the correct design * would be an icon or something to open it up in a dialog instead of pull down."* * * THAT RULING RESOLVES THE DESIGN DOC'S OWN OPEN QUESTION, and it resolves it by weighing WHO is on * the device rather than by defending the layout: a header extension at 390px cost the next-step * preview that made the resting line worth having - and the people who would have paid that cost are * the ones who are almost never on a phone. A tablet keeps the pull-down, because 768 is the phone line. */ if (narrow) { /* The phone keeps its DIALOG - Spencer, 2026-08-14, and that ruling is untouched. Only where the icon lives changes: it joins the crumb row like every other width, instead of floating below the header aligned to nothing. */ const phoneTrigger = ( ); return ( <> {crumbSlot ? ( createPortal(phoneTrigger, crumbSlot) ) : (
{phoneTrigger}
)} {/* THE SAME PANEL A LAPTOP GETS, ARRIVING THE SAME WAY. Spencer, 2026-09-10: the assistant on mobile *"SHOULD be a right slide overlay just like on larger screens, not a modal or slide up"*. It was a centred `Dialog` capped at `max-w-md` with the conversation inside a 60vh box - so on a phone the one surface built for a long back-and-forth was the one given the least room to have it, with the page dimmed behind a floating card. A sheet from the right is the product's own idiom for an aside, it is what every other width already shows, and it takes the height it needs. ONE SHAPE ACROSS WIDTHS is the point rather than tidiness: somebody who learns the assistant on a laptop and opens it on a phone should not meet a different object. */} {callLive ? null : ( { if (!callLive) setOpen(next); }} > setOpen(false)} />
{children}
)} ); } /** * - ONE ELEMENT, RENDERED IN ONE OF TWO PLACES, never both. * In the header it is a plain control in a row of controls, so it loses the tab's `rounded-b-lg` * and top-borderless edge - those existed to make it read as hanging off the header's bottom, which * is the thing it no longer does. */ const supportTab = ( ); /* * THE ASSISTANT ON ITS OWN BAND - the trigger sits IN the crumb row and the panel hangs under it. * * THE PANEL HAS TO COME WITH IT. `top-full` measures from whatever the panel is positioned inside; * portaling only the button would have left the panel anchored to the header, one band ABOVE the * button, opening directly over the control that opened it. The crumb slot is `relative` and both * pieces go into it, so the browser still does the arithmetic and nothing acquires a pixel offset. * * `right-0` rather than `right-4`: the slot already ends at the content's right edge, so an inset * here would pull the panel away from the icon it belongs to. */ /** * THE ONBOARDING NEXT-STEP LINE USED TO RENDER HERE, a band under the header holding * `Next: Add a location - 11 of 13`, a chevron and a dismiss. It is gone with `checklist` mode: the * steps already live on the overview page, and the widget was a second place to read one list. * * ITS COLLAPSE PREFERENCE WENT WITH IT, and that is the piece worth naming, because it was the only * thing this widget persisted per user. Dismissal existed to drop the resting line to the help * affordance - with no resting line there is nothing to dismiss, and a remembered preference nothing * reads is a stale answer waiting for a future reader. */ /** * THE ASSISTANT IS A SLIDE-OUT PANEL DOWN THE RIGHT EDGE - Spencer, 2026-08-27, ruling on a surface no * mockup covers. The trigger is untouched; the decks settle that and this changes only the open panel. * * IT USED TO DROP OUT OF THE CRUMB ROW, straight into the band the page's own primary actions sit in. * Measured with it open: the panel body covered "Add a referral", and `elementFromPoint` at the * button's centre returned the assistant's ask-a-question input. The wrapper was already * `pointer-events-none`, so presses passed through the empty space AROUND the panel - which solved the * part of the problem nobody had, because the BODY is what sat on the button. * -> Anchored to that row, ANY downward panel lands on the action band, and moving it sideways does not * help: the panel and the actions are both right-aligned. The shape had to change. * * AND IT INSETS THE PAGE RATHER THAN COVERING IT. That is the half that makes this a fix rather than a * relocation: a right-edge overlay on a 1440px window covers x 1088 onward, which is exactly where a * right-aligned action lives. The strip takes its width FROM the content, so nothing is underneath the * panel at any width. * * THE INSET IS AN INLINE STYLE ON THE APP ROOT, deliberately, rather than a class and a rule in * index.css. A stylesheet rule would be a second place this behaviour lives, and that file is 3,800 * lines of id-scoped rules a utility cannot override anyway. The previous value is restored on close, * so a surface that had already set one is not clobbered. */ /** * PORTALED TO THE OVERLAY ROOT rather than left in the crumb slot. * * `fixed` is viewport-relative only while no ancestor carries a transform, a filter or a * backdrop-filter - any one of those silently makes it relative to THAT element instead, and only on * the screens where such an ancestor exists. The overlay root is a direct child of the app root for * exactly this class of surface, and every other overlay in the app already lives there. * * `z-40`: above page content, below the z-50 app-chrome banners, and far below a floating window at * 1000 - so a live, billed meeting is never painted over by a help panel. That is the same intent the * `z-30` it replaces carried; the number moves because it is now relative to the viewport rather than * to the crumb row. * * MOUNTED ONLY WHILE OPEN, unchanged. The assistant's own panel fetches when it mounts, and leaving it * mounted to animate a transform would put that request behind every screen in the product. * The entrance uses the app's own four animation utilities rather than a new keyframe. * * NO `pointer-events-none` WRAPPER ANY MORE, and nothing is lost with it. It existed so presses in the * empty space around a floating pull-down still reached the page. A full-height strip has no empty * space around it: everything outside the strip is page, presses land there as they always would, and * the outside-press listener still closes the panel in the same gesture. */ const panel = (
setOpen(false)} /> {/* The strip never scrolls as a whole; `min-h-0` lets the transcript inside it scroll instead. */}
{children}
); /** * IN PLACE WHEN THERE IS NO APP ROOT, rather than nothing at all - the same fallback the support tab * takes, and for the same stated reason: the control is never simply absent. That state is a unit test * or a surface mounted on its own, where there is no scoped stylesheet to be inside and so nothing to * bleed out of. `FloatingWindow` refuses to render in that state on purpose, because a floating window * escaping into the host page IS the defect; a help panel that quietly stops existing would be one. */ const slideOut = !open ? null : hasAkashicPortalHost() ? createPortal(panel, getAkashicPortalContainer()) : panel; const crumbAssistant = ( <> {supportTab} {slideOut} ); if (crumbSlot) { return ( <>{createPortal(crumbAssistant, crumbSlot)} ); } return ( /* ATTACHED TO THE HEADER, NOT FLOATING NEAR IT -, and `top-full` rather than a number is the whole point. Spencer: *"a small tab attached to the top right (right under the bottom right of the header) as basically part of the header element."* Measured on staging before changing it: the header is 55px and this sat at `top-16` (64px), so it floated NINE pixels below it - and 28px to the right of the header's own right edge, aligned with nothing. That reads as an alert about your setup rather than as part of the product. A hardcoded `top-[55px]` would be the SAME defect the header's own comment in App.tsx warns about - *"the header has no fixed height... a hardcoded number would go stale the first time it changed, and silently"* ( was exactly that bug). `top-full` needs no number: this now renders inside the header, so the browser does the arithmetic. NO `ref={pullDown}` HERE ANY MORE, and its absence is the fix rather than a tidy-up. `pullDown` is what the outside-press listener asks "was that press inside the panel?" - and while the panel LIVED in this wrapper that was the same node. It does not any more: the panel is a fixed strip portaled to the overlay root, and it carries the ref itself. -> With the ref on BOTH, whichever mounted last won, so the listener tested the wrong node and closed the panel in the gesture that opened it. Measured on the deployed build: four clicks on Support, no panel, and a MutationObserver seeing a REMOVE with no ADD. This wrapper is the assistant's home only when there is no crumb row. */
{/* Unconditional: the assistant is for everybody, not only during setup. No navigate() here. */} {supportTab} {/* The panel is NOT conditioned on anything but `open`. It once rendered only during onboarding, because the only thing it could hold was the checklist — so a viewer in the help state had a button that opened nothing and the container's own `children` were unreachable. The assistant is `children`, and it is for everybody. ONE PANEL, DECLARED ONCE. Both branches render the same `slideOut` now. Two copies of this markup is how a change lands on one width and misses the other. */} {slideOut}
); }; export default SetupHelpWidget;