@flamingo-stack/openframe-frontend-core 0.0.684 → 0.0.685-2452.7452.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 (170) 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-K2HZG4RM.cjs → chunk-2OD2PPNO.cjs} +32 -32
  4. package/dist/{chunk-K2HZG4RM.cjs.map → chunk-2OD2PPNO.cjs.map} +1 -1
  5. package/dist/{chunk-QFJHEDZV.js → chunk-3NY5ILEE.js} +3 -3
  6. package/dist/{chunk-QMUALBRX.js → chunk-4A5PYSPH.js} +6 -5
  7. package/dist/{chunk-QMUALBRX.js.map → chunk-4A5PYSPH.js.map} +1 -1
  8. package/dist/{chunk-CHFHYEZR.js → chunk-5QXN54QK.js} +3 -3
  9. package/dist/{chunk-6T4RTIW6.cjs → chunk-7CTJLBZM.cjs} +5 -5
  10. package/dist/{chunk-6T4RTIW6.cjs.map → chunk-7CTJLBZM.cjs.map} +1 -1
  11. package/dist/{chunk-ZYHN5ABN.js → chunk-7GKYMBQ4.js} +4 -4
  12. package/dist/{chunk-GJ7UAN7G.js → chunk-AELY4TAA.js} +3 -3
  13. package/dist/{chunk-SHNSCUIR.cjs → chunk-AKFA7ZDD.cjs} +28 -28
  14. package/dist/{chunk-SHNSCUIR.cjs.map → chunk-AKFA7ZDD.cjs.map} +1 -1
  15. package/dist/{chunk-PUZQENGY.js → chunk-BYG2NLD7.js} +3 -3
  16. package/dist/{chunk-VW4JDXAT.js → chunk-EXZPVTUM.js} +2086 -1644
  17. package/dist/chunk-EXZPVTUM.js.map +1 -0
  18. package/dist/{chunk-XQXIFRFQ.js → chunk-FTPDLV7B.js} +4 -4
  19. package/dist/{chunk-WCZJQIDW.js → chunk-G6G6R3HQ.js} +3 -3
  20. package/dist/{chunk-4IRROD7M.cjs → chunk-H5JOWXGI.cjs} +30 -30
  21. package/dist/{chunk-4IRROD7M.cjs.map → chunk-H5JOWXGI.cjs.map} +1 -1
  22. package/dist/{chunk-TUG7IT6H.js → chunk-HLEXYTOE.js} +2 -2
  23. package/dist/{chunk-UHLN2S7J.cjs → chunk-HTIOL44C.cjs} +1247 -805
  24. package/dist/chunk-HTIOL44C.cjs.map +1 -0
  25. package/dist/{chunk-777QY5YZ.cjs → chunk-ILMGYP66.cjs} +3 -3
  26. package/dist/{chunk-777QY5YZ.cjs.map → chunk-ILMGYP66.cjs.map} +1 -1
  27. package/dist/{chunk-7GSLZBGB.cjs → chunk-JEEYLBPQ.cjs} +10 -10
  28. package/dist/{chunk-7GSLZBGB.cjs.map → chunk-JEEYLBPQ.cjs.map} +1 -1
  29. package/dist/{chunk-KSZBUUTY.js → chunk-KLWCURLZ.js} +6 -6
  30. package/dist/{chunk-7HROJ2CB.cjs → chunk-KVOA22BW.cjs} +81 -81
  31. package/dist/{chunk-7HROJ2CB.cjs.map → chunk-KVOA22BW.cjs.map} +1 -1
  32. package/dist/{chunk-FVEQYZDH.js → chunk-L5OLMZGC.js} +7 -7
  33. package/dist/{chunk-64MGLDCX.cjs → chunk-LSY7OGTC.cjs} +384 -383
  34. package/dist/chunk-LSY7OGTC.cjs.map +1 -0
  35. package/dist/{chunk-J5CGC6ES.cjs → chunk-MGYWANYK.cjs} +12 -12
  36. package/dist/{chunk-J5CGC6ES.cjs.map → chunk-MGYWANYK.cjs.map} +1 -1
  37. package/dist/{chunk-E3G22CS7.cjs → chunk-OFYXFNVF.cjs} +33 -33
  38. package/dist/{chunk-E3G22CS7.cjs.map → chunk-OFYXFNVF.cjs.map} +1 -1
  39. package/dist/{chunk-DJ5UHDWB.cjs → chunk-OIAI5J35.cjs} +59 -59
  40. package/dist/{chunk-DJ5UHDWB.cjs.map → chunk-OIAI5J35.cjs.map} +1 -1
  41. package/dist/{chunk-DQOQ3KTP.cjs → chunk-QNWQQ4RT.cjs} +78 -73
  42. package/dist/chunk-QNWQQ4RT.cjs.map +1 -0
  43. package/dist/{chunk-5GMTMALA.js → chunk-ROOCATH7.js} +6 -6
  44. package/dist/{chunk-7MCK6KMW.js → chunk-RXFHPJ5O.js} +5 -5
  45. package/dist/{chunk-P7TNZJBZ.cjs → chunk-SO5NWQMG.cjs} +6 -6
  46. package/dist/{chunk-P7TNZJBZ.cjs.map → chunk-SO5NWQMG.cjs.map} +1 -1
  47. package/dist/{chunk-B5JLPHHE.js → chunk-THLCPUEG.js} +2 -2
  48. package/dist/{chunk-WA4O5ILG.js → chunk-TKRGKDNG.js} +5 -5
  49. package/dist/{chunk-KCEACU3F.js → chunk-URJD7BME.js} +3 -3
  50. package/dist/{chunk-OKXB2A2F.cjs → chunk-UU7NOXIK.cjs} +7 -7
  51. package/dist/{chunk-OKXB2A2F.cjs.map → chunk-UU7NOXIK.cjs.map} +1 -1
  52. package/dist/{chunk-CZUO5KVD.cjs → chunk-XWP5E453.cjs} +20 -20
  53. package/dist/{chunk-CZUO5KVD.cjs.map → chunk-XWP5E453.cjs.map} +1 -1
  54. package/dist/{chunk-ERTG6C5E.cjs → chunk-YTVZJNJ7.cjs} +22 -22
  55. package/dist/{chunk-ERTG6C5E.cjs.map → chunk-YTVZJNJ7.cjs.map} +1 -1
  56. package/dist/{chunk-VNNEAPXL.cjs → chunk-YVARUYL5.cjs} +15 -15
  57. package/dist/{chunk-VNNEAPXL.cjs.map → chunk-YVARUYL5.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/index.cjs +27 -9
  121. package/dist/index.cjs.map +1 -1
  122. package/dist/index.js +25 -7
  123. package/dist/index.js.map +1 -1
  124. package/package.json +1 -1
  125. package/src/components/chat/__tests__/chat-appearance.test.tsx +79 -0
  126. package/src/components/chat/__tests__/onboarding-carousel.test.tsx +56 -0
  127. package/src/components/chat/ai-assistant-info.tsx +7 -1
  128. package/src/components/chat/approval-request-message.tsx +6 -1
  129. package/src/components/chat/chat-appearance-context.tsx +15 -0
  130. package/src/components/chat/chat-input.tsx +26 -1
  131. package/src/components/chat/chat-message-enhanced.tsx +180 -142
  132. package/src/components/chat/chat-message-list.tsx +169 -164
  133. package/src/components/chat/chat-nav-sidebar.tsx +163 -0
  134. package/src/components/chat/chat-top-navigation.tsx +109 -0
  135. package/src/components/chat/error-message-display.tsx +21 -3
  136. package/src/components/chat/index.ts +6 -0
  137. package/src/components/chat/msp-organization-card.tsx +35 -0
  138. package/src/components/chat/onboarding-carousel.tsx +162 -0
  139. package/src/components/chat/system-event-message.tsx +54 -0
  140. package/src/components/chat/types/chat.types.ts +15 -0
  141. package/src/components/chat/types/component.types.ts +20 -0
  142. package/src/components/ui/__tests__/avatar-stack.test.tsx +25 -0
  143. package/src/components/ui/avatar-stack.tsx +15 -5
  144. package/src/components/ui/reopen-ticket-request-modal.tsx +6 -2
  145. package/src/stories/FaeLayoutV2.stories.tsx +150 -0
  146. package/src/stories/FaeOnboardingV2.stories.tsx +68 -0
  147. package/src/stories/FaeThreadV2.stories.tsx +192 -0
  148. package/src/stories/fae-brand-decorator.tsx +28 -0
  149. package/src/styles/ods-colors.css +10 -0
  150. package/src/styles/ods-effects.css +13 -0
  151. package/dist/chunk-64MGLDCX.cjs.map +0 -1
  152. package/dist/chunk-DQOQ3KTP.cjs.map +0 -1
  153. package/dist/chunk-T6KWNJDW.js.map +0 -1
  154. package/dist/chunk-UHLN2S7J.cjs.map +0 -1
  155. package/dist/chunk-VW4JDXAT.js.map +0 -1
  156. /package/dist/{chunk-QFJHEDZV.js.map → chunk-3NY5ILEE.js.map} +0 -0
  157. /package/dist/{chunk-CHFHYEZR.js.map → chunk-5QXN54QK.js.map} +0 -0
  158. /package/dist/{chunk-ZYHN5ABN.js.map → chunk-7GKYMBQ4.js.map} +0 -0
  159. /package/dist/{chunk-GJ7UAN7G.js.map → chunk-AELY4TAA.js.map} +0 -0
  160. /package/dist/{chunk-PUZQENGY.js.map → chunk-BYG2NLD7.js.map} +0 -0
  161. /package/dist/{chunk-XQXIFRFQ.js.map → chunk-FTPDLV7B.js.map} +0 -0
  162. /package/dist/{chunk-WCZJQIDW.js.map → chunk-G6G6R3HQ.js.map} +0 -0
  163. /package/dist/{chunk-TUG7IT6H.js.map → chunk-HLEXYTOE.js.map} +0 -0
  164. /package/dist/{chunk-KSZBUUTY.js.map → chunk-KLWCURLZ.js.map} +0 -0
  165. /package/dist/{chunk-FVEQYZDH.js.map → chunk-L5OLMZGC.js.map} +0 -0
  166. /package/dist/{chunk-5GMTMALA.js.map → chunk-ROOCATH7.js.map} +0 -0
  167. /package/dist/{chunk-7MCK6KMW.js.map → chunk-RXFHPJ5O.js.map} +0 -0
  168. /package/dist/{chunk-B5JLPHHE.js.map → chunk-THLCPUEG.js.map} +0 -0
  169. /package/dist/{chunk-WA4O5ILG.js.map → chunk-TKRGKDNG.js.map} +0 -0
  170. /package/dist/{chunk-KCEACU3F.js.map → chunk-URJD7BME.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
+ });
@@ -33,6 +33,10 @@ export interface AvatarStackProps {
33
33
  * so the text beside every stack starts at the same x.
34
34
  */
35
35
  slots?: number;
36
+ /** How many people the group has when `people` is only a sample of it (a
37
+ * server that returns the first few faces plus a count). The "+N" circle
38
+ * then counts everyone not shown. Defaults to `people.length`. */
39
+ total?: number;
36
40
  /** Size bucket (also sizes the "+N" circle). `xs` (24px) is for
37
41
  * dense card meta rows (chat compact cards, delivery rows). */
38
42
  size?: AvatarStackSize;
@@ -63,22 +67,28 @@ export function AvatarStack({
63
67
  people,
64
68
  max = 3,
65
69
  slots,
70
+ total,
66
71
  size = 'md',
67
72
  ringClassName = 'ring-ods-card',
68
73
  label = 'Assignees',
69
74
  className,
70
75
  }: AvatarStackProps) {
71
76
  if (people.length === 0) return null;
72
- const faces = slots ? (people.length > slots ? slots - 1 : slots) : max;
77
+ const groupSize = Math.max(total ?? people.length, people.length);
78
+ const faces = slots ? (groupSize > slots ? slots - 1 : slots) : max;
73
79
  const visible = people.slice(0, faces);
74
80
  const overflow = people.slice(faces);
81
+ const overflowCount = groupSize - visible.length;
82
+ const names = people.map(p => p.name).join(', ');
83
+ // Names beyond the sample are unknown here; say how many there are instead.
84
+ const unnamed = groupSize - people.length;
75
85
  const compact = size === 'xs';
76
86
  return (
77
87
  <div
78
88
  className={cn('flex items-center', slots && 'shrink-0', className)}
79
89
  style={slots ? { width: SIZE_PX[size] + (slots - 1) * (SIZE_PX[size] - OVERLAP_PX[size]) } : undefined}
80
90
  role="group"
81
- aria-label={`${label}: ${people.map(p => p.name).join(', ')}`}
91
+ aria-label={`${label}: ${names}${unnamed > 0 ? ` and ${unnamed} more` : ''}`}
82
92
  >
83
93
  {visible.map((person, i) => (
84
94
  <SquareAvatar
@@ -95,9 +105,9 @@ export function AvatarStack({
95
105
  style={{ zIndex: visible.length - i }}
96
106
  />
97
107
  ))}
98
- {overflow.length > 0 && (
108
+ {overflowCount > 0 && (
99
109
  <span
100
- title={overflow.map(p => p.name).join(', ')}
110
+ title={overflow.length > 0 ? overflow.map(p => p.name).join(', ') : `${overflowCount} more`}
101
111
  className={cn(
102
112
  'relative z-0 flex shrink-0 items-center justify-center rounded-full bg-ods-bg text-ods-text-secondary ring-2',
103
113
  ringClassName,
@@ -109,7 +119,7 @@ export function AvatarStack({
109
119
  OVERFLOW_CIRCLE_SIZE[size],
110
120
  )}
111
121
  >
112
- +{overflow.length}
122
+ +{overflowCount}
113
123
  </span>
114
124
  )}
115
125
  </div>
@@ -28,6 +28,9 @@ export interface ReopenTicketRequestModalProps {
28
28
  * omits `handoffToTechnician`.
29
29
  */
30
30
  showHandoffOption?: boolean;
31
+ /** Opens with the handoff checkbox already ticked — the user reached the
32
+ * modal by asking for a technician, so that is the choice to confirm. */
33
+ defaultHandoff?: boolean;
31
34
  /** Confirm in-flight: buttons lock and the modal refuses to close. */
32
35
  isPending?: boolean;
33
36
  onConfirm: (selection: ReopenTicketRequestSelection) => void;
@@ -45,11 +48,12 @@ export function ReopenTicketRequestModal({
45
48
  isOpen,
46
49
  onClose,
47
50
  showHandoffOption = false,
51
+ defaultHandoff = false,
48
52
  isPending = false,
49
53
  onConfirm,
50
54
  }: ReopenTicketRequestModalProps) {
51
55
  const [reason, setReason] = useState('');
52
- const [handoff, setHandoff] = useState(false);
56
+ const [handoff, setHandoff] = useState(defaultHandoff);
53
57
 
54
58
  // Re-seed the inputs every time the modal opens. Adjusted while rendering,
55
59
  // not from an effect: the modal does not unmount when it closes, so an effect
@@ -60,7 +64,7 @@ export function ReopenTicketRequestModal({
60
64
  setWasOpen(isOpen);
61
65
  if (isOpen) {
62
66
  setReason('');
63
- setHandoff(false);
67
+ setHandoff(defaultHandoff);
64
68
  }
65
69
  }
66
70