@flamingo-stack/openframe-frontend-core 0.0.438-snapshot.20260716203356 → 0.0.438

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 (121) hide show
  1. package/dist/{chunk-HTVVXVJL.cjs → chunk-2DCP2TIF.cjs} +1490 -1966
  2. package/dist/chunk-2DCP2TIF.cjs.map +1 -0
  3. package/dist/{chunk-PBTY7AH5.js → chunk-44HNH57W.js} +4 -4
  4. package/dist/{chunk-G5FALXNO.js → chunk-5D5SJOLE.js} +4 -4
  5. package/dist/{chunk-NQQ4NLUT.js → chunk-6ETVJ5ON.js} +6101 -6577
  6. package/dist/chunk-6ETVJ5ON.js.map +1 -0
  7. package/dist/{chunk-3WJQL4DN.cjs → chunk-6R5SZPAQ.cjs} +15 -15
  8. package/dist/{chunk-3WJQL4DN.cjs.map → chunk-6R5SZPAQ.cjs.map} +1 -1
  9. package/dist/{chunk-P7PISHM5.js → chunk-A25RPWYF.js} +2 -2
  10. package/dist/{chunk-AUJXLVXA.cjs → chunk-B5SGPIJ4.cjs} +37 -37
  11. package/dist/{chunk-AUJXLVXA.cjs.map → chunk-B5SGPIJ4.cjs.map} +1 -1
  12. package/dist/{chunk-VWGF6IFR.cjs → chunk-CMP56RUC.cjs} +62 -62
  13. package/dist/{chunk-VWGF6IFR.cjs.map → chunk-CMP56RUC.cjs.map} +1 -1
  14. package/dist/{chunk-F7YVI54P.js → chunk-D5RTZ2WN.js} +4 -4
  15. package/dist/{chunk-R46QEE3R.cjs → chunk-DTYPYYKD.cjs} +59 -59
  16. package/dist/{chunk-R46QEE3R.cjs.map → chunk-DTYPYYKD.cjs.map} +1 -1
  17. package/dist/{chunk-RQQZF6G6.cjs → chunk-H2TQ44VD.cjs} +31 -31
  18. package/dist/{chunk-RQQZF6G6.cjs.map → chunk-H2TQ44VD.cjs.map} +1 -1
  19. package/dist/{chunk-7WM77CSW.js → chunk-KBFQF4AV.js} +2 -2
  20. package/dist/{chunk-2ELBNZJ3.js → chunk-LR6A67KZ.js} +2 -2
  21. package/dist/{chunk-ZMUFTGL7.cjs → chunk-PCMDRDYX.cjs} +5 -5
  22. package/dist/{chunk-ZMUFTGL7.cjs.map → chunk-PCMDRDYX.cjs.map} +1 -1
  23. package/dist/{chunk-WAGTZVQN.js → chunk-POFDZH2C.js} +4 -4
  24. package/dist/{chunk-EGRZHR7J.js → chunk-RTHOCS3S.js} +2 -2
  25. package/dist/{chunk-M3LD6DNE.cjs → chunk-SYTL2VIU.cjs} +7 -7
  26. package/dist/{chunk-M3LD6DNE.cjs.map → chunk-SYTL2VIU.cjs.map} +1 -1
  27. package/dist/{chunk-WZ6TCAYQ.cjs → chunk-VYG2CJFJ.cjs} +9 -9
  28. package/dist/{chunk-WZ6TCAYQ.cjs.map → chunk-VYG2CJFJ.cjs.map} +1 -1
  29. package/dist/{chunk-COMBYYQY.js → chunk-W2PRXXUD.js} +2 -2
  30. package/dist/{chunk-IAN7GUIV.cjs → chunk-WF7VGEYR.cjs} +26 -26
  31. package/dist/{chunk-IAN7GUIV.cjs.map → chunk-WF7VGEYR.cjs.map} +1 -1
  32. package/dist/{chunk-C3FH53AP.cjs → chunk-X66KRL5G.cjs} +11 -11
  33. package/dist/{chunk-C3FH53AP.cjs.map → chunk-X66KRL5G.cjs.map} +1 -1
  34. package/dist/{chunk-WPVBTVI3.js → chunk-XFSDFO6U.js} +2 -2
  35. package/dist/{chunk-M6QGB6DC.cjs → chunk-YKP2F2NC.cjs} +26 -26
  36. package/dist/{chunk-M6QGB6DC.cjs.map → chunk-YKP2F2NC.cjs.map} +1 -1
  37. package/dist/{chunk-PEIR7D25.js → chunk-ZEY7A3MP.js} +2 -2
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/chat-archive-page.d.ts +8 -28
  41. package/dist/components/chat/chat-archive-page.d.ts.map +1 -1
  42. package/dist/components/chat/chat-header-icon-button.d.ts +2 -6
  43. package/dist/components/chat/chat-header-icon-button.d.ts.map +1 -1
  44. package/dist/components/chat/chat-panel-header.d.ts +9 -19
  45. package/dist/components/chat/chat-panel-header.d.ts.map +1 -1
  46. package/dist/components/chat/embeddable-chat.d.ts +6 -15
  47. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  48. package/dist/components/chat/hooks/use-chat-dialog-manager.d.ts +0 -2
  49. package/dist/components/chat/hooks/use-chat-dialog-manager.d.ts.map +1 -1
  50. package/dist/components/chat/index.cjs +2 -4
  51. package/dist/components/chat/index.cjs.map +1 -1
  52. package/dist/components/chat/index.d.ts +0 -2
  53. package/dist/components/chat/index.d.ts.map +1 -1
  54. package/dist/components/chat/index.js +3 -5
  55. package/dist/components/chat/mingo-chat-history.d.ts +10 -5
  56. package/dist/components/chat/mingo-chat-history.d.ts.map +1 -1
  57. package/dist/components/chat/mingo-welcome.d.ts +25 -6
  58. package/dist/components/chat/mingo-welcome.d.ts.map +1 -1
  59. package/dist/components/contact/index.cjs +3 -3
  60. package/dist/components/contact/index.js +2 -2
  61. package/dist/components/docs/index.cjs +5 -5
  62. package/dist/components/docs/index.js +4 -4
  63. package/dist/components/embeds/index.cjs +3 -3
  64. package/dist/components/embeds/index.js +2 -2
  65. package/dist/components/faq/index.cjs +3 -3
  66. package/dist/components/faq/index.js +2 -2
  67. package/dist/components/features/index.cjs +2 -2
  68. package/dist/components/features/index.js +1 -1
  69. package/dist/components/help-center-pages/index.cjs +20 -20
  70. package/dist/components/help-center-pages/index.js +11 -11
  71. package/dist/components/index.cjs +128 -130
  72. package/dist/components/index.cjs.map +1 -1
  73. package/dist/components/index.js +11 -13
  74. package/dist/components/index.js.map +1 -1
  75. package/dist/components/navigation/index.cjs +2 -2
  76. package/dist/components/navigation/index.js +1 -1
  77. package/dist/components/onboarding-guides/index.cjs +5 -5
  78. package/dist/components/onboarding-guides/index.js +4 -4
  79. package/dist/components/related-content/index.cjs +3 -3
  80. package/dist/components/related-content/index.js +2 -2
  81. package/dist/components/tickets/index.cjs +5 -5
  82. package/dist/components/tickets/index.js +4 -4
  83. package/dist/components/ui/index.cjs +2 -4
  84. package/dist/components/ui/index.cjs.map +1 -1
  85. package/dist/components/ui/index.js +3 -5
  86. package/dist/index.cjs +2 -4
  87. package/dist/index.cjs.map +1 -1
  88. package/dist/index.js +3 -5
  89. package/package.json +1 -1
  90. package/src/components/chat/chat-archive-page.tsx +73 -89
  91. package/src/components/chat/chat-header-icon-button.tsx +5 -12
  92. package/src/components/chat/chat-panel-header.tsx +29 -76
  93. package/src/components/chat/embeddable-chat.tsx +125 -548
  94. package/src/components/chat/guide-welcome.tsx +4 -4
  95. package/src/components/chat/hooks/use-chat-dialog-manager.ts +5 -59
  96. package/src/components/chat/index.ts +0 -2
  97. package/src/components/chat/mingo-chat-history.tsx +100 -46
  98. package/src/components/chat/mingo-welcome.tsx +98 -17
  99. package/src/stories/MingoWelcome.stories.tsx +11 -2
  100. package/dist/chunk-HTVVXVJL.cjs.map +0 -1
  101. package/dist/chunk-NQQ4NLUT.js.map +0 -1
  102. package/dist/components/chat/chat-header-search-field.d.ts +0 -25
  103. package/dist/components/chat/chat-header-search-field.d.ts.map +0 -1
  104. package/dist/components/chat/chat-list-empty-state.d.ts +0 -20
  105. package/dist/components/chat/chat-list-empty-state.d.ts.map +0 -1
  106. package/dist/components/chat/mingo-history-rail.d.ts +0 -54
  107. package/dist/components/chat/mingo-history-rail.d.ts.map +0 -1
  108. package/src/components/chat/chat-header-search-field.tsx +0 -144
  109. package/src/components/chat/chat-list-empty-state.tsx +0 -44
  110. package/src/components/chat/mingo-history-rail.tsx +0 -161
  111. /package/dist/{chunk-PBTY7AH5.js.map → chunk-44HNH57W.js.map} +0 -0
  112. /package/dist/{chunk-G5FALXNO.js.map → chunk-5D5SJOLE.js.map} +0 -0
  113. /package/dist/{chunk-P7PISHM5.js.map → chunk-A25RPWYF.js.map} +0 -0
  114. /package/dist/{chunk-F7YVI54P.js.map → chunk-D5RTZ2WN.js.map} +0 -0
  115. /package/dist/{chunk-7WM77CSW.js.map → chunk-KBFQF4AV.js.map} +0 -0
  116. /package/dist/{chunk-2ELBNZJ3.js.map → chunk-LR6A67KZ.js.map} +0 -0
  117. /package/dist/{chunk-WAGTZVQN.js.map → chunk-POFDZH2C.js.map} +0 -0
  118. /package/dist/{chunk-EGRZHR7J.js.map → chunk-RTHOCS3S.js.map} +0 -0
  119. /package/dist/{chunk-COMBYYQY.js.map → chunk-W2PRXXUD.js.map} +0 -0
  120. /package/dist/{chunk-WPVBTVI3.js.map → chunk-XFSDFO6U.js.map} +0 -0
  121. /package/dist/{chunk-PEIR7D25.js.map → chunk-ZEY7A3MP.js.map} +0 -0
