@flamingo-stack/openframe-frontend-core 0.0.181 → 0.0.182
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.
- package/dist/{chunk-VEOBMVF5.js → chunk-CLZ3QQMJ.js} +4211 -4123
- package/dist/chunk-CLZ3QQMJ.js.map +1 -0
- package/dist/{chunk-L5AAJ3QN.cjs → chunk-IWMK4MH4.cjs} +615 -527
- package/dist/chunk-IWMK4MH4.cjs.map +1 -0
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/cycling-phrase.d.ts +30 -0
- package/dist/components/chat/cycling-phrase.d.ts.map +1 -0
- package/dist/components/chat/hooks/index.d.ts +0 -1
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/index.d.ts +0 -1
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/index.cjs +2 -6
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +1 -5
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/ui/index.cjs +2 -6
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +1 -5
- package/dist/index.cjs +2 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -5
- package/package.json +1 -1
- package/src/components/chat/chat-message-enhanced.tsx +37 -5
- package/src/components/chat/chat-message-list.tsx +51 -179
- package/src/components/chat/cycling-phrase.tsx +129 -0
- package/src/components/chat/hooks/index.ts +0 -1
- package/src/components/chat/index.ts +0 -1
- package/dist/chunk-L5AAJ3QN.cjs.map +0 -1
- package/dist/chunk-VEOBMVF5.js.map +0 -1
- package/dist/components/chat/chat-message-loader.d.ts +0 -23
- package/dist/components/chat/chat-message-loader.d.ts.map +0 -1
- package/dist/components/chat/hooks/use-delayed-flag.d.ts +0 -25
- package/dist/components/chat/hooks/use-delayed-flag.d.ts.map +0 -1
- package/src/components/chat/chat-message-loader.tsx +0 -67
- package/src/components/chat/hooks/use-delayed-flag.ts +0 -56
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { useRef,
|
|
3
|
+
import { useRef, useEffect, useLayoutEffect, useImperativeHandle, forwardRef } from "react"
|
|
4
4
|
import { useStickToBottom } from "use-stick-to-bottom"
|
|
5
5
|
import { cn } from "../../utils/cn"
|
|
6
6
|
import { ChatMessageEnhanced } from "./chat-message-enhanced"
|
|
7
|
-
import {
|
|
8
|
-
import { useDelayedFlag } from "./hooks/use-delayed-flag"
|
|
9
|
-
import { PulseDots } from "../ui/pulse-dots"
|
|
7
|
+
import { ChatMessageListSkeleton } from "./chat-message-skeleton"
|
|
10
8
|
import type { ChatMessageListProps } from "./types"
|
|
11
9
|
|
|
12
10
|
/*
|
|
@@ -40,7 +38,6 @@ import type { ChatMessageListProps } from "./types"
|
|
|
40
38
|
* — pass them directly as `ref={scrollRef}` / `ref={contentRef}`.
|
|
41
39
|
*/
|
|
42
40
|
|
|
43
|
-
|
|
44
41
|
const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
45
42
|
(
|
|
46
43
|
{
|
|
@@ -67,33 +64,17 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
67
64
|
// `resize: 'smooth'` — during streaming, content grows token-by-
|
|
68
65
|
// token; library uses spring physics to follow the bottom for a
|
|
69
66
|
// ChatGPT/Claude.ai-like feel (vs jarring instant snaps).
|
|
70
|
-
// `initial:
|
|
71
|
-
//
|
|
72
|
-
// history
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
// is already at the bottom.
|
|
76
|
-
//
|
|
77
|
-
// `escapedFromLock` is exposed so the stream-tail branch below can
|
|
78
|
-
// honor user intent: if they scroll UP during streaming, the lib
|
|
79
|
-
// flips it true via the wheel/touch handlers, and we stop forcing
|
|
80
|
-
// the viewport to follow. Scrolling back DOWN to near-bottom
|
|
81
|
-
// resets it to false and tailing resumes.
|
|
82
|
-
const { scrollRef, contentRef, scrollToBottom, escapedFromLock } = useStickToBottom({
|
|
67
|
+
// `initial: false` — DON'T auto-scroll on mount; our dialog-change
|
|
68
|
+
// effect below owns first-paint positioning so re-opening a chat
|
|
69
|
+
// with prior history lands at the bottom without a smooth-scroll
|
|
70
|
+
// animation playing over the cold-paint.
|
|
71
|
+
const { scrollRef, contentRef, scrollToBottom } = useStickToBottom({
|
|
83
72
|
resize: 'smooth',
|
|
84
|
-
initial:
|
|
73
|
+
initial: false,
|
|
85
74
|
})
|
|
86
75
|
|
|
87
76
|
// ---- Prepend / load-more state (NOT owned by the library) --------
|
|
88
|
-
|
|
89
|
-
// effect re-runs when either element mounts/unmounts. This is the
|
|
90
|
-
// critical fix for the reload pagination bug: when the loader is
|
|
91
|
-
// showing, the scroll container is unmounted (`scrollRef.current ==
|
|
92
|
-
// null`); when the loader disappears later, neither `hasNextPage`
|
|
93
|
-
// nor `messages.length` changes — but `scrollEl` flips from null to
|
|
94
|
-
// the new element, which re-runs the effect.
|
|
95
|
-
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null)
|
|
96
|
-
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null)
|
|
77
|
+
const sentinelRef = useRef<HTMLDivElement>(null)
|
|
97
78
|
const onLoadMoreRef = useRef(onLoadMore)
|
|
98
79
|
onLoadMoreRef.current = onLoadMore
|
|
99
80
|
const isFetchingRef = useRef(isFetchingNextPage)
|
|
@@ -118,36 +99,9 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
118
99
|
scrollHeight: number
|
|
119
100
|
}>({ firstMessageId: undefined, firstMessageContent: undefined, scrollHeight: 0 })
|
|
120
101
|
|
|
121
|
-
// ---- Force-stick: dialog change / first-load / new user message
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// top of the list (oldest msgs) for one frame before snapping down.
|
|
125
|
-
//
|
|
126
|
-
// Critical: gate on `scrollEl` (state, not ref). When `useDelayedFlag`
|
|
127
|
-
// is showing the loader, the scroll container is UNMOUNTED. If we
|
|
128
|
-
// ran the snap-logic during the loader phase, `scrollToBottom` would
|
|
129
|
-
// no-op AND `prevRenderRef` would record the new dialog/count — so
|
|
130
|
-
// when the loader finally hides and the container remounts, we'd
|
|
131
|
-
// think "no dialog change" and never snap. By depending on
|
|
132
|
-
// `scrollEl`, this effect re-runs the moment the container mounts
|
|
133
|
-
// and catches up on any transition that happened during the loader.
|
|
134
|
-
//
|
|
135
|
-
// Streaming tail (added 2026-05): the library's RO-driven follow
|
|
136
|
-
// depends on its internal `state.isAtBottom`, which starts FALSE
|
|
137
|
-
// and only flips true on an explicit `scrollToBottom` call
|
|
138
|
-
// (without `preserveScrollPosition`). For a flow where the
|
|
139
|
-
// consumer wraps `contentRef` with a `<div className="flex-1" />`
|
|
140
|
-
// spacer + `minHeight: 100%` (so messages visually bottom-align
|
|
141
|
-
// when content is short), the library's initial-snap RO callback
|
|
142
|
-
// runs with `preserveScrollPosition: true` → bails on
|
|
143
|
-
// `!state.isAtBottom`, never flips it true. Result: streaming
|
|
144
|
-
// assistant content grows but viewport stays at scrollTop=0.
|
|
145
|
-
// We explicitly tail on every messages update when the user is
|
|
146
|
-
// within `STREAM_TAIL_THRESHOLD_PX` of the bottom — independent
|
|
147
|
-
// of the library's internal state. This restores 2026-grade
|
|
148
|
-
// "follow during stream, release when user scrolls up" UX.
|
|
149
|
-
useLayoutEffect(() => {
|
|
150
|
-
if (!autoScroll || !scrollEl) return
|
|
102
|
+
// ---- Force-stick: dialog change / first-load / new user message --
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!autoScroll) return
|
|
151
105
|
|
|
152
106
|
const dialogChanged = dialogId !== prevRenderRef.current.dialogId
|
|
153
107
|
const prevCount = prevRenderRef.current.messageCount
|
|
@@ -166,18 +120,6 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
166
120
|
return
|
|
167
121
|
}
|
|
168
122
|
if (newCount > prevCount) {
|
|
169
|
-
// Load-older PREPEND: new content arrived at the START of the
|
|
170
|
-
// array (firstMessageId changed). DON'T snap — the prepend-
|
|
171
|
-
// anchor effect below preserves the user's reading position.
|
|
172
|
-
// Without this guard, `messages.slice(prevCount)` would pick
|
|
173
|
-
// up old user messages now sitting at the tail and falsely
|
|
174
|
-
// trigger a scrollToBottom.
|
|
175
|
-
const isPrepend =
|
|
176
|
-
prependRef.current.firstMessageId !== undefined &&
|
|
177
|
-
messages[0]?.id !== prependRef.current.firstMessageId
|
|
178
|
-
|
|
179
|
-
if (isPrepend) return
|
|
180
|
-
|
|
181
123
|
// Scan the new tail for any user-role message. Handles the
|
|
182
124
|
// coalesced-render case where optimistic-user + server-
|
|
183
125
|
// assistant land in the same diff (last is assistant, but a
|
|
@@ -188,34 +130,12 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
188
130
|
const hasNewUser = newSlice.some((m) => m.role === 'user')
|
|
189
131
|
if (hasNewUser) {
|
|
190
132
|
void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
|
|
191
|
-
return
|
|
192
133
|
}
|
|
193
|
-
// Assistant
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// this snap, the library's RO can fail to follow if its
|
|
197
|
-
// internal `isAtBottom` was never set true (see top comment).
|
|
198
|
-
void scrollToBottom({ animation: 'instant', ignoreEscapes: true })
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Stream tail: same count, last id unchanged → assistant content
|
|
203
|
-
// is streaming in. Follow the bottom unless the user has
|
|
204
|
-
// explicitly escaped the lock (scrolled up). We can't gate on
|
|
205
|
-
// current `distToBottom` because the server may emit ~100KB
|
|
206
|
-
// of sources/refs metadata in ONE chunk before the text stream
|
|
207
|
-
// begins — by the time this useLayoutEffect runs after that
|
|
208
|
-
// render, distToBottom is already in the thousands of pixels
|
|
209
|
-
// and any threshold check would mis-fire as "user is far away".
|
|
210
|
-
// `escapedFromLock` is the canonical "did the user intentionally
|
|
211
|
-
// step out of follow-mode" signal — driven by the lib's wheel
|
|
212
|
-
// + touch + scroll handlers, not by post-render geometry.
|
|
213
|
-
// `smooth` animation matches the library's `resize: 'smooth'`
|
|
214
|
-
// option — spring-physics glide instead of jarring snap.
|
|
215
|
-
if (!escapedFromLock) {
|
|
216
|
-
void scrollToBottom({ animation: 'smooth' })
|
|
134
|
+
// Assistant-only new messages → the library's resize-watch
|
|
135
|
+
// already keeps the bottom locked when the user hasn't
|
|
136
|
+
// escaped. No explicit call needed; spring animation runs.
|
|
217
137
|
}
|
|
218
|
-
}, [autoScroll, messages, dialogId, scrollToBottom
|
|
138
|
+
}, [autoScroll, messages, dialogId, scrollToBottom])
|
|
219
139
|
|
|
220
140
|
// ---- Prepend anchoring (load-older) ------------------------------
|
|
221
141
|
// The library doesn't preserve user position when content prepends
|
|
@@ -224,7 +144,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
224
144
|
// user on the same message they were reading. `useLayoutEffect`
|
|
225
145
|
// so the scrollTop write lands before paint (no visible jump).
|
|
226
146
|
useLayoutEffect(() => {
|
|
227
|
-
const el =
|
|
147
|
+
const el = scrollRef.current
|
|
228
148
|
if (!el) {
|
|
229
149
|
prependRef.current = { firstMessageId: undefined, firstMessageContent: undefined, scrollHeight: 0 }
|
|
230
150
|
return
|
|
@@ -260,63 +180,46 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
260
180
|
if (currentFirstContent !== prependRef.current.firstMessageContent) {
|
|
261
181
|
prependRef.current.firstMessageContent = currentFirstContent
|
|
262
182
|
}
|
|
263
|
-
}, [messages,
|
|
183
|
+
}, [messages, scrollRef])
|
|
264
184
|
|
|
265
185
|
// ---- Load-more (infinite-scroll UP) ------------------------------
|
|
266
|
-
// Distinct from stick-to-bottom
|
|
267
|
-
//
|
|
268
|
-
// the sentinel actually enters the viewport (± rootMargin).
|
|
269
|
-
// 2. Scroll-listener fallback (200px from top) — covers the race
|
|
270
|
-
// where IO mounts before the sentinel/scroll container has its
|
|
271
|
-
// final geometry on page reload, and the case where the user
|
|
272
|
-
// reaches the top on a chat that initially had `hasNextPage`
|
|
273
|
-
// undefined (cache cold). Both call `onLoadMoreRef.current` and
|
|
274
|
-
// guard with `isFetchingRef` so a double-fire is a single fetch.
|
|
275
|
-
// Deps include `messages.length` so the effect re-binds once content
|
|
276
|
-
// actually renders (fixes the case where the first commit has
|
|
277
|
-
// hasNextPage=true but sentinel ref isn't yet attached).
|
|
186
|
+
// Distinct from stick-to-bottom; uses its own IntersectionObserver
|
|
187
|
+
// on the top sentinel.
|
|
278
188
|
useEffect(() => {
|
|
279
|
-
const scrollContainer =
|
|
280
|
-
const sentinelElement =
|
|
281
|
-
if (!scrollContainer || !hasNextPage) return
|
|
282
|
-
|
|
283
|
-
const tryLoad = () => {
|
|
284
|
-
if (isFetchingRef.current) return
|
|
285
|
-
onLoadMoreRef.current?.()
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
let observer: IntersectionObserver | undefined
|
|
289
|
-
if (sentinelElement) {
|
|
290
|
-
observer = new IntersectionObserver(
|
|
291
|
-
(entries) => {
|
|
292
|
-
const entry = entries[0]
|
|
293
|
-
if (entry?.isIntersecting) tryLoad()
|
|
294
|
-
},
|
|
295
|
-
{ root: scrollContainer, rootMargin: '200px', threshold: 0.1 },
|
|
296
|
-
)
|
|
297
|
-
observer.observe(sentinelElement)
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const onScroll = () => {
|
|
301
|
-
if (scrollContainer.scrollTop <= 200) tryLoad()
|
|
302
|
-
}
|
|
303
|
-
scrollContainer.addEventListener('scroll', onScroll, { passive: true })
|
|
189
|
+
const scrollContainer = scrollRef.current
|
|
190
|
+
const sentinelElement = sentinelRef.current
|
|
191
|
+
if (!scrollContainer || !sentinelElement || !hasNextPage) return
|
|
304
192
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
193
|
+
const observer = new IntersectionObserver(
|
|
194
|
+
(entries) => {
|
|
195
|
+
const entry = entries[0]
|
|
196
|
+
if (!entry) return
|
|
197
|
+
if (entry.isIntersecting && !isFetchingRef.current) {
|
|
198
|
+
onLoadMoreRef.current?.()
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
{ root: scrollContainer, rootMargin: '200px', threshold: 0.1 },
|
|
202
|
+
)
|
|
203
|
+
observer.observe(sentinelElement)
|
|
204
|
+
return () => observer.disconnect()
|
|
205
|
+
}, [hasNextPage, scrollRef])
|
|
310
206
|
|
|
311
207
|
// Expose the scroll container ref to parents that need it (rare,
|
|
312
208
|
// but the existing public contract). Library's `scrollRef` is a
|
|
313
209
|
// MutableRefObject<HTMLElement> so we cast to the public type.
|
|
314
210
|
useImperativeHandle(ref, () => scrollRef.current as HTMLDivElement, [scrollRef])
|
|
315
211
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
212
|
+
if (isLoading) {
|
|
213
|
+
return (
|
|
214
|
+
<ChatMessageListSkeleton
|
|
215
|
+
className={className}
|
|
216
|
+
showAvatars={showAvatars}
|
|
217
|
+
assistantType={assistantType}
|
|
218
|
+
contentClassName={contentClassName}
|
|
219
|
+
messageCount={6}
|
|
220
|
+
/>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
320
223
|
|
|
321
224
|
// Adapt the library's refs to React's `Ref<HTMLDivElement>` JSX
|
|
322
225
|
// slot. The library types its refs against `HTMLElement` (broader
|
|
@@ -333,31 +236,11 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
333
236
|
// cleanup contract). The hub's React types reject that union in a
|
|
334
237
|
// JSX `Ref<T>` slot. Force `void` here — the library doesn't use
|
|
335
238
|
// the cleanup return path in any meaningful way for the public hook.
|
|
336
|
-
|
|
337
|
-
// each render) cause React to call cleanup(null) then setup(el) on
|
|
338
|
-
// EVERY render — which would flip `scrollEl` state null→el every
|
|
339
|
-
// render, churning the prepend-anchor `useLayoutEffect` and
|
|
340
|
-
// wiping `prependRef.current.scrollHeight` to 0 on each null pass
|
|
341
|
-
// (see the `if (!el)` branch in the prepend effect). The net
|
|
342
|
-
// effect is that load-older never has a valid prev-height snapshot
|
|
343
|
-
// and the user's scroll position isn't preserved after pagination.
|
|
344
|
-
// Must live ABOVE the `showLoader` early return — Rules of Hooks.
|
|
345
|
-
const setScrollRef = useCallback((el: HTMLDivElement | null): void => {
|
|
239
|
+
const setScrollRef = (el: HTMLDivElement | null): void => {
|
|
346
240
|
scrollRef(el)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const setContentRef = useCallback((el: HTMLDivElement | null): void => {
|
|
241
|
+
}
|
|
242
|
+
const setContentRef = (el: HTMLDivElement | null): void => {
|
|
350
243
|
contentRef(el)
|
|
351
|
-
}, [contentRef])
|
|
352
|
-
|
|
353
|
-
if (showLoader) {
|
|
354
|
-
return (
|
|
355
|
-
<ChatMessageListLoader
|
|
356
|
-
className={className}
|
|
357
|
-
assistantIcon={assistantIcon}
|
|
358
|
-
assistantType={assistantType}
|
|
359
|
-
/>
|
|
360
|
-
)
|
|
361
244
|
}
|
|
362
245
|
|
|
363
246
|
return (
|
|
@@ -379,19 +262,8 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
379
262
|
)}
|
|
380
263
|
style={{ minHeight: '100%' }}
|
|
381
264
|
>
|
|
382
|
-
{/* Infinite scroll sentinel + loader for older pages */}
|
|
383
265
|
{hasNextPage && (
|
|
384
|
-
<div ref={
|
|
385
|
-
)}
|
|
386
|
-
{isFetchingNextPage && (
|
|
387
|
-
<div
|
|
388
|
-
className="flex justify-center py-3 animate-in fade-in duration-200"
|
|
389
|
-
role="status"
|
|
390
|
-
aria-live="polite"
|
|
391
|
-
aria-busy="true"
|
|
392
|
-
>
|
|
393
|
-
<PulseDots size="sm" />
|
|
394
|
-
</div>
|
|
266
|
+
<div ref={sentinelRef} className="h-px" />
|
|
395
267
|
)}
|
|
396
268
|
<div className="flex-1" />
|
|
397
269
|
{messages.map((message, index) => (
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from "react"
|
|
4
|
+
import { cn } from "../../utils/cn"
|
|
5
|
+
|
|
6
|
+
interface CyclingPhraseProps {
|
|
7
|
+
/** Words to cycle through. Cycle wraps around indefinitely. */
|
|
8
|
+
words: readonly string[]
|
|
9
|
+
/** Optional className applied to the outer span. */
|
|
10
|
+
className?: string
|
|
11
|
+
/** Milliseconds per character during the morph step. */
|
|
12
|
+
charMs?: number
|
|
13
|
+
/** Milliseconds to hold a fully-typed word before starting next morph. */
|
|
14
|
+
holdMs?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Idempotent keyframe — multiple component instances share the same
|
|
18
|
+
// CSS rule, no duplication issues.
|
|
19
|
+
const BLINK_KEYFRAMES = `
|
|
20
|
+
@keyframes cyclingCursorBlink {
|
|
21
|
+
50% { opacity: 0; }
|
|
22
|
+
}
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Terminal-style cycling word: a fixed-size block cursor walks left-
|
|
27
|
+
* to-right through the word, overwriting the previous word's char at
|
|
28
|
+
* each position with the new word's char (or trimming trailing chars
|
|
29
|
+
* if the new word is shorter). On hold the cursor is hidden — the
|
|
30
|
+
* morph is the only time it appears. A static "..." suffix follows
|
|
31
|
+
* the word at all times.
|
|
32
|
+
*
|
|
33
|
+
* Visual sequence (Thinking → Vibing):
|
|
34
|
+
* Thinking... (hold, no cursor)
|
|
35
|
+
* V█hinking... → Vi█inking... → ... → Vibing█...
|
|
36
|
+
* Vibing... (hold, no cursor)
|
|
37
|
+
* ...
|
|
38
|
+
*
|
|
39
|
+
* Width is pinned to the longest word in the list (rendered invisibly
|
|
40
|
+
* underneath) so cycling doesn't shift the dots or surrounding layout.
|
|
41
|
+
*/
|
|
42
|
+
export function CyclingPhrase({
|
|
43
|
+
words,
|
|
44
|
+
className,
|
|
45
|
+
charMs = 60,
|
|
46
|
+
holdMs = 4500,
|
|
47
|
+
}: CyclingPhraseProps) {
|
|
48
|
+
const [wordIndex, setWordIndex] = useState(0)
|
|
49
|
+
const [text, setText] = useState('')
|
|
50
|
+
const [cursor, setCursor] = useState(0)
|
|
51
|
+
const [holding, setHolding] = useState(false)
|
|
52
|
+
|
|
53
|
+
// Width-reservation: longest word + "..." sets the outer min-width.
|
|
54
|
+
const placeholder = useMemo(
|
|
55
|
+
() => words.reduce((longest, w) => (w.length > longest.length ? w : longest), ''),
|
|
56
|
+
[words],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (words.length === 0) return
|
|
61
|
+
const target = words[wordIndex]
|
|
62
|
+
let timeoutId: ReturnType<typeof setTimeout>
|
|
63
|
+
|
|
64
|
+
if (holding) {
|
|
65
|
+
timeoutId = setTimeout(() => {
|
|
66
|
+
setWordIndex((i) => (i + 1) % words.length)
|
|
67
|
+
setCursor(0)
|
|
68
|
+
setHolding(false)
|
|
69
|
+
}, holdMs)
|
|
70
|
+
return () => clearTimeout(timeoutId)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const maxLen = Math.max(text.length, target.length)
|
|
74
|
+
if (cursor >= maxLen) {
|
|
75
|
+
// Morph complete — pin to target and enter hold (cursor hides).
|
|
76
|
+
if (text !== target) setText(target)
|
|
77
|
+
setHolding(true)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
timeoutId = setTimeout(() => {
|
|
82
|
+
setText((prev) => {
|
|
83
|
+
if (cursor < target.length) {
|
|
84
|
+
return target.slice(0, cursor + 1) + prev.slice(cursor + 1)
|
|
85
|
+
}
|
|
86
|
+
return prev.slice(0, cursor)
|
|
87
|
+
})
|
|
88
|
+
setCursor((c) => c + 1)
|
|
89
|
+
}, charMs)
|
|
90
|
+
return () => clearTimeout(timeoutId)
|
|
91
|
+
}, [wordIndex, cursor, text, holding, words, charMs, holdMs])
|
|
92
|
+
|
|
93
|
+
if (words.length === 0) return null
|
|
94
|
+
|
|
95
|
+
const before = text.slice(0, cursor)
|
|
96
|
+
const after = text.slice(cursor)
|
|
97
|
+
|
|
98
|
+
// Block cursor — consistent size regardless of phase. Same style
|
|
99
|
+
// every time it appears (no scaling/jitter). Blinks while visible.
|
|
100
|
+
const cursorBlock = (
|
|
101
|
+
<span
|
|
102
|
+
aria-hidden
|
|
103
|
+
className="inline-block bg-current align-baseline"
|
|
104
|
+
style={{
|
|
105
|
+
width: '0.6em',
|
|
106
|
+
height: '1em',
|
|
107
|
+
verticalAlign: '-0.1em',
|
|
108
|
+
marginLeft: '1px',
|
|
109
|
+
marginRight: '1px',
|
|
110
|
+
animation: 'cyclingCursorBlink 1s steps(1) infinite',
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<span className={cn("relative inline-block whitespace-nowrap", className)}>
|
|
117
|
+
<style dangerouslySetInnerHTML={{ __html: BLINK_KEYFRAMES }} />
|
|
118
|
+
{/* Invisible placeholder — pins width to longest word + "..." */}
|
|
119
|
+
<span aria-hidden className="invisible">{placeholder}...</span>
|
|
120
|
+
{/* Live layer */}
|
|
121
|
+
<span className="absolute inset-0 inline-flex items-baseline" aria-live="polite">
|
|
122
|
+
<span>{before}</span>
|
|
123
|
+
{!holding && cursorBlock}
|
|
124
|
+
<span>{after}</span>
|
|
125
|
+
<span>...</span>
|
|
126
|
+
</span>
|
|
127
|
+
</span>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -12,7 +12,6 @@ export * from './chat-input'
|
|
|
12
12
|
export * from './slash-command-suggestions'
|
|
13
13
|
export * from './chat-message-enhanced'
|
|
14
14
|
export * from './chat-message-list'
|
|
15
|
-
export * from './chat-message-loader'
|
|
16
15
|
export * from './chat-quick-action'
|
|
17
16
|
export * from './chat-ticket-item'
|
|
18
17
|
export * from './chat-ticket-list'
|