@flamingo-stack/openframe-frontend-core 0.0.690 → 0.0.691-snapshot.20261001154617

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 (186) hide show
  1. package/dist/{chunk-22RSJ57Z.js → chunk-2YVTGPM7.js} +2 -2
  2. package/dist/{chunk-QNWQQ4RT.cjs → chunk-53O6PQ3F.cjs} +7 -7
  3. package/dist/{chunk-QNWQQ4RT.cjs.map → chunk-53O6PQ3F.cjs.map} +1 -1
  4. package/dist/{chunk-54X75QTC.cjs → chunk-5XPK2QGU.cjs} +14 -14
  5. package/dist/{chunk-54X75QTC.cjs.map → chunk-5XPK2QGU.cjs.map} +1 -1
  6. package/dist/{chunk-4HBYEZ3I.cjs → chunk-7BNWMOPM.cjs} +82 -82
  7. package/dist/{chunk-4HBYEZ3I.cjs.map → chunk-7BNWMOPM.cjs.map} +1 -1
  8. package/dist/{chunk-HLEXYTOE.js → chunk-7EPGJOKN.js} +4 -4
  9. package/dist/{chunk-XRSJER2B.js → chunk-A5NHAHV6.js} +4 -4
  10. package/dist/{chunk-P27OGSQL.cjs → chunk-ATREVNIN.cjs} +37 -37
  11. package/dist/{chunk-P27OGSQL.cjs.map → chunk-ATREVNIN.cjs.map} +1 -1
  12. package/dist/{chunk-S5M5SEW2.js → chunk-AVAZ6BD6.js} +5 -5
  13. package/dist/{chunk-DLRE5Y3G.js → chunk-C2SVJVEB.js} +2 -2
  14. package/dist/{chunk-KZXKT5AX.cjs → chunk-CG7OWW7X.cjs} +14 -14
  15. package/dist/{chunk-KZXKT5AX.cjs.map → chunk-CG7OWW7X.cjs.map} +1 -1
  16. package/dist/{chunk-AIXPQT3G.js → chunk-CKO6NKNE.js} +801 -222
  17. package/dist/chunk-CKO6NKNE.js.map +1 -0
  18. package/dist/{chunk-UFXV64WQ.cjs → chunk-CKUPANVU.cjs} +9 -9
  19. package/dist/{chunk-UFXV64WQ.cjs.map → chunk-CKUPANVU.cjs.map} +1 -1
  20. package/dist/{chunk-3ZAIHRQH.cjs → chunk-DKJDO7YM.cjs} +35 -35
  21. package/dist/{chunk-3ZAIHRQH.cjs.map → chunk-DKJDO7YM.cjs.map} +1 -1
  22. package/dist/{chunk-D7BFS7N6.cjs → chunk-DUURFYGS.cjs} +3 -3
  23. package/dist/{chunk-D7BFS7N6.cjs.map → chunk-DUURFYGS.cjs.map} +1 -1
  24. package/dist/{chunk-NMXL34Z2.cjs → chunk-DVSPRY2V.cjs} +3 -3
  25. package/dist/{chunk-NMXL34Z2.cjs.map → chunk-DVSPRY2V.cjs.map} +1 -1
  26. package/dist/{chunk-VLWUKFQE.cjs → chunk-EXWS2QSD.cjs} +1301 -722
  27. package/dist/chunk-EXWS2QSD.cjs.map +1 -0
  28. package/dist/{chunk-5CBQBTGN.cjs → chunk-F6LXGP6S.cjs} +7 -7
  29. package/dist/{chunk-5CBQBTGN.cjs.map → chunk-F6LXGP6S.cjs.map} +1 -1
  30. package/dist/{chunk-MDYTAKJ5.cjs → chunk-FTK3S57R.cjs} +22 -22
  31. package/dist/{chunk-MDYTAKJ5.cjs.map → chunk-FTK3S57R.cjs.map} +1 -1
  32. package/dist/{chunk-UAKWSR3U.js → chunk-GDZ7IC5B.js} +2 -2
  33. package/dist/{chunk-OD2WMUAO.cjs → chunk-GZRU2A4L.cjs} +197 -197
  34. package/dist/{chunk-OD2WMUAO.cjs.map → chunk-GZRU2A4L.cjs.map} +1 -1
  35. package/dist/{chunk-Q76YVNCQ.js → chunk-HM7ZGABI.js} +2 -2
  36. package/dist/{chunk-YCKGUQA2.cjs → chunk-I27PWVWE.cjs} +17 -12
  37. package/dist/chunk-I27PWVWE.cjs.map +1 -0
  38. package/dist/{chunk-TSENCZUL.js → chunk-IQTQIBZ3.js} +11 -11
  39. package/dist/{chunk-QUGV2GT3.cjs → chunk-KDCIYRZV.cjs} +34 -34
  40. package/dist/{chunk-QUGV2GT3.cjs.map → chunk-KDCIYRZV.cjs.map} +1 -1
  41. package/dist/{chunk-2MGTNYHW.cjs → chunk-KQZMM3QJ.cjs} +100 -100
  42. package/dist/{chunk-2MGTNYHW.cjs.map → chunk-KQZMM3QJ.cjs.map} +1 -1
  43. package/dist/{chunk-BJHM2OEY.cjs → chunk-LNKLB7GM.cjs} +17 -17
  44. package/dist/{chunk-BJHM2OEY.cjs.map → chunk-LNKLB7GM.cjs.map} +1 -1
  45. package/dist/{chunk-Z622ACFD.js → chunk-LYJJH4D6.js} +5 -5
  46. package/dist/{chunk-42RQ4NIL.js → chunk-LYQULSK4.js} +6 -6
  47. package/dist/{chunk-TBJWPNHU.js → chunk-MCNQKS6I.js} +4 -4
  48. package/dist/chunk-MCNQKS6I.js.map +1 -0
  49. package/dist/{chunk-JGVJRUGB.cjs → chunk-MIZU2OTO.cjs} +4 -4
  50. package/dist/{chunk-JGVJRUGB.cjs.map → chunk-MIZU2OTO.cjs.map} +1 -1
  51. package/dist/{chunk-BOM6MMWB.cjs → chunk-N3526CTH.cjs} +3 -3
  52. package/dist/{chunk-BOM6MMWB.cjs.map → chunk-N3526CTH.cjs.map} +1 -1
  53. package/dist/{chunk-2PQKZWWP.js → chunk-PKZGXRU2.js} +6 -6
  54. package/dist/{chunk-FCQR6VY4.cjs → chunk-Q25VYAEL.cjs} +26 -26
  55. package/dist/{chunk-FCQR6VY4.cjs.map → chunk-Q25VYAEL.cjs.map} +1 -1
  56. package/dist/{chunk-25X43LSD.js → chunk-RK47E3PP.js} +4 -4
  57. package/dist/{chunk-V5NOURSR.js → chunk-RLNNV46K.js} +5 -5
  58. package/dist/{chunk-ZBQZJAC7.cjs → chunk-RTKF5AYA.cjs} +7 -7
  59. package/dist/chunk-RTKF5AYA.cjs.map +1 -0
  60. package/dist/{chunk-RF2EYX3B.js → chunk-SC44YJCF.js} +2 -2
  61. package/dist/{chunk-2OD2PPNO.cjs → chunk-SDORFSFK.cjs} +37 -37
  62. package/dist/{chunk-2OD2PPNO.cjs.map → chunk-SDORFSFK.cjs.map} +1 -1
  63. package/dist/{chunk-5A3WSWZD.js → chunk-TEIHEFXP.js} +8 -8
  64. package/dist/{chunk-U4OJCJTH.js → chunk-TXFGG47A.js} +4 -4
  65. package/dist/{chunk-DMXI5D2Q.cjs → chunk-USKQURNF.cjs} +3 -3
  66. package/dist/{chunk-DMXI5D2Q.cjs.map → chunk-USKQURNF.cjs.map} +1 -1
  67. package/dist/{chunk-2KILTCND.js → chunk-UVUNBXS6.js} +6 -1
  68. package/dist/chunk-UVUNBXS6.js.map +1 -0
  69. package/dist/{chunk-X4WBGDNW.cjs → chunk-WTYQCT4A.cjs} +18 -18
  70. package/dist/{chunk-X4WBGDNW.cjs.map → chunk-WTYQCT4A.cjs.map} +1 -1
  71. package/dist/{chunk-OC3QH5WG.js → chunk-X5XFKI6N.js} +2 -2
  72. package/dist/{chunk-2V4KPBNZ.js → chunk-XAOAYDGR.js} +3 -3
  73. package/dist/{chunk-4BUPMSLY.js → chunk-Y7ZEVTLC.js} +3 -3
  74. package/dist/{chunk-DOUG4UEZ.js → chunk-Z7QCNCBN.js} +11 -11
  75. package/dist/{chunk-OQVHGPHA.js → chunk-ZAV2F4VX.js} +3 -3
  76. package/dist/components/case-studies/index.cjs +20 -20
  77. package/dist/components/case-studies/index.js +7 -7
  78. package/dist/components/chat/index.cjs +4 -4
  79. package/dist/components/chat/index.js +3 -3
  80. package/dist/components/contact/index.cjs +8 -8
  81. package/dist/components/contact/index.js +7 -7
  82. package/dist/components/docs/index.cjs +11 -11
  83. package/dist/components/docs/index.js +10 -10
  84. package/dist/components/embeds/index.cjs +8 -8
  85. package/dist/components/embeds/index.js +7 -7
  86. package/dist/components/faq/index.cjs +9 -9
  87. package/dist/components/faq/index.js +8 -8
  88. package/dist/components/features/index.cjs +39 -7
  89. package/dist/components/features/index.cjs.map +1 -1
  90. package/dist/components/features/index.d.ts +1 -0
  91. package/dist/components/features/index.d.ts.map +1 -1
  92. package/dist/components/features/index.js +38 -6
  93. package/dist/components/features/remote-session/index.d.ts +13 -0
  94. package/dist/components/features/remote-session/index.d.ts.map +1 -0
  95. package/dist/components/features/remote-session/remote-access-request-card.d.ts +19 -0
  96. package/dist/components/features/remote-session/remote-access-request-card.d.ts.map +1 -0
  97. package/dist/components/features/remote-session/remote-session-block.d.ts +41 -0
  98. package/dist/components/features/remote-session/remote-session-block.d.ts.map +1 -0
  99. package/dist/components/features/remote-session/remote-session-chat-panel.d.ts +23 -0
  100. package/dist/components/features/remote-session/remote-session-chat-panel.d.ts.map +1 -0
  101. package/dist/components/features/remote-session/remote-session-dialog.d.ts +15 -0
  102. package/dist/components/features/remote-session/remote-session-dialog.d.ts.map +1 -0
  103. package/dist/components/features/remote-session/remote-session-overlay.d.ts +12 -0
  104. package/dist/components/features/remote-session/remote-session-overlay.d.ts.map +1 -0
  105. package/dist/components/features/remote-session/remote-session-parts.d.ts +34 -0
  106. package/dist/components/features/remote-session/remote-session-parts.d.ts.map +1 -0
  107. package/dist/components/features/remote-session/types.d.ts +33 -0
  108. package/dist/components/features/remote-session/types.d.ts.map +1 -0
  109. package/dist/components/help-center-pages/index.cjs +101 -101
  110. package/dist/components/help-center-pages/index.js +21 -21
  111. package/dist/components/index.cjs +97 -65
  112. package/dist/components/index.cjs.map +1 -1
  113. package/dist/components/index.js +53 -21
  114. package/dist/components/index.js.map +1 -1
  115. package/dist/components/meeting-scheduler/index.cjs +58 -58
  116. package/dist/components/meeting-scheduler/index.js +8 -8
  117. package/dist/components/navigation/index.cjs +10 -10
  118. package/dist/components/navigation/index.js +9 -9
  119. package/dist/components/onboarding-guides/index.cjs +11 -11
  120. package/dist/components/onboarding-guides/index.js +10 -10
  121. package/dist/components/related-content/index.cjs +6 -6
  122. package/dist/components/related-content/index.js +5 -5
  123. package/dist/components/tickets/index.cjs +12 -12
  124. package/dist/components/tickets/index.js +11 -11
  125. package/dist/components/tickets/ticket-live-provider.d.ts.map +1 -1
  126. package/dist/components/ui/index.cjs +7 -7
  127. package/dist/components/ui/index.js +6 -6
  128. package/dist/hooks/index.cjs +4 -4
  129. package/dist/hooks/index.js +3 -3
  130. package/dist/index.cjs +44 -12
  131. package/dist/index.cjs.map +1 -1
  132. package/dist/index.js +42 -10
  133. package/dist/index.js.map +1 -1
  134. package/dist/utils/embed-authed-fetch.d.ts +18 -0
  135. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  136. package/dist/utils/index.cjs +50 -6
  137. package/dist/utils/index.cjs.map +1 -1
  138. package/dist/utils/index.js +50 -6
  139. package/dist/utils/index.js.map +1 -1
  140. package/dist/utils/sse-subscription.d.ts +29 -11
  141. package/dist/utils/sse-subscription.d.ts.map +1 -1
  142. package/package.json +1 -1
  143. package/src/components/features/__tests__/remote-session.test.tsx +97 -0
  144. package/src/components/features/index.ts +1 -0
  145. package/src/components/features/remote-session/index.ts +36 -0
  146. package/src/components/features/remote-session/remote-access-request-card.tsx +102 -0
  147. package/src/components/features/remote-session/remote-session-block.tsx +182 -0
  148. package/src/components/features/remote-session/remote-session-chat-panel.tsx +178 -0
  149. package/src/components/features/remote-session/remote-session-dialog.tsx +77 -0
  150. package/src/components/features/remote-session/remote-session-overlay.tsx +33 -0
  151. package/src/components/features/remote-session/remote-session-parts.tsx +138 -0
  152. package/src/components/features/remote-session/types.ts +41 -0
  153. package/src/components/tickets/ticket-live-provider.tsx +14 -12
  154. package/src/stories/RemoteSessionAccessRequest.stories.tsx +71 -0
  155. package/src/stories/RemoteSessionBlock.stories.tsx +164 -0
  156. package/src/stories/RemoteSessionDialog.stories.tsx +49 -0
  157. package/src/stories/RemoteSessionIndicator.stories.tsx +44 -0
  158. package/src/utils/embed-authed-fetch.ts +21 -0
  159. package/src/utils/sse-subscription.ts +107 -20
  160. package/dist/chunk-2KILTCND.js.map +0 -1
  161. package/dist/chunk-AIXPQT3G.js.map +0 -1
  162. package/dist/chunk-TBJWPNHU.js.map +0 -1
  163. package/dist/chunk-VLWUKFQE.cjs.map +0 -1
  164. package/dist/chunk-YCKGUQA2.cjs.map +0 -1
  165. package/dist/chunk-ZBQZJAC7.cjs.map +0 -1
  166. /package/dist/{chunk-22RSJ57Z.js.map → chunk-2YVTGPM7.js.map} +0 -0
  167. /package/dist/{chunk-HLEXYTOE.js.map → chunk-7EPGJOKN.js.map} +0 -0
  168. /package/dist/{chunk-XRSJER2B.js.map → chunk-A5NHAHV6.js.map} +0 -0
  169. /package/dist/{chunk-S5M5SEW2.js.map → chunk-AVAZ6BD6.js.map} +0 -0
  170. /package/dist/{chunk-DLRE5Y3G.js.map → chunk-C2SVJVEB.js.map} +0 -0
  171. /package/dist/{chunk-UAKWSR3U.js.map → chunk-GDZ7IC5B.js.map} +0 -0
  172. /package/dist/{chunk-Q76YVNCQ.js.map → chunk-HM7ZGABI.js.map} +0 -0
  173. /package/dist/{chunk-TSENCZUL.js.map → chunk-IQTQIBZ3.js.map} +0 -0
  174. /package/dist/{chunk-Z622ACFD.js.map → chunk-LYJJH4D6.js.map} +0 -0
  175. /package/dist/{chunk-42RQ4NIL.js.map → chunk-LYQULSK4.js.map} +0 -0
  176. /package/dist/{chunk-2PQKZWWP.js.map → chunk-PKZGXRU2.js.map} +0 -0
  177. /package/dist/{chunk-25X43LSD.js.map → chunk-RK47E3PP.js.map} +0 -0
  178. /package/dist/{chunk-V5NOURSR.js.map → chunk-RLNNV46K.js.map} +0 -0
  179. /package/dist/{chunk-RF2EYX3B.js.map → chunk-SC44YJCF.js.map} +0 -0
  180. /package/dist/{chunk-5A3WSWZD.js.map → chunk-TEIHEFXP.js.map} +0 -0
  181. /package/dist/{chunk-U4OJCJTH.js.map → chunk-TXFGG47A.js.map} +0 -0
  182. /package/dist/{chunk-OC3QH5WG.js.map → chunk-X5XFKI6N.js.map} +0 -0
  183. /package/dist/{chunk-2V4KPBNZ.js.map → chunk-XAOAYDGR.js.map} +0 -0
  184. /package/dist/{chunk-4BUPMSLY.js.map → chunk-Y7ZEVTLC.js.map} +0 -0
  185. /package/dist/{chunk-DOUG4UEZ.js.map → chunk-Z7QCNCBN.js.map} +0 -0
  186. /package/dist/{chunk-OQVHGPHA.js.map → chunk-ZAV2F4VX.js.map} +0 -0
