@flamingo-stack/openframe-frontend-core 0.0.216 → 0.0.217-snapshot.20260601151546

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 (144) hide show
  1. package/dist/{chunk-2V4SACHE.js → chunk-EKBM4FHK.js} +2 -2
  2. package/dist/{chunk-VC3ND5RB.js → chunk-EWA2NFUR.js} +2 -2
  3. package/dist/{chunk-IH76P5R6.cjs → chunk-FZZBCRID.cjs} +8 -86
  4. package/dist/chunk-FZZBCRID.cjs.map +1 -0
  5. package/dist/{chunk-K4DFAVSO.cjs → chunk-GE64T3JT.cjs} +9 -9
  6. package/dist/{chunk-K4DFAVSO.cjs.map → chunk-GE64T3JT.cjs.map} +1 -1
  7. package/dist/{chunk-ZGTDUPTW.cjs → chunk-L5RSJE2I.cjs} +2318 -1136
  8. package/dist/chunk-L5RSJE2I.cjs.map +1 -0
  9. package/dist/{chunk-QTKU6ULP.js → chunk-OHOUSDAY.js} +2 -2
  10. package/dist/{chunk-572WQWIX.cjs → chunk-S4SVD5JI.cjs} +9 -9
  11. package/dist/{chunk-572WQWIX.cjs.map → chunk-S4SVD5JI.cjs.map} +1 -1
  12. package/dist/{chunk-GVNQAGXB.js → chunk-SWIR5EB2.js} +3 -81
  13. package/dist/chunk-SWIR5EB2.js.map +1 -0
  14. package/dist/{chunk-SMCG2CCC.cjs → chunk-TCJ5B2ZD.cjs} +24 -24
  15. package/dist/{chunk-SMCG2CCC.cjs.map → chunk-TCJ5B2ZD.cjs.map} +1 -1
  16. package/dist/{chunk-CDLYRFDE.js → chunk-V5JY5RSY.js} +5710 -4528
  17. package/dist/chunk-V5JY5RSY.js.map +1 -0
  18. package/dist/components/chat/chat-attachment-bar.d.ts +13 -2
  19. package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
  20. package/dist/components/chat/chat-input.d.ts.map +1 -1
  21. package/dist/components/chat/chat-message-row.d.ts +25 -0
  22. package/dist/components/chat/chat-message-row.d.ts.map +1 -0
  23. package/dist/components/chat/embeddable-chat.d.ts +13 -0
  24. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  25. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +104 -10
  26. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  27. package/dist/components/chat/hooks/use-slash-commands.d.ts +6 -0
  28. package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
  29. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  30. package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
  31. package/dist/components/chat/index.cjs +6 -2
  32. package/dist/components/chat/index.cjs.map +1 -1
  33. package/dist/components/chat/index.d.ts +1 -0
  34. package/dist/components/chat/index.d.ts.map +1 -1
  35. package/dist/components/chat/index.js +5 -1
  36. package/dist/components/chat/types/component.types.d.ts +8 -1
  37. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  38. package/dist/components/chat/types/unified-chat-state.types.d.ts +81 -0
  39. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  40. package/dist/components/contact/index.cjs +3 -3
  41. package/dist/components/contact/index.js +2 -2
  42. package/dist/components/features/index.cjs +2 -2
  43. package/dist/components/features/index.js +1 -1
  44. package/dist/components/form.d.ts +1 -1
  45. package/dist/components/form.d.ts.map +1 -1
  46. package/dist/components/index.cjs +78 -52
  47. package/dist/components/index.cjs.map +1 -1
  48. package/dist/components/index.js +31 -5
  49. package/dist/components/index.js.map +1 -1
  50. package/dist/components/navigation/app-header.d.ts +7 -0
  51. package/dist/components/navigation/app-header.d.ts.map +1 -1
  52. package/dist/components/navigation/app-layout-drawer.d.ts +65 -0
  53. package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -0
  54. package/dist/components/navigation/app-layout.d.ts +9 -1
  55. package/dist/components/navigation/app-layout.d.ts.map +1 -1
  56. package/dist/components/navigation/header-mingo-button.d.ts +21 -0
  57. package/dist/components/navigation/header-mingo-button.d.ts.map +1 -0
  58. package/dist/components/navigation/index.cjs +24 -2
  59. package/dist/components/navigation/index.cjs.map +1 -1
  60. package/dist/components/navigation/index.d.ts +5 -1
  61. package/dist/components/navigation/index.d.ts.map +1 -1
  62. package/dist/components/navigation/index.js +23 -1
  63. package/dist/components/onboarding-guides/index.cjs +18 -18
  64. package/dist/components/onboarding-guides/index.js +3 -3
  65. package/dist/components/shared/dev-section/dev-card-row.d.ts +5 -45
  66. package/dist/components/shared/dev-section/dev-card-row.d.ts.map +1 -1
  67. package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -1
  68. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  69. package/dist/components/tickets/help-center-list.d.ts.map +1 -1
  70. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +9 -1
  71. package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
  72. package/dist/components/tickets/hooks/use-tickets-list.d.ts +7 -0
  73. package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
  74. package/dist/components/tickets/index.cjs +309 -256
  75. package/dist/components/tickets/index.cjs.map +1 -1
  76. package/dist/components/tickets/index.d.ts +1 -0
  77. package/dist/components/tickets/index.d.ts.map +1 -1
  78. package/dist/components/tickets/index.js +376 -323
  79. package/dist/components/tickets/index.js.map +1 -1
  80. package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
  81. package/dist/components/tickets/ticket-reply-composer.d.ts +33 -0
  82. package/dist/components/tickets/ticket-reply-composer.d.ts.map +1 -0
  83. package/dist/components/tickets/types.d.ts +13 -0
  84. package/dist/components/tickets/types.d.ts.map +1 -1
  85. package/dist/components/ui/index.cjs +6 -2
  86. package/dist/components/ui/index.cjs.map +1 -1
  87. package/dist/components/ui/index.js +5 -1
  88. package/dist/components/ui/ticket-attachments-list.d.ts +5 -1
  89. package/dist/components/ui/ticket-attachments-list.d.ts.map +1 -1
  90. package/dist/index.cjs +30 -2
  91. package/dist/index.cjs.map +1 -1
  92. package/dist/index.js +29 -1
  93. package/dist/utils/embed-authed-fetch.d.ts +80 -0
  94. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  95. package/dist/utils/index.cjs +129 -9
  96. package/dist/utils/index.cjs.map +1 -1
  97. package/dist/utils/index.d.ts +1 -1
  98. package/dist/utils/index.d.ts.map +1 -1
  99. package/dist/utils/index.js +129 -10
  100. package/dist/utils/index.js.map +1 -1
  101. package/dist/utils/scroll-into-view.d.ts +43 -48
  102. package/dist/utils/scroll-into-view.d.ts.map +1 -1
  103. package/package.json +2 -2
  104. package/src/components/chat/chat-attachment-bar.tsx +58 -22
  105. package/src/components/chat/chat-input.tsx +68 -29
  106. package/src/components/chat/chat-message-row.tsx +124 -0
  107. package/src/components/chat/embeddable-chat.tsx +154 -37
  108. package/src/components/chat/hooks/use-nats-chat-adapter.ts +601 -23
  109. package/src/components/chat/hooks/use-slash-commands.ts +10 -1
  110. package/src/components/chat/hooks/use-sse-chat-adapter.ts +45 -0
  111. package/src/components/chat/hooks/use-unified-chat.ts +59 -0
  112. package/src/components/chat/index.ts +1 -0
  113. package/src/components/chat/types/component.types.ts +8 -1
  114. package/src/components/chat/types/unified-chat-state.types.ts +116 -0
  115. package/src/components/navigation/app-header.tsx +23 -0
  116. package/src/components/navigation/app-layout-drawer.tsx +620 -0
  117. package/src/components/navigation/app-layout.tsx +65 -26
  118. package/src/components/navigation/header-mingo-button.tsx +58 -0
  119. package/src/components/navigation/index.ts +17 -1
  120. package/src/components/shared/dev-section/dev-card-row.tsx +5 -183
  121. package/src/components/shared/legal-document/use-legal-docs.ts +5 -1
  122. package/src/components/tickets/help-center-card.tsx +26 -29
  123. package/src/components/tickets/help-center-list.tsx +57 -10
  124. package/src/components/tickets/hooks/use-ticket-engagements.ts +41 -5
  125. package/src/components/tickets/hooks/use-tickets-list.ts +13 -0
  126. package/src/components/tickets/index.ts +4 -0
  127. package/src/components/tickets/ticket-detail-drawer.tsx +144 -200
  128. package/src/components/tickets/ticket-reply-composer.tsx +195 -0
  129. package/src/components/tickets/types.ts +14 -0
  130. package/src/components/ui/ticket-attachments-list.tsx +26 -8
  131. package/src/stories/AppLayoutDrawer.stories.tsx +228 -0
  132. package/src/styles/app-globals.css +13 -0
  133. package/src/utils/.embed-authed-fetch.md +7 -0
  134. package/src/utils/__tests__/embed-authed-fetch.test.ts +103 -1
  135. package/src/utils/embed-authed-fetch.ts +247 -7
  136. package/src/utils/index.ts +5 -1
  137. package/src/utils/scroll-into-view.ts +127 -53
  138. package/dist/chunk-CDLYRFDE.js.map +0 -1
  139. package/dist/chunk-GVNQAGXB.js.map +0 -1
  140. package/dist/chunk-IH76P5R6.cjs.map +0 -1
  141. package/dist/chunk-ZGTDUPTW.cjs.map +0 -1
  142. /package/dist/{chunk-2V4SACHE.js.map → chunk-EKBM4FHK.js.map} +0 -0
  143. /package/dist/{chunk-VC3ND5RB.js.map → chunk-EWA2NFUR.js.map} +0 -0
  144. /package/dist/{chunk-QTKU6ULP.js.map → chunk-OHOUSDAY.js.map} +0 -0
