@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.
- package/dist/{chunk-2V4SACHE.js → chunk-EKBM4FHK.js} +2 -2
- package/dist/{chunk-VC3ND5RB.js → chunk-EWA2NFUR.js} +2 -2
- package/dist/{chunk-IH76P5R6.cjs → chunk-FZZBCRID.cjs} +8 -86
- package/dist/chunk-FZZBCRID.cjs.map +1 -0
- package/dist/{chunk-K4DFAVSO.cjs → chunk-GE64T3JT.cjs} +9 -9
- package/dist/{chunk-K4DFAVSO.cjs.map → chunk-GE64T3JT.cjs.map} +1 -1
- package/dist/{chunk-ZGTDUPTW.cjs → chunk-L5RSJE2I.cjs} +2318 -1136
- package/dist/chunk-L5RSJE2I.cjs.map +1 -0
- package/dist/{chunk-QTKU6ULP.js → chunk-OHOUSDAY.js} +2 -2
- package/dist/{chunk-572WQWIX.cjs → chunk-S4SVD5JI.cjs} +9 -9
- package/dist/{chunk-572WQWIX.cjs.map → chunk-S4SVD5JI.cjs.map} +1 -1
- package/dist/{chunk-GVNQAGXB.js → chunk-SWIR5EB2.js} +3 -81
- package/dist/chunk-SWIR5EB2.js.map +1 -0
- package/dist/{chunk-SMCG2CCC.cjs → chunk-TCJ5B2ZD.cjs} +24 -24
- package/dist/{chunk-SMCG2CCC.cjs.map → chunk-TCJ5B2ZD.cjs.map} +1 -1
- package/dist/{chunk-CDLYRFDE.js → chunk-V5JY5RSY.js} +5710 -4528
- package/dist/chunk-V5JY5RSY.js.map +1 -0
- package/dist/components/chat/chat-attachment-bar.d.ts +13 -2
- package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
- package/dist/components/chat/chat-input.d.ts.map +1 -1
- package/dist/components/chat/chat-message-row.d.ts +25 -0
- package/dist/components/chat/chat-message-row.d.ts.map +1 -0
- package/dist/components/chat/embeddable-chat.d.ts +13 -0
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +104 -10
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-slash-commands.d.ts +6 -0
- package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +6 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +5 -1
- package/dist/components/chat/types/component.types.d.ts +8 -1
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +81 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/form.d.ts +1 -1
- package/dist/components/form.d.ts.map +1 -1
- package/dist/components/index.cjs +78 -52
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +31 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/app-header.d.ts +7 -0
- package/dist/components/navigation/app-header.d.ts.map +1 -1
- package/dist/components/navigation/app-layout-drawer.d.ts +65 -0
- package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -0
- package/dist/components/navigation/app-layout.d.ts +9 -1
- package/dist/components/navigation/app-layout.d.ts.map +1 -1
- package/dist/components/navigation/header-mingo-button.d.ts +21 -0
- package/dist/components/navigation/header-mingo-button.d.ts.map +1 -0
- package/dist/components/navigation/index.cjs +24 -2
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.d.ts +5 -1
- package/dist/components/navigation/index.d.ts.map +1 -1
- package/dist/components/navigation/index.js +23 -1
- package/dist/components/onboarding-guides/index.cjs +18 -18
- package/dist/components/onboarding-guides/index.js +3 -3
- package/dist/components/shared/dev-section/dev-card-row.d.ts +5 -45
- package/dist/components/shared/dev-section/dev-card-row.d.ts.map +1 -1
- package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -1
- package/dist/components/tickets/help-center-card.d.ts.map +1 -1
- package/dist/components/tickets/help-center-list.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +9 -1
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-tickets-list.d.ts +7 -0
- package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +309 -256
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.d.ts +1 -0
- package/dist/components/tickets/index.d.ts.map +1 -1
- package/dist/components/tickets/index.js +376 -323
- package/dist/components/tickets/index.js.map +1 -1
- package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
- package/dist/components/tickets/ticket-reply-composer.d.ts +33 -0
- package/dist/components/tickets/ticket-reply-composer.d.ts.map +1 -0
- package/dist/components/tickets/types.d.ts +13 -0
- package/dist/components/tickets/types.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +6 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +5 -1
- package/dist/components/ui/ticket-attachments-list.d.ts +5 -1
- package/dist/components/ui/ticket-attachments-list.d.ts.map +1 -1
- package/dist/index.cjs +30 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +29 -1
- package/dist/utils/embed-authed-fetch.d.ts +80 -0
- package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
- package/dist/utils/index.cjs +129 -9
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +129 -10
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/scroll-into-view.d.ts +43 -48
- package/dist/utils/scroll-into-view.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/chat-attachment-bar.tsx +58 -22
- package/src/components/chat/chat-input.tsx +68 -29
- package/src/components/chat/chat-message-row.tsx +124 -0
- package/src/components/chat/embeddable-chat.tsx +154 -37
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +601 -23
- package/src/components/chat/hooks/use-slash-commands.ts +10 -1
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +45 -0
- package/src/components/chat/hooks/use-unified-chat.ts +59 -0
- package/src/components/chat/index.ts +1 -0
- package/src/components/chat/types/component.types.ts +8 -1
- package/src/components/chat/types/unified-chat-state.types.ts +116 -0
- package/src/components/navigation/app-header.tsx +23 -0
- package/src/components/navigation/app-layout-drawer.tsx +620 -0
- package/src/components/navigation/app-layout.tsx +65 -26
- package/src/components/navigation/header-mingo-button.tsx +58 -0
- package/src/components/navigation/index.ts +17 -1
- package/src/components/shared/dev-section/dev-card-row.tsx +5 -183
- package/src/components/shared/legal-document/use-legal-docs.ts +5 -1
- package/src/components/tickets/help-center-card.tsx +26 -29
- package/src/components/tickets/help-center-list.tsx +57 -10
- package/src/components/tickets/hooks/use-ticket-engagements.ts +41 -5
- package/src/components/tickets/hooks/use-tickets-list.ts +13 -0
- package/src/components/tickets/index.ts +4 -0
- package/src/components/tickets/ticket-detail-drawer.tsx +144 -200
- package/src/components/tickets/ticket-reply-composer.tsx +195 -0
- package/src/components/tickets/types.ts +14 -0
- package/src/components/ui/ticket-attachments-list.tsx +26 -8
- package/src/stories/AppLayoutDrawer.stories.tsx +228 -0
- package/src/styles/app-globals.css +13 -0
- package/src/utils/.embed-authed-fetch.md +7 -0
- package/src/utils/__tests__/embed-authed-fetch.test.ts +103 -1
- package/src/utils/embed-authed-fetch.ts +247 -7
- package/src/utils/index.ts +5 -1
- package/src/utils/scroll-into-view.ts +127 -53
- package/dist/chunk-CDLYRFDE.js.map +0 -1
- package/dist/chunk-GVNQAGXB.js.map +0 -1
- package/dist/chunk-IH76P5R6.cjs.map +0 -1
- package/dist/chunk-ZGTDUPTW.cjs.map +0 -1
- /package/dist/{chunk-2V4SACHE.js.map → chunk-EKBM4FHK.js.map} +0 -0
- /package/dist/{chunk-VC3ND5RB.js.map → chunk-EWA2NFUR.js.map} +0 -0
- /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
|
|
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
|
|
43
|
-
|
|
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
|
|
48
|
-
|
|
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'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
|
+
})
|