@@ -0,0 +1,164 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2
+ import type React from 'react';
3
+ import { useState } from 'react';
4
+ import { fn } from 'storybook/test';
5
+
6
+ import {
7
+ RemoteSessionBlockFrame,
8
+ RemoteSessionChatPanel,
9
+ RemoteSessionMinimizedChip,
10
+ RemoteSessionScope,
11
+ RemoteSessionSummary,
12
+ type RemoteSessionChatMessage,
13
+ type RemoteSessionParty,
14
+ } from '../components/features/remote-session';
15
+
16
+ /**
17
+ * The session block the end user sees while a technician is connected: the
18
+ * summary, the chat with the technician, and the minimized chip. In the
19
+ * desktop chat it lives in its own 440 px window the user can drag around.
20
+ */
21
+
22
+ const organization: RemoteSessionParty = { organizationName: 'TechFlow Solutions' };
23
+ const technician: RemoteSessionParty = { ...organization, technicianName: 'Mike Rodriguez' };
24
+
25
+ const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000);
26
+
27
+ const messages: RemoteSessionChatMessage[] = [
28
+ { id: 'm1', author: 'system', text: 'Mike Rodriguez joined the session', at: minutesAgo(6) },
29
+ { id: 'm2', author: 'technician', text: 'Hi! I can see your screen now.', at: minutesAgo(5) },
30
+ { id: 'm3', author: 'user', text: 'Great, the printer is the one on the left.', at: minutesAgo(4) },
31
+ { id: 'm4', author: 'technician', text: 'Got it - reinstalling the driver now.', at: minutesAgo(2) },
32
+ ];
33
+
34
+ function WindowDecorator(Story: React.ComponentType) {
35
+ return (
36
+ <RemoteSessionScope>
37
+ <Story />
38
+ </RemoteSessionScope>
39
+ );
40
+ }
41
+
42
+ const meta = {
43
+ title: 'Remote Session/Client/Session Block',
44
+ component: RemoteSessionSummary,
45
+ tags: ['autodocs'],
46
+ decorators: [WindowDecorator],
47
+ args: {
48
+ party: technician,
49
+ elapsed: '5:23',
50
+ showRecordingTag: true,
51
+ onOpenChat: fn(),
52
+ onEndSession: fn(),
53
+ },
54
+ render: args => (
55
+ <RemoteSessionBlockFrame onHide={fn()}>
56
+ <RemoteSessionSummary {...args} dragHandlers={{ onPointerDown: fn() }} />
57
+ </RemoteSessionBlockFrame>
58
+ ),
59
+ } satisfies Meta<typeof RemoteSessionSummary>;
60
+
61
+ export default meta;
62
+ type Story = StoryObj<typeof meta>;
63
+
64
+ /** A named technician: avatar and "<name> connected". */
65
+ export const Technician: Story = {};
66
+
67
+ /** No technician name on the session: the organization's logo and "IT support team is connected". */
68
+ export const Organization: Story = {
69
+ args: { party: organization },
70
+ };
71
+
72
+ /** Not recorded: no SESSION RECORDING tag. */
73
+ export const NotRecorded: Story = {
74
+ args: { showRecordingTag: false },
75
+ };
76
+
77
+ /** A long name truncates before the dragger in the corner. */
78
+ export const LongName: Story = {
79
+ args: { party: { ...technician, technicianName: 'Maximilian Alexander Rodriguez-Fernandez' } },
80
+ };
81
+
82
+ /** The chat with the technician, opened from the block. */
83
+ export const Chat: Story = {
84
+ render: args => {
85
+ const [draft, setDraft] = useState('');
86
+ return (
87
+ <RemoteSessionBlockFrame expanded onHide={fn()}>
88
+ <RemoteSessionChatPanel
89
+ party={args.party}
90
+ elapsed={args.elapsed}
91
+ showRecordingTag={args.showRecordingTag}
92
+ messages={messages}
93
+ draft={draft}
94
+ onDraftChange={setDraft}
95
+ onSend={fn()}
96
+ onCloseChat={fn()}
97
+ onEndSession={args.onEndSession}
98
+ dragHandlers={{ onPointerDown: fn() }}
99
+ />
100
+ </RemoteSessionBlockFrame>
101
+ );
102
+ },
103
+ };
104
+
105
+ /** The chat while it is still connecting: the composer is disabled. */
106
+ export const ChatConnecting: Story = {
107
+ render: args => (
108
+ <RemoteSessionBlockFrame expanded onHide={fn()}>
109
+ <RemoteSessionChatPanel
110
+ party={args.party}
111
+ elapsed={args.elapsed}
112
+ messages={[]}
113
+ ready={false}
114
+ draft=""
115
+ onDraftChange={fn()}
116
+ onSend={fn()}
117
+ onCloseChat={fn()}
118
+ onEndSession={args.onEndSession}
119
+ />
120
+ </RemoteSessionBlockFrame>
121
+ ),
122
+ };
123
+
124
+ /** "Hide this Block": the technician's avatar and a chevron to bring the block back. */
125
+ export const MinimizedTechnician: Story = {
126
+ render: args => <RemoteSessionMinimizedChip party={args.party} onExpand={fn()} />,
127
+ };
128
+
129
+ /** The minimized chip of the organization variant. */
130
+ export const MinimizedOrganization: Story = {
131
+ render: () => <RemoteSessionMinimizedChip party={organization} onExpand={fn()} />,
132
+ };
133
+
134
+ /** The whole block, switching between summary, chat and the chip like the desktop window does. */
135
+ export const Interactive: Story = {
136
+ render: args => {
137
+ const [panel, setPanel] = useState<'summary' | 'chat'>('summary');
138
+ const [minimized, setMinimized] = useState(false);
139
+ const [draft, setDraft] = useState('');
140
+ const [thread, setThread] = useState(messages);
141
+ if (minimized) return <RemoteSessionMinimizedChip party={args.party} onExpand={() => setMinimized(false)} />;
142
+ return (
143
+ <RemoteSessionBlockFrame expanded={panel === 'chat'} onHide={() => setMinimized(true)}>
144
+ {panel === 'summary' ? (
145
+ <RemoteSessionSummary {...args} onOpenChat={() => setPanel('chat')} />
146
+ ) : (
147
+ <RemoteSessionChatPanel
148
+ party={args.party}
149
+ elapsed={args.elapsed}
150
+ showRecordingTag={args.showRecordingTag}
151
+ messages={thread}
152
+ draft={draft}
153
+ onDraftChange={setDraft}
154
+ onSend={text =>
155
+ setThread(current => [...current, { id: `m${current.length + 1}`, author: 'user', text, at: new Date() }])
156
+ }
157
+ onCloseChat={() => setPanel('summary')}
158
+ onEndSession={args.onEndSession}
159
+ />
160
+ )}
161
+ </RemoteSessionBlockFrame>
162
+ );
163
+ },
164
+ };
@@ -0,0 +1,49 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2
+ import type React from 'react';
3
+ import { fn } from 'storybook/test';
4
+
5
+ import { RemoteSessionDialog, RemoteSessionScope } from '../components/features/remote-session';
6
+
7
+ /** The session's terminal dialogs, each shown by the desktop chat in its own 600 px window. */
8
+
9
+ function WindowDecorator(Story: React.ComponentType) {
10
+ return (
11
+ <RemoteSessionScope className="w-[600px]">
12
+ <Story />
13
+ </RemoteSessionScope>
14
+ );
15
+ }
16
+
17
+ const meta = {
18
+ title: 'Remote Session/Client/Dialogs',
19
+ component: RemoteSessionDialog,
20
+ tags: ['autodocs'],
21
+ decorators: [WindowDecorator],
22
+ args: {
23
+ variant: 'end-confirm',
24
+ organizationName: 'TechFlow Solutions',
25
+ onEndSession: fn(),
26
+ onClose: fn(),
27
+ },
28
+ } satisfies Meta<typeof RemoteSessionDialog>;
29
+
30
+ export default meta;
31
+ type Story = StoryObj<typeof meta>;
32
+
33
+ /** End Session from the block or the pill asks first. */
34
+ export const EndConfirm: Story = {};
35
+
36
+ /** The end call is in flight. */
37
+ export const Ending: Story = {
38
+ args: { ending: true },
39
+ };
40
+
41
+ /** The technician (or the user) ended the session. */
42
+ export const SessionEnded: Story = {
43
+ args: { variant: 'session-ended' },
44
+ };
45
+
46
+ /** The relay dropped: the session is over, there is no rejoin. */
47
+ export const ConnectionLost: Story = {
48
+ args: { variant: 'connection-lost' },
49
+ };
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2
+ import { fn } from 'storybook/test';
3
+
4
+ import { RemoteSessionBorder, RemoteSessionPill, RemoteSessionScope } from '../components/features/remote-session';
5
+
6
+ /**
7
+ * The always-visible "your screen is being controlled" indicator: the yellow
8
+ * border around every screen and the End Session pill hanging from its top
9
+ * edge. The desktop chat draws each in its own click-through window.
10
+ */
11
+
12
+ const meta = {
13
+ title: 'Remote Session/Client/Screen Indicator',
14
+ component: RemoteSessionPill,
15
+ tags: ['autodocs'],
16
+ args: { onEndSession: fn() },
17
+ decorators: [
18
+ Story => (
19
+ <RemoteSessionScope>
20
+ <Story />
21
+ </RemoteSessionScope>
22
+ ),
23
+ ],
24
+ } satisfies Meta<typeof RemoteSessionPill>;
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ /** The pill on its own. */
30
+ export const Pill: Story = {};
31
+
32
+ /** Border and pill together, on a screen-sized stand-in. */
33
+ export const Screen: Story = {
34
+ render: args => (
35
+ <div className="relative h-[360px] w-[640px] bg-ods-bg">
36
+ <div className="absolute inset-0">
37
+ <RemoteSessionBorder />
38
+ </div>
39
+ <div className="absolute inset-x-0 top-0 flex justify-center">
40
+ <RemoteSessionPill {...args} />
41
+ </div>
42
+ </div>
43
+ ),
44
+ };
@@ -99,6 +99,16 @@ export interface EmbedAuthAdapter {
99
99
  * remains the rule, this is the narrow exception.
100
100
  */
101
101
  allowedOrigins?: string[];
102
+ /**
103
+ * Optional: call `listener` whenever `getHeaders()` starts returning a NEW
104
+ * credential (a rotation the host ran on its own — e.g. a native shell's
105
+ * background refresh). Returns an unsubscribe. Lets a long-lived surface
106
+ * that stopped after a 401 retry as soon as a usable credential exists,
107
+ * instead of on its next backoff step. Fire only on a real change: a
108
+ * listener may respond with a request, and through `refresh` that request
109
+ * can lead the host to notify again.
110
+ */
111
+ subscribe?: (listener: () => void) => () => void;
102
112
  }