@@ -0,0 +1,195 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useState } from 'react'
4
+ import { Button } from './../ui/button'
5
+ import { Textarea } from './../ui/textarea'
6
+ import {
7
+ AlertDialog,
8
+ AlertDialogAction,
9
+ AlertDialogCancel,
10
+ AlertDialogContent,
11
+ AlertDialogDescription,
12
+ AlertDialogFooter,
13
+ AlertDialogHeader,
14
+ AlertDialogTitle,
15
+ } from './../ui/alert-dialog'
16
+ import { ChatInput } from './../chat/chat-input'
17
+ import {
18
+ ChatAttachmentAddButton,
19
+ ChatAttachmentChipStrip,
20
+ } from './../chat/chat-attachment-bar'
21
+ import { useChatAttachments } from './../chat/hooks/use-chat-attachments'
22
+ import type { AnyTicket } from './types'
23
+ import { TICKET_TEXT_MAX_CHARS } from './types'
24
+ import type { TicketDetailDrawerProps, TicketRef } from './ticket-detail-drawer'
25
+
26
+ export interface TicketReplyComposerProps {
27
+ ticket: AnyTicket
28
+ busy: boolean
29
+ supportSystemDown: boolean
30
+ onSendMessage: TicketDetailDrawerProps['onSendMessage']
31
+ onClose: TicketDetailDrawerProps['onClose']
32
+ }
33
+
34
+ /**
35
+ * Open-ticket reply composer — REUSES the exact same layout as the global
36
+ * Ask-AI chat composer (`embeddable-chat.tsx`): the shared `<ChatInput>` with
37
+ * the staged-file chip strip above it and the attachment `+` button in a
38
+ * controls row BELOW the input (identical placement to the global chat), plus
39
+ * the destructive close-confirm `AlertDialog`.
40
+ *
41
+ * Replaces the former `OpenActions` raw-`<Textarea>` composer. The text lives
42
+ * inside `ChatInput`; this component owns only the attachment bag + the close
43
+ * dialog. Send semantics:
44
+ * - `sending={busy || hasInflightUploads}` disables the input while sending
45
+ * OR uploading (same as the global chat); `allowEmptySend` lets an
46
+ * attachments-only reply send once uploads finish;
47
+ * - the typed draft + staged files survive a FAILED send: `handleSend`
48
+ * returns `false`, so `ChatInput` keeps the text and we only `clear()` the
49
+ * attachments on success;
50
+ * - close does NOT collapse the drawer (no `onActionCollapsed`).
51
+ *
52
+ * `disabled={supportSystemDown}` is the only flag that drives the "Connection
53
+ * lost…" placeholder. The `+` attach gate stays `!supportSystemDown` (same gate
54
+ * the old composer used).
55
+ */
56
+ export function TicketReplyComposer({
57
+ ticket,
58
+ busy,
59
+ supportSystemDown,
60
+ onSendMessage,
61
+ onClose,
62
+ }: TicketReplyComposerProps) {
63
+ const [resolution, setResolution] = useState('')
64
+ const [closeDialogOpen, setCloseDialogOpen] = useState(false)
65
+ const attachments = useChatAttachments()
66
+
67
+ const ticketRef: TicketRef = { id: ticket.id, external_id: ticket.external_id }
68
+ const hasReadyFiles = attachments.readyAttachments.length > 0
69
+
70
+ const handleSend = useCallback(
71
+ async (text: string): Promise<boolean> => {
72
+ const ref: TicketRef = { id: ticket.id, external_id: ticket.external_id }
73
+ const ok = await onSendMessage(ref, text.trim(), attachments.readyAttachments)
74
+ if (ok) attachments.clear()
75
+ return ok
76
+ },
77
+ // Depend on the reactive projections, not the whole bag (a fresh object each
78
+ // render). `readyAttachments` is memo-stable; `clear` is callback-stable.
79
+ [
80
+ onSendMessage,
81
+ ticket.id,
82
+ ticket.external_id,
83
+ attachments.readyAttachments,
84
+ attachments.clear,
85
+ ],
86
+ )
87
+
88
+ const confirmClose = async () => {
89
+ setCloseDialogOpen(false)
90
+ await onClose(ticketRef, resolution.trim() || undefined)
91
+ setResolution('')
92
+ // Intentionally NO `onActionCollapsed()` — collapsing the drawer after a
93
+ // close dismisses the ticket the user is working on (PR #1053). The
94
+ // optimistic in-place status update keeps the row mounted with the new
95
+ // badge; that is the only feedback needed.
96
+ }
97
+
98
+ const disabled = busy || supportSystemDown
99
+
100
+ return (
101
+ <div className="flex flex-col gap-2">
102
+ {/* Unified composer — mirrors the global Ask-AI chat layout
103
+ (embeddable-chat.tsx :1160-1206): compact staged-file chip strip
104
+ above, the shared <ChatInput> (Send icon = the PRIMARY action), then a
105
+ quiet bottom toolbar: attachment "+" on the left, and a LOW-EMPHASIS
106
+ "Close ticket" text button on the right. Closing is reversible (Reopen
107
+ exists), so it is NOT styled as destructive/danger — that would
108
+ over-signal a routine, undoable status change (UX best practice:
109
+ reserve red for irreversible actions). */}
110
+ <ChatAttachmentChipStrip
111
+ attachments={attachments.attachments}
112
+ onRemove={attachments.removeAttachment}
113
+ disabled={disabled}
114
+ size="compact"
115
+ />
116
+ <ChatInput
117
+ fullWidth
118
+ // Focus the reply box when the drawer opens so the customer can type
119
+ // immediately. `ChatInput`'s autoFocus uses `{ preventScroll: true }`,
120
+ // so this does NOT scroll the page — the card's smooth scroll-to-top
121
+ // (HelpCenterCard) wins, and the input stays focused + visible (it
122
+ // sits within the viewport below the fixed-height feed).
123
+ autoFocus
124
+ placeholder="Type a reply…"
125
+ sending={busy || attachments.hasInflightUploads}
126
+ disabled={supportSystemDown}
127
+ allowEmptySend={hasReadyFiles}
128
+ maxLength={TICKET_TEXT_MAX_CHARS}
129
+ onSend={handleSend}
130
+ />
131
+ <div className="flex items-center gap-2 w-full">
132
+ {!supportSystemDown && (
133
+ <ChatAttachmentAddButton
134
+ attachmentsEnabled
135
+ attachmentsCount={attachments.attachments.length}
136
+ onAddFiles={attachments.addFiles}
137
+ disabled={disabled}
138
+ size="compact"
139
+ />
140
+ )}
141
+ <div className="flex-1 min-w-0" />
142
+ <Button
143
+ type="button"
144
+ variant="transparent"
145
+ size="small"
146
+ onClick={() => setCloseDialogOpen(true)}
147
+ disabled={disabled}
148
+ className="text-ods-text-secondary hover:text-ods-text-primary"
149
+ >
150
+ Close ticket
151
+ </Button>
152
+ </div>
153
+
154
+ {/* Confirm dialog — collects an optional resolution note. Closing is
155
+ REVERSIBLE, so the confirm action is the standard accent primary, NOT
156
+ a red destructive button. */}
157
+ <AlertDialog open={closeDialogOpen} onOpenChange={setCloseDialogOpen}>
158
+ <AlertDialogContent className="bg-ods-card border-ods-border">
159
+ <AlertDialogHeader>
160
+ <AlertDialogTitle className="text-ods-text-primary">
161
+ Close this ticket?
162
+ </AlertDialogTitle>
163
+ <AlertDialogDescription className="text-ods-text-secondary">
164
+ Add an optional resolution note below. You can reopen the ticket
165
+ later if needed.
166
+ </AlertDialogDescription>
167
+ </AlertDialogHeader>
168
+ <Textarea
169
+ value={resolution}
170
+ onChange={(e) => setResolution(e.target.value)}
171
+ placeholder="Resolution (optional)"
172
+ rows={3}
173
+ maxLength={TICKET_TEXT_MAX_CHARS}
174
+ className="mt-2"
175
+ />
176
+ <AlertDialogFooter>
177
+ <AlertDialogCancel
178
+ disabled={busy}
179
+ className="bg-transparent border-ods-border text-ods-text-primary hover:bg-ods-border"
180
+ >
181
+ Cancel
182
+ </AlertDialogCancel>
183
+ <AlertDialogAction
184
+ onClick={() => void confirmClose()}
185
+ disabled={busy}
186
+ className="bg-ods-accent text-ods-text-on-accent hover:bg-ods-accent-hover"
187
+ >
188
+ Close ticket
189
+ </AlertDialogAction>
190
+ </AlertDialogFooter>
191
+ </AlertDialogContent>
192
+ </AlertDialog>
193
+ </div>
194
+ )
195
+ }
@@ -211,6 +211,20 @@ export interface MappedTicketActionError {
211
211
  */
212
212
  export const TICKET_TEXT_MAX_CHARS = 5000
213
213
 
214
+ /**
215
+ * Live-refresh cadence (ms) for an OPEN ticket drawer. Drives BOTH
216
+ * surfaces that must stay current while the customer is looking at a
217
+ * ticket:
218
+ * - the ticket LIST poll (`useTicketsList.refetchInterval`) → surfaces
219
+ * out-of-band status / pipeline / priority / assignee changes;
220
+ * - the CONVERSATION poll (`useTicketEngagements.refetchInterval`) →
221
+ * surfaces new agent replies + attachments.
222
+ * Single source of truth so the two surfaces never drift. Both leave
223
+ * `refetchIntervalInBackground` at its default (false), so polling pauses
224
+ * on a hidden tab — no wasted requests when the user tabs away.
225
+ */
226
+ export const TICKET_LIVE_POLL_MS = 8000
227
+
214
228
  /**
215
229
  * Centralized toast copy. Keep all wording here so QA / localization
216
230
  * can find every user-visible string in one file.
@@ -16,10 +16,15 @@ export interface TicketAttachment {
16
16
  export interface TicketAttachmentsListProps {
17
17
  attachments: TicketAttachment[]
18
18
  className?: string
19
+ /** `compact` shrinks padding / icon / text / download-button for
20
+ * in-message rendering (the ticket conversation feed). Default keeps the
21
+ * roomier full-row layout for any other surface. */
22
+ size?: 'default' | 'compact'
19
23
  }
