@flamingo-stack/openframe-frontend-core 0.0.685 → 0.0.686-2463.7461.1

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 (174) hide show
  1. package/dist/{chunk-T6KWNJDW.js → chunk-22RSJ57Z.js} +470 -465
  2. package/dist/chunk-22RSJ57Z.js.map +1 -0
  3. package/dist/{chunk-65KTXRBY.js → chunk-2G2CB7ZZ.js} +6 -6
  4. package/dist/{chunk-K2HZG4RM.cjs → chunk-2OD2PPNO.cjs} +32 -32
  5. package/dist/{chunk-K2HZG4RM.cjs.map → chunk-2OD2PPNO.cjs.map} +1 -1
  6. package/dist/{chunk-HMXUOLZR.cjs → chunk-3DO76UAB.cjs} +20 -20
  7. package/dist/{chunk-HMXUOLZR.cjs.map → chunk-3DO76UAB.cjs.map} +1 -1
  8. package/dist/{chunk-XW7MUIW4.js → chunk-3NIZ2VLQ.js} +3 -3
  9. package/dist/{chunk-5LJZPJHX.cjs → chunk-4LBCUBVL.cjs} +59 -59
  10. package/dist/{chunk-5LJZPJHX.cjs.map → chunk-4LBCUBVL.cjs.map} +1 -1
  11. package/dist/{chunk-ZF4Z36G4.js → chunk-4RJQMOQW.js} +7 -7
  12. package/dist/{chunk-TJI5V5BR.cjs → chunk-AF4XOURN.cjs} +7 -7
  13. package/dist/{chunk-TJI5V5BR.cjs.map → chunk-AF4XOURN.cjs.map} +1 -1
  14. package/dist/{chunk-7XF63THA.js → chunk-ASBBGP36.js} +6 -6
  15. package/dist/{chunk-O7LOZW4R.js → chunk-C5TFPMOM.js} +5 -5
  16. package/dist/{chunk-CMQXPD5C.cjs → chunk-GETQTNRV.cjs} +28 -28
  17. package/dist/{chunk-CMQXPD5C.cjs.map → chunk-GETQTNRV.cjs.map} +1 -1
  18. package/dist/{chunk-TUG7IT6H.js → chunk-HLEXYTOE.js} +2 -2
  19. package/dist/{chunk-M4HYIXP6.cjs → chunk-IBUFFKAK.cjs} +399 -383
  20. package/dist/chunk-IBUFFKAK.cjs.map +1 -0
  21. package/dist/{chunk-GXV6UEVY.js → chunk-IELOPDTA.js} +3 -3
  22. package/dist/{chunk-T4DUTP5C.cjs → chunk-II5GVLTN.cjs} +5 -5
  23. package/dist/{chunk-T4DUTP5C.cjs.map → chunk-II5GVLTN.cjs.map} +1 -1
  24. package/dist/{chunk-B32YLOYQ.js → chunk-JBJZL5PI.js} +4 -4
  25. package/dist/{chunk-UT333EOT.js → chunk-LCOMNQLX.js} +4 -4
  26. package/dist/{chunk-VDPCK437.js → chunk-MHTB67ZH.js} +21 -5
  27. package/dist/{chunk-VDPCK437.js.map → chunk-MHTB67ZH.js.map} +1 -1
  28. package/dist/{chunk-EFBMNVSH.cjs → chunk-MZAZUSPQ.cjs} +15 -15
  29. package/dist/{chunk-EFBMNVSH.cjs.map → chunk-MZAZUSPQ.cjs.map} +1 -1
  30. package/dist/{chunk-C3B36OYZ.cjs → chunk-N3I53B5W.cjs} +3 -3
  31. package/dist/{chunk-C3B36OYZ.cjs.map → chunk-N3I53B5W.cjs.map} +1 -1
  32. package/dist/{chunk-WJK2EETW.js → chunk-NINO3PI7.js} +2 -2
  33. package/dist/{chunk-YCTVLN47.cjs → chunk-P4SBHO7U.cjs} +10 -10
  34. package/dist/{chunk-YCTVLN47.cjs.map → chunk-P4SBHO7U.cjs.map} +1 -1
  35. package/dist/{chunk-PSQKD6ZN.js → chunk-PG65AE3I.js} +3 -3
  36. package/dist/{chunk-DQOQ3KTP.cjs → chunk-QNWQQ4RT.cjs} +78 -73
  37. package/dist/chunk-QNWQQ4RT.cjs.map +1 -0
  38. package/dist/{chunk-BZ2XOEHW.js → chunk-QOH45QSE.js} +3 -3
  39. package/dist/{chunk-DWXWEGGO.cjs → chunk-QTXDXHMD.cjs} +22 -22
  40. package/dist/{chunk-DWXWEGGO.cjs.map → chunk-QTXDXHMD.cjs.map} +1 -1
  41. package/dist/{chunk-B25CF3PO.cjs → chunk-RBCMBRI7.cjs} +30 -30
  42. package/dist/{chunk-B25CF3PO.cjs.map → chunk-RBCMBRI7.cjs.map} +1 -1
  43. package/dist/{chunk-QHLJSB2J.cjs → chunk-RKH7DEDF.cjs} +12 -12
  44. package/dist/{chunk-QHLJSB2J.cjs.map → chunk-RKH7DEDF.cjs.map} +1 -1
  45. package/dist/{chunk-Y7ZYOQNB.js → chunk-RSMAVO2Z.js} +3 -3
  46. package/dist/{chunk-A3CSQB4A.cjs → chunk-SI77HF4U.cjs} +1247 -805
  47. package/dist/chunk-SI77HF4U.cjs.map +1 -0
  48. package/dist/{chunk-GHLGXFNU.js → chunk-U6JC3RS2.js} +3 -3
  49. package/dist/{chunk-B5N3AHPG.cjs → chunk-UBXEETWQ.cjs} +81 -81
  50. package/dist/{chunk-B5N3AHPG.cjs.map → chunk-UBXEETWQ.cjs.map} +1 -1
  51. package/dist/{chunk-N4Z6X5BV.js → chunk-WFH52OVQ.js} +5 -5
  52. package/dist/{chunk-U3IXG7TR.cjs → chunk-WJX3ZSOE.cjs} +33 -33
  53. package/dist/{chunk-U3IXG7TR.cjs.map → chunk-WJX3ZSOE.cjs.map} +1 -1
  54. package/dist/{chunk-XGX6JSEU.js → chunk-YPXXXX6R.js} +2086 -1644
  55. package/dist/chunk-YPXXXX6R.js.map +1 -0
  56. package/dist/{chunk-P2ASEPA2.cjs → chunk-ZS3YCJYY.cjs} +6 -6
  57. package/dist/{chunk-P2ASEPA2.cjs.map → chunk-ZS3YCJYY.cjs.map} +1 -1
  58. package/dist/components/case-studies/index.cjs +16 -16
  59. package/dist/components/case-studies/index.js +4 -4
  60. package/dist/components/chat/ai-assistant-info.d.ts.map +1 -1
  61. package/dist/components/chat/approval-request-message.d.ts.map +1 -1
  62. package/dist/components/chat/chat-appearance-context.d.ts +9 -0
  63. package/dist/components/chat/chat-appearance-context.d.ts.map +1 -0
  64. package/dist/components/chat/chat-input.d.ts.map +1 -1
  65. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  66. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  67. package/dist/components/chat/chat-nav-sidebar.d.ts +50 -0
  68. package/dist/components/chat/chat-nav-sidebar.d.ts.map +1 -0
  69. package/dist/components/chat/chat-top-navigation.d.ts +29 -0
  70. package/dist/components/chat/chat-top-navigation.d.ts.map +1 -0
  71. package/dist/components/chat/error-message-display.d.ts.map +1 -1
  72. package/dist/components/chat/index.cjs +21 -3
  73. package/dist/components/chat/index.cjs.map +1 -1
  74. package/dist/components/chat/index.d.ts +6 -0
  75. package/dist/components/chat/index.d.ts.map +1 -1
  76. package/dist/components/chat/index.js +20 -2
  77. package/dist/components/chat/msp-organization-card.d.ts +7 -1
  78. package/dist/components/chat/msp-organization-card.d.ts.map +1 -1
  79. package/dist/components/chat/onboarding-carousel.d.ts +29 -0
  80. package/dist/components/chat/onboarding-carousel.d.ts.map +1 -0
  81. package/dist/components/chat/system-event-message.d.ts +14 -0
  82. package/dist/components/chat/system-event-message.d.ts.map +1 -0
  83. package/dist/components/chat/types/chat.types.d.ts +13 -0
  84. package/dist/components/chat/types/chat.types.d.ts.map +1 -1
  85. package/dist/components/chat/types/component.types.d.ts +20 -1
  86. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  87. package/dist/components/contact/index.cjs +5 -5
  88. package/dist/components/contact/index.js +4 -4
  89. package/dist/components/docs/index.cjs +8 -8
  90. package/dist/components/docs/index.js +7 -7
  91. package/dist/components/embeds/index.cjs +5 -5
  92. package/dist/components/embeds/index.js +4 -4
  93. package/dist/components/faq/index.cjs +5 -5
  94. package/dist/components/faq/index.js +4 -4
  95. package/dist/components/features/index.cjs +4 -4
  96. package/dist/components/features/index.js +3 -3
  97. package/dist/components/help-center-pages/index.cjs +91 -91
  98. package/dist/components/help-center-pages/index.js +15 -15
  99. package/dist/components/index.cjs +73 -55
  100. package/dist/components/index.cjs.map +1 -1
  101. package/dist/components/index.js +33 -15
  102. package/dist/components/index.js.map +1 -1
  103. package/dist/components/meeting-scheduler/index.cjs +41 -41
  104. package/dist/components/meeting-scheduler/index.js +3 -3
  105. package/dist/components/navigation/index.cjs +7 -7
  106. package/dist/components/navigation/index.js +6 -6
  107. package/dist/components/onboarding-guides/index.cjs +7 -7
  108. package/dist/components/onboarding-guides/index.js +6 -6
  109. package/dist/components/related-content/index.cjs +3 -3
  110. package/dist/components/related-content/index.js +2 -2
  111. package/dist/components/tickets/index.cjs +8 -8
  112. package/dist/components/tickets/index.js +7 -7
  113. package/dist/components/ui/avatar-stack.d.ts +5 -1
  114. package/dist/components/ui/avatar-stack.d.ts.map +1 -1
  115. package/dist/components/ui/index.cjs +22 -4
  116. package/dist/components/ui/index.cjs.map +1 -1
  117. package/dist/components/ui/index.js +21 -3
  118. package/dist/components/ui/reopen-ticket-request-modal.d.ts +4 -1
  119. package/dist/components/ui/reopen-ticket-request-modal.d.ts.map +1 -1
  120. package/dist/components/ui/tab-navigation.d.ts +7 -0
  121. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  122. package/dist/index.cjs +27 -9
  123. package/dist/index.cjs.map +1 -1
  124. package/dist/index.js +25 -7
  125. package/dist/index.js.map +1 -1
  126. package/package.json +1 -1
  127. package/src/components/chat/__tests__/chat-appearance.test.tsx +79 -0
  128. package/src/components/chat/__tests__/onboarding-carousel.test.tsx +56 -0
  129. package/src/components/chat/ai-assistant-info.tsx +7 -1
  130. package/src/components/chat/approval-request-message.tsx +6 -1
  131. package/src/components/chat/chat-appearance-context.tsx +15 -0
  132. package/src/components/chat/chat-input.tsx +26 -1
  133. package/src/components/chat/chat-message-enhanced.tsx +180 -142
  134. package/src/components/chat/chat-message-list.tsx +169 -164
  135. package/src/components/chat/chat-nav-sidebar.tsx +163 -0
  136. package/src/components/chat/chat-top-navigation.tsx +109 -0
  137. package/src/components/chat/error-message-display.tsx +21 -3
  138. package/src/components/chat/index.ts +6 -0
  139. package/src/components/chat/msp-organization-card.tsx +35 -0
  140. package/src/components/chat/onboarding-carousel.tsx +162 -0
  141. package/src/components/chat/system-event-message.tsx +54 -0
  142. package/src/components/chat/types/chat.types.ts +15 -0
  143. package/src/components/chat/types/component.types.ts +20 -0
  144. package/src/components/ui/__tests__/avatar-stack.test.tsx +25 -0
  145. package/src/components/ui/__tests__/tab-navigation.test.tsx +56 -2
  146. package/src/components/ui/avatar-stack.tsx +15 -5
  147. package/src/components/ui/reopen-ticket-request-modal.tsx +6 -2
  148. package/src/components/ui/tab-navigation.tsx +23 -0
  149. package/src/stories/FaeLayoutV2.stories.tsx +150 -0
  150. package/src/stories/FaeOnboardingV2.stories.tsx +68 -0
  151. package/src/stories/FaeThreadV2.stories.tsx +192 -0
  152. package/src/stories/fae-brand-decorator.tsx +28 -0
  153. package/src/styles/ods-colors.css +10 -0
  154. package/src/styles/ods-effects.css +13 -0
  155. package/dist/chunk-A3CSQB4A.cjs.map +0 -1
  156. package/dist/chunk-DQOQ3KTP.cjs.map +0 -1
  157. package/dist/chunk-M4HYIXP6.cjs.map +0 -1
  158. package/dist/chunk-T6KWNJDW.js.map +0 -1
  159. package/dist/chunk-XGX6JSEU.js.map +0 -1
  160. /package/dist/{chunk-65KTXRBY.js.map → chunk-2G2CB7ZZ.js.map} +0 -0
  161. /package/dist/{chunk-XW7MUIW4.js.map → chunk-3NIZ2VLQ.js.map} +0 -0
  162. /package/dist/{chunk-ZF4Z36G4.js.map → chunk-4RJQMOQW.js.map} +0 -0
  163. /package/dist/{chunk-7XF63THA.js.map → chunk-ASBBGP36.js.map} +0 -0
  164. /package/dist/{chunk-O7LOZW4R.js.map → chunk-C5TFPMOM.js.map} +0 -0
  165. /package/dist/{chunk-TUG7IT6H.js.map → chunk-HLEXYTOE.js.map} +0 -0
  166. /package/dist/{chunk-GXV6UEVY.js.map → chunk-IELOPDTA.js.map} +0 -0
  167. /package/dist/{chunk-B32YLOYQ.js.map → chunk-JBJZL5PI.js.map} +0 -0
  168. /package/dist/{chunk-UT333EOT.js.map → chunk-LCOMNQLX.js.map} +0 -0
  169. /package/dist/{chunk-WJK2EETW.js.map → chunk-NINO3PI7.js.map} +0 -0
  170. /package/dist/{chunk-PSQKD6ZN.js.map → chunk-PG65AE3I.js.map} +0 -0
  171. /package/dist/{chunk-BZ2XOEHW.js.map → chunk-QOH45QSE.js.map} +0 -0
  172. /package/dist/{chunk-Y7ZYOQNB.js.map → chunk-RSMAVO2Z.js.map} +0 -0
  173. /package/dist/{chunk-GHLGXFNU.js.map → chunk-U6JC3RS2.js.map} +0 -0
  174. /package/dist/{chunk-N4Z6X5BV.js.map → chunk-WFH52OVQ.js.map} +0 -0