103
113
 
104
114
  /**
@@ -156,6 +166,17 @@ export function hasEmbedAuthAdapter(): boolean {
156
166
  return getRegisteredAuthAdapter() !== null;
157
167
  }
158
168
 
169
+ /**
170
+ * Subscribe to the registered adapter's credential changes
171
+ * (`EmbedAuthAdapter.subscribe`). A no-op when no adapter is registered, or
172
+ * the adapter doesn't report changes. Reads the adapter once, at call time:
173
+ * an adapter registered (or replaced) after a subscriber starts is not
174
+ * picked up — register it before the surfaces that need it mount.
175
+ */
176
+ export function subscribeEmbedCredentialChange(listener: () => void): () => void {
177
+ return getRegisteredAuthAdapter()?.subscribe?.(listener) ?? (() => {});
178
+ }
179
+
159
180
  /**
160
181
  * Whether `url` is an asset the browser CANNOT load natively because its
161
182
  * auth rides in request headers.
@@ -16,11 +16,26 @@
16
16
  * (INCLUDING `: keepalive` comment lines — they reset the timer
17
17
  * before frame parsing drops them) count as life. Silence beyond
18
18
  * `silenceTimeoutMs` (default 45s = 3× keepalive) aborts + reconnects.
19
- * - Terminal responses (NO retry): any 4xx except 408/429 — the
20
- * `x-block-layer` header, when readable, is logged for attribution
21
- * only, never used as a retry gate (proxies may strip it); and
22
- * 204 → a distinct `no-stream` status (the caller decides when to
23
- * try again). Backoff-retry is for transport errors, 408/429, and 5xx.
19
+ * - Terminal responses: any 4xx except 408/429 — the `x-block-layer`
20
+ * header, when readable, is logged for attribution only, never used
21
+ * as a retry gate (proxies may strip it); and 204 → a distinct
22
+ * `no-stream` status (the caller decides when to try again).
23
+ * Backoff-retry is for transport errors, 408/429, and 5xx.
24
+ * - Long-lived streams (`pauseWhenHidden`) never stay terminal, because
25
+ * a page a desktop shell keeps open for days would otherwise stay
26
+ * dead until reloaded:
27
+ * · 401 is retried on a slow timer (30s doubling to 5min, plus
28
+ * jitter). The default fetch already refreshed + retried once, so
29
+ * this is a session the host could not renew yet — and every
30
+ * attempt makes it try again, which rotates a refresh token. So
31
+ * visible / focus do NOT shortcut this timer. What does: a new
32
+ * credential reported by the host (`EmbedAuthAdapter.subscribe`,
33
+ * default fetch only), plus the unconditional reconnect paths
34
+ * (`online`, resuming after a suspend, `reconnectNow()`).
35
+ * · Any other terminal 4xx is retried on visible / window focus, no
36
+ * sooner than 30s after the last connect attempt.
37
+ * Finite streams keep the terminal behavior: their monitors have no
38
+ * auth adapter to recover with and must reach a final state.
24
39
  *
25
40
  * THE common SSE client for lib + hosts — exported from the `utils`
26
41
  * barrel. Consumers: `TicketLiveProvider` (lib) and the hub's
@@ -31,10 +46,11 @@
31
46
  * never-terminating reconnect loop re-opens the finished stream.
32
47
  */
