@flamingo-stack/openframe-frontend-core 0.0.438 → 0.0.439

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-LR6A67KZ.js → chunk-2ELBNZJ3.js} +2 -2
  2. package/dist/{chunk-6R5SZPAQ.cjs → chunk-3WJQL4DN.cjs} +15 -15
  3. package/dist/{chunk-6R5SZPAQ.cjs.map → chunk-3WJQL4DN.cjs.map} +1 -1
  4. package/dist/{chunk-KBFQF4AV.js → chunk-7WM77CSW.js} +2 -2
  5. package/dist/{chunk-B5SGPIJ4.cjs → chunk-AUJXLVXA.cjs} +37 -37
  6. package/dist/{chunk-B5SGPIJ4.cjs.map → chunk-AUJXLVXA.cjs.map} +1 -1
  7. package/dist/{chunk-X66KRL5G.cjs → chunk-C3FH53AP.cjs} +11 -11
  8. package/dist/{chunk-X66KRL5G.cjs.map → chunk-C3FH53AP.cjs.map} +1 -1
  9. package/dist/{chunk-W2PRXXUD.js → chunk-COMBYYQY.js} +2 -2
  10. package/dist/{chunk-RTHOCS3S.js → chunk-EGRZHR7J.js} +2 -2
  11. package/dist/{chunk-D5RTZ2WN.js → chunk-F7YVI54P.js} +4 -4
  12. package/dist/{chunk-5D5SJOLE.js → chunk-G5FALXNO.js} +4 -4
  13. package/dist/{chunk-2DCP2TIF.cjs → chunk-HTVVXVJL.cjs} +1966 -1490
  14. package/dist/chunk-HTVVXVJL.cjs.map +1 -0
  15. package/dist/{chunk-WF7VGEYR.cjs → chunk-IAN7GUIV.cjs} +26 -26
  16. package/dist/{chunk-WF7VGEYR.cjs.map → chunk-IAN7GUIV.cjs.map} +1 -1
  17. package/dist/{chunk-SYTL2VIU.cjs → chunk-M3LD6DNE.cjs} +7 -7
  18. package/dist/{chunk-SYTL2VIU.cjs.map → chunk-M3LD6DNE.cjs.map} +1 -1
  19. package/dist/{chunk-YKP2F2NC.cjs → chunk-M6QGB6DC.cjs} +26 -26
  20. package/dist/{chunk-YKP2F2NC.cjs.map → chunk-M6QGB6DC.cjs.map} +1 -1
  21. package/dist/{chunk-6ETVJ5ON.js → chunk-NQQ4NLUT.js} +6577 -6101
  22. package/dist/chunk-NQQ4NLUT.js.map +1 -0
  23. package/dist/{chunk-A25RPWYF.js → chunk-P7PISHM5.js} +2 -2
  24. package/dist/{chunk-44HNH57W.js → chunk-PBTY7AH5.js} +4 -4
  25. package/dist/{chunk-ZEY7A3MP.js → chunk-PEIR7D25.js} +2 -2
  26. package/dist/{chunk-DTYPYYKD.cjs → chunk-R46QEE3R.cjs} +59 -59
  27. package/dist/{chunk-DTYPYYKD.cjs.map → chunk-R46QEE3R.cjs.map} +1 -1
  28. package/dist/{chunk-H2TQ44VD.cjs → chunk-RQQZF6G6.cjs} +31 -31
  29. package/dist/{chunk-H2TQ44VD.cjs.map → chunk-RQQZF6G6.cjs.map} +1 -1
  30. package/dist/{chunk-CMP56RUC.cjs → chunk-VWGF6IFR.cjs} +62 -62
  31. package/dist/{chunk-CMP56RUC.cjs.map → chunk-VWGF6IFR.cjs.map} +1 -1
  32. package/dist/{chunk-POFDZH2C.js → chunk-WAGTZVQN.js} +4 -4
  33. package/dist/{chunk-XFSDFO6U.js → chunk-WPVBTVI3.js} +2 -2
  34. package/dist/{chunk-VYG2CJFJ.cjs → chunk-WZ6TCAYQ.cjs} +9 -9
  35. package/dist/{chunk-VYG2CJFJ.cjs.map → chunk-WZ6TCAYQ.cjs.map} +1 -1
  36. package/dist/{chunk-PCMDRDYX.cjs → chunk-ZMUFTGL7.cjs} +5 -5
  37. package/dist/{chunk-PCMDRDYX.cjs.map → chunk-ZMUFTGL7.cjs.map} +1 -1
  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 +28 -8
  41. package/dist/components/chat/chat-archive-page.d.ts.map +1 -1
  42. package/dist/components/chat/chat-header-icon-button.d.ts +6 -2
  43. package/dist/components/chat/chat-header-icon-button.d.ts.map +1 -1
  44. package/dist/components/chat/chat-header-search-field.d.ts +25 -0
  45. package/dist/components/chat/chat-header-search-field.d.ts.map +1 -0
  46. package/dist/components/chat/chat-list-empty-state.d.ts +20 -0
  47. package/dist/components/chat/chat-list-empty-state.d.ts.map +1 -0
  48. package/dist/components/chat/chat-panel-header.d.ts +19 -9
  49. package/dist/components/chat/chat-panel-header.d.ts.map +1 -1
  50. package/dist/components/chat/embeddable-chat.d.ts +15 -6
  51. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  52. package/dist/components/chat/hooks/use-chat-dialog-manager.d.ts +2 -0
  53. package/dist/components/chat/hooks/use-chat-dialog-manager.d.ts.map +1 -1
  54. package/dist/components/chat/index.cjs +4 -2
  55. package/dist/components/chat/index.cjs.map +1 -1
  56. package/dist/components/chat/index.d.ts +2 -0
  57. package/dist/components/chat/index.d.ts.map +1 -1
  58. package/dist/components/chat/index.js +5 -3
  59. package/dist/components/chat/mingo-chat-history.d.ts +5 -10
  60. package/dist/components/chat/mingo-chat-history.d.ts.map +1 -1
  61. package/dist/components/chat/mingo-history-rail.d.ts +54 -0
  62. package/dist/components/chat/mingo-history-rail.d.ts.map +1 -0
  63. package/dist/components/chat/mingo-welcome.d.ts +6 -25
  64. package/dist/components/chat/mingo-welcome.d.ts.map +1 -1
  65. package/dist/components/contact/index.cjs +3 -3
  66. package/dist/components/contact/index.js +2 -2
  67. package/dist/components/docs/index.cjs +5 -5
  68. package/dist/components/docs/index.js +4 -4
  69. package/dist/components/embeds/index.cjs +3 -3
  70. package/dist/components/embeds/index.js +2 -2
  71. package/dist/components/faq/index.cjs +3 -3
  72. package/dist/components/faq/index.js +2 -2
  73. package/dist/components/features/index.cjs +2 -2
  74. package/dist/components/features/index.js +1 -1
  75. package/dist/components/help-center-pages/index.cjs +20 -20
  76. package/dist/components/help-center-pages/index.js +11 -11
  77. package/dist/components/index.cjs +130 -128
  78. package/dist/components/index.cjs.map +1 -1
  79. package/dist/components/index.js +13 -11
  80. package/dist/components/index.js.map +1 -1
  81. package/dist/components/navigation/index.cjs +2 -2
  82. package/dist/components/navigation/index.js +1 -1
  83. package/dist/components/onboarding-guides/index.cjs +5 -5
  84. package/dist/components/onboarding-guides/index.js +4 -4
  85. package/dist/components/related-content/index.cjs +3 -3
  86. package/dist/components/related-content/index.js +2 -2
  87. package/dist/components/tickets/index.cjs +5 -5
  88. package/dist/components/tickets/index.js +4 -4
  89. package/dist/components/ui/index.cjs +4 -2
  90. package/dist/components/ui/index.cjs.map +1 -1
  91. package/dist/components/ui/index.js +5 -3
  92. package/dist/index.cjs +4 -2
  93. package/dist/index.cjs.map +1 -1
  94. package/dist/index.js +5 -3
  95. package/package.json +1 -1
  96. package/src/components/chat/chat-archive-page.tsx +89 -73
  97. package/src/components/chat/chat-header-icon-button.tsx +12 -5
  98. package/src/components/chat/chat-header-search-field.tsx +144 -0
  99. package/src/components/chat/chat-list-empty-state.tsx +44 -0
  100. package/src/components/chat/chat-panel-header.tsx +76 -29
  101. package/src/components/chat/embeddable-chat.tsx +548 -125
  102. package/src/components/chat/guide-welcome.tsx +4 -4
  103. package/src/components/chat/hooks/use-chat-dialog-manager.ts +59 -5
  104. package/src/components/chat/index.ts +2 -0
  105. package/src/components/chat/mingo-chat-history.tsx +46 -100
  106. package/src/components/chat/mingo-history-rail.tsx +161 -0
  107. package/src/components/chat/mingo-welcome.tsx +17 -98
  108. package/src/stories/MingoWelcome.stories.tsx +2 -11
  109. package/dist/chunk-2DCP2TIF.cjs.map +0 -1
  110. package/dist/chunk-6ETVJ5ON.js.map +0 -1
  111. /package/dist/{chunk-LR6A67KZ.js.map → chunk-2ELBNZJ3.js.map} +0 -0
  112. /package/dist/{chunk-KBFQF4AV.js.map → chunk-7WM77CSW.js.map} +0 -0
  113. /package/dist/{chunk-W2PRXXUD.js.map → chunk-COMBYYQY.js.map} +0 -0
  114. /package/dist/{chunk-RTHOCS3S.js.map → chunk-EGRZHR7J.js.map} +0 -0
  115. /package/dist/{chunk-D5RTZ2WN.js.map → chunk-F7YVI54P.js.map} +0 -0
  116. /package/dist/{chunk-5D5SJOLE.js.map → chunk-G5FALXNO.js.map} +0 -0
  117. /package/dist/{chunk-A25RPWYF.js.map → chunk-P7PISHM5.js.map} +0 -0
  118. /package/dist/{chunk-44HNH57W.js.map → chunk-PBTY7AH5.js.map} +0 -0
  119. /package/dist/{chunk-ZEY7A3MP.js.map → chunk-PEIR7D25.js.map} +0 -0
  120. /package/dist/{chunk-POFDZH2C.js.map → chunk-WAGTZVQN.js.map} +0 -0
  121. /package/dist/{chunk-XFSDFO6U.js.map → chunk-WPVBTVI3.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-1">
172
- <p className="text-h4 text-ods-text-primary">{title}</p>
171
+ <div className="flex w-full flex-col gap-[var(--spacing-system-l)]">
172
+ <p className="text-h2 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-h6 text-ods-text-secondary">{subtitle}</p>
178
+ <p className="text-h4 text-ods-text-secondary">{subtitle}</p>
179
179
  ) : subtitleLoading ? (
180
180
  <div className="flex w-full justify-center">
181
- <Skeleton className="h-4 w-3/4 max-w-80 rounded-sm" />
181
+ <Skeleton className="h-5 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, useState } from 'react'
3
+ import { useCallback, useMemo, useRef, useState } from 'react'
4
4
  import type { DialogItem } from '../types/component.types'
5
5
  import type {
6
6
  FetchDialogsParams,
@@ -84,28 +84,68 @@ 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)
87
98
  const loadArchivedPage = useCallback(
88
99
  async (cursor?: string): Promise<void> => {
89
100
  if (!fetchArchivedDialogs) return
90
- setArchivedLoading(true)
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
+ }
91
121
  try {
92
122
  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
93
126
  setArchivedCursor(result.nextCursor)
94
127
  setArchivedDialogs((prev) =>
95
128
  cursor ? [...prev, ...result.dialogs] : result.dialogs,
96
129
  )
97
130
  } catch (err) {
131
+ if (!isCurrent()) return
98
132
  console.error('[useChatDialogManager] fetchArchivedDialogs failed:', err)
99
133
  } finally {
100
- setArchivedLoading(false)
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
+ }
101
140
  }
102
141
  },