@@ -0,0 +1,109 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
4
+ import { cn } from '../../utils/cn';
5
+ import { Settings01Icon, SidebarIcon } from '../icons-v2-generated';
6
+ import { Skeleton } from '../ui/skeleton';
7
+ import { SquareAvatar } from '../ui/square-avatar';
8
+
9
+ export interface ChatTopNavigationProps extends HTMLAttributes<HTMLElement> {
10
+ /** Who the user is talking to. */
11
+ name: string;
12
+ /** Line under the name (e.g. "Your AI Assistant", "IT Technician"). */
13
+ subtitle?: string;
14
+ avatarUrl?: string | null;
15
+ /** Actions after the identity (e.g. "Request a Technician"). */
16
+ actions?: ReactNode;
17
+ /** Shows the sidebar toggle at the start of the bar. */
18
+ onToggleSidebar?: () => void;
19
+ /** Whether the sidebar is currently shown; drives the toggle's state. */
20
+ sidebarOpen?: boolean;
21
+ /** Shows the settings button at the end of the bar. */
22
+ onOpenSettings?: () => void;
23
+ /** Content above the bar, full width (e.g. a "Back to ..." strip). */
24
+ before?: ReactNode;
25
+ /** Identity still resolving: placeholders instead of a default name that
26
+ * would flash before the tenant's branding lands. */
27
+ isLoading?: boolean;
28
+ }
29
+
30
+ const edgeButtonClass = cn(
31
+ 'flex size-14 shrink-0 items-center justify-center border-ods-border text-ods-text-secondary',
32
+ 'hover:text-ods-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-focus',
33
+ '[&_svg]:size-6',
34
+ );
35
+
36
+ /**
37
+ * Top bar of the fae chat v2 layout (Figma `chat-top-navigation`): an optional
38
+ * sidebar toggle, the counterpart's avatar, name and role, the host's actions
39
+ * and an optional settings button, split by vertical dividers.
40
+ */
41
+ const ChatTopNavigation = forwardRef<HTMLElement, ChatTopNavigationProps>(
42
+ (
43
+ {
44
+ className,
45
+ name,
46
+ subtitle,
47
+ avatarUrl,
48
+ actions,
49
+ onToggleSidebar,
50
+ sidebarOpen,
51
+ onOpenSettings,
52
+ before,
53
+ isLoading,
54
+ ...props
55
+ },
56
+ ref,
57
+ ) => (
58
+ <header ref={ref} className={cn('flex shrink-0 flex-col bg-ods-bg', className)} {...props}>
59
+ {before}
60
+ <div className="flex h-14 items-stretch border-b border-ods-border">
61
+ {onToggleSidebar && (
62
+ <button
63
+ type="button"
64
+ onClick={onToggleSidebar}
65
+ aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
66
+ aria-expanded={sidebarOpen}
67
+ className={cn(edgeButtonClass, 'border-r')}
68
+ >
69
+ <SidebarIcon />
70
+ </button>
71
+ )}
72
+ <div className="flex min-w-0 flex-1 items-center gap-[var(--spacing-system-m)] px-[var(--spacing-system-m)]">
73
+ {isLoading ? (
74
+ <>
75
+ <Skeleton className="size-10 shrink-0 rounded-full" />
76
+ <div className="flex min-w-0 flex-1 flex-col gap-[var(--spacing-system-xxs)]">
77
+ <Skeleton className="h-5 w-24" />
78
+ <Skeleton className="h-4 w-32" />
79
+ </div>
80
+ </>
81
+ ) : (
82
+ <>
83
+ <SquareAvatar src={avatarUrl ?? undefined} alt={name} fallback={name} size="md" variant="round" />
84
+ <div className="flex min-w-0 flex-1 flex-col">
85
+ <p className="truncate text-ods-text-primary text-h3">{name}</p>
86
+ {subtitle && <p className="truncate text-ods-text-secondary text-h6">{subtitle}</p>}
87
+ </div>
88
+ </>
89
+ )}
90
+ {actions && <div className="flex shrink-0 items-center gap-[var(--spacing-system-xs)]">{actions}</div>}
91
+ </div>
92
+ {onOpenSettings && (
93
+ <button
94
+ type="button"
95
+ onClick={onOpenSettings}
96
+ aria-label="Settings"
97
+ className={cn(edgeButtonClass, 'border-l')}
98
+ >
99
+ <Settings01Icon />
100
+ </button>
101
+ )}
102
+ </div>
103
+ </header>
104
+ ),
105
+ );
106
+
107
+ ChatTopNavigation.displayName = 'ChatTopNavigation';
108
+
109
+ export { ChatTopNavigation };
@@ -3,9 +3,11 @@
3
3
  import { forwardRef, useState } from 'react';