33
48
 
34
- import { embedAuthedFetch } from './embed-authed-fetch';
49
+ import { embedAuthedFetch, subscribeEmbedCredentialChange } from './embed-authed-fetch';
35
50
 
36
51
  /** Transport-level status. `suspended` = paused by `pauseWhenHidden`
37
- * after the hidden grace elapsed (resumes automatically on visible). */
52
+ * after the hidden grace elapsed (resumes automatically on visible).
53
+ * `terminal` is final only without `pauseWhenHidden`. */
38
54
  export type SseTransportStatus =
39
55
  'connecting' | 'open' | 'reconnecting' | 'no-stream' | 'terminal' | 'suspended' | 'closed';
40
56
 
@@ -64,10 +80,11 @@ export interface SseSubscriptionOptions {
64
80
  /**
65
81
  * Pause the subscription while the tab is hidden (45s grace so
66
82
  * Cmd-Tab thrash doesn't churn connections), resume + reconnect on
67
- * visible, and reconnect on `online`. OPT-IN — short-lived streams
68
- * (workflow/invocation monitors) must keep running in background
69
- * tabs. Long-lived per-user streams should enable it (scale relief:
70
- * a hidden tab holds no server invocation).
83
+ * visible, and reconnect on `online`. Also makes the stream
84
+ * self-healing after a 401 or terminal 4xx (see the module header).
85
+ * OPT-IN — short-lived streams (workflow/invocation monitors) must
86
+ * keep running in background tabs. Long-lived per-user streams should
87
+ * enable it (scale relief: a hidden tab holds no server invocation).
71
88
  */
72
89
  pauseWhenHidden?: boolean;
73
90
  /** Silence threshold before the connection is presumed dead. MUST be
@@ -82,7 +99,8 @@ export interface SseSubscription {
82
99
  /** Permanently stop — no further reconnects, no callbacks. */
83
100
  close: () => void;
84
101
  /** Drop the current connection (if any) and reconnect immediately,
85
- * resetting backoff. No-op after `close()`. */
102
+ * resetting the transport backoff (a 401 backoff carries over until a
103
+ * successful open). No-op after `close()`. */
86
104
  reconnectNow: () => void;
87
105
  }