103
142
  [fetchArchivedDialogs],
104
143
  )
105
144
  const openArchive = useCallback(() => {
106
145
  setArchiveOpen(true)
107
- setArchivedDialogs([])
108
- setArchivedCursor(null)
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.
109
149
  void loadArchivedPage()
110
150
  }, [loadArchivedPage])
111
151
  const closeArchive = useCallback(() => setArchiveOpen(false), [])
@@ -183,6 +223,18 @@ export function useChatDialogManager({
183
223
  if (wasArchived) setArchiveOpen(true)
184
224
  }, [isViewingArchived, selectDialog, clearMessages])
185
225
 
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
+
186
238
  // Active-conversation dialog — resolved from the active list, or the
187
239
  // archived list when an archived chat is open (archived dialogs aren't in
188
240
  // `dialogs`, so the header title and ⋯ / restore actions need this).
@@ -202,6 +254,7 @@ export function useChatDialogManager({
202
254
  archivedDialogs,
203
255
  archivedCursor,
204
256
  archivedLoading,
257
+ archivedPending,
205
258
  openArchive,
206
259
  closeArchive,
207
260
  loadArchivedPage,
@@ -212,6 +265,7 @@ export function useChatDialogManager({
212
265
  // archived conversation
213
266
  isViewingArchived,
214
267
  handleBack,
268
+ resetToNewChat,
215
269
  activeDialog,
216
270
  // action modals
217
271
  renameTarget,
@@ -31,8 +31,10 @@ 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'
34
35
  export * from './mingo-chat-modals'
35
36
  export * from './chat-header-icon-button'
37
+ export * from './chat-header-search-field'
36
38
  export * from './chat-panel-header'
37
39
  export * from './chat-composer'
38
40
  export * from './chat-context-picker'
@@ -2,12 +2,11 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { cn } from '../../utils/cn'
5
- import { useDebounce } from '../../hooks/ui/use-debounce'
6
5
  import { ActionsMenuDropdown, type ActionsMenuItem } from '../ui/actions-menu'
7
6
  import { Button } from '../ui/button'
8
- import { Input } from '../ui/input'
9
7
  import { ScrollFadeOverlay, useScrollFade } from '../ui/scroll-fade'
10
- import { Ellipsis01Icon, SearchIcon } from '../icons-v2-generated'
8
+ import { Ellipsis01Icon, SearchXmarkIcon } from '../icons-v2-generated'
9
+ import { ChatListEmptyState } from './chat-list-empty-state'
11
10
  import type { DialogItem } from './types/component.types'
12
11
 
13
12
  // =============================================================================
@@ -27,12 +26,10 @@ export interface MingoChatHistoryProps {
27
26
  /** Request archive — enables the row "Archive chat" action. The host opens
28
27
  * the Archive confirmation modal. */
29
28
  onRequestArchive?: (dialog: DialogItem) => void
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. */
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. */
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
36
33
  /** Whether more dialogs remain (cursor pagination). */
37
34
  hasMore?: boolean
38
35
  /** True while the next page is loading. */
@@ -134,11 +131,21 @@ function MingoChatHistoryRow({
134
131
  return (
135
132
  <div
136
133
  className={cn(
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',
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',
140
141
  )}
141
142
  >
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}
142
149
  <div
143
150
  role="button"
144
151
  tabIndex={0}
@@ -149,7 +156,7 @@ function MingoChatHistoryRow({
149
156
  onSelect?.(dialog.id)
150
157
  }
151
158
  }}
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"
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"
153
160
  >
154
161
  {unread > 0 ? (
155
162
  <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">
@@ -157,7 +164,10 @@ function MingoChatHistoryRow({
157
164
  </span>
158
165
  ) : null}
159
166
  <span
160
- className="min-w-0 flex-1 truncate text-h4 text-ods-text-primary"
167
+ className={cn(
168
+ 'min-w-0 flex-1 truncate text-h4',
169
+ isActive ? 'text-ods-open-yellow' : 'text-ods-text-primary',
170
+ )}
161
171
  title={title}
162
172
  >
163
173
  {title}
@@ -222,61 +232,39 @@ function MingoChatHistoryRow({
222
232
  */
223
233
  export function MingoChatHistorySkeleton({
224
234
  className,
225
- searchable = false,
226
235
  }: {
227
236
  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
231
237
  }) {
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'] },
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'],
241
247
  ]
242
248
  return (
243
249
  <div
244
250
  aria-hidden
245
251
  // `overflow-hidden` clips the placeholder rows to the flex-1 box (the real
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.
252
+ // list scrolls; the skeleton just needs to stay within bounds).
249
253
  className={cn(
250
254
  'flex flex-1 min-h-0 flex-col gap-[var(--spacing-system-m)] overflow-hidden',
251
255
  className,
252
256
  )}
253
257
  >
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) => (
258
+ {groups.map((widths, g) => (
264
259
  <div key={g} className="flex shrink-0 flex-col gap-[var(--spacing-system-xxs)]">
265
- {/* Group label (Today / Yesterday). */}
260
+ {/* Group label (Today / Older). */}
266
261
  <div className="h-3 w-16 animate-pulse rounded bg-ods-border" />
267
262
  <div className="overflow-hidden rounded-md border border-ods-border">
268
- {group.widths.map((w, i) => (
263
+ {widths.map((w, i) => (
269
264
  <div
270
265
  key={i}
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"
266
+ className="flex h-12 items-center bg-ods-bg border-b border-ods-border px-[var(--spacing-system-s)] last:border-b-0"
276
267
  >
277
- {i < group.withBadge && (
278
- <div className="h-8 w-8 shrink-0 animate-pulse rounded-md bg-ods-border" />
279
- )}
280
268
  <div className={cn('h-4 animate-pulse rounded bg-ods-border', w)} />
281
269
  </div>
282
270
  ))}
@@ -287,46 +275,6 @@ export function MingoChatHistorySkeleton({
287
275
  )
288
276
  }
289
277
 
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
-
330
278
  // =============================================================================
331
279
  // Component
332
280
  // =============================================================================
@@ -349,7 +297,6 @@ export function MingoChatHistory({
349
297
  onRequestRename,
350
298
  onRequestArchive,
351
299
  searchQuery,
352
- onSearchChange,
353
300
  hasMore = false,
354
301
  isLoadingMore = false,
355
302
  onLoadMore,
@@ -388,13 +335,8 @@ export function MingoChatHistory({
388
335
 
389
336
  return (
390
337
  <div className={cn('relative flex flex-1 min-h-0 flex-col gap-[var(--spacing-system-m)]', className)}>
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. */}
338
+ {/* Scroll regionthe search INPUT now lives in the panel header
339
+ (Figma 116:51217), so the list is just the grouped rows + fades. */}
398
340
  <div className="relative flex flex-1 min-h-0 flex-col">
399
341
  <div
400
342
  ref={scrollRef}
@@ -402,9 +344,13 @@ export function MingoChatHistory({
402
344
  className="flex flex-1 min-h-0 flex-col gap-[var(--spacing-system-m)] overflow-y-auto"
403
345
  >
404
346
  {noSearchResults ? (
405
- <p className="py-[var(--spacing-system-m)] text-center text-h5 text-ods-text-secondary">
406
- No chats found
407
- </p>
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
+ />
408
354
  ) : (
409
355
  groups.map((group) => (
410
356
  <div
@@ -0,0 +1,161 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../utils/cn'
5
+ import { Button } from '../ui/button'
6
+ import { ChatsIcon } from '../icons-v2-generated/communication/chats-icon'
7
+ import { PlusCircleIcon } from '../icons-v2-generated/signs-and-symbols/plus-circle-icon'
8
+ import { AlertCircleIcon, Refresh01RightIcon } from '../icons-v2-generated'
9
+ import { MingoChatHistory, MingoChatHistorySkeleton } from './mingo-chat-history'
10
+ import { ChatListEmptyState } from './chat-list-empty-state'
11
+ import type { DialogItem } from './types/component.types'
12
+
13
+ // =============================================================================
14
+ // Types
15
+ // =============================================================================
16
+
17
+ export interface MingoHistoryRailProps {
18
+ /** Dialogs to list, assumed already sorted newest-first by the host. */
19
+ dialogs: ReadonlyArray<DialogItem>
20
+ /** Currently-open dialog id (highlighted). */
21
+ activeDialogId?: string
22
+ /** Open a dialog. */
23
+ onSelectDialog?: (id: string) => void
24
+ /** Start a fresh chat — clears the open conversation so the chat block shows
25
+ * the welcome. Rendered as the pinned "Start New Chat" button above the list. */
26
+ onNewChat?: () => void
27
+ /** Show the "Start New Chat" button even when there are no chats yet. Needed
28
+ * for the narrow single-column list, where there's no composer to fall back
29
+ * on; the wide rail leaves it off (the chat block already has a composer). */
30
+ newChatAlways?: boolean
31
+ /** Request rename — enables the row "Rename chat" action. */
32
+ onRequestRename?: (dialog: DialogItem) => void
33
+ /** Request archive — enables the row "Archive chat" action. */
34
+ onRequestArchive?: (dialog: DialogItem) => void
35
+ /** Current server-side search term. Drives the list's "No chats found"
36
+ * empty state; the search INPUT itself lives in the panel header now, not
37
+ * in the rail body. */
38
+ searchQuery?: string
39
+ /** Whether more dialogs remain (cursor pagination). */
40
+ hasMore?: boolean
41
+ /** True while the next page is loading. */
42
+ isLoadingMore?: boolean
43
+ /** Fetch the next page. */
44
+ onLoadMore?: () => void
45
+ /** True while the FIRST page of dialogs is still loading. Renders a skeleton
46
+ * so the rail doesn't flash the empty state before the list arrives. */
47
+ isLoadingHistory?: boolean
48
+ /** The dialog-list load FAILED with nothing cached — renders an error + retry
49
+ * block instead of the empty state. */
50
+ loadError?: boolean
51
+ /** Retry handler for the `loadError` state. */
52
+ onRetry?: () => void
53
+ /** Appended to the root element. */
54
+ className?: string
55
+ }
56
+
57
+ // =============================================================================
58
+ // Component
59
+ // =============================================================================
60
+
61
+ /**
62
+ * MingoHistoryRail — the persistent "Current Chats" left column of the split
63
+ * (wide) Mingo layout (Figma 113:60931 / 113:63630).
64
+ *
65
+ * The dialog history that the stacked layout renders inline in the Mingo empty
66
+ * state is hoisted here into a fixed-width rail beside the chat block. On top
67
+ * sits a pinned "Start New Chat" button (shown once the user has chats); below
68
+ * it the grouped Today / Yesterday / Older list (`MingoChatHistory`). With no
69
+ * chats it collapses to a centred empty state; loading / error states mirror
70
+ * the stacked empty state so the two layouts stay consistent.
71
+ */
72
+ export function MingoHistoryRail({
73
+ dialogs,
74
+ activeDialogId,
75
+ onSelectDialog,
76
+ onNewChat,
77
+ newChatAlways = false,
78
+ onRequestRename,
79
+ onRequestArchive,
80
+ searchQuery,
81
+ hasMore = false,
82
+ isLoadingMore = false,
83
+ onLoadMore,
84
+ isLoadingHistory = false,
85
+ loadError = false,
86
+ onRetry,
87
+ className,
88
+ }: MingoHistoryRailProps) {
89
+ const hasSearch = !!searchQuery?.trim()
90
+ // The list (vs. the empty state) shows whenever there are chats OR an active
91
+ // search is running — a no-match query must keep the search bar mounted
92
+ // rather than flash the "No Current Chats" state.
93
+ const hasList = dialogs.length > 0 || hasSearch
94
+
95
+ return (
96
+ <div
97
+ className={cn(
98
+ 'flex flex-1 min-h-0 flex-col gap-[var(--spacing-system-m)] p-[var(--spacing-system-m)]',
99
+ className,
100
+ )}
101
+ >
102
+ {/* Pinned "Start New Chat". In the wide rail it's offered only once the
103
+ user has chats (the chat block's composer is the empty-state entry);
104
+ the narrow list forces it on (`newChatAlways`) since it has no composer. */}
105
+ {onNewChat && (hasList || newChatAlways) && (
106
+ <Button
107
+ variant="outline"
108
+ fullWidth
109
+ leftIcon={<PlusCircleIcon size={20} />}
110
+ onClick={onNewChat}
111
+ className="shrink-0 justify-start"
112
+ >
113
+ Start New Chat
114
+ </Button>
115
+ )}
116
+
117
+ {loadError ? (
118
+ <div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-[var(--spacing-system-m)] text-center">
119
+ <AlertCircleIcon className="h-8 w-8 text-ods-text-secondary shrink-0" />
120
+ <div className="flex flex-col gap-[var(--spacing-system-xxs)]">
121
+ <p className="text-h4 text-ods-text-primary">Couldn’t load your chats</p>
122
+ <p className="text-h6 text-ods-text-secondary">
123
+ Something went wrong reaching the server. Try again.
124
+ </p>
125
+ </div>
126
+ {onRetry && (
127
+ <Button
128
+ variant="outline"
129
+ size="small"
130
+ leftIcon={<Refresh01RightIcon />}
131
+ onClick={onRetry}
132
+ >
133
+ Try again
134
+ </Button>
135
+ )}
136
+ </div>
137
+ ) : isLoadingHistory ? (
138
+ <MingoChatHistorySkeleton />
139
+ ) : hasList ? (
140
+ <MingoChatHistory
141
+ dialogs={dialogs}
142
+ activeDialogId={activeDialogId}
143
+ onSelectDialog={onSelectDialog}
144
+ onRequestRename={onRequestRename}
145
+ onRequestArchive={onRequestArchive}
146
+ searchQuery={searchQuery}
147
+ hasMore={hasMore}
148
+ isLoadingMore={isLoadingMore}
149
+ onLoadMore={onLoadMore}
150
+ />
151
+ ) : (
152
+ // Figma 113:60939 — no-chats empty state.
153
+ <ChatListEmptyState
154
+ icon={<ChatsIcon size={24} />}
155
+ title="No Current Chats"
156
+ description="Previous Mingo sessions will show here"
157
+ />
158
+ )}
159
+ </div>
160
+ )
161
+ }