The goal was to create a persistent AI assistant experience across the entire application — similar to Shopify's theme editor or WordPress's site customizer. The user (an admin) can enter a special "Studio mode" from anywhere in the app, get a side panel with an AI chat agent, and navigate freely without losing the conversation. When done, they exit and the app returns to normal.
The key product requirements that shaped every decision:
- The AI panel must be persistent across all navigation — the chat session should never reset when moving between pages
- It must work on any layout — not just the built-in admin layout, but also fully custom layouts that consuming apps create
- It must work on public (unauthenticated) routes as well as protected ones
- Only admin role users can enter Studio mode
- The panel is only visible when the user is logged in
- Studio state persists across page refreshes (localStorage)
- On logout, Studio mode exits automatically
The AI chat component (SolidAiChat) existed but was embedded inline in specific views:
SolidDashboard.tsx— rendered in a collapsible panel sectionSolidListView.tsx— rendered in a side panel inside the list viewSolidChatterLocaleTabView.tsx— rendered as a tab called "SolidX AI"
Every time the user navigated away from these views, the chat component unmounted and the conversation was lost. It was also completely invisible from any other page.
The first approach was to lift the panel into the layout. We created SolidStudioWrapper — a component that, when studio mode was active, restructured the layout into a two-column grid: the normal app on the left, the AI panel on the right.
AdminLayout
└── if studioMode: SolidAiStudioLayout (grid: [app | AI panel])
else: Layout (normal)
Problem: This only worked for AdminLayout. Any consuming app that built a custom layout (e.g. MerchantAppLayout in mswipe-nbf-app) would not get Studio mode — they'd have to duplicate the logic themselves. It was layout-specific, not global.
The insight was to stop thinking about Studio as a layout concern and start thinking about it like a chat widget — something mounted once at the app root that floats over everything, regardless of what route or layout is active.
BrowserRouter
└── StoreProvider (Redux, includes solidStudio reducer)
└── AppEventListener ← renders <SolidStudioPanel /> here
└── AppRoutes ← all routes, unchanged
SolidStudioPanel is position: fixed on the right edge of the screen. It reads from Redux to decide whether to render. When active, it sets document.body.style.paddingRight to the panel width (420px), which pushes all page content left — including custom layouts in consuming apps — without requiring any CSS changes in those projects.
// Inside SolidStudioPanel's useEffect
document.body.style.paddingRight = active ? PANEL_WIDTH : "";
document.body.style.transition = "padding-right 220ms ease";This is cleaned up on unmount so it never leaks.
padding-right on body was chosen over margin-right for two reasons:
- No visual gap — padding extends the body's background into the padded area, so the page background fills the space between content and the panel.
margin-rightcreates a gap that shows the<html>background instead, which breaks dark-mode and custom themed apps. - Industry standard — this is the same technique Bootstrap uses for modal scroll-lock (
body { padding-right: scrollbarWidth }). It's a well-understood, widely tested pattern.
One caveat: if a consuming app applies overflow-x: hidden directly to body, padding can be clipped. In that case, document.documentElement.style.marginRight on <html> is a safe fallback — but in practice none of our consuming apps have this.
AppEventListener is already mounted once at the app root in every consuming app:
// App.tsx in any consuming project
<AppEventListener />Adding <SolidStudioPanel /> to its return value means consuming projects get Studio support automatically with zero code changes. They don't need to know it exists.
src/redux/features/solidStudioSlice.ts
Redux slice that owns Studio mode state. Persists to localStorage under key solidx.studio.mode so the mode survives page refreshes.
// State shape
{ isStudioMode: boolean }
// Actions
enterStudioMode() // sets true, writes to localStorage
exitStudioMode() // sets false, removes from localStorageInitialises from localStorage on load:
const initialState = { isStudioMode: readFromLS() };src/components/layout/SolidAiStudioLayout.tsx
Contains SolidStudioPanel — the fixed right panel. Key behaviours:
- Auth gate — reads
useSession(). Ifstatus !== "authenticated", returnsnull. The panel is never shown to logged-out users. - Auto-exit on logout — if session becomes
"unauthenticated"while studio is active, dispatchesexitStudioMode()which also clears localStorage. - Body padding — sets
document.body.style.paddingRightto420pxwhen studio is active, shifting all page content left universally. Cleaned up on unmount. - Always-mounted AI chat —
SolidAiChatlives inside the panel and is never unmounted while studio mode is active, preserving the WebSocket connection and full message history across all navigation.
// Render guard
if (!isStudioMode || !isAuthenticated) return null;src/redux/store/defaultStoreConfig.ts
Added solidStudio: solidStudioReducer to solidReducers. Because createSolidStore consumes this object, all consuming apps automatically get the slice in their store — no action needed from the consuming project.
src/routes/AppEventListener.tsx
Changed from returning null to returning <SolidStudioPanel />. This is the mount point that makes Studio available everywhere without consuming apps needing any code changes.
src/components/layout/AdminTopHeader.tsx
Added the Studio trigger button. Visible only to users with the admin role (checked via hasAnyRole(user?.roles, ["Admin"])). Dispatches enterStudioMode() on click.
src/resources/solid-custom.css
Added Studio panel CSS classes. All values use existing CSS custom properties (--primary, --border, --background, etc.) so the panel automatically adapts to light/dark theme.
Key classes:
.solid-studio-panel-fixed— the fixed right panel.solid-studio-bar— branded top bar inside the panel with exit button.solid-studio-trigger-btn— the "Studio" button in the admin header
No layout shift CSS is needed anywhere — content shifting is handled entirely via document.body.style.paddingRight at runtime.
src/components/core/list/SolidListView.tsx
src/components/core/dashboard/SolidDashboard.tsx
src/components/core/locales/SolidChatterLocaleTabView.tsx
All inline SolidAiMainWrapper usages commented out. The AI assistant is no longer embedded per-view — it lives in the global Studio panel.
Zero changes required. AdminLayout → AdminTopHeader already has the Studio button. AppEventListener already mounts the panel. Body padding shifts the content automatically.
Also zero changes. AppEventListener is already in App.tsx:
<AppEventListener /> // SolidStudioPanel is rendered inside this
<AppRoutes />When an admin activates Studio from an admin route and navigates to a public merchant-app route (/merchant-app/home), the panel stays visible — the Redux state and the AI chat session are preserved. The body padding-right shifts the merchant-app content left automatically, just like any other layout. No CSS opt-in required.
| Check | Where | What it does |
|---|---|---|
hasAnyRole(user?.roles, ["Admin"]) |
AdminTopHeader |
Hides the Studio button from non-admins |
status === "authenticated" |
SolidStudioPanel |
Prevents panel rendering for logged-out users |
status === "unauthenticated" && isStudioMode |
SolidStudioPanel |
Auto-exits and clears localStorage on logout |
User clicks "Studio" button (admin only)
→ dispatch(enterStudioMode())
→ Redux: isStudioMode = true
→ localStorage: solidx.studio.mode = "true"
→ SolidStudioPanel: renders fixed panel
→ document.body.style.paddingRight = "420px"
→ All page content shifts left by 420px (any layout, any consuming app)
User clicks "Exit Studio"
→ dispatch(exitStudioMode())
→ Redux: isStudioMode = false
→ localStorage: solidx.studio.mode removed
→ SolidStudioPanel: returns null
→ document.body.style.paddingRight = ""
→ Content shifts back
User logs out while in Studio
→ useSession: status → "unauthenticated"
→ SolidStudioPanel effect: dispatch(exitStudioMode()) automatically
→ Same cleanup as above