88
106
 
@@ -102,6 +120,13 @@ const SERVER_RETRY_GRACE_MS = 90_000;
102
120
  const FAILED_RECONNECT_DELAY_MS = 30_000;
103
121
  /** Grace before a hidden tab's subscription is suspended. */
104
122
  const HIDDEN_GRACE_MS = 45_000;
123
+ /** 401 retry backoff for long-lived streams — slow on purpose, see the
124
+ * module header. */
125
+ const AUTH_RETRY_INITIAL_MS = 30_000;
126
+ const AUTH_RETRY_MAX_MS = 300_000;
127
+ /** Minimum time since the last connect attempt before a presence signal
128
+ * retries a terminal stream — focus can fire many times a minute. */
129
+ const PRESENCE_RETRY_MIN_INTERVAL_MS = 30_000;
105
130
 
106
131
  export function createSseSubscription(options: SseSubscriptionOptions): SseSubscription {
107
132
  const {
@@ -119,6 +144,18 @@ export function createSseSubscription(options: SseSubscriptionOptions): SseSubsc
119
144
  let closed = false;
120
145
  let suspended = false;
121
146
  let attempt = 0;
147
+ let authAttempt = 0;
148
+ /** Stopped on a terminal response — what a presence signal retries.
149
+ * Cleared by every connect(). */
150
+ let stalled = false;
151
+ /** A 401 retry timer is pending — the only state a host credential change
152
+ * acts on, so routine rotations never tear down a live stream. Cleared by
153
+ * every connect(): a change reported while a request is in flight is
154
+ * indistinguishable from the default fetch's own refresh, so it is not
155
+ * acted on (a 401 that follows waits for the next backoff step) — acting
156
+ * on it would let a 401 → refresh → notify cycle loop. */
157
+ let awaitingAuth = false;
158
+ let lastConnectAt = 0;
122
159
  /** Connection generation — bumped by each connect() and by
123
160
  * reconnectNow(). A stale loop (aborted, still unwinding) compares
124
161
  * its captured generation before scheduling a reconnect, so an
@@ -223,7 +260,8 @@ export function createSseSubscription(options: SseSubscriptionOptions): SseSubsc
223
260
  confirmTimerId = clearLifecycleTimer(confirmTimerId);
224
261
  serverGraceTimerId = clearLifecycleTimer(serverGraceTimerId);
225
262
  failedDelayTimerId = clearLifecycleTimer(failedDelayTimerId);
226
- hiddenGraceTimerId = clearLifecycleTimer(hiddenGraceTimerId);
263
+ // NOT the hidden-grace timer: it tracks the page's visibility, not this
264
+ // connection, and a reconnect while hidden must still suspend on time.
227
265
  };
228
266
 
229
267
  const armSilenceTimer = () => {
@@ -235,16 +273,29 @@ export function createSseSubscription(options: SseSubscriptionOptions): SseSubsc
235
273
  }, silenceTimeoutMs);
236
274
  };
237
275
 
238
- const scheduleReconnect = () => {
276
+ /** `delayMs` overrides the transport backoff (auth-failure pacing). */
277
+ const scheduleReconnect = (delayMs?: number) => {
239
278
  if (closed || suspended || retryTimerId) return;
240
- setStatus('reconnecting');
241
- const backoff = Math.min(maxBackoffMs, initialBackoffMs * 2 ** attempt);
279
+ if (failedDelayTimerId) {
280
+ // A server `reconnect_failed` already scheduled the next attempt at the
281
+ // no-stampede delay. A transport retry here would jump that queue, and
282
+ // the pending timer would then abort the connection it opened.
283
+ setStatus('reconnecting');
284
+ return;
285
+ }
286
+ let backoff = delayMs;
287
+ if (backoff === undefined) {
288
+ backoff = Math.min(maxBackoffMs, initialBackoffMs * 2 ** attempt);
289
+ attempt += 1;
290
+ }
242
291
  const jitter = backoff * 0.25 * Math.random();
243
- attempt += 1;
244
292
  retryTimerId = setTimeout(() => {
245
293
  retryTimerId = null;
246
294
  void connect();
247
295
  }, backoff + jitter);
296
+ // After arming: a status callback that calls reconnectNow() must find
297
+ // the timer to clear, or it would later fire behind a live stream.
298
+ setStatus('reconnecting');
248
299
  };
249
300
 
250
301
  const parseFrame = (rawFrame: string) => {
@@ -285,6 +336,9 @@ export function createSseSubscription(options: SseSubscriptionOptions): SseSubsc
285
336
  async function connect(): Promise<void> {
286
337
  if (closed || suspended) return;
287
338
  const gen = ++generation;
339
+ stalled = false;
340
+ awaitingAuth = false;
341
+ lastConnectAt = Date.now();
288
342
  setStatus('connecting');
289
343
  abortController = new AbortController();
290
344
 
@@ -316,12 +370,23 @@ export function createSseSubscription(options: SseSubscriptionOptions): SseSubsc
316
370
  scheduleReconnect();
317
371
  return;
318
372
  }
319
- // Terminal 4xx. x-block-layer (proxy surface block) is logged for
320
- // attribution when readable — never gates the decision.
373
+ // x-block-layer (proxy surface block) is logged for attribution
374
+ // when readable — never gates the decision.
321
375
  const blockLayer = response.headers.get('x-block-layer');
376
+ const blockNote = blockLayer ? ` (x-block-layer: ${blockLayer})` : '';
377
+ if (response.status === 401 && pauseWhenHidden) {
378
+ const delayMs = Math.min(AUTH_RETRY_MAX_MS, AUTH_RETRY_INITIAL_MS * 2 ** authAttempt);
379
+ authAttempt += 1;
380
+ console.warn(`[sse-subscription] 401 for ${url}${blockNote} — retrying in ~${Math.round(delayMs / 1000)}s`);
381
+ awaitingAuth = true;
382
+ scheduleReconnect(delayMs);
383
+ return;
384
+ }
322
385
  console.warn(
323
- `[sse-subscription] terminal ${response.status} for ${url}${blockLayer ? ` (x-block-layer: ${blockLayer})` : ''} — not retrying`,
386
+ `[sse-subscription] terminal ${response.status} for ${url}${blockNote} — ${pauseWhenHidden ? 'retrying on presence' : 'not retrying'}`,
324
387
  );
388
+ // Before the status callback, which may re-enter via reconnectNow().
389
+ stalled = pauseWhenHidden;
325
390
  setStatus('terminal');
326
391
  return;
327
392
  }
@@ -332,6 +397,7 @@ export function createSseSubscription(options: SseSubscriptionOptions): SseSubsc
332
397
 
333
398
  // Successful open — reset backoff, start liveness accounting.
334
399
  attempt = 0;
400
+ authAttempt = 0;
335
401
  setStatus('open');
336
402
  armSilenceTimer();
337
403
  // Transport open ≠ connected: require the server's `subscribed`
@@ -418,6 +484,12 @@ export function createSseSubscription(options: SseSubscriptionOptions): SseSubsc
418
484
  abortController?.abort();
419
485
  setStatus('suspended');
420
486
  };
487
+ /** A person is back at the page: the moment a fixed backend or a
488
+ * restored permission can be noticed without a reload. */
489
+ const retryIfStalled = () => {
490
+ if (!stalled || Date.now() - lastConnectAt < PRESENCE_RETRY_MIN_INTERVAL_MS) return;
491
+ doReconnect();
492
+ };
421
493
  const onVisibilityChange = () => {
422
494
  if (document.visibilityState === 'hidden') {
423
495
  if (!hiddenGraceTimerId) {
@@ -435,15 +507,27 @@ export function createSseSubscription(options: SseSubscriptionOptions): SseSubsc
435
507
  if (suspended) {
436
508
  suspended = false;
437
509
  void connect();
510
+ return;
438
511
  }
512
+ retryIfStalled();
439
513
  };
440
514
  const onOnline = () => {
441
515
  if (!suspended) doReconnect();
442
516
  };
517
+ // Only requests that carry the adapter's credentials can be rescued by a
518
+ // new one, and only long-lived streams ever wait out a 401. Subscribed
519
+ // before the DOM listeners, so a throwing host adapter leaks none of them.
520
+ const unsubscribeCredentials =
521
+ pauseWhenHidden && fetchImpl === embedAuthedFetch
522
+ ? subscribeEmbedCredentialChange(() => {
523
+ if (awaitingAuth) doReconnect();
524
+ })
525
+ : null;
443
526
  const listenersActive = pauseWhenHidden && typeof document !== 'undefined' && typeof window !== 'undefined';
444
527
  if (listenersActive) {
445
528
  document.addEventListener('visibilitychange', onVisibilityChange);
446
529
  window.addEventListener('online', onOnline);
530
+ window.addEventListener('focus', retryIfStalled);
447
531
  }
448
532
 
449
533
  void connect();
@@ -454,11 +538,14 @@ export function createSseSubscription(options: SseSubscriptionOptions): SseSubsc
454
538
  closed = true;
455
539
  generation += 1;
456
540
  clearTimers();
541
+ hiddenGraceTimerId = clearLifecycleTimer(hiddenGraceTimerId);
457
542
  abortController?.abort();
458
543
  if (listenersActive) {
459
544
  document.removeEventListener('visibilitychange', onVisibilityChange);
460
545
  window.removeEventListener('online', onOnline);
546
+ window.removeEventListener('focus', retryIfStalled);
461
547
  }
548
+ unsubscribeCredentials?.();
462
549
  setConnected(false);
463
550
  onStatusChange?.('closed');
464
551
  },