@@ -168,17 +168,17 @@ export function GuideWelcome({
168
168
  cornerColor="var(--ods-flamingo-cyan-base)"
169
169
  />
170
170
  )}
171
- <div className="flex w-full flex-col gap-[var(--spacing-system-l)]">
172
- <p className="text-h2 text-ods-text-primary">{title}</p>
171
+ <div className="flex w-full flex-col gap-1">
172
+ <p className="text-h4 text-ods-text-primary">{title}</p>
173
173
  {/* Sub-line: while the greeting is still being fetched show a
174
174
  one-line skeleton; once settled, render the greeting (admin
175
175
  copy / host override) or nothing — no built-in default, so by
176
176
  default the empty state shows the title alone. */}
177
177
  {subtitle ? (
178
- <p className="text-h4 text-ods-text-secondary">{subtitle}</p>
178
+ <p className="text-h6 text-ods-text-secondary">{subtitle}</p>
179
179
  ) : subtitleLoading ? (
180
180
  <div className="flex w-full justify-center">
181
- <Skeleton className="h-5 w-3/4 max-w-80 rounded-sm" />
181
+ <Skeleton className="h-4 w-3/4 max-w-80 rounded-sm" />
182
182
  </div>
183
183
  ) : null}
184
184
  </div>
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useMemo, useRef, useState } from 'react'
3
+ import { useCallback, useMemo, useState } from 'react'
4
4
  import type { DialogItem } from '../types/component.types'