4
4
  import { cn } from '../../utils/cn';
5
5
  import { AlertCircleIcon } from '../icons-v2-generated';
6
+ import { useChatAppearance } from './chat-appearance-context';
6
7
  import { ExpandChevron } from './expand-chevron';
7
8
  import { useCollapsible } from './hooks/use-collapsible';
8
9
  import type { ErrorMessageDisplayProps } from './types';
10
+ import { CHAT_APPEARANCE } from './types/chat.types';
9
11
 
10
12
  const iconTint = {
11
13
  error: 'text-ods-error',
@@ -18,11 +20,20 @@ const ErrorMessageDisplay = forwardRef<HTMLDivElement, ErrorMessageDisplayProps>
18
20
  const [expanded, setExpanded] = useState(false);
19
21
  const { innerRef, containerStyle } = useCollapsible({ expanded });
20
22
  const hasDetails = Boolean(details);
23
+ // v2 (fae chat `chat-info-block`): outlined on the page surface, the title
24
+ // always reads as a heading, and the row spacing comes from the thread.
25
+ const isV2 = useChatAppearance() === CHAT_APPEARANCE.V2;
21
26
 
22
27
  return (
23
28
  <div
24
29
  ref={ref}
25
- className={cn('mb-[var(--spacing-system-xsf)] rounded-md bg-ods-card p-[var(--spacing-system-xsf)]', className)}
30
+ className={cn(
31
+ 'rounded-md',
32
+ isV2
33
+ ? 'border border-ods-border bg-ods-bg p-[var(--spacing-system-xs)]'
34
+ : 'mb-[var(--spacing-system-xsf)] bg-ods-card p-[var(--spacing-system-xsf)]',
35
+ className,
36
+ )}
26
37
  {...props}
27
38
  >
28
39
  <button
@@ -40,7 +51,8 @@ const ErrorMessageDisplay = forwardRef<HTMLDivElement, ErrorMessageDisplayProps>
40
51
  <span
41
52
  className={cn(
42
53
  'min-w-0 flex-1 text-h5',
43
- expanded ? 'text-ods-text-primary' : 'truncate text-ods-text-secondary',
54
+ expanded || isV2 ? 'text-ods-text-primary' : 'text-ods-text-secondary',
55
+ !expanded && 'truncate',
44
56
  )}
45
57
  >
46
58
  {title}
@@ -50,7 +62,13 @@ const ErrorMessageDisplay = forwardRef<HTMLDivElement, ErrorMessageDisplayProps>
50
62
 
51
63
  {hasDetails && (
52
64
  <div style={containerStyle}>
53
- <div ref={innerRef} className="px-[var(--spacing-system-lf)] pt-[var(--spacing-system-xsf)]">
65
+ <div
66
+ ref={innerRef}
67
+ className={cn(
68
+ 'px-[var(--spacing-system-lf)]',
69
+ isV2 ? 'pt-[var(--spacing-system-xs)]' : 'pt-[var(--spacing-system-xsf)]',
70
+ )}
71
+ >
54
72
  <p className="text-ods-text-primary text-h6">{details}</p>
55
73
  </div>
56
74
  </div>
@@ -6,7 +6,9 @@ export * from './approval-batch-message';
6
6
  export * from './escalation-offer-message';
7
7
  export * from './ticket-escalated-message';
8
8
  export * from './ticket-event-message';
9
+ export * from './system-event-message';
9
10
  export * from './ai-assistant-info';
11
+ export * from './chat-appearance-context';
10
12
  export * from './context-compaction-display';
11
13
  export * from './expand-chevron';
12
14
  export * from './thinking-display';
@@ -47,6 +49,10 @@ export * from './context-items-list';
47
49
  export * from './chat-archive-page';
48
50
  export * from './model-display';
49
51
  export * from './chat-sidebar';
52
+ export * from './chat-nav-sidebar';
53
+ export * from './chat-list-empty-state';
54
+ export * from './chat-top-navigation';
55
+ export * from './onboarding-carousel';
50
56
  export type { ChatRef } from './chat-ref.types';
51
57
  export { remarkCardLinks } from './remark-card-links';
52
58
  export { remarkMentionChips } from './remark-mention-chips';
@@ -22,6 +22,12 @@ export interface MspOrganizationCardProps {
22
22
  href?: string;
23
23
  /** Click handler for the trailing action (used when `href` is not set). */
24
24
  onOpenWebsite?: () => void;
25
+ /**
26
+ * `card` (default): the welcome-screen card, title with the website beneath.
27
+ * `footer`: the compact sidebar footer of the fae chat v2 layout, a caption
28
+ * "Your IT is managed by" over the name, no website line, no card chrome.
29
+ */
30
+ variant?: 'card' | 'footer';
25
31
  /** Appended to the root element. */
26
32
  className?: string;
27
33
  }
@@ -44,10 +50,39 @@ export function MspOrganizationCard({
44
50
  title,
45
51
  href,
46
52
  onOpenWebsite,
53
+ variant = 'card',
47
54
  className,
48
55
  }: MspOrganizationCardProps) {
49
56
  const actionLabel = website ? `Open ${website}` : 'Open organization website';
50
57
 
58
+ if (variant === 'footer') {
59
+ return (
60
+ <div
61
+ className={cn(
62
+ 'flex items-center gap-[var(--spacing-system-s)] px-[var(--spacing-system-m)] py-[var(--spacing-system-s)]',
63
+ className,
64
+ )}
65
+ >
66
+ <SquareAvatar src={logoUrl} alt={name} fallback={name} size="sm" variant="square" />
67
+ <div className="flex min-w-0 flex-1 flex-col">
68
+ <span className="truncate text-ods-text-secondary text-h6">Your IT is managed by</span>
69
+ <span className="truncate text-ods-text-primary text-h4">{title ?? name}</span>
70
+ </div>
71
+ {href ? (
72
+ <Button asChild variant="outline" size="icon-sm" aria-label={actionLabel}>
73
+ <a href={href} target="_blank" rel="noreferrer noopener">
74
+ <ExternalLinkIcon />
75
+ </a>
76
+ </Button>
77
+ ) : onOpenWebsite ? (
78
+ <Button variant="outline" size="icon-sm" aria-label={actionLabel} onClick={onOpenWebsite}>
79
+ <ExternalLinkIcon />
80
+ </Button>
81
+ ) : null}
82
+ </div>
83
+ );
84
+ }
85
+
51
86
  return (
52
87
  <div
53
88
  className={cn(
@@ -0,0 +1,162 @@
1
+ 'use client';
2
+
3
+ import { type CSSProperties, type KeyboardEvent, type ReactNode, useState } from 'react';
4
+ import { cn } from '../../utils/cn';
5
+ import { Button } from '../ui/button';
6
+
7
+ export interface OnboardingCarouselStep {
8
+ id: string;
9
+ /** Glyph in the 64px tile above the title. Pass it sized to 28px. */
10
+ icon: ReactNode;
11
+ title: ReactNode;
12
+ description?: ReactNode;
13
+ }
14
+
15
+ export interface OnboardingCarouselProps {
16
+ steps: OnboardingCarouselStep[];
17
+ /** The last step's primary action. */
18
+ onComplete: () => void;
19
+ /** Label of the last step's primary action. */
20
+ completeLabel?: string;
21
+ previousLabel?: string;
22
+ nextLabel?: string;
23
+ /** Accessible name of the carousel region. */
24
+ label?: string;
25
+ className?: string;
26
+ }
27
+
28
+ /**
29
+ * Step-by-step introduction: one card at a time in the middle, its neighbours
30
+ * peeking in from the sides under an edge fade, dots below, Previous / Next
31
+ * underneath. The last step swaps Next for `completeLabel`. There is no skip:
32
+ * the design walks every step. Fills the height its parent gives it: the cards
33
+ * stretch, the dots and buttons keep their size.
34
+ */
35
+ export function OnboardingCarousel({
36
+ steps,
37
+ onComplete,
38
+ completeLabel = 'Start Chat',
39
+ previousLabel = 'Previous Step',
40
+ nextLabel = 'Next Step',
41
+ label = 'Introduction',
42
+ className,
43
+ }: OnboardingCarouselProps) {
44
+ const [index, setIndex] = useState(0);
45
+ const lastIndex = steps.length - 1;
46
+ const isFirst = index === 0;
47
+ const isLast = index === lastIndex;
48
+
49
+ const goTo = (next: number) => setIndex(Math.min(Math.max(next, 0), lastIndex));
50
+
51
+ const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
52
+ if (event.key === 'ArrowRight') goTo(index + 1);
53
+ else if (event.key === 'ArrowLeft') goTo(index - 1);
54
+ else return;
55
+ event.preventDefault();
56
+ };
57
+
58
+ // Card width and the gap between cards drive both the card size and the
59
+ // track offset, so they live in one place. Percentages resolve against the
60
+ // viewport width in both uses (the track is as wide as the viewport).
61
+ const trackStyle = {
62
+ '--onboarding-card-w': 'min(600px, calc(100% - 2 * var(--spacing-system-xl)))',
63
+ '--onboarding-gap': 'var(--spacing-system-lf)',
64
+ transform: `translateX(calc(${-index} * (var(--onboarding-card-w) + var(--onboarding-gap))))`,
65
+ } as CSSProperties;
66
+
67
+ // The design's large steps (card padding, dots to buttons) are 80px at the
68
+ // app's 1024px default width, where `--spacing-system-xxl` is still 48px:
69
+ // spelled as twice the fixed 40px step.
70
+ return (
71
+ <section
72
+ aria-roledescription="carousel"
73
+ aria-label={label}
74
+ onKeyDown={handleKeyDown}
75
+ className={cn(
76
+ 'flex min-h-0 w-full flex-1 flex-col items-center gap-[calc(2*var(--spacing-system-xlf))]',
77
+ className,
78
+ )}
79
+ >
80
+ <div className="flex min-h-0 w-full flex-1 flex-col items-center gap-[var(--spacing-system-xs)]">
81
+ <div className="relative flex min-h-0 w-full flex-1 overflow-hidden">
82
+ <div
83
+ className="flex w-full gap-[var(--onboarding-gap)] transition-transform duration-300 ease-out motion-reduce:transition-none"
84
+ style={trackStyle}
85
+ >
86
+ {steps.map((step, i) => {
87
+ const isCurrent = i === index;
88
+ return (
89
+ <div
90
+ key={step.id}
91
+ role="group"
92
+ aria-roledescription="slide"
93
+ aria-label={`${i + 1} of ${steps.length}`}
94
+ aria-hidden={!isCurrent}
95
+ inert={!isCurrent || undefined}
96
+ className={cn(
97
+ 'flex w-[var(--onboarding-card-w)] shrink-0 flex-col items-center justify-center gap-[var(--spacing-system-m)] rounded-md border border-ods-border bg-ods-card px-[var(--spacing-system-xl)] py-[calc(2*var(--spacing-system-xlf))] text-center',
98
+ i === 0 && 'ml-[calc((100%-var(--onboarding-card-w))/2)]',
99
+ )}
100
+ >
101
+ <span className="flex size-16 shrink-0 items-center justify-center rounded-md border border-ods-border bg-ods-bg text-ods-accent">
102
+ {step.icon}
103
+ </span>
104
+ <div className="flex flex-col gap-[var(--spacing-system-l)] text-ods-text-primary [word-break:break-word]">
105
+ <h2 className="text-h2">{step.title}</h2>
106
+ {step.description && <p className="whitespace-pre-line text-h6">{step.description}</p>}
107
+ </div>
108
+ </div>
109
+ );
110
+ })}
111
+ </div>
112
+ <div className="pointer-events-none absolute inset-y-0 left-0 w-[120px] bg-gradient-to-r from-ods-bg to-transparent" />
113
+ <div className="pointer-events-none absolute inset-y-0 right-0 w-[120px] bg-gradient-to-l from-ods-bg to-transparent" />
114
+ </div>
115
+
116
+ {/* The slides only toggle visibility, which is not announced. */}
117
+ <p className="sr-only" aria-live="polite">
118
+ Step {index + 1} of {steps.length}: {steps[index]?.title}
119
+ </p>
120
+
121
+ <div className="flex items-center" role="group" aria-label="Choose step">
122
+ {steps.map((step, i) => {
123
+ const isCurrent = i === index;
124
+ return (
125
+ <Button
126
+ key={step.id}
127
+ variant="transparent"
128
+ aria-current={isCurrent ? 'step' : undefined}
129
+ aria-label={`Go to step ${i + 1}`}
130
+ onClick={() => goTo(i)}
131
+ className="flex size-6 shrink-0 items-center justify-center rounded-full p-0 md:h-6"
132
+ >
133
+ <span
134
+ className={cn(
135
+ 'size-1.5 rounded-full transition-colors duration-150',
136
+ isCurrent ? 'bg-ods-accent' : 'bg-ods-text-secondary',
137
+ )}
138
+ />
139
+ </Button>
140
+ );
141
+ })}
142
+ </div>
143
+ </div>
144
+
145
+ <div className="flex flex-wrap items-center justify-center gap-[var(--spacing-system-m)]">
146
+ {/* aria-disabled, not disabled: stepping back onto the first card must
147
+ not drop keyboard focus from the button the user just pressed. */}
148
+ <Button
149
+ variant="outline"
150
+ className="w-[200px]"
151
+ aria-disabled={isFirst || undefined}
152
+ onClick={() => goTo(index - 1)}
153
+ >
154
+ {previousLabel}
155
+ </Button>
156
+ <Button variant="accent" className="w-[200px]" onClick={isLast ? onComplete : () => goTo(index + 1)}>
157
+ {isLast ? completeLabel : nextLabel}
158
+ </Button>
159
+ </div>
160
+ </section>
161
+ );
162
+ }
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, type HTMLAttributes } from 'react';
4
+ import { InfoCircleIcon } from '../icons-v2-generated';
5
+ import { SquareAvatar } from '../ui/square-avatar';
6
+ import { AiAssistantInfo } from './ai-assistant-info';
7
+
8
+ // The chat backend announces a technician taking the chat over (or handing it
9
+ // back) as a plain system line: "{name} joined the chat" / "{name} left the
10
+ // chat". The copy is fixed server-side, so these two shapes are the contract.
11
+ const JOINED = /^(.+) joined the chat$/;
12
+ const LEFT = /^(.+) left the chat$/;
13
+
14
+ export interface SystemEventMessageProps extends HTMLAttributes<HTMLDivElement> {
15
+ /** The system line as the backend sent it. */
16
+ text: string;
17
+ timestamp?: Date;
18
+ }
19
+
20
+ /**
21
+ * A system line in the fae chat v2 thread (`ai-assistant-info`,
22
+ * type=direct-chat): a technician joining becomes "Technician Joined" with
23
+ * their initials; any other line keeps its own wording under an info icon.
24
+ */
25
+ const SystemEventMessage = forwardRef<HTMLDivElement, SystemEventMessageProps>(({ text, timestamp, ...props }, ref) => {
26
+ const joined = JOINED.exec(text)?.[1];
27
+ if (joined) {
28
+ return (
29
+ <AiAssistantInfo
30
+ ref={ref}
31
+ leading={<SquareAvatar alt={joined} fallback={joined} size="lg" variant="round" className="shrink-0" />}
32
+ title="Technician Joined"
33
+ body={`You're now chatting with ${joined}.`}
34
+ timestamp={timestamp}
35
+ {...props}
36
+ />
37
+ );
38
+ }
39
+ const left = LEFT.exec(text)?.[1];
40
+ return (
41
+ <AiAssistantInfo
42
+ ref={ref}
43
+ icon={<InfoCircleIcon className="size-6 text-ods-accent" />}
44
+ title={left ? 'Technician Left' : text}
45
+ body={left ? `${left} left the conversation.` : undefined}
46
+ timestamp={timestamp}
47
+ {...props}
48
+ />
49
+ );
50
+ });
51
+
52
+ SystemEventMessage.displayName = 'SystemEventMessage';
53
+
54
+ export { SystemEventMessage };
@@ -81,6 +81,21 @@ export const APPROVAL_BLOCK_VARIANT = {
81
81
 
82
82
  export type ApprovalBlockVariant = (typeof APPROVAL_BLOCK_VARIANT)[keyof typeof APPROVAL_BLOCK_VARIANT];
83
83
 
84
+ /**
85
+ * Visual generation of a chat thread. `v2` is the fae chat v2 design: author
86
+ * names without the trailing colon, a 24px assistant avatar, 16px between
87
+ * messages, and in-thread info/error blocks on the page surface. `classic`
88
+ * (default) is everything that predates it. The Fae client opts in first;
89
+ * Mingo and the admin views stay classic until their own redesign lands, then
90
+ * `classic` goes away.
91
+ */
92
+ export const CHAT_APPEARANCE = {
93
+ CLASSIC: 'classic',
94
+ V2: 'v2',
95
+ } as const;
96
+
97
+ export type ChatAppearance = (typeof CHAT_APPEARANCE)[keyof typeof CHAT_APPEARANCE];
98
+
84
99
  // ========== Connection Status Definitions ==========
85
100
 
86
101
  export const CONNECTION_STATUS = {
@@ -3,12 +3,14 @@
3
3
  */
4
4
 
5
5
  import type { ComponentType, HTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'react';
6
+ import type { AvatarStackPerson } from '../../ui/avatar-stack';
6
7
  import type { ChatRef } from '../chat-ref.types';
7
8
  import type { MspOrganizationCardProps } from '../msp-organization-card';
8
9
  import type {
9
10
  ApprovalBlockVariant,
10
11
  AssistantType,
11
12
  AuthorType,
13
+ ChatAppearance,
12
14
  ChatApprovalStatus,
13
15
  ConnectionStatus,
14
16
  } from './chat.types';
@@ -141,6 +143,9 @@ export interface ChatMessageEnhancedProps extends Omit<HTMLAttributes<HTMLDivEle
141
143
  * (Fae desktop app) title-only card. Forwarded to
142
144
  * ApprovalRequestMessage / ApprovalBatchMessage. */
143
145
  approvalVariant?: ApprovalBlockVariant;
146
+ /** Visual generation of the row. Omit to inherit the thread's
147
+ * (`ChatMessageList.appearance`); `classic` outside a list. */
148
+ appearance?: ChatAppearance;
144
149
  assistantIcon?: ReactNode;
145
150
  avatar?: string | null;
146
151
  timestamp?: Date;
@@ -288,6 +293,9 @@ export interface ChatMessageListProps extends HTMLAttributes<HTMLDivElement> {
288
293
  * `'client'` ONLY on true end-client surfaces — admin views of a Fae
289
294
  * dialog (tickets dialog client tab) keep the default. */
290
295
  approvalVariant?: ApprovalBlockVariant;
296
+ /** Visual generation of the whole thread, blocks included. Default
297
+ * `classic`; the Fae client passes `v2`. See `ChatAppearance`. */
298
+ appearance?: ChatAppearance;
291
299
  assistantIcon?: ReactNode;
292
300
  pendingApprovals?: MessageSegment[];
293
301
  onApprove?: ApprovalResolutionHandler;
@@ -442,7 +450,19 @@ export interface ChatInputProps extends Omit<TextareaHTMLAttributes<HTMLTextArea
442
450
  onSend?: (message: string) => void | boolean | Promise<boolean | void>;
443
451
  onStop?: () => void | Promise<void>;
444
452
  sending?: boolean;
453
+ /** Replaces the editor with the hand-off bar: the conversation is with the
454
+ * technicians now, and the client waits for one of them to reply. */
445
455
  awaitingResponse?: boolean;
456
+ /** Faces shown in the v2 hand-off bar (the tenant's technicians). Omit or
457
+ * pass an empty list for a text-only bar. */
458
+ awaitingTeam?: AvatarStackPerson[];
459
+ /** Size of the whole team when `awaitingTeam` is only a sample of it: the
460
+ * bar shows those faces and "+N" for the rest. */
461
+ awaitingTeamCount?: number;
462
+ /** `v2`: the fae chat v2 hand-off bar ("Handed off to your technical
463
+ * support team" + faces). `classic` (default): "Waiting for Technician
464
+ * Response". Only the awaiting state differs. */
465
+ appearance?: ChatAppearance;
446
466
  /** Same `fullWidth` semantics as `ChatHeaderProps.fullWidth` — drops
447
467
  * the default `max-w-ods-content-narrow` so the input fills the
448
468
  * parent. */
@@ -0,0 +1,25 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { AvatarStack } from '../avatar-stack';
4
+
5
+ const people = [{ name: 'Ada Lin' }, { name: 'Roman Smith' }, { name: 'Michael Johnson' }];
6
+
7
+ describe('AvatarStack', () => {
8
+ it('counts the overflow from the people it gets', () => {
9
+ render(<AvatarStack people={[...people, { name: 'Kim Park' }]} max={3} label="Technicians" />);
10
+ expect(screen.getByText('+1')).toHaveAttribute('title', 'Kim Park');
11
+ });
12
+
13
+ it('counts everyone not shown when the people are a sample of a larger group', () => {
14
+ render(<AvatarStack people={people} total={7} max={3} label="Technicians" />);
15
+ expect(screen.getByText('+4')).toHaveAttribute('title', '4 more');
16
+ expect(screen.getByRole('group')).toHaveAccessibleName(
17
+ 'Technicians: Ada Lin, Roman Smith, Michael Johnson and 4 more',
18
+ );
19
+ });
20
+
21
+ it('shows no overflow when the sample is the whole group', () => {
22
+ render(<AvatarStack people={people} total={3} max={3} label="Technicians" />);
23
+ expect(screen.queryByText(/^\+/)).not.toBeInTheDocument();
24
+ });
25
+ });
@@ -1,7 +1,7 @@
1
1
  import { render, fireEvent, screen } from '@testing-library/react';
2
2
  import { describe, it, expect, beforeAll, vi } from 'vitest';
3
- import { mockReplace, setMockSearchParams } from '../../../../vitest.setup';
4
- import { TabNavigation, type TabItem } from '../tab-navigation';
3
+ import { mockPush, mockReplace, setMockSearchParams } from '../../../../vitest.setup';
4
+ import { TabNavigation, type TabItem, type TabNavigationUrlSyncOptions } from '../tab-navigation';
5
5
 
6
6
  beforeAll(() => {
7
7
  // jsdom ships no ResizeObserver; the tab strip observes itself on mount to
@@ -156,6 +156,60 @@ describe('TabNavigation with urlSync', () => {
156
156
  expect(nav.active).toBe('general');
157
157
  });
158
158
  });
159
+
160
+ describe('with sync options', () => {
161
+ function renderWith(options: TabNavigationUrlSyncOptions) {
162
+ let seen = '__never-rendered__';
163
+ render(
164
+ <TabNavigation tabs={TABS} urlSync={options}>
165
+ {active => {
166
+ seen = active;
167
+ return null;
168
+ }}
169
+ </TabNavigation>,
170
+ );
171
+ return {
172
+ get active() {
173
+ return seen;
174
+ },
175
+ };
176
+ }
177
+
178
+ it('reads and writes the param it was given, leaving `tab` alone', () => {
179
+ setMockSearchParams(new URLSearchParams('tab=general&view=software'));
180
+ const nav = renderWith({ paramName: 'view' });
181
+ expect(nav.active).toBe('software');
182
+
183
+ fireEvent.click(screen.getByText('General'));
184
+ expect(mockReplace).toHaveBeenCalledWith('/?tab=general&view=general', { scroll: false });
185
+ });
186
+
187
+ it('pushes a history entry when `replaceState` is off', () => {
188
+ setMockSearchParams(new URLSearchParams('tab=general'));
189
+ renderWith({ replaceState: false });
190
+ fireEvent.click(screen.getByText('Software'));
191
+
192
+ expect(mockPush).toHaveBeenCalledWith('/?tab=software', { scroll: false });
193
+ expect(mockReplace).not.toHaveBeenCalled();
194
+ });
195
+ });
196
+
197
+ describe('a tab with a badge', () => {
198
+ it('draws the stamp after the label and names the tab with it', () => {
199
+ setMockSearchParams(new URLSearchParams('tab=general'));
200
+ render(
201
+ <TabNavigation tabs={[...TABS, { id: 'logs', label: 'Device Logs', badge: 'Beta' }]} urlSync>
202
+ {() => null}
203
+ </TabNavigation>,
204
+ );
205
+
206
+ const tab = screen.getByRole('button', { name: 'Device Logs (Beta)' });
207
+ expect(tab.textContent).toBe('Device LogsBeta');
208
+ // Tabs without a badge keep their label as the accessible name.
209
+ expect(screen.getByRole('button', { name: 'General' }).textContent).toBe('General');
210
+ });
211
+ });
212
+
159
213
  describe('the underline', () => {
160
214
  it('is sized in real pixels rather than a scaled 1px bar', () => {
161
215
  // Regression guard. The underline used to be `w-px` stretched with