20
24
 
21
- export function TicketAttachmentsList({ attachments, className }: TicketAttachmentsListProps) {
25
+ export function TicketAttachmentsList({ attachments, className, size = 'default' }: TicketAttachmentsListProps) {
22
26
  if (attachments.length === 0) return null
27
+ const compact = size === 'compact'
23
28
 
24
29
  return (
25
30
  <div className={cn("rounded-[6px] border border-ods-border overflow-hidden", className)}>
@@ -27,7 +32,8 @@ export function TicketAttachmentsList({ attachments, className }: TicketAttachme
27
32
  <div
28
33
  key={attachment.id}
29
34
  className={cn(
30
- "flex items-center gap-4 px-4 py-3 bg-ods-card",
35
+ "flex items-center bg-ods-card",
36
+ compact ? "gap-2 px-2 py-1.5" : "gap-4 px-4 py-3",
31
37
  index < attachments.length - 1 && "border-b border-ods-border",
32
38
  )}
33
39
  >
@@ -35,17 +41,29 @@ export function TicketAttachmentsList({ attachments, className }: TicketAttachme
35
41
  <SquareAvatar
36
42
  src={attachment.thumbnailSrc}
37
43
  alt={attachment.fileName}
38
- size="md"
44
+ size={compact ? "sm" : "md"}
39
45
  className="shrink-0"
40
46
  />
41
47
  ) : (
42
- <div className="shrink-0 size-10 flex items-center justify-center rounded-[6px] bg-ods-card border border-ods-border">
43
- <FileIcon className="size-6 text-ods-text-secondary" />
48
+ <div
49
+ className={cn(
50
+ "shrink-0 flex items-center justify-center rounded-[6px] bg-ods-card border border-ods-border",
51
+ compact ? "size-8" : "size-10",
52
+ )}
53
+ >
54
+ <FileIcon className={cn("text-ods-text-secondary", compact ? "size-4" : "size-6")} />
44
55
  </div>
45
56
  )}
46
57
  <div className="flex-1 min-w-0 overflow-hidden">
47
- <p className="text-h4 text-ods-text-primary truncate" title={attachment.fileName}>{attachment.fileName}</p>
48
- <p className="text-h6 text-ods-text-secondary">{attachment.fileSize}</p>
58
+ <p
59
+ className={cn("text-ods-text-primary truncate", compact ? "text-h5" : "text-h4")}
60
+ title={attachment.fileName}
61
+ >
62
+ {attachment.fileName}
63
+ </p>
64
+ {attachment.fileSize && (
65
+ <p className="text-h6 text-ods-text-secondary">{attachment.fileSize}</p>
66
+ )}
49
67
  </div>
50
68
  {attachment.onDownload && (
51
69
  <button
@@ -54,7 +72,7 @@ export function TicketAttachmentsList({ attachments, className }: TicketAttachme
54
72
  className="shrink-0 text-ods-text-secondary hover:text-ods-text-primary transition-colors"
55
73
  aria-label={`Download ${attachment.fileName}`}
56
74
  >
57
- <Download className="size-6" />
75
+ <Download className={compact ? "size-4" : "size-6"} />
58
76
  </button>
59
77
  )}
60
78
  </div>
@@ -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
+ }
@@ -39,11 +39,24 @@ img {
39
39
 
40
40
  html {
41
41
  background-color: var(--color-bg);
42
+ /* Defense-in-depth for programmatic smooth scrolls (see
43
+ `utils/scroll-into-view.ts`). Browser SCROLL ANCHORING issues a synchronous
44
+ scrollTop correction when content is inserted/removed (e.g. a ticket drawer
45
+ expanding) to keep the anchored element stable — and per CSSOM-View that
46
+ correction ABORTS any in-flight scroll. That cancelled our smooth
47
+ scroll-to-card on every open after the first (anchoring is suppressed only
48
+ at scrollY=0, so the first open looked smooth and every repeat jumped).
49
+ Disabling anchoring on the document scroller removes the correction
50
+ entirely. Put on html/body (the page scroller), NOT a descendant — a child
51
+ can't re-enable it for an ancestor scroll container. The self-driven rAF
52
+ tween in scroll-into-view.ts is the primary fix; this is belt-and-braces. */
53
+ overflow-anchor: none;
42
54
  }
43
55
 
44
56
  body {
45
57
  background-color: var(--color-bg);
46
58
  color: var(--color-text-primary);
59
+ overflow-anchor: none;
47
60
  }
48
61
 
49
62
  /* Light mode specific styles (used in alternatives-list.tsx) */
@@ -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
+ })