5
5
  import type {
6
6
  FetchDialogsParams,
@@ -84,68 +84,28 @@ export function useChatDialogManager({
84
84
  const [archivedDialogs, setArchivedDialogs] = useState<DialogItem[]>([])
85
85
  const [archivedCursor, setArchivedCursor] = useState<string | null>(null)
86
86
  const [archivedLoading, setArchivedLoading] = useState(false)
87
- // A fetch is in flight, tracked SEPARATELY from the delayed skeleton
88
- // (`archivedLoading`). Set synchronously so the empty state stays suppressed
89
- // during the 120ms skeleton delay — otherwise an uncached first open flashes
90
- // "No archived chats" before the skeleton/data appears.
91
- const [archivedPending, setArchivedPending] = useState(false)
92
- // Monotonic request id — a background page-1 refresh and a pagination fetch
93
- // can overlap (the retained list stays interactive while page 1 revalidates).
94
- // Only the LATEST request may mutate the list / cursor / loading flags, so a
95
- // slow earlier response can't clobber newer rows, append stale ones, or clear
96
- // the shared loading flag while another request is still in flight.
97
- const archivedRequestIdRef = useRef(0)
98
87
  const loadArchivedPage = useCallback(
99
88
  async (cursor?: string): Promise<void> => {
100
89
  if (!fetchArchivedDialogs) return
101
- const requestId = ++archivedRequestIdRef.current
102
- const isCurrent = () => archivedRequestIdRef.current === requestId
103
- // Only a first-page load drives the full-view skeleton, and only if it's
104
- // actually slow: a 120ms delay means a cached/fast fetch (the host caches
105
- // page 1) resolves first and never flashes a skeleton. Pagination
106
- // (`cursor`) keeps the current list visible.
107
- const isInitial = !cursor
108
- setArchivedPending(true)
109
- let skeletonTimer: ReturnType<typeof setTimeout> | undefined
110
- if (isInitial) {
111
- // Delay the full-view skeleton so a cached/fast page-1 fetch never
112
- // flashes it (and skip it if a newer request already superseded us).
113
- skeletonTimer = setTimeout(() => {
114
- if (isCurrent()) setArchivedLoading(true)
115
- }, 120)
116
- } else {
117
- // Pagination shows its "load more" indicator immediately (the list
118
- // stays visible), so no delay here.
119
- setArchivedLoading(true)
120
- }
90
+ setArchivedLoading(true)
121
91
  try {
122
92
  const result = await fetchArchivedDialogs({ cursor, limit: 20 })
123
- // A newer request superseded this one — drop the response so overlapping
124
- // refresh / pagination can't append stale rows or clobber newer results.
125
- if (!isCurrent()) return
126
93
  setArchivedCursor(result.nextCursor)
127
94
  setArchivedDialogs((prev) =>
128
95
  cursor ? [...prev, ...result.dialogs] : result.dialogs,
129
96
  )
130
97
  } catch (err) {
131
- if (!isCurrent()) return
132
98
  console.error('[useChatDialogManager] fetchArchivedDialogs failed:', err)
133
99
  } finally {
134
- if (skeletonTimer) clearTimeout(skeletonTimer)
135
- // Only the latest request owns the shared loading / pending flags.
136
- if (isCurrent()) {
137
- setArchivedLoading(false)
138
- setArchivedPending(false)
139
- }
100
+ setArchivedLoading(false)
140
101
  }
141
102
  },
142
103
  [fetchArchivedDialogs],
143
104
  )
144
105
  const openArchive = useCallback(() => {
145
106
  setArchiveOpen(true)
146
- // Don't clear the list — keep any already-loaded page visible and refresh
147
- // page 1 in the background (stale-while-revalidate), so reopening the
148
- // archive within a session doesn't blank out to a skeleton.
107
+ setArchivedDialogs([])
108
+ setArchivedCursor(null)
149
109
  void loadArchivedPage()
150
110
  }, [loadArchivedPage])
151
111
  const closeArchive = useCallback(() => setArchiveOpen(false), [])
@@ -223,18 +183,6 @@ export function useChatDialogManager({
223
183
  if (wasArchived) setArchiveOpen(true)
224
184
  }, [isViewingArchived, selectDialog, clearMessages])
225
185
 
226
- // "Start New Chat" reset — clears the open conversation and ALWAYS lands on
227
- // the new-chat welcome. Unlike `handleBack`, it never reopens the archive
228
- // (backing out of an archived chat returns to the archive; starting a NEW
229
- // chat must not), so the archive is force-closed here regardless of state.
230
- const resetToNewChat = useCallback(() => {
231
- setViewingArchivedId(null)
232
- setOpeningDialogId(null)
233
- selectDialog(null)
234
- clearMessages()
235
- setArchiveOpen(false)
236
- }, [selectDialog, clearMessages])
237
-
238
186
  // Active-conversation dialog — resolved from the active list, or the
239
187
  // archived list when an archived chat is open (archived dialogs aren't in
240
188
  // `dialogs`, so the header title and ⋯ / restore actions need this).
@@ -254,7 +202,6 @@ export function useChatDialogManager({
254
202
  archivedDialogs,
255
203
  archivedCursor,
256
204
  archivedLoading,
257
- archivedPending,
258
205
  openArchive,
259
206
  closeArchive,
260
207
  loadArchivedPage,
@@ -265,7 +212,6 @@ export function useChatDialogManager({
265
212
  // archived conversation
266
213
  isViewingArchived,
267
214
  handleBack,
268
- resetToNewChat,
269
215
  activeDialog,
270
216
  // action modals
271
217
  renameTarget,
@@ -31,10 +31,8 @@ export * from './msp-organization-card'
31
31
  export * from './msp-organization-card-skeleton'
32
32
  export * from './guide-mode-banner'
33
33
  export * from './mingo-chat-history'
34
- export * from './mingo-history-rail'
35
34
  export * from './mingo-chat-modals'
36
35
  export * from './chat-header-icon-button'
37
- export * from './chat-header-search-field'
38
36
  export * from './chat-panel-header'
39
37
  export * from './chat-composer'
40
38
  export * from './chat-context-picker'
@@ -2,11 +2,12 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { cn } from '../../utils/cn'
5
+ import { useDebounce } from '../../hooks/ui/use-debounce'
5
6
  import { ActionsMenuDropdown, type ActionsMenuItem } from '../ui/actions-menu'
6
7
  import { Button } from '../ui/button'
8
+ import { Input } from '../ui/input'
7
9
  import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade'
8
- import { Ellipsis01Icon, SearchXmarkIcon } from '../icons-v2-generated'
9
- import { ChatListEmptyState } from './chat-list-empty-state'
10
+ import { Ellipsis01Icon, SearchIcon } from '../icons-v2-generated'
10
11
  import type { DialogItem } from './types/component.types'
11
12
 
12
13
  // =============================================================================
@@ -26,10 +27,12 @@ export interface MingoChatHistoryProps {
26
27
  /** Request archive — enables the row "Archive chat" action. The host opens
27
28
  * the Archive confirmation modal. */
28
29
  onRequestArchive?: (dialog: DialogItem) => void
29
- /** Current server-side search term. Drives the "No chats found" empty state;
30
- * the search INPUT lives in the panel header, not in this list. The host
31
- * owns the term and refetches server-side — the list does no filtering. */
30
+ /** Seed value for the search input. The host owns the search term and refetches
31
+ * the dialog list server-side; the list itself does no filtering. */
32
32
  searchQuery?: string
33
+ /** Emit the (debounced) search term — enables the dialog search bar above the
34
+ * list. Omit to hide the search bar entirely. */
35
+ onSearchChange?: (query: string) => void
33
36
  /** Whether more dialogs remain (cursor pagination). */
34
37
  hasMore?: boolean
35
38
  /** True while the next page is loading. */
@@ -131,21 +134,11 @@ function MingoChatHistoryRow({
131
134
  return (
132
135
  <div
133
136
  className={cn(
134
- 'group/row relative flex h-12 items-center border-b border-ods-border last:border-b-0 transition-colors',
135
- // Active (selected) dialog — Figma 259:91610: an open-yellow-secondary
136
- // fill, a 4px open-yellow accent bar down the leading edge, and yellow
137
- // title text. Inactive rows keep the dark surface with a hover tint.
138
- isActive
139
- ? 'bg-ods-open-yellow-secondary'
140
- : 'bg-ods-bg hover:bg-ods-bg-hover',
137
+ 'group/row flex h-12 items-center bg-ods-bg border-b border-ods-border last:border-b-0',
138
+ 'transition-colors hover:bg-ods-bg-hover',
139
+ isActive && 'bg-ods-bg-hover',
141
140
  )}
142
141
  >
143
- {isActive ? (
144
- <span
145
- aria-hidden
146
- className="pointer-events-none absolute inset-y-0 left-0 w-1 bg-ods-open-yellow"
147
- />
148
- ) : null}
149
142
  <div
150
143
  role="button"
151
144
  tabIndex={0}
@@ -156,7 +149,7 @@ function MingoChatHistoryRow({
156
149
  onSelect?.(dialog.id)
157
150
  }
158
151
  }}
159
- className="flex min-w-0 flex-1 items-center gap-[var(--spacing-system-xsf)] p-[var(--spacing-system-s)] cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ods-accent"
152
+ className="flex min-w-0 flex-1 items-center gap-[var(--spacing-system-xsf)] p-[var(--spacing-system-s)] cursor-pointer focus:outline-none focus-visible:bg-ods-bg-hover"
160
153
  >
161
154
  {unread > 0 ? (
162
155
  <span className="flex h-8 min-w-8 shrink-0 items-center justify-center rounded-md bg-ods-accent px-[var(--spacing-system-xsf)] text-h5 text-ods-text-on-accent">
@@ -164,10 +157,7 @@ function MingoChatHistoryRow({
164
157
  </span>
165
158
  ) : null}
166
159
  <span
167
- className={cn(
168
- 'min-w-0 flex-1 truncate text-h4',
169
- isActive ? 'text-ods-open-yellow' : 'text-ods-text-primary',
170
- )}
160
+ className="min-w-0 flex-1 truncate text-h4 text-ods-text-primary"
171
161
  title={title}
172
162
  >
173
163
  {title}
@@ -232,39 +222,61 @@ function MingoChatHistoryRow({
232
222
  */
233
223
  export function MingoChatHistorySkeleton({
234
224
  className,
225
+ searchable = false,
235
226
  }: {
236
227
  className?: string
228
+ /** Render a search-bar placeholder above the groups — match the real list,
229
+ * which shows the search bar only when search is wired. */
230
+ searchable?: boolean
237
231
  }) {
238
- // Mirror the real grouped list exactly: a small group label, then a bordered
239
- // rounded container of `h-12` rows, each with a single title bar of varying
240
- // width (no unread-badge squares — the real rows currently never show one, so
241
- // a badge placeholder would mis-promise the layout). `bg-ods-border` matches
242
- // the kit's canonical `Skeleton` placeholder colour; `bg-ods-bg` rows read a
243
- // touch darker than the `bg-ods-card` panel so the bars stay legible.
244
- const groups: ReadonlyArray<ReadonlyArray<string>> = [
245
- ['w-3/5', 'w-4/5', 'w-2/5', 'w-3/4'],
246
- ['w-1/2', 'w-2/3'],
232
+ // Mirror the real grouped list: a group label, then a bordered container of
233
+ // `h-12` rows. `withBadge` adds the leading unread-badge square on the first
234
+ // N rows; `widths` varies the title-bar length so the placeholder reads like
235
+ // a list of differing-length chat titles, not identical bars. `bg-ods-border`
236
+ // (not `bg-ods-bg-secondary`) so the placeholders are actually visible on the
237
+ // dark surface.
238
+ const groups: ReadonlyArray<{ withBadge: number; widths: ReadonlyArray<string> }> = [
239
+ { withBadge: 2, widths: ['w-3/5', 'w-4/5', 'w-2/5', 'w-3/4'] },
240
+ { withBadge: 0, widths: ['w-1/2', 'w-3/5', 'w-2/3'] },
247
241
  ]
248
242
  return (
249
243
  <div
250
244
  aria-hidden
251
245
  // `overflow-hidden` clips the placeholder rows to the flex-1 box (the real
252
- // list scrolls; the skeleton just needs to stay within bounds).
246
+ // list scrolls; the skeleton just needs to stay within bounds). Without it
247
+ // the rows bleed past their allotment and render under the pinned composer
248
+ // — which has no surface of its own — so the input appears to overlap them.
253
249
  className={cn(
254
250
  'flex flex-1 min-h-0 flex-col gap-[var(--spacing-system-m)] overflow-hidden',
255
251
  className,
256
252
  )}
257
253
  >
258
- {groups.map((widths, g) => (
254
+ {/* Search-bar placeholder — mirrors the real `Input` (border, rounded,
255
+ h-11/12) with a leading icon + text bar so the loading state matches
256
+ the searchable layout. */}
257
+ {searchable && (
258
+ <div className="flex h-11 md:h-12 shrink-0 items-center gap-2 rounded-[6px] border border-ods-border bg-ods-card px-3">
259
+ <div className="size-4 md:size-6 shrink-0 animate-pulse rounded bg-ods-border" />
260
+ <div className="h-4 w-32 animate-pulse rounded bg-ods-border" />
261
+ </div>
262
+ )}
263
+ {groups.map((group, g) => (
259
264
  <div key={g} className="flex shrink-0 flex-col gap-[var(--spacing-system-xxs)]">
260
- {/* Group label (Today / Older). */}
265
+ {/* Group label (Today / Yesterday). */}
261
266
  <div className="h-3 w-16 animate-pulse rounded bg-ods-border" />
262
267
  <div className="overflow-hidden rounded-md border border-ods-border">
263
- {widths.map((w, i) => (
268
+ {group.widths.map((w, i) => (
264
269
  <div
265
270
  key={i}
266
- className="flex h-12 items-center bg-ods-bg border-b border-ods-border px-[var(--spacing-system-s)] last:border-b-0"
271
+ // Match the real row surface: `bg-ods-bg` (#161616) sits darker
272
+ // than the `bg-ods-card` (#212121) panel, which is what makes
273
+ // the `bg-ods-border` (#3a3a3a) placeholders read clearly. On
274
+ // the bare card they'd be muddy (too close in value).
275
+ className="flex h-12 items-center gap-[var(--spacing-system-xsf)] bg-ods-bg border-b border-ods-border px-[var(--spacing-system-s)] last:border-b-0"
267
276
  >
277
+ {i < group.withBadge && (
278
+ <div className="h-8 w-8 shrink-0 animate-pulse rounded-md bg-ods-border" />
279
+ )}
268
280
  <div className={cn('h-4 animate-pulse rounded bg-ods-border', w)} />
269
281
  </div>
270
282
  ))}
@@ -275,6 +287,46 @@ export function MingoChatHistorySkeleton({
275
287
  )
276
288
  }
277
289
 
290
+ // =============================================================================
291
+ // Search bar
292
+ // =============================================================================
293
+
294
+ /**
295
+ * Dialog search field. Holds its own input text for snappy typing and emits
296
+ * the DEBOUNCED term via `onSearchChange` — the host owns the search state
297
+ * and refetches the dialog list server-side, so the list never
298
+ * filters locally. `initialValue` only seeds the field on mount.
299
+ */
300
+ function DialogSearchBar({
301
+ initialValue,
302
+ onSearchChange,
303
+ }: {
304
+ initialValue?: string
305
+ onSearchChange: (query: string) => void
306
+ }) {
307
+ const [value, setValue] = React.useState(initialValue ?? '')
308
+ const debounced = useDebounce(value, 300)
309
+ const lastEmitted = React.useRef(initialValue ?? '')
310
+
311
+ React.useEffect(() => {
312
+ if (debounced === lastEmitted.current) return
313
+ lastEmitted.current = debounced
314
+ onSearchChange(debounced)
315
+ }, [debounced, onSearchChange])
316
+
317
+ return (
318
+ <Input
319
+ type="text"
320
+ value={value}
321
+ onChange={(e) => setValue(e.target.value)}
322
+ placeholder="Search for Chats"
323
+ aria-label="Search chats"
324
+ startAdornment={<SearchIcon />}
325
+ className="shrink-0"
326
+ />
327
+ )
328
+ }
329
+
278
330
  // =============================================================================
279
331
  // Component
280
332
  // =============================================================================
@@ -297,6 +349,7 @@ export function MingoChatHistory({
297
349
  onRequestRename,
298
350
  onRequestArchive,
299
351
  searchQuery,
352
+ onSearchChange,
300
353
  hasMore = false,
301
354
  isLoadingMore = false,
302
355
  onLoadMore,
@@ -335,8 +388,13 @@ export function MingoChatHistory({
335
388
 
336
389
  return (
337
390
  <div className={cn('relative flex flex-1 min-h-0 flex-col gap-[var(--spacing-system-m)]', className)}>
338
- {/* Scroll region — the search INPUT now lives in the panel header
339
- (Figma 116:51217), so the list is just the grouped rows + fades. */}
391
+ {/* Pinned search bar — stays put while the grouped list scrolls below. */}
392
+ {onSearchChange ? (
393
+ <DialogSearchBar initialValue={searchQuery} onSearchChange={onSearchChange} />
394
+ ) : null}
395
+
396
+ {/* Scroll region — `relative` so the fades anchor here (below the search
397
+ bar), not over it. */}
340
398
  <div className="relative flex flex-1 min-h-0 flex-col">
341
399
  <div
342
400
  ref={scrollRef}
@@ -344,13 +402,9 @@ export function MingoChatHistory({
344
402
  className="flex flex-1 min-h-0 flex-col gap-[var(--spacing-system-m)] overflow-y-auto"
345
403
  >
346
404
  {noSearchResults ? (
347
- // No search matches — same centred glyph + title + caption layout as
348
- // the other chat-list empty states (Figma 113:60939).
349
- <ChatListEmptyState
350
- icon={<SearchXmarkIcon size={24} />}
351
- title="No Chats Found"
352
- description="Try a different search term"
353
- />
405
+ <p className="py-[var(--spacing-system-m)] text-center text-h5 text-ods-text-secondary">
406
+ No chats found
407
+ </p>
354
408
  ) : (
355
409
  groups.map((group) => (
356
410
  <div
@@ -10,6 +10,10 @@ import { Button } from '../ui/button'
10
10
  import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade'
11
11
  import { XmarkIcon } from '../icons-v2-generated/signs-and-symbols/xmark-icon'
12
12
  import {
13
+ Rocket01Icon,
14
+ BracketCurlyIcon,
15
+ SearchIcon,
16
+ LayersIcon,
13
17
  CompassIcon,
14
18
  Arrow01DownIcon,
15
19
  AlertCircleIcon,
@@ -20,6 +24,17 @@ import {
20
24
  // Types
21
25
  // =============================================================================
22
26
 
27
+ /** A single capability cell in the 2-up feature grid. */
28
+ export interface MingoFeatureCard {
29
+ /** Stable React key. */
30
+ id: string
31
+ /** Leading 16×16 icon (monochrome `ods-text-secondary`). */
32
+ icon?: React.ReactNode
33
+ /** Caption text. Line breaks (`\n`) are honoured (`whitespace-pre-line`)
34
+ * so the two-line wrap stays fixed regardless of panel width. */
35
+ text: React.ReactNode
36
+ }
37
+
23
38
  /** "New to OpenFrame?" one-time notification config. `null` hides the card. */
24
39
  export interface MingoWelcomePromo {
25
40
  title: React.ReactNode
@@ -42,10 +57,14 @@ export interface MingoQuickAction {
42
57
  }
43
58
 
44
59
  export interface MingoWelcomeProps {
45
- /** Greeting heading. Defaults to `Hey, I'm Mingo`. */
60
+ /** First name woven into the greeting. Empty/undefined → no-name variant. */
61
+ userName?: string
62
+ /** Greeting heading. Defaults to `Hey{, name}, I'm Mingo`. */
46
63
  title?: React.ReactNode
47
64
  /** Greeting sub-line under the heading. */
48
65
  subtitle?: React.ReactNode
66
+ /** 2-up capability grid. Defaults to the four OpenFrame highlights. */
67
+ featureCards?: ReadonlyArray<MingoFeatureCard>
49
68
  /** One-time "New to OpenFrame?" notification below the grid. `null` hides
50
69
  * it; omitting falls back to the OpenFrame default (only rendered when
51
70
  * `onStartGuideChat` is wired, i.e. Guide mode exists to advertise). */
@@ -83,6 +102,10 @@ export interface MingoWelcomeProps {
83
102
  loadError?: boolean
84
103
  /** Retry handler for the `loadError` state. */
85
104
  onRetry?: () => void
105
+ /** Whether the dialog history has a search bar — when true, the loading
106
+ * skeleton includes a search-bar placeholder so it matches the loaded
107
+ * layout. Ignored once `dialogHistory` is provided. */
108
+ historySearchable?: boolean
86
109
  /** When provided, renders the "Start Guide Chat" chip (the only wired
87
110
  * action — switches the host chat to Guide mode) and enables the default
88
111
  * promo notification. When omitted, both are suppressed. */
@@ -98,6 +121,29 @@ export interface MingoWelcomeProps {
98
121
  const DEFAULT_SUBTITLE =
99
122
  'Ready to help with your technical tasks. What can I do for you?'
100
123
 
124
+ const DEFAULT_FEATURE_CARDS: ReadonlyArray<MingoFeatureCard> = [
125
+ {
126
+ id: 'answers',
127
+ icon: <Rocket01Icon size={16} />,
128
+ text: 'Get instant answers about\ndevices, tickets, and clients',
129
+ },
130
+ {
131
+ id: 'scripts',
132
+ icon: <BracketCurlyIcon size={16} />,
133
+ text: 'Run scripts and queries\nthrough natural language',
134
+ },
135
+ {
136
+ id: 'summarize',
137
+ icon: <SearchIcon size={16} />,
138
+ text: 'Summarize long ticket\nthreads or activity history',
139
+ },
140
+ {
141
+ id: 'delegate',
142
+ icon: <LayersIcon size={16} />,
143
+ text: 'Delegate tasks, let Mingo\nwork in the background',
144
+ },
145
+ ]
146
+
101
147
  const DEFAULT_PROMO: MingoWelcomePromo = {
102
148
  title: 'New to OpenFrame?',
103
149
  description: 'Start a Guide Chat to learn how it works and how to set it up.',
@@ -110,12 +156,13 @@ const DEFAULT_PROMO_STORAGE_KEY = 'mingo-welcome:promo-dismissed'
110
156
  // =============================================================================
111
157
 
112
158
  /**
113
- * MingoWelcome — Figma node `113:69208`.
159
+ * MingoWelcome — Figma node `7532:222444`.
114
160
  *
115
161
  * Default (Mingo-mode) chat empty state: a vertically-centred greeting that
116
- * grows to fill available height, then a pinned stack of an optional one-time
117
- * "New to OpenFrame?" notification and a quick-action chip row. The Guide-mode
118
- * empty state keeps the slash-command onboarding list.
162
+ * grows to fill available height, then a pinned stack of a 2-up capability
163
+ * grid, an optional one-time "New to OpenFrame?" notification, and a
164
+ * quick-action chip row. The Guide-mode empty state keeps the slash-command
165
+ * onboarding list.
119
166
  *
120
167
  * Content is configurable (props) with OpenFrame defaults so the kit stays
121
168
  * platform-agnostic. The "Start Guide Chat" chip is the only wired action —
@@ -124,8 +171,10 @@ const DEFAULT_PROMO_STORAGE_KEY = 'mingo-welcome:promo-dismissed'
124
171
  * storage so it shows only until the user closes it once.
125
172
  */
126
173
  export function MingoWelcome({
174
+ userName,
127
175
  title,
128
176
  subtitle = DEFAULT_SUBTITLE,
177
+ featureCards = DEFAULT_FEATURE_CARDS,
129
178
  promo,
130
179
  promoStorageKey = DEFAULT_PROMO_STORAGE_KEY,
131
180
  promoStorage = 'local',
@@ -137,12 +186,12 @@ export function MingoWelcome({
137
186
  isLoadingHistory = false,
138
187
  loadError = false,
139
188
  onRetry,
189
+ historySearchable = false,
140
190
  onStartGuideChat,
141
191
  className,
142
192
  }: MingoWelcomeProps) {
143
- // Greeting never weaves the user's name in — always the plain "Hey, I'm
144
- // Mingo" (design node 113:69208). A host `title` override still wins.
145
- const heading = title ?? "Hey, I'm Mingo"
193
+ const heading =
194
+ title ?? (userName ? `Hey ${userName}, I'm Mingo` : "Hey, I'm Mingo")
146
195
 
147
196
  // `promo` omitted → fall back to the OpenFrame default, but only when guide
148
197
  // mode exists to advertise (otherwise the notification points nowhere).
@@ -187,6 +236,10 @@ export function MingoWelcome({
187
236
  // only while content is hidden in that direction).
188
237
  const { scrollRef, fadeTop, fadeBottom, update: updateScrollFade } = useScrollFade<HTMLDivElement>()
189
238
 
239
+ const cellCount = featureCards.length
240
+ // Last row's first index — used to drop the bottom divider on the final row.
241
+ const lastRowStart = cellCount - ((cellCount % 2) || 2)
242
+
190
243
  // While we don't yet know whether the user is new or returning (first page
191
244
  // loading, or it errored with nothing cached), suppress the pinned region —
192
245
  // the "New to OpenFrame?" promo + quick-action chips. Showing them over the
@@ -231,7 +284,7 @@ export function MingoWelcome({
231
284
  )}
232
285
  </div>
233
286
  ) : isLoadingHistory ? (
234
- <MingoChatHistorySkeleton />
287
+ <MingoChatHistorySkeleton searchable={historySearchable} />
235
288
  ) : (
236
289
  <>
237
290
  {/* Scrollable region — only the greeting + grid scroll; the notification
@@ -256,18 +309,46 @@ export function MingoWelcome({
256
309
  eyesColor="var(--ods-flamingo-cyan-base)"
257
310
  cornerColor="var(--ods-flamingo-cyan-base)"
258
311
  />
259
- <div className="flex w-full flex-col gap-[var(--spacing-system-l)]">
260
- <p className="text-h2 text-ods-text-primary">{heading}</p>
261
- <p className="text-h4 text-ods-text-secondary">{subtitle}</p>
312
+ <div className="flex w-full flex-col gap-1">
313
+ <p className="text-h4 text-ods-text-primary">{heading}</p>
314
+ <p className="text-h6 text-ods-text-secondary">{subtitle}</p>
262
315
  </div>
263
316
  </div>
317
+
318
+ {/* 2-up capability grid. `shrink-0` keeps every cell at its natural
319
+ height so the bottom row is never clipped (the root scrolls instead).
320
+ Cells share the lighter `ods-card` surface; 1px `ods-border` dividers
321
+ (right on the left column, bottom on every row but the last) draw the
322
+ inner cross. Captions use explicit `\n` breaks (`whitespace-pre-line`)
323
+ so the two-line wrap is fixed and never reflows with panel width. */}
324
+ {cellCount > 0 && (
325
+ <div className="grid shrink-0 grid-cols-2 overflow-hidden rounded-md border border-ods-border bg-ods-card">
326
+ {featureCards.map((card, i) => (
327
+ <div
328
+ key={card.id}
329
+ className={cn(
330
+ 'flex flex-col items-center justify-center gap-[var(--spacing-system-m)] p-[var(--spacing-system-m)] text-center',
331
+ i % 2 === 0 && 'border-r border-ods-border',
332
+ i < lastRowStart && 'border-b border-ods-border',
333
+ )}
334
+ >
335
+ {card.icon ? (
336
+ <span className="flex size-4 shrink-0 items-center justify-center text-ods-text-primary">
337
+ {card.icon}
338
+ </span>
339
+ ) : null}
340
+ <p className="text-h4 text-ods-text-secondary whitespace-pre-line">
341
+ {card.text}
342
+ </p>
343
+ </div>
344
+ ))}
345
+ </div>
346
+ )}
264
347
  </div>
265
348
 
266
- {/* Edge scroll-fades — visible only when content is hidden beyond them.
267
- Fade into the panel's dark `ods-bg` surface (the default color), matching
268
- the black Mingo welcome background (node 113:69208). */}
269
- <ScrollFadeOverlay edge="top" visible={fadeTop} className="h-12" />
270
- <ScrollFadeOverlay edge="bottom" visible={fadeBottom} className="h-12" />
349
+ {/* Edge scroll-fades — visible only when content is hidden beyond them. */}
350
+ <ScrollFadeOverlay edge="top" visible={fadeTop} color="var(--color-bg-card)" className="h-12" />
351
+ <ScrollFadeOverlay edge="bottom" visible={fadeBottom} color="var(--color-bg-card)" className="h-12" />
271
352
  </div>
272
353
  </>
273
354
  ))}
@@ -26,13 +26,14 @@ const meta: Meta<typeof MingoWelcome> = {
26
26
  docs: {
27
27
  description: {
28
28
  component:
29
- 'Figma node `113:69208`. Default (Mingo-mode) chat empty state — a vertically-centred greeting, an optional "New to OpenFrame?" Guide-chat promo, and a quick-action chip row. Content is configurable with OpenFrame defaults; the only wired action is "Start Guide Chat" (`onStartGuideChat`).',
29
+ 'Figma node `7532:222444`. Default (Mingo-mode) chat empty state — a vertically-centred greeting, a 2-up capability grid, an optional "New to OpenFrame?" Guide-chat promo, and a quick-action chip row. Content is configurable with OpenFrame defaults; the only wired action is "Start Guide Chat" (`onStartGuideChat`).',
30
30
  },
31
31
  },
32
32
  },
33
33
  argTypes: {
34
34
  title: { control: false },
35
35
  subtitle: { control: false },
36
+ featureCards: { control: false },
36
37
  promo: { control: false },
37
38
  quickActions: { control: false },
38
39
  onStartGuideChat: { control: false },
@@ -60,6 +61,14 @@ export const Default: Story = {
60
61
  },
61
62
  }
62
63
 
64
+ /** Greeting personalised with a first name. */
65
+ export const WithUserName: Story = {
66
+ args: {
67
+ userName: 'Grace',
68
+ onStartGuideChat: () => console.log('switch to guide mode'),
69
+ },
70
+ }
71
+
63
72
  /** Guide mode not configured → the promo card and "Start Guide Chat" chip are
64
73
  * suppressed (no dead-end CTA). */
65
74
  export const WithoutGuide: Story = {
@@ -91,7 +100,7 @@ export const ReturningUser: Story = {
91
100
  },
92
101
  }
93
102
 
94
- /** Wide panel (e.g. in-layout / desktop). */
103
+ /** Wide panel (e.g. in-layout / desktop) — the grid columns read roomy. */
95
104
  export const Wide: Story = {
96
105
  args: {
97
106
  onStartGuideChat: () => console.log('switch to guide mode'),