@flamingo-stack/openframe-frontend-core 0.0.217 → 0.0.218

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/{chunk-L6IBKPVM.js → chunk-EKBM4FHK.js} +2 -2
  2. package/dist/{chunk-SWZUZYWR.js → chunk-EWA2NFUR.js} +2 -2
  3. package/dist/{chunk-TYIBMDUZ.cjs → chunk-FZZBCRID.cjs} +7 -7
  4. package/dist/{chunk-TYIBMDUZ.cjs.map → chunk-FZZBCRID.cjs.map} +1 -1
  5. package/dist/{chunk-G2HHSZ3S.cjs → chunk-GE64T3JT.cjs} +9 -9
  6. package/dist/{chunk-G2HHSZ3S.cjs.map → chunk-GE64T3JT.cjs.map} +1 -1
  7. package/dist/{chunk-YWDC5BXM.cjs → chunk-L5RSJE2I.cjs} +1940 -915
  8. package/dist/chunk-L5RSJE2I.cjs.map +1 -0
  9. package/dist/{chunk-BVFRD34B.js → chunk-OHOUSDAY.js} +2 -2
  10. package/dist/{chunk-MVQ3OODK.cjs → chunk-S4SVD5JI.cjs} +9 -9
  11. package/dist/{chunk-MVQ3OODK.cjs.map → chunk-S4SVD5JI.cjs.map} +1 -1
  12. package/dist/{chunk-N5IKPYRL.js → chunk-SWIR5EB2.js} +2 -2
  13. package/dist/{chunk-6DCKL73F.cjs → chunk-TCJ5B2ZD.cjs} +24 -24
  14. package/dist/{chunk-6DCKL73F.cjs.map → chunk-TCJ5B2ZD.cjs.map} +1 -1
  15. package/dist/{chunk-ENBGG2K2.js → chunk-V5JY5RSY.js} +2954 -1929
  16. package/dist/chunk-V5JY5RSY.js.map +1 -0
  17. package/dist/components/chat/embeddable-chat.d.ts +13 -0
  18. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  19. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +104 -10
  20. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  21. package/dist/components/chat/hooks/use-slash-commands.d.ts +6 -0
  22. package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
  23. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  24. package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
  25. package/dist/components/chat/index.cjs +2 -2
  26. package/dist/components/chat/index.js +1 -1
  27. package/dist/components/chat/types/unified-chat-state.types.d.ts +81 -0
  28. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  29. package/dist/components/contact/index.cjs +3 -3
  30. package/dist/components/contact/index.js +2 -2
  31. package/dist/components/features/index.cjs +2 -2
  32. package/dist/components/features/index.js +1 -1
  33. package/dist/components/index.cjs +73 -51
  34. package/dist/components/index.cjs.map +1 -1
  35. package/dist/components/index.js +26 -4
  36. package/dist/components/index.js.map +1 -1
  37. package/dist/components/navigation/app-header.d.ts +7 -0
  38. package/dist/components/navigation/app-header.d.ts.map +1 -1
  39. package/dist/components/navigation/app-layout-drawer.d.ts +65 -0
  40. package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -0
  41. package/dist/components/navigation/app-layout.d.ts +9 -1
  42. package/dist/components/navigation/app-layout.d.ts.map +1 -1
  43. package/dist/components/navigation/header-mingo-button.d.ts +21 -0
  44. package/dist/components/navigation/header-mingo-button.d.ts.map +1 -0
  45. package/dist/components/navigation/index.cjs +24 -2
  46. package/dist/components/navigation/index.cjs.map +1 -1
  47. package/dist/components/navigation/index.d.ts +5 -1
  48. package/dist/components/navigation/index.d.ts.map +1 -1
  49. package/dist/components/navigation/index.js +23 -1
  50. package/dist/components/onboarding-guides/index.cjs +18 -18
  51. package/dist/components/onboarding-guides/index.js +3 -3
  52. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  53. package/dist/components/tickets/index.cjs +80 -66
  54. package/dist/components/tickets/index.cjs.map +1 -1
  55. package/dist/components/tickets/index.js +20 -6
  56. package/dist/components/tickets/index.js.map +1 -1
  57. package/dist/components/ui/index.cjs +2 -2
  58. package/dist/components/ui/index.js +1 -1
  59. package/dist/index.cjs +26 -2
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.js +25 -1
  62. package/dist/utils/embed-authed-fetch.d.ts +80 -0
  63. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  64. package/dist/utils/index.cjs +70 -5
  65. package/dist/utils/index.cjs.map +1 -1
  66. package/dist/utils/index.d.ts +1 -1
  67. package/dist/utils/index.d.ts.map +1 -1
  68. package/dist/utils/index.js +70 -6
  69. package/dist/utils/index.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/components/chat/embeddable-chat.tsx +154 -37
  72. package/src/components/chat/hooks/use-nats-chat-adapter.ts +601 -23
  73. package/src/components/chat/hooks/use-slash-commands.ts +10 -1
  74. package/src/components/chat/hooks/use-sse-chat-adapter.ts +45 -0
  75. package/src/components/chat/hooks/use-unified-chat.ts +59 -0
  76. package/src/components/chat/types/unified-chat-state.types.ts +116 -0
  77. package/src/components/navigation/app-header.tsx +23 -0
  78. package/src/components/navigation/app-layout-drawer.tsx +620 -0
  79. package/src/components/navigation/app-layout.tsx +65 -26
  80. package/src/components/navigation/header-mingo-button.tsx +58 -0
  81. package/src/components/navigation/index.ts +17 -1
  82. package/src/components/tickets/hooks/use-ticket-engagements.ts +24 -4
  83. package/src/stories/AppLayoutDrawer.stories.tsx +228 -0
  84. package/src/utils/.embed-authed-fetch.md +7 -0
  85. package/src/utils/__tests__/embed-authed-fetch.test.ts +103 -1
  86. package/src/utils/embed-authed-fetch.ts +247 -7
  87. package/src/utils/index.ts +5 -1
  88. package/dist/chunk-ENBGG2K2.js.map +0 -1
  89. package/dist/chunk-YWDC5BXM.cjs.map +0 -1
  90. /package/dist/{chunk-L6IBKPVM.js.map → chunk-EKBM4FHK.js.map} +0 -0
  91. /package/dist/{chunk-SWZUZYWR.js.map → chunk-EWA2NFUR.js.map} +0 -0
  92. /package/dist/{chunk-BVFRD34B.js.map → chunk-OHOUSDAY.js.map} +0 -0
  93. /package/dist/{chunk-N5IKPYRL.js.map → chunk-SWIR5EB2.js.map} +0 -0
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { Suspense, useCallback, useState } from 'react'
3
+ import { createContext, Suspense, useCallback, useContext, useState } from 'react'
4
4
  import { NavigationSidebarConfig } from '../../types/navigation'
5
5
  import { cn } from '../../utils'
6
6
  import { NotificationDrawer } from '../features/notifications/notification-drawer'
@@ -8,6 +8,18 @@ import { AppHeader, AppHeaderProps } from './app-header'
8
8
  import { MobileBurgerMenu, MobileBurgerMenuProps } from './mobile-burger-menu'
9
9
  import { NavigationSidebar } from './navigation-sidebar'
10
10
 
11
+ /**
12
+ * Container element that wraps `<main>` and serves as the portal target for
13
+ * `AppLayoutDrawer`. Drawers rendered into this container sit on top of the
14
+ * main content area only — the sidebar and header remain visible and
15
+ * interactive. Null when AppLayout hasn't mounted (or when used outside of it).
16
+ */
17
+ const AppLayoutDrawerContainerContext = createContext<HTMLElement | null>(null)
18
+
19
+ export function useAppLayoutDrawerContainer(): HTMLElement | null {
20
+ return useContext(AppLayoutDrawerContainerContext)
21
+ }
22
+
11
23
  export interface AppLayoutProps {
12
24
  children: React.ReactNode
13
25
  sidebarConfig: NavigationSidebarConfig
@@ -22,6 +34,13 @@ export interface AppLayoutProps {
22
34
  * (`children`) is not affected and stays fully interactive.
23
35
  */
24
36
  disabled?: boolean
37
+ /**
38
+ * Slot for an in-layout drawer (typically an `AppLayoutDrawer` tree). Rendered
39
+ * inside the layout's drawer-container context so the drawer can portal into
40
+ * the main-area container. Keeping it separate from `children` clarifies that
41
+ * the drawer is part of the layout chrome, not page content.
42
+ */
43
+ drawer?: React.ReactNode
25
44
  }
26
45
 
27
46
  export function AppLayout({
@@ -33,8 +52,10 @@ export function AppLayout({
33
52
  className,
34
53
  mobileBurgerMenuProps,
35
54
  disabled = false,
55
+ drawer,
36
56
  }: AppLayoutProps) {
37
57
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
58
+ const [drawerContainer, setDrawerContainer] = useState<HTMLDivElement | null>(null)
38
59
 
39
60
  const handleToggleMobileMenu = useCallback(() => {
40
61
  setMobileMenuOpen(prev => !prev)
@@ -45,34 +66,52 @@ export function AppLayout({
45
66
  }, [])
46
67
 
47
68
  return (
48
- <div className={cn("flex h-screen bg-ods-bg", className)}>
49
- <NavigationSidebar config={sidebarConfig} disabled={disabled} />
50
- {/* Mobile Burger Menu - opens below header */}
51
- <MobileBurgerMenu
52
- {...mobileBurgerMenuProps}
53
- isOpen={mobileMenuOpen}
54
- onClose={handleCloseMobileMenu}
55
- config={sidebarConfig}
56
- disabled={disabled}
57
- />
58
-
59
- {/* Main Content Area */}
60
- <div className="flex-1 flex flex-col overflow-hidden">
61
- <AppHeader
62
- {...headerProps}
63
- isMobileMenuOpen={mobileMenuOpen}
64
- onToggleMobileMenu={handleToggleMobileMenu}
69
+ <AppLayoutDrawerContainerContext.Provider value={drawerContainer}>
70
+ <div className={cn("flex h-screen bg-ods-bg", className)}>
71
+ <NavigationSidebar config={sidebarConfig} disabled={disabled} />
72
+ {/* Mobile Burger Menu - opens below header */}
73
+ <MobileBurgerMenu
74
+ {...mobileBurgerMenuProps}
75
+ isOpen={mobileMenuOpen}
76
+ onClose={handleCloseMobileMenu}
77
+ config={sidebarConfig}
65
78
  disabled={disabled}
66
79
  />
67
- <NotificationDrawer />
68
80
 
69
- {/* Main Content */}
70
- <main className={cn("flex-1 overflow-y-auto", mainClassName)}>
71
- <Suspense fallback={loadingFallback ?? null}>
72
- {children}
73
- </Suspense>
74
- </main>
81
+ {/* Main Content Area */}
82
+ <div className="flex-1 flex flex-col overflow-hidden">
83
+ <AppHeader
84
+ {...headerProps}
85
+ isMobileMenuOpen={mobileMenuOpen}
86
+ onToggleMobileMenu={handleToggleMobileMenu}
87
+ disabled={disabled}
88
+ />
89
+ <NotificationDrawer />
90
+
91
+ {/* Main + AppLayoutDrawer portal target. `relative` so the drawer
92
+ can absolutely position within just this area (not over
93
+ header/sidebar); `overflow-hidden` clips the drawer's slide-in
94
+ animation visually AND contains layout overflow so it doesn't
95
+ propagate up to <html>. (Scroll-snap-back below handles the
96
+ browser's programmatic scroll-on-focus side effect.) */}
97
+ <div
98
+ ref={setDrawerContainer}
99
+ className="flex-1 flex flex-col relative overflow-hidden"
100
+ >
101
+ <main className={cn("flex-1 overflow-y-auto", mainClassName)}>
102
+ <Suspense fallback={loadingFallback ?? null}>
103
+ {children}
104
+ </Suspense>
105
+ </main>
106
+ {/* `drawer` slot — rendered here so it sits inside the
107
+ AppLayoutDrawerContainerContext and can portal into this exact
108
+ container. Mount location is irrelevant for visual placement
109
+ (Radix Portal handles that), but keeping it close to the target
110
+ makes the React tree match the visual nesting. */}
111
+ {drawer}
112
+ </div>
113
+ </div>
75
114
  </div>
76
- </div>
115
+ </AppLayoutDrawerContainerContext.Provider>
77
116
  )
78
117
  }
@@ -0,0 +1,58 @@
1
+ 'use client'
2
+
3
+ import React from 'react'
4
+ import { cn } from '../../utils/cn'
5
+ import { MingoIcon } from '../icons'
6
+
7
+ export interface HeaderMingoButtonProps
8
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
9
+ /** Active/pressed state — set to `true` while the Mingo drawer is open. */
10
+ isActive?: boolean
11
+ /** When true, hides the "Mingo AI" label and renders only the icon (so the
12
+ * control collapses to a square `HeaderButton`-sized affordance on narrow
13
+ * viewports). Defaults to `false`. */
14
+ iconOnly?: boolean
15
+ className?: string
16
+ }
17
+
18
+ /**
19
+ * "Mingo AI" launcher button for `AppHeader`. Mirrors the `HeaderButton`
20
+ * visual contract (sticky header height, `ods-card` rest / `ods-bg-hover`
21
+ * hover / `ods-bg-active` active, divider via `AppHeader`'s `divide-x`), but
22
+ * carries both the Mingo logo and the bold "Mingo AI" wordmark.
23
+ *
24
+ * Figma: 7532:222103 — `button-full`.
25
+ */
26
+ export function HeaderMingoButton({
27
+ isActive = false,
28
+ iconOnly = false,
29
+ className,
30
+ ...props
31
+ }: HeaderMingoButtonProps) {
32
+ return (
33
+ <button
34
+ type="button"
35
+ aria-label="Mingo AI"
36
+ aria-pressed={isActive}
37
+ className={cn(
38
+ 'flex items-center shrink-0 h-full gap-2 px-4',
39
+ 'transition-colors duration-200',
40
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ods-accent',
41
+ isActive
42
+ ? 'text-ods-text-primary bg-ods-bg-active'
43
+ : 'text-ods-text-primary bg-ods-card hover:bg-ods-bg-hover',
44
+ className,
45
+ )}
46
+ {...props}
47
+ >
48
+ <MingoIcon className="w-6 h-6 shrink-0" />
49
+ {!iconOnly && (
50
+ <span className="text-h3 font-bold tracking-[-0.36px] whitespace-nowrap">
51
+ Mingo AI
52
+ </span>
53
+ )}
54
+ </button>
55
+ )
56
+ }
57
+
58
+ export default HeaderMingoButton
@@ -25,15 +25,31 @@ export type { NavigationSidebarProps } from './navigation-sidebar'
25
25
  export { AppHeader } from './app-header'
26
26
  export type { AppHeaderProps } from './app-header'
27
27
 
28
- export { AppLayout } from './app-layout'
28
+ export { AppLayout, useAppLayoutDrawerContainer } from './app-layout'
29
29
  export type { AppLayoutProps } from './app-layout'
30
30
 
31
+ export {
32
+ AppLayoutDrawer,
33
+ AppLayoutDrawerTrigger,
34
+ AppLayoutDrawerClose,
35
+ AppLayoutDrawerContent,
36
+ AppLayoutDrawerHeader,
37
+ AppLayoutDrawerTitle,
38
+ AppLayoutDrawerDescription,
39
+ AppLayoutDrawerBody,
40
+ AppLayoutDrawerFooter,
41
+ } from './app-layout-drawer'
42
+ export type { AppLayoutDrawerContentProps } from './app-layout-drawer'
43
+
31
44
  export { MobileBurgerMenu } from './mobile-burger-menu'
32
45
  export type { MobileBurgerMenuProps } from './mobile-burger-menu'
33
46
 
34
47
  export { HeaderButton } from './header-button'
35
48
  export type { HeaderButtonProps } from './header-button'
36
49
 
50
+ export { HeaderMingoButton } from './header-mingo-button'
51
+ export type { HeaderMingoButtonProps } from './header-mingo-button'
52
+
37
53
  export { HeaderGlobalSearch } from './header-global-search'
38
54
  export type { HeaderGlobalSearchProps } from './header-global-search'
39
55
 
@@ -88,13 +88,18 @@ export function useTicketEngagements(
88
88
  const identity = useChatIdentity()
89
89
  const identityKey = identity.user?.email ?? 'anon'
90
90
 
91
- const queryEnabled =
91
+ // "Will this ticket fetch its timeline once identity is ready?" — i.e. it's a
92
+ // real, non-optimistic ticket the caller enabled. INDEPENDENT of whether
93
+ // identity has resolved yet, so the loading state is correct from the very
94
+ // first render (before `useChatIdentity` settles).
95
+ const fetchable =
92
96
  enabled &&
93
- identity.authTier !== 'anon' &&
94
- !!identity.user?.email &&
95
97
  !!externalTicketId &&
96
98
  !externalTicketId.startsWith('temp-') // optimistic placeholders have no real id yet
97
99
 
100
+ const queryEnabled =
101
+ fetchable && identity.authTier !== 'anon' && !!identity.user?.email
102
+
98
103
  const query = useQuery({
99
104
  queryKey: ['ticket-engagements', externalTicketId, identityKey],
100
105
  enabled: queryEnabled,
@@ -126,7 +131,22 @@ export function useTicketEngagements(
126
131
 
127
132
  return {
128
133
  engagements: query.data ?? [],
129
- isLoading: queryEnabled && query.isLoading,
134
+ // Loading-state truth that prevents the "body → blink → skeleton → data"
135
+ // double-flash. The bug: `useChatIdentity` starts at anon defaults and
136
+ // resolves async, so on the first render `queryEnabled` is false and the
137
+ // OLD `queryEnabled && query.isLoading` returned FALSE — the panel rendered
138
+ // the ticket body, THEN identity resolved, the query enabled, isLoading
139
+ // flipped true → skeleton appeared (the blink), then data landed.
140
+ //
141
+ // Fix: for a fetchable ticket we are "loading" whenever we don't yet have
142
+ // the timeline to show — that includes the window while identity is still
143
+ // resolving (so we skeleton from the FIRST render, never the body) AND the
144
+ // cold query fetch (`data === undefined`). A background poll keeps
145
+ // `query.data` defined, so it never re-flashes the skeleton. Non-fetchable
146
+ // (optimistic/disabled) or a resolved-anon viewer → not loading.
147
+ isLoading:
148
+ fetchable &&
149
+ (identity.isLoading || (queryEnabled && query.data === undefined)),
130
150
  isFetching: query.isFetching,
131
151
  error: (query.error as Error | null) ?? null,
132
152
  refetch: () => {
@@ -0,0 +1,228 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
+ import { useState } from 'react'
3
+ import { fn } from 'storybook/test'
4
+
5
+ import {
6
+ BracketCurlyIcon,
7
+ ChartDonutIcon,
8
+ IdCardIcon,
9
+ MonitorIcon,
10
+ Settings02Icon,
11
+ } from '../components/icons-v2-generated'
12
+ import {
13
+ AppLayout,
14
+ AppLayoutDrawer,
15
+ AppLayoutDrawerBody,
16
+ AppLayoutDrawerContent,
17
+ AppLayoutDrawerDescription,
18
+ AppLayoutDrawerHeader,
19
+ AppLayoutDrawerTitle,
20
+ } from '../components/navigation'
21
+ import { Button } from '../components/ui/button'
22
+ import { NavigationSidebarConfig } from '../types/navigation'
23
+
24
+ const navigationItems: NavigationSidebarConfig['items'] = [
25
+ { id: 'dashboard', label: 'Dashboard', icon: <ChartDonutIcon size={24} />, path: '/dashboard', isActive: true },
26
+ { id: 'customers', label: 'Customers', icon: <IdCardIcon size={24} />, path: '/customers' },
27
+ { id: 'devices', label: 'Devices', icon: <MonitorIcon size={24} />, path: '/devices' },
28
+ { id: 'scripts', label: 'Scripts', icon: <BracketCurlyIcon size={24} />, path: '/scripts' },
29
+ { id: 'settings', label: 'Settings', icon: <Settings02Icon size={24} />, path: '/settings', section: 'secondary' },
30
+ ]
31
+
32
+ const meta = {
33
+ title: 'Navigation/AppLayoutDrawer',
34
+ component: AppLayoutDrawer,
35
+ parameters: {
36
+ layout: 'fullscreen',
37
+ docs: {
38
+ description: {
39
+ component: `
40
+ A Drawer variant that renders **inside** AppLayout's main content area
41
+ instead of as a viewport-level overlay. The sidebar and header remain
42
+ visible and interactive while it is open.
43
+
44
+ ## When to use
45
+ - Side panels (chat, details, filters) that should not cover the global
46
+ chrome (header, sidebar).
47
+ - Anything that conceptually belongs to the current page rather than the
48
+ whole app.
49
+
50
+ For an overlay that covers the entire viewport (e.g. notifications,
51
+ auth modals), use the standard \`Drawer\` from \`components/ui\` instead.
52
+ `,
53
+ },
54
+ },
55
+ },
56
+ tags: ['autodocs'],
57
+ } satisfies Meta<typeof AppLayoutDrawer>
58
+
59
+ export default meta
60
+ type Story = StoryObj<typeof meta>
61
+
62
+ function DashboardChildren({ onOpenDrawer }: { onOpenDrawer: () => void }) {
63
+ return (
64
+ <div className="space-y-6 p-6">
65
+ <div>
66
+ <h1 className="text-2xl font-semibold text-ods-text-primary">Devices Overview</h1>
67
+ <p className="text-ods-text-secondary mt-1">8,250 Devices in Total</p>
68
+ </div>
69
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
70
+ {[
71
+ { label: 'Active Devices', value: '6,930' },
72
+ { label: 'Active Tickets', value: '136' },
73
+ { label: 'Resolved Tickets', value: '825' },
74
+ ].map((stat) => (
75
+ <div key={stat.label} className="p-4 bg-ods-card rounded-lg border border-ods-border">
76
+ <p className="text-sm text-ods-text-secondary">{stat.label}</p>
77
+ <p className="text-2xl font-semibold text-ods-text-primary mt-1">{stat.value}</p>
78
+ </div>
79
+ ))}
80
+ </div>
81
+ <Button onClick={onOpenDrawer}>Open in-layout drawer</Button>
82
+ </div>
83
+ )
84
+ }
85
+
86
+ /**
87
+ * Right-side in-layout drawer, passed via the `drawer` slot prop.
88
+ * The drawer is persistent — clicks on the overlay/header/sidebar
89
+ * do NOT close it; use the X button or Escape.
90
+ */
91
+ export const Right: Story = {
92
+ render: function Render() {
93
+ const [open, setOpen] = useState(false)
94
+ return (
95
+ <AppLayout
96
+ sidebarConfig={{ items: navigationItems, onNavigate: fn(), onToggleMinimized: fn() }}
97
+ headerProps={{
98
+ showNotifications: true,
99
+ showUser: true,
100
+ userName: 'Mingo AI',
101
+ userEmail: 'mingo@openframe.dev',
102
+ onProfile: fn(),
103
+ onLogout: fn(),
104
+ }}
105
+ mobileBurgerMenuProps={{
106
+ user: {
107
+ userName: 'Mingo AI',
108
+ userEmail: 'mingo@openframe.dev',
109
+ userRole: 'Admin',
110
+ },
111
+ }}
112
+ drawer={
113
+ <AppLayoutDrawer open={open} onOpenChange={setOpen}>
114
+ <AppLayoutDrawerContent side="right">
115
+ <AppLayoutDrawerHeader>
116
+ <AppLayoutDrawerTitle>Current Chats</AppLayoutDrawerTitle>
117
+ <AppLayoutDrawerDescription>
118
+ Persistent: clicks on AppLayout chrome don&apos;t close this.
119
+ </AppLayoutDrawerDescription>
120
+ </AppLayoutDrawerHeader>
121
+ <AppLayoutDrawerBody>
122
+ <p className="text-sm text-ods-text-secondary">Chat content goes here.</p>
123
+ </AppLayoutDrawerBody>
124
+ </AppLayoutDrawerContent>
125
+ </AppLayoutDrawer>
126
+ }
127
+ >
128
+ <DashboardChildren onOpenDrawer={() => setOpen(true)} />
129
+ </AppLayout>
130
+ )
131
+ },
132
+ }
133
+
134
+ /**
135
+ * Resizable variant — drag the inside edge to resize. Clamped to the
136
+ * AppLayout main-area dimensions (not the viewport).
137
+ */
138
+ export const ResizableRight: Story = {
139
+ render: function Render() {
140
+ const [open, setOpen] = useState(true)
141
+ return (
142
+ <AppLayout
143
+ sidebarConfig={{ items: navigationItems, onNavigate: fn(), onToggleMinimized: fn() }}
144
+ headerProps={{
145
+ showNotifications: true,
146
+ showUser: true,
147
+ userName: 'Mingo AI',
148
+ userEmail: 'mingo@openframe.dev',
149
+ onProfile: fn(),
150
+ onLogout: fn(),
151
+ }}
152
+ mobileBurgerMenuProps={{
153
+ user: {
154
+ userName: 'Mingo AI',
155
+ userEmail: 'mingo@openframe.dev',
156
+ userRole: 'Admin',
157
+ },
158
+ }}
159
+ drawer={
160
+ <AppLayoutDrawer open={open} onOpenChange={setOpen}>
161
+ <AppLayoutDrawerContent
162
+ side="right"
163
+ resizable
164
+ minSize={360}
165
+ defaultSize={560}
166
+ storageKey="storybook:app-layout-drawer:right"
167
+ >
168
+ <AppLayoutDrawerHeader>
169
+ <AppLayoutDrawerTitle>Resizable Drawer</AppLayoutDrawerTitle>
170
+ </AppLayoutDrawerHeader>
171
+ <AppLayoutDrawerBody>
172
+ <p className="text-sm text-ods-text-secondary">
173
+ Drag the grip on the left edge to resize. The panel can extend all
174
+ the way to the container edge (minus a 16px symmetric gap).
175
+ </p>
176
+ </AppLayoutDrawerBody>
177
+ </AppLayoutDrawerContent>
178
+ </AppLayoutDrawer>
179
+ }
180
+ >
181
+ <DashboardChildren onOpenDrawer={() => setOpen(true)} />
182
+ </AppLayout>
183
+ )
184
+ },
185
+ }
186
+
187
+ /**
188
+ * Left-side variant.
189
+ */
190
+ export const Left: Story = {
191
+ render: function Render() {
192
+ const [open, setOpen] = useState(false)
193
+ return (
194
+ <AppLayout
195
+ sidebarConfig={{ items: navigationItems, onNavigate: fn(), onToggleMinimized: fn() }}
196
+ headerProps={{
197
+ showNotifications: true,
198
+ showUser: true,
199
+ userName: 'Mingo AI',
200
+ userEmail: 'mingo@openframe.dev',
201
+ onProfile: fn(),
202
+ onLogout: fn(),
203
+ }}
204
+ mobileBurgerMenuProps={{
205
+ user: {
206
+ userName: 'Mingo AI',
207
+ userEmail: 'mingo@openframe.dev',
208
+ userRole: 'Admin',
209
+ },
210
+ }}
211
+ drawer={
212
+ <AppLayoutDrawer open={open} onOpenChange={setOpen}>
213
+ <AppLayoutDrawerContent side="left">
214
+ <AppLayoutDrawerHeader>
215
+ <AppLayoutDrawerTitle>Filters</AppLayoutDrawerTitle>
216
+ </AppLayoutDrawerHeader>
217
+ <AppLayoutDrawerBody>
218
+ <p className="text-sm text-ods-text-secondary">Left-side drawer content.</p>
219
+ </AppLayoutDrawerBody>
220
+ </AppLayoutDrawerContent>
221
+ </AppLayoutDrawer>
222
+ }
223
+ >
224
+ <DashboardChildren onOpenDrawer={() => setOpen(true)} />
225
+ </AppLayout>
226
+ )
227
+ },
228
+ }
@@ -9,6 +9,13 @@ Drop-in replacement for `fetch()` that:
9
9
  - Delegates to `applyProxyAuth` to merge `Authorization` and `X-Chat-Act-As` headers — **proxy credentials always win** over caller-supplied headers
10
10
  - Defaults `Content-Type: application/json` when no headers are provided
11
11
  - Forces `credentials: 'same-origin'` to ensure Supabase auth cookies are included alongside bearer tokens
12
+ - **Self-heals on `401`** when a host registered an `EmbedAuthAdapter` with a `refresh` callback: calls `refresh()` once, and on success retries the same request a single time with freshly-recomputed headers (so a rotated bearer is picked up). Concurrent 401s share ONE refresh. With no adapter / no `refresh`, the 401 passes through unchanged.
13
+
14
+ ### `setEmbedAuthAdapter(adapter | null)` + `EmbedAuthAdapter`
15
+ Optional host-owned auth override registered at module level (one chat panel per app). The adapter can supply:
16
+ - `getHeaders()` — headers merged onto every call AFTER proxy-auth (adapter wins), re-read per request so token rotation is seen
17
+ - `credentials` — overrides the default `'same-origin'` (e.g. `'include'` for cross-origin cookie auth)
18
+ - `refresh()` — `Promise<boolean>` 401 self-heal hook (see above). This lifts the openframe `apiClient`'s refresh-then-retry behaviour into the lib so embedded surfaces no longer need a host-side `window.fetch` monkey-patch to survive an expired token mid-session.
12
19
 
13
20
  ### `assertSameOrigin(url)` *(internal)*
14
21
  Defense-in-depth guard that:
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
- import { embedAuthedFetch } from '../embed-authed-fetch'
2
+ import { embedAuthedFetch, setEmbedAuthAdapter } from '../embed-authed-fetch'
3
3
 
4
4
  /**
5
5
  * Tests focus on the same-origin guard rather than the proxy-auth
@@ -11,9 +11,15 @@ describe('embedAuthedFetch.assertSameOrigin guard', () => {
11
11
 
12
12
  beforeEach(() => {
13
13
  globalThis.fetch = vi.fn(async () => new Response('ok')) as typeof fetch
14
+ // The cross-origin guard has a dev-mode escape hatch (warn + allow
15
+ // instead of throw) when `NODE_ENV !== 'production'` — see
16
+ // `embed-authed-fetch.ts`. Pin the env to production so these tests
17
+ // exercise the bearer-leak defense, not the dev convenience path.
18
+ vi.stubEnv('NODE_ENV', 'production')
14
19
  })
15
20
  afterEach(() => {
16
21
  globalThis.fetch = originalFetch
22
+ vi.unstubAllEnvs()
17
23
  vi.restoreAllMocks()
18
24
  })
19
25
 
@@ -64,3 +70,99 @@ describe('embedAuthedFetch.assertSameOrigin guard', () => {
64
70
  expect(spy).not.toHaveBeenCalled()
65
71
  })
66
72
  })
73
+
74
+ describe('embedAuthedFetch 401 self-heal (adapter.refresh)', () => {
75
+ const originalFetch = globalThis.fetch
76
+
77
+ afterEach(() => {
78
+ globalThis.fetch = originalFetch
79
+ setEmbedAuthAdapter(null)
80
+ vi.restoreAllMocks()
81
+ })
82
+
83
+ it('refreshes once then retries the request on a 401', async () => {
84
+ const fetchSpy = vi
85
+ .fn()
86
+ .mockResolvedValueOnce(new Response('nope', { status: 401 }))
87
+ .mockResolvedValueOnce(new Response('ok', { status: 200 }))
88
+ globalThis.fetch = fetchSpy as typeof fetch
89
+
90
+ const refresh = vi.fn(async () => true)
91
+ setEmbedAuthAdapter({ getHeaders: () => ({ Authorization: 'Bearer t' }), refresh })
92
+
93
+ const res = await embedAuthedFetch('/api/chat/x')
94
+ expect(res.status).toBe(200)
95
+ expect(refresh).toHaveBeenCalledTimes(1)
96
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
97
+ })
98
+
99
+ it('surfaces the 401 (no retry) when refresh resolves false', async () => {
100
+ const fetchSpy = vi.fn(async () => new Response('nope', { status: 401 }))
101
+ globalThis.fetch = fetchSpy as typeof fetch
102
+
103
+ const refresh = vi.fn(async () => false)
104
+ setEmbedAuthAdapter({ refresh })
105
+
106
+ const res = await embedAuthedFetch('/api/chat/x')
107
+ expect(res.status).toBe(401)
108
+ expect(refresh).toHaveBeenCalledTimes(1)
109
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
110
+ })
111
+
112
+ it('does not retry a second time when the refreshed token is also 401', async () => {
113
+ const fetchSpy = vi.fn(async () => new Response('nope', { status: 401 }))
114
+ globalThis.fetch = fetchSpy as typeof fetch
115
+
116
+ const refresh = vi.fn(async () => true)
117
+ setEmbedAuthAdapter({ refresh })
118
+
119
+ const res = await embedAuthedFetch('/api/chat/x')
120
+ expect(res.status).toBe(401)
121
+ // refresh fired once; the retry's 401 is surfaced rather than looping.
122
+ expect(refresh).toHaveBeenCalledTimes(1)
123
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
124
+ })
125
+
126
+ it('de-dupes concurrent 401s into a single refresh', async () => {
127
+ const fetchSpy = vi
128
+ .fn()
129
+ .mockResolvedValueOnce(new Response('nope', { status: 401 }))
130
+ .mockResolvedValueOnce(new Response('nope', { status: 401 }))
131
+ .mockResolvedValue(new Response('ok', { status: 200 }))
132
+ globalThis.fetch = fetchSpy as typeof fetch
133
+
134
+ // A refresh that stays pending until we release it — so BOTH 401s are
135
+ // parked on the single shared slot before it settles. (If it resolved
136
+ // eagerly, the slot could clear between the two 401s and the test would
137
+ // race.)
138
+ let releaseRefresh: (v: boolean) => void = () => {}
139
+ const refreshGate = new Promise<boolean>((resolve) => {
140
+ releaseRefresh = resolve
141
+ })
142
+ const refresh = vi.fn(() => refreshGate)
143
+ setEmbedAuthAdapter({ refresh })
144
+
145
+ const p1 = embedAuthedFetch('/api/chat/a')
146
+ const p2 = embedAuthedFetch('/api/chat/b')
147
+
148
+ // Wait until both initial fetches have 401'd and reached the (still
149
+ // pending) refresh slot, then release it so both retries fire.
150
+ await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2))
151
+ releaseRefresh(true)
152
+
153
+ const [r1, r2] = await Promise.all([p1, p2])
154
+ expect(r1.status).toBe(200)
155
+ expect(r2.status).toBe(200)
156
+ // Both 401s shared ONE refresh call.
157
+ expect(refresh).toHaveBeenCalledTimes(1)
158
+ })
159
+
160
+ it('passes a 401 through untouched when no adapter is registered', async () => {
161
+ const fetchSpy = vi.fn(async () => new Response('nope', { status: 401 }))
162
+ globalThis.fetch = fetchSpy as typeof fetch
163
+
164
+ const res = await embedAuthedFetch('/api/chat/x')
165
+ expect(res.status).toBe(401)
166
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
167
+ })
168
+ })