'use client' import * as React from 'react' import { cn } from '../../utils/cn' import { MingoIcon } from '../icons' import { MingoChatHistorySkeleton } from './mingo-chat-history' import { QuickActionChipButton } from './quick-action-chip' import { QuickActionWall } from './quick-action-wall' import { Button } from '../ui/button' import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade' import { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon' import { CompassIcon, Arrow01DownIcon, AlertCircleIcon, Refresh01RightIcon, } from '../icons-v2-generated' // ============================================================================= // Types // ============================================================================= /** "New to OpenFrame?" one-time notification config. `null` hides the card. */ export interface MingoWelcomePromo { title: React.ReactNode description: React.ReactNode } /** Extra quick-action chip rendered after the built-in "Start Guide Chat". */ export interface MingoQuickAction { /** Stable React key. */ id: string label: string icon?: React.ReactNode /** `'primary'` = accent (yellow) chip, `'outline'` = bordered chip. */ variant?: 'primary' | 'outline' onClick?: () => void /** Full prompt text previewed as ghost text in the composer on hover/focus. * The chip `label` is short; this reveals what the action will actually ask. * Omitted → falls back to `label`. */ prompt?: string } export interface MingoWelcomeProps { /** Greeting heading. Defaults to `Hey, I'm Mingo`. */ title?: React.ReactNode /** Greeting sub-line under the heading. */ subtitle?: React.ReactNode /** One-time "New to OpenFrame?" notification below the grid. `null` hides * it; omitting falls back to the OpenFrame default (only rendered when * `onStartGuideChat` is wired, i.e. Guide mode exists to advertise). */ promo?: MingoWelcomePromo | null /** Storage key used to remember the promo dismissal. */ promoStorageKey?: string /** Where the dismissal is persisted. `'local'` (default) survives across * sessions; `'session'` clears when the tab closes. */ promoStorage?: 'local' | 'session' /** Extra quick-action chips appended after the "Start Guide Chat" chip. */ quickActions?: ReadonlyArray /** Pointer/keyboard focus enters a quick-action chip — e.g. preview the * action's full `prompt` in the composer input. */ onQuickActionHover?: (action: MingoQuickAction) => void /** Pointer/keyboard focus leaves the chip — e.g. restore the composer. */ onQuickActionHoverEnd?: () => void /** Returning-user variation: the user already has chats. Hides the * "New to OpenFrame?" notification entirely and renders the "Start Guide * Chat" chip in the muted `outline` style instead of the accent yellow. */ hasExistingChats?: boolean /** Returning-user main content — when provided (typically a * ``), it replaces the greeting + feature grid and owns * its own scroll region. The chips below stay pinned. */ dialogHistory?: React.ReactNode /** True while the FIRST page of dialogs is still loading and we don't yet * know if the user is new or returning. Renders a history skeleton in place * of both the greeting+grid and the history, so the empty state doesn't * flash the new-user layout before the list arrives. Ignored once * `dialogHistory` is provided. */ isLoadingHistory?: boolean /** The dialog-list load FAILED (e.g. backend down) and there's nothing * cached. Renders an error + retry block in place of the new-user empty * state (which would otherwise misleadingly advertise Guide). Takes * priority over `isLoadingHistory`. Ignored once `dialogHistory` is set. */ loadError?: boolean /** Retry handler for the `loadError` state. */ onRetry?: () => void /** When provided, renders the "Start Guide Chat" chip (the only wired * action — switches the host chat to Guide mode) and enables the default * promo notification. When omitted, both are suppressed. */ onStartGuideChat?: () => void /** Agent this empty state belongs to — forwarded to the quick-action * {@link QuickActionWall} so a built-in agent (`'fae'`/`'mingo'`) caps the * brick stack at 2 rows. Defaults to `'mingo'` (this is the Mingo surface). */ agentSlug?: string /** Appended to the root element. */ className?: string } // ============================================================================= // Defaults (OpenFrame copy — overridable per Core Rule: platform-agnostic kit) // ============================================================================= const DEFAULT_SUBTITLE = 'Ready to help with your technical tasks. What can I do for you?' const DEFAULT_PROMO: MingoWelcomePromo = { title: 'New to OpenFrame?', description: 'Start a Guide Chat to learn how it works and how to set it up.', } const DEFAULT_PROMO_STORAGE_KEY = 'mingo-welcome:promo-dismissed' // ============================================================================= // Component // ============================================================================= /** * MingoWelcome — Figma node `113:69208`. * * Default (Mingo-mode) chat empty state: a vertically-centred greeting that * grows to fill available height, then a pinned stack of an optional one-time * "New to OpenFrame?" notification and a quick-action chip row. The Guide-mode * empty state keeps the slash-command onboarding list. * * Content is configurable (props) with OpenFrame defaults so the kit stays * platform-agnostic. The "Start Guide Chat" chip is the only wired action — * it switches the host chat to Guide mode via `onStartGuideChat`. The * notification carries no action; its dismiss "X" persists to local/session * storage so it shows only until the user closes it once. */ export function MingoWelcome({ title, subtitle = DEFAULT_SUBTITLE, promo, promoStorageKey = DEFAULT_PROMO_STORAGE_KEY, promoStorage = 'local', quickActions, onQuickActionHover, onQuickActionHoverEnd, hasExistingChats = false, dialogHistory, isLoadingHistory = false, loadError = false, onRetry, onStartGuideChat, agentSlug = 'mingo', className, }: MingoWelcomeProps) { // Greeting never weaves the user's name in — always the plain "Hey, I'm // Mingo" (design node 113:69208). A host `title` override still wins. const heading = title ?? "Hey, I'm Mingo" // `promo` omitted → fall back to the OpenFrame default, but only when guide // mode exists to advertise (otherwise the notification points nowhere). // Returning users (`hasExistingChats`) never see the onboarding notification. const resolvedPromo = hasExistingChats ? null : promo === undefined ? onStartGuideChat ? DEFAULT_PROMO : null : promo // One-time notification: hydrate the dismissal from storage after mount // (SSR-safe — `window` is untouched on the server). Default to "not // dismissed" so the first paint shows it, then hide if storage says so. const [promoDismissed, setPromoDismissed] = React.useState(false) React.useEffect(() => { if (!resolvedPromo) return try { const store = promoStorage === 'session' ? window.sessionStorage : window.localStorage if (store.getItem(promoStorageKey) === '1') setPromoDismissed(true) } catch { // Storage can throw (private mode, blocked cookies) — treat as // "not dismissed" and simply keep showing the notification. } }, [resolvedPromo, promoStorage, promoStorageKey]) const dismissPromo = React.useCallback(() => { setPromoDismissed(true) try { const store = promoStorage === 'session' ? window.sessionStorage : window.localStorage store.setItem(promoStorageKey, '1') } catch { // Best-effort persistence; the in-memory state still hides it for // the rest of this session. } }, [promoStorage, promoStorageKey]) // Scroll-fade affordances — shared ui/scroll-fade (48px edge gradients shown // only while content is hidden in that direction). const { scrollRef, fadeTop, fadeBottom, update: updateScrollFade } = useScrollFade() // While we don't yet know whether the user is new or returning (first page // loading, or it errored with nothing cached), suppress the pinned region — // the "New to OpenFrame?" promo + quick-action chips. Showing them over the // history skeleton both clutters the loading frame and pre-judges the user as // new. They return once a resolved state (history, or the new-user greeting) // renders. `dialogHistory` always wins — a returning user keeps the chips. const showPinnedRegion = !!dialogHistory || (!isLoadingHistory && !loadError) return (
{/* Returning users see their dialog history (its own scroll region) in place of the greeting + grid. Precedence: load error (retry) → first- page loading (skeleton) → new-user greeting+grid. The error branch stops a failed fetch from masquerading as "no chats" (which would otherwise show the Guide promo). */} {dialogHistory ?? (loadError ? (

Couldn’t load your chats

Something went wrong reaching the server. Check your connection and try again.

{onRetry && ( )}
) : isLoadingHistory ? ( ) : ( <> {/* Scrollable region — only the greeting + grid scroll; the notification and chips below stay pinned so they're always visible above the input. The wrapper is `relative` so the scroll-fade gradients can overlay the top/bottom edges. */}
{/* Greeting — grows to fill (`flex-1`) so it centres vertically, keeping the grid anchored at the bottom of the scroll area. Default `min-height: auto` (no `min-h-0`) stops it shrinking below its own content, so the region scrolls instead of the greeting overlapping the grid. Padding is modest so it never dominates the narrow panel. */}

{heading}

{subtitle}

{/* Edge scroll-fades — visible only when content is hidden beyond them. Fade into the panel's dark `ods-bg` surface (the default color), matching the black Mingo welcome background (node 113:69208). */}
))} {/* Pinned region — visible above the input once the new/returning-user state is known (hidden during first-page load / error). */} {showPinnedRegion && (
{/* "New to OpenFrame?" — a one-time informational notification (no action). The leading down-arrow (Figma node 7532:317130) only shows once the panel is wide (`@2xl`), mirroring the responsive design. The dismiss "X" persists to local/session storage via `dismissPromo`, so once closed it stays hidden. The actual Guide entry point is the "Start Guide Chat" chip below. */} {resolvedPromo && !promoDismissed && (
{resolvedPromo.title} {resolvedPromo.description}
{/* Plain cross — no button chrome, just a clickable icon. */}
)} {/* Quick actions. "Start Guide Chat" stays pinned above the wall — it's the primary mode switch and must never scroll out of reach. The rest render in the shared {@link QuickActionWall} in BRICK mode (the SAME chip wall the website hero uses): 2 stacked row marquees under left/ right edge fades, so a long agent action set gets "reach" without squeezing the composer. `pauseOnHover` freezes the hovered row so a moving chip never dodges a click; hover/focus previews the action's full prompt in the composer. */} {(onStartGuideChat || (quickActions && quickActions.length > 0)) && (
{onStartGuideChat && ( } variant={hasExistingChats ? 'outline' : 'primary'} onSelect={onStartGuideChat} className="self-start" /> )} {quickActions && quickActions.length > 0 && ( ({ id: action.id, label: action.label, icon: action.icon, variant: action.variant, onSelect: action.onClick, onHoverStart: () => onQuickActionHover?.(action), onHoverEnd: () => onQuickActionHoverEnd?.(), }))} agentSlug={agentSlug} rows={4} pauseOnHover dragScroll fade={['left', 'right']} fadeSize={{ left: 32 }} fadeColor="var(--color-bg)" copyGap="var(--spacing-system-xxs)" className="max-h-44 shrink-0" /> )}
)}
)}
) }