@flamingo-stack/openframe-frontend-core 0.0.172 → 0.0.173-snapshot.20260513212448
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-AY3E5FKM.cjs → chunk-TX2C3PJM.cjs} +2227 -1649
- package/dist/chunk-TX2C3PJM.cjs.map +1 -0
- package/dist/{chunk-FEEPEOW2.js → chunk-X7AOT3WQ.js} +5770 -5192
- package/dist/chunk-X7AOT3WQ.js.map +1 -0
- package/dist/components/chat/approval-batch-message.d.ts +10 -0
- package/dist/components/chat/approval-batch-message.d.ts.map +1 -0
- package/dist/components/chat/chat-input.d.ts.map +1 -1
- 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/chat-message-loader.d.ts +23 -0
- package/dist/components/chat/chat-message-loader.d.ts.map +1 -0
- package/dist/components/chat/hooks/index.d.ts +1 -0
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-collapsible.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-delayed-flag.d.ts +25 -0
- package/dist/components/chat/hooks/use-delayed-flag.d.ts.map +1 -0
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
- package/dist/components/chat/index.d.ts +3 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/thinking-display.d.ts.map +1 -1
- package/dist/components/chat/tool-call-blocks.d.ts +18 -0
- package/dist/components/chat/tool-call-blocks.d.ts.map +1 -0
- package/dist/components/chat/tool-execution-display.d.ts +1 -1
- package/dist/components/chat/tool-execution-display.d.ts.map +1 -1
- package/dist/components/chat/types/api.types.d.ts +16 -1
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/component.types.d.ts +2 -2
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/chat/types/message.types.d.ts +54 -1
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/types/network.types.d.ts +2 -0
- package/dist/components/chat/types/network.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +14 -1
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +1 -0
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/message-segment-accumulator.d.ts +32 -4
- package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +2 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/chat/utils/tool-call-helpers.d.ts +38 -0
- package/dist/components/chat/utils/tool-call-helpers.d.ts.map +1 -0
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/index.cjs +14 -2
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +13 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/ui/index.cjs +14 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +13 -1
- package/dist/components/ui/textarea.d.ts +11 -0
- package/dist/components/ui/textarea.d.ts.map +1 -1
- package/dist/index.cjs +14 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -1
- package/package.json +3 -3
- package/src/components/chat/approval-batch-message.tsx +237 -0
- package/src/components/chat/chat-input.tsx +34 -106
- package/src/components/chat/chat-message-enhanced.tsx +11 -0
- package/src/components/chat/chat-message-list.tsx +100 -37
- package/src/components/chat/chat-message-loader.tsx +67 -0
- package/src/components/chat/hooks/index.ts +1 -0
- package/src/components/chat/hooks/use-collapsible.ts +10 -1
- package/src/components/chat/hooks/use-delayed-flag.ts +56 -0
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +109 -20
- package/src/components/chat/index.ts +3 -0
- package/src/components/chat/thinking-display.tsx +76 -12
- package/src/components/chat/tool-call-blocks.tsx +58 -0
- package/src/components/chat/tool-execution-display.tsx +60 -81
- package/src/components/chat/types/api.types.ts +22 -3
- package/src/components/chat/types/component.types.ts +2 -2
- package/src/components/chat/types/message.types.ts +58 -1
- package/src/components/chat/types/network.types.ts +2 -0
- package/src/components/chat/types/processing.types.ts +13 -2
- package/src/components/chat/utils/chunk-parser.ts +40 -5
- package/src/components/chat/utils/extract-incomplete-message-state.ts +19 -1
- package/src/components/chat/utils/index.ts +3 -0
- package/src/components/chat/utils/message-segment-accumulator.ts +136 -12
- package/src/components/chat/utils/process-historical-messages.ts +88 -13
- package/src/components/chat/utils/tool-call-helpers.ts +105 -0
- package/src/components/ui/textarea.tsx +107 -25
- package/dist/chunk-AY3E5FKM.cjs.map +0 -1
- package/dist/chunk-FEEPEOW2.js.map +0 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { useRef, useEffect, useLayoutEffect, useImperativeHandle, forwardRef } from "react"
|
|
3
|
+
import { useRef, useState, 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 {
|
|
7
|
+
import { ChatMessageListLoader } from "./chat-message-loader"
|
|
8
|
+
import { useDelayedFlag } from "./hooks/use-delayed-flag"
|
|
9
|
+
import { PulseDots } from "../ui/pulse-dots"
|
|
8
10
|
import type { ChatMessageListProps } from "./types"
|
|
9
11
|
|
|
10
12
|
/*
|
|
@@ -64,17 +66,27 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
64
66
|
// `resize: 'smooth'` — during streaming, content grows token-by-
|
|
65
67
|
// token; library uses spring physics to follow the bottom for a
|
|
66
68
|
// ChatGPT/Claude.ai-like feel (vs jarring instant snaps).
|
|
67
|
-
// `initial:
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
69
|
+
// `initial: 'instant'` — snap to bottom on first mount BEFORE
|
|
70
|
+
// browser paint. Without this, a page reload that lands on a long
|
|
71
|
+
// history would briefly paint the top of the list (oldest msgs)
|
|
72
|
+
// before any post-paint effect could run `scrollTop = bottom`. The
|
|
73
|
+
// library writes scrollTop in the commit phase, so the first paint
|
|
74
|
+
// is already at the bottom.
|
|
71
75
|
const { scrollRef, contentRef, scrollToBottom } = useStickToBottom({
|
|
72
76
|
resize: 'smooth',
|
|
73
|
-
initial:
|
|
77
|
+
initial: 'instant',
|
|
74
78
|
})
|
|
75
79
|
|
|
76
80
|
// ---- Prepend / load-more state (NOT owned by the library) --------
|
|
77
|
-
|
|
81
|
+
// `scrollEl` and `sentinelEl` are STATE, not refs, so the load-more
|
|
82
|
+
// effect re-runs when either element mounts/unmounts. This is the
|
|
83
|
+
// critical fix for the reload pagination bug: when the loader is
|
|
84
|
+
// showing, the scroll container is unmounted (`scrollRef.current ==
|
|
85
|
+
// null`); when the loader disappears later, neither `hasNextPage`
|
|
86
|
+
// nor `messages.length` changes — but `scrollEl` flips from null to
|
|
87
|
+
// the new element, which re-runs the effect.
|
|
88
|
+
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null)
|
|
89
|
+
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null)
|
|
78
90
|
const onLoadMoreRef = useRef(onLoadMore)
|
|
79
91
|
onLoadMoreRef.current = onLoadMore
|
|
80
92
|
const isFetchingRef = useRef(isFetchingNextPage)
|
|
@@ -100,8 +112,20 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
100
112
|
}>({ firstMessageId: undefined, firstMessageContent: undefined, scrollHeight: 0 })
|
|
101
113
|
|
|
102
114
|
// ---- Force-stick: dialog change / first-load / new user message --
|
|
103
|
-
|
|
104
|
-
|
|
115
|
+
// `useLayoutEffect` so the scrollTop write lands BEFORE browser
|
|
116
|
+
// paint — otherwise a dialog switch with prior history flashes the
|
|
117
|
+
// top of the list (oldest msgs) for one frame before snapping down.
|
|
118
|
+
//
|
|
119
|
+
// Critical: gate on `scrollEl` (state, not ref). When `useDelayedFlag`
|
|
120
|
+
// is showing the loader, the scroll container is UNMOUNTED. If we
|
|
121
|
+
// ran the snap-logic during the loader phase, `scrollToBottom` would
|
|
122
|
+
// no-op AND `prevRenderRef` would record the new dialog/count — so
|
|
123
|
+
// when the loader finally hides and the container remounts, we'd
|
|
124
|
+
// think "no dialog change" and never snap. By depending on
|
|
125
|
+
// `scrollEl`, this effect re-runs the moment the container mounts
|
|
126
|
+
// and catches up on any transition that happened during the loader.
|
|
127
|
+
useLayoutEffect(() => {
|
|
128
|
+
if (!autoScroll || !scrollEl) return
|
|
105
129
|
|
|
106
130
|
const dialogChanged = dialogId !== prevRenderRef.current.dialogId
|
|
107
131
|
const prevCount = prevRenderRef.current.messageCount
|
|
@@ -135,7 +159,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
135
159
|
// already keeps the bottom locked when the user hasn't
|
|
136
160
|
// escaped. No explicit call needed; spring animation runs.
|
|
137
161
|
}
|
|
138
|
-
}, [autoScroll, messages, dialogId, scrollToBottom])
|
|
162
|
+
}, [autoScroll, messages, dialogId, scrollToBottom, scrollEl])
|
|
139
163
|
|
|
140
164
|
// ---- Prepend anchoring (load-older) ------------------------------
|
|
141
165
|
// The library doesn't preserve user position when content prepends
|
|
@@ -144,7 +168,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
144
168
|
// user on the same message they were reading. `useLayoutEffect`
|
|
145
169
|
// so the scrollTop write lands before paint (no visible jump).
|
|
146
170
|
useLayoutEffect(() => {
|
|
147
|
-
const el =
|
|
171
|
+
const el = scrollEl
|
|
148
172
|
if (!el) {
|
|
149
173
|
prependRef.current = { firstMessageId: undefined, firstMessageContent: undefined, scrollHeight: 0 }
|
|
150
174
|
return
|
|
@@ -180,43 +204,70 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
180
204
|
if (currentFirstContent !== prependRef.current.firstMessageContent) {
|
|
181
205
|
prependRef.current.firstMessageContent = currentFirstContent
|
|
182
206
|
}
|
|
183
|
-
}, [messages,
|
|
207
|
+
}, [messages, scrollEl])
|
|
184
208
|
|
|
185
209
|
// ---- Load-more (infinite-scroll UP) ------------------------------
|
|
186
|
-
// Distinct from stick-to-bottom
|
|
187
|
-
// on the top sentinel
|
|
210
|
+
// Distinct from stick-to-bottom. Two mechanisms in parallel:
|
|
211
|
+
// 1. IntersectionObserver on the top sentinel — cheap, fires when
|
|
212
|
+
// the sentinel actually enters the viewport (± rootMargin).
|
|
213
|
+
// 2. Scroll-listener fallback (200px from top) — covers the race
|
|
214
|
+
// where IO mounts before the sentinel/scroll container has its
|
|
215
|
+
// final geometry on page reload, and the case where the user
|
|
216
|
+
// reaches the top on a chat that initially had `hasNextPage`
|
|
217
|
+
// undefined (cache cold). Both call `onLoadMoreRef.current` and
|
|
218
|
+
// guard with `isFetchingRef` so a double-fire is a single fetch.
|
|
219
|
+
// Deps include `messages.length` so the effect re-binds once content
|
|
220
|
+
// actually renders (fixes the case where the first commit has
|
|
221
|
+
// hasNextPage=true but sentinel ref isn't yet attached).
|
|
188
222
|
useEffect(() => {
|
|
189
|
-
const scrollContainer =
|
|
190
|
-
const sentinelElement =
|
|
191
|
-
if (!scrollContainer || !
|
|
223
|
+
const scrollContainer = scrollEl
|
|
224
|
+
const sentinelElement = sentinelEl
|
|
225
|
+
if (!scrollContainer || !hasNextPage) return
|
|
192
226
|
|
|
193
|
-
const
|
|
194
|
-
(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
227
|
+
const tryLoad = () => {
|
|
228
|
+
if (isFetchingRef.current) return
|
|
229
|
+
onLoadMoreRef.current?.()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let observer: IntersectionObserver | undefined
|
|
233
|
+
if (sentinelElement) {
|
|
234
|
+
observer = new IntersectionObserver(
|
|
235
|
+
(entries) => {
|
|
236
|
+
const entry = entries[0]
|
|
237
|
+
if (entry?.isIntersecting) tryLoad()
|
|
238
|
+
},
|
|
239
|
+
{ root: scrollContainer, rootMargin: '200px', threshold: 0.1 },
|
|
240
|
+
)
|
|
241
|
+
observer.observe(sentinelElement)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const onScroll = () => {
|
|
245
|
+
if (scrollContainer.scrollTop <= 200) tryLoad()
|
|
246
|
+
}
|
|
247
|
+
scrollContainer.addEventListener('scroll', onScroll, { passive: true })
|
|
248
|
+
|
|
249
|
+
return () => {
|
|
250
|
+
observer?.disconnect()
|
|
251
|
+
scrollContainer.removeEventListener('scroll', onScroll)
|
|
252
|
+
}
|
|
253
|
+
}, [hasNextPage, scrollEl, sentinelEl, messages.length])
|
|
206
254
|
|
|
207
255
|
// Expose the scroll container ref to parents that need it (rare,
|
|
208
256
|
// but the existing public contract). Library's `scrollRef` is a
|
|
209
257
|
// MutableRefObject<HTMLElement> so we cast to the public type.
|
|
210
258
|
useImperativeHandle(ref, () => scrollRef.current as HTMLDivElement, [scrollRef])
|
|
211
259
|
|
|
212
|
-
|
|
260
|
+
// Gate the loader: only show after 200ms (fast loads never flicker),
|
|
261
|
+
// and once shown, hold for at least 400ms (no sub-frame flash if data
|
|
262
|
+
// arrives a moment later).
|
|
263
|
+
const showLoader = useDelayedFlag(isLoading, { delay: 200, minDuration: 400 })
|
|
264
|
+
|
|
265
|
+
if (showLoader) {
|
|
213
266
|
return (
|
|
214
|
-
<
|
|
267
|
+
<ChatMessageListLoader
|
|
215
268
|
className={className}
|
|
216
|
-
|
|
269
|
+
assistantIcon={assistantIcon}
|
|
217
270
|
assistantType={assistantType}
|
|
218
|
-
contentClassName={contentClassName}
|
|
219
|
-
messageCount={6}
|
|
220
271
|
/>
|
|
221
272
|
)
|
|
222
273
|
}
|
|
@@ -238,6 +289,7 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
238
289
|
// the cleanup return path in any meaningful way for the public hook.
|
|
239
290
|
const setScrollRef = (el: HTMLDivElement | null): void => {
|
|
240
291
|
scrollRef(el)
|
|
292
|
+
setScrollEl(el)
|
|
241
293
|
}
|
|
242
294
|
const setContentRef = (el: HTMLDivElement | null): void => {
|
|
243
295
|
contentRef(el)
|
|
@@ -262,8 +314,19 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
|
|
|
262
314
|
)}
|
|
263
315
|
style={{ minHeight: '100%' }}
|
|
264
316
|
>
|
|
317
|
+
{/* Infinite scroll sentinel + loader for older pages */}
|
|
265
318
|
{hasNextPage && (
|
|
266
|
-
<div ref={
|
|
319
|
+
<div ref={setSentinelEl} className="h-px" />
|
|
320
|
+
)}
|
|
321
|
+
{isFetchingNextPage && (
|
|
322
|
+
<div
|
|
323
|
+
className="flex justify-center py-3 animate-in fade-in duration-200"
|
|
324
|
+
role="status"
|
|
325
|
+
aria-live="polite"
|
|
326
|
+
aria-busy="true"
|
|
327
|
+
>
|
|
328
|
+
<PulseDots size="sm" />
|
|
329
|
+
</div>
|
|
267
330
|
)}
|
|
268
331
|
<div className="flex-1" />
|
|
269
332
|
{messages.map((message, index) => (
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../utils/cn"
|
|
6
|
+
import { MingoIcon } from "../icons/mingo-icon"
|
|
7
|
+
|
|
8
|
+
interface ChatMessageListLoaderProps {
|
|
9
|
+
className?: string
|
|
10
|
+
/**
|
|
11
|
+
* Brand mark rendered at the centre of the loader. When omitted, a neutral
|
|
12
|
+
* MingoIcon is used (color derived from `assistantType`). Pass a custom
|
|
13
|
+
* element to match a different brand.
|
|
14
|
+
*/
|
|
15
|
+
assistantIcon?: ReactNode
|
|
16
|
+
assistantType?: 'mingo' | 'fae'
|
|
17
|
+
/** Defaults to `"Loading conversation..."`. */
|
|
18
|
+
label?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Centered loading indicator for the chat message list. Replaces the
|
|
23
|
+
* multi-row skeleton with a single, brand-neutral pulsating mark — the same
|
|
24
|
+
* pattern Claude/ChatGPT use when fetching a conversation: don't fake the
|
|
25
|
+
* shape of the content, just signal "we're working on it" and let the real
|
|
26
|
+
* messages fade in once they arrive.
|
|
27
|
+
*/
|
|
28
|
+
export function ChatMessageListLoader({
|
|
29
|
+
className,
|
|
30
|
+
assistantIcon,
|
|
31
|
+
assistantType = 'fae',
|
|
32
|
+
label = 'Loading conversation...',
|
|
33
|
+
}: ChatMessageListLoaderProps) {
|
|
34
|
+
const accentColor =
|
|
35
|
+
assistantType === 'mingo'
|
|
36
|
+
? 'var(--ods-flamingo-cyan-base)'
|
|
37
|
+
: 'var(--ods-flamingo-pink-base)'
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
role="status"
|
|
42
|
+
aria-live="polite"
|
|
43
|
+
aria-busy="true"
|
|
44
|
+
className={cn(
|
|
45
|
+
"relative flex-1 min-h-0 flex flex-col items-center justify-center gap-3 px-8",
|
|
46
|
+
"animate-in fade-in duration-300",
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<div className="relative flex items-center justify-center w-10 h-10">
|
|
51
|
+
<span
|
|
52
|
+
className="absolute inset-0 rounded-full opacity-30 blur-md animate-pulse"
|
|
53
|
+
style={{ backgroundColor: accentColor }}
|
|
54
|
+
aria-hidden="true"
|
|
55
|
+
/>
|
|
56
|
+
<div className="relative motion-safe:animate-pulse">
|
|
57
|
+
{assistantIcon ?? (
|
|
58
|
+
<MingoIcon className="w-8 h-8" eyesColor={accentColor} cornerColor={accentColor} />
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<span className="text-sm font-medium text-ods-text-secondary tracking-tight motion-safe:animate-pulse">
|
|
63
|
+
{label}
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -35,9 +35,18 @@ export function useCollapsible({
|
|
|
35
35
|
return () => observer.disconnect()
|
|
36
36
|
}, [element, collapsedHeight])
|
|
37
37
|
|
|
38
|
+
// When the caller asks for `"1lh"`, fall back to a measured pixel value
|
|
39
|
+
// (inner element's line-height) as soon as it's available. Setting
|
|
40
|
+
// `max-height: 1lh` via CSS resolves against the OUTER element's
|
|
41
|
+
// line-height, which can differ from the inner content's line-height and
|
|
42
|
+
// leak the top of the next block element past the clip line. The measured
|
|
43
|
+
// pixel value matches the inner content exactly.
|
|
44
|
+
const collapsedValue: number | string =
|
|
45
|
+
collapsedHeight === "1lh" ? collapsedPx || collapsedHeight : collapsedHeight
|
|
46
|
+
|
|
38
47
|
const containerStyle: CSSProperties = {
|
|
39
48
|
overflow: "hidden",
|
|
40
|
-
maxHeight: expanded ? contentHeight :
|
|
49
|
+
maxHeight: expanded ? contentHeight : collapsedValue,
|
|
41
50
|
transition: disableTransition ? "none" : `max-height ${durationMs}ms ease-in-out`,
|
|
42
51
|
}
|
|
43
52
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react"
|
|
4
|
+
|
|
5
|
+
interface UseDelayedFlagOptions {
|
|
6
|
+
/** Wait this long before flipping the returned flag to `true`. Default 200ms. */
|
|
7
|
+
delay?: number
|
|
8
|
+
/**
|
|
9
|
+
* Once the flag has gone `true`, hold it for at least this long even if
|
|
10
|
+
* the source signal flips back to `false` sooner. Prevents a sub-frame
|
|
11
|
+
* flash when the operation finishes right after the loader appeared.
|
|
12
|
+
* Default 400ms.
|
|
13
|
+
*/
|
|
14
|
+
minDuration?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Debounce + min-hold gate for transient boolean flags (most useful for
|
|
19
|
+
* loading indicators).
|
|
20
|
+
*
|
|
21
|
+
* - source `true` → wait `delay` ms; if still true, flip output to `true`
|
|
22
|
+
* - source `false` → if output is `true`, hold for `minDuration - elapsed`
|
|
23
|
+
* ms before flipping back; otherwise flip immediately
|
|
24
|
+
*
|
|
25
|
+
* The net effect: fast operations never trigger the indicator, and slow
|
|
26
|
+
* ones always show it for long enough to avoid a flicker.
|
|
27
|
+
*/
|
|
28
|
+
export function useDelayedFlag(
|
|
29
|
+
source: boolean,
|
|
30
|
+
{ delay = 200, minDuration = 400 }: UseDelayedFlagOptions = {},
|
|
31
|
+
): boolean {
|
|
32
|
+
const [active, setActive] = useState(false)
|
|
33
|
+
const activatedAtRef = useRef<number | null>(null)
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (source) {
|
|
37
|
+
if (active) return
|
|
38
|
+
const timer = window.setTimeout(() => {
|
|
39
|
+
activatedAtRef.current = Date.now()
|
|
40
|
+
setActive(true)
|
|
41
|
+
}, delay)
|
|
42
|
+
return () => window.clearTimeout(timer)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!active) return
|
|
46
|
+
const elapsed = activatedAtRef.current ? Date.now() - activatedAtRef.current : minDuration
|
|
47
|
+
const remaining = Math.max(0, minDuration - elapsed)
|
|
48
|
+
const timer = window.setTimeout(() => {
|
|
49
|
+
activatedAtRef.current = null
|
|
50
|
+
setActive(false)
|
|
51
|
+
}, remaining)
|
|
52
|
+
return () => window.clearTimeout(timer)
|
|
53
|
+
}, [source, active, delay, minDuration])
|
|
54
|
+
|
|
55
|
+
return active
|
|
56
|
+
}
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
createMessageSegmentAccumulator,
|
|
13
13
|
} from '../utils/message-segment-accumulator'
|
|
14
14
|
import { MESSAGE_TYPE } from '../types'
|
|
15
|
-
import type { UseRealtimeChunkProcessorReturn, UseRealtimeChunkProcessorOptions, ChatApprovalStatus } from '../types'
|
|
15
|
+
import type { UseRealtimeChunkProcessorReturn, UseRealtimeChunkProcessorOptions, ChatApprovalStatus, PendingToolCallData } from '../types'
|
|
16
|
+
import { getCommandText } from '../utils/tool-call-helpers'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Hook for processing real-time NATS chunks into message segments
|
|
@@ -20,7 +21,17 @@ import type { UseRealtimeChunkProcessorReturn, UseRealtimeChunkProcessorOptions,
|
|
|
20
21
|
export function useRealtimeChunkProcessor(
|
|
21
22
|
options: UseRealtimeChunkProcessorOptions
|
|
22
23
|
): UseRealtimeChunkProcessorReturn {
|
|
23
|
-
const {
|
|
24
|
+
const {
|
|
25
|
+
callbacks,
|
|
26
|
+
displayApprovalTypes = ['CLIENT'],
|
|
27
|
+
approvalStatuses = {},
|
|
28
|
+
initialState,
|
|
29
|
+
enableThinking = false,
|
|
30
|
+
// Owned by the consumer (e.g. oss-tenant chat client / openframe-frontend
|
|
31
|
+
// tickets view). Default ON so consumers that haven't wired the flag yet
|
|
32
|
+
// get the new batch UI; pass `false` explicitly to fall back to legacy.
|
|
33
|
+
batchApprovalsEnabled = true,
|
|
34
|
+
} = options
|
|
24
35
|
|
|
25
36
|
const accumulatorRef = useRef<MessageSegmentAccumulator>(
|
|
26
37
|
createMessageSegmentAccumulator({
|
|
@@ -50,10 +61,10 @@ export function useRealtimeChunkProcessor(
|
|
|
50
61
|
|
|
51
62
|
const isInStreamRef = useRef(false)
|
|
52
63
|
|
|
53
|
-
// Track pending escalated approvals
|
|
54
|
-
const pendingEscalatedRef = useRef<
|
|
55
|
-
|
|
56
|
-
)
|
|
64
|
+
// Track pending escalated approvals (single or batch)
|
|
65
|
+
const pendingEscalatedRef = useRef<
|
|
66
|
+
Map<string, { command: string; explanation?: string; approvalType: string; toolCalls?: PendingToolCallData[] }>
|
|
67
|
+
>(new Map())
|
|
57
68
|
|
|
58
69
|
const processChunk = useCallback(
|
|
59
70
|
(chunk: unknown) => {
|
|
@@ -128,28 +139,106 @@ export function useRealtimeChunkProcessor(
|
|
|
128
139
|
break
|
|
129
140
|
}
|
|
130
141
|
|
|
142
|
+
case 'approval_batch': {
|
|
143
|
+
const { requestId, approvalType, toolCalls } = action
|
|
144
|
+
const status = (approvalStatuses[requestId] || 'pending') as ChatApprovalStatus
|
|
145
|
+
|
|
146
|
+
if (!displayApprovalTypes.includes(approvalType)) {
|
|
147
|
+
// Escalated: keep batch context locally for replay on result; surface a
|
|
148
|
+
// summary command via the legacy escalation callback.
|
|
149
|
+
const required = toolCalls.find((c) => c.requiresApproval) ?? toolCalls[0]
|
|
150
|
+
const summary = required ? getCommandText(required) : `Batch of ${toolCalls.length} tool calls`
|
|
151
|
+
pendingEscalatedRef.current.set(requestId, {
|
|
152
|
+
command: summary,
|
|
153
|
+
explanation: required?.toolExplanation,
|
|
154
|
+
approvalType,
|
|
155
|
+
toolCalls,
|
|
156
|
+
})
|
|
157
|
+
callbacks.onEscalatedApproval?.(requestId, {
|
|
158
|
+
command: summary,
|
|
159
|
+
explanation: required?.toolExplanation,
|
|
160
|
+
approvalType,
|
|
161
|
+
})
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (batchApprovalsEnabled) {
|
|
166
|
+
const segments = accumulator.addApprovalBatch(requestId, approvalType, toolCalls, status)
|
|
167
|
+
callbacks.onSegmentsUpdate?.(segments)
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Flag OFF — unfold batch into N legacy approval cards. They share
|
|
172
|
+
// `requestId`, so a click on any will approve the whole batch via a
|
|
173
|
+
// single backend call, and the resulting APPROVAL_RESULT chunk will
|
|
174
|
+
// flip status on every matching segment (see updateApprovalStatus).
|
|
175
|
+
let segments = accumulator.getSegments()
|
|
176
|
+
for (const call of toolCalls) {
|
|
177
|
+
if (!call.requiresApproval) continue
|
|
178
|
+
segments = accumulator.addApprovalRequest(
|
|
179
|
+
requestId,
|
|
180
|
+
getCommandText(call),
|
|
181
|
+
call.toolExplanation,
|
|
182
|
+
approvalType,
|
|
183
|
+
status,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
callbacks.onSegmentsUpdate?.(segments)
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
|
|
131
190
|
case 'approval_result': {
|
|
132
191
|
const { requestId, approved, approvalType } = action
|
|
133
192
|
const escalatedData = pendingEscalatedRef.current.get(requestId)
|
|
193
|
+
const status: ChatApprovalStatus = approved ? 'approved' : 'rejected'
|
|
194
|
+
|
|
134
195
|
if (escalatedData) {
|
|
135
196
|
pendingEscalatedRef.current.delete(requestId)
|
|
136
|
-
callbacks.onEscalatedApprovalResult?.(requestId, approved,
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
197
|
+
callbacks.onEscalatedApprovalResult?.(requestId, approved, {
|
|
198
|
+
command: escalatedData.command,
|
|
199
|
+
explanation: escalatedData.explanation,
|
|
200
|
+
approvalType: escalatedData.approvalType,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
if (escalatedData.toolCalls && escalatedData.toolCalls.length > 0) {
|
|
204
|
+
if (batchApprovalsEnabled) {
|
|
205
|
+
const segments = accumulator.addApprovalBatch(
|
|
206
|
+
requestId,
|
|
207
|
+
escalatedData.approvalType,
|
|
208
|
+
escalatedData.toolCalls,
|
|
209
|
+
status,
|
|
210
|
+
)
|
|
211
|
+
callbacks.onSegmentsUpdate?.(segments)
|
|
212
|
+
} else {
|
|
213
|
+
let segments = accumulator.getSegments()
|
|
214
|
+
for (const call of escalatedData.toolCalls) {
|
|
215
|
+
if (!call.requiresApproval) continue
|
|
216
|
+
segments = accumulator.addApprovalRequest(
|
|
217
|
+
requestId,
|
|
218
|
+
getCommandText(call),
|
|
219
|
+
call.toolExplanation,
|
|
220
|
+
escalatedData.approvalType,
|
|
221
|
+
status,
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
callbacks.onSegmentsUpdate?.(segments)
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
const segments = accumulator.addApprovalRequest(
|
|
228
|
+
requestId,
|
|
229
|
+
escalatedData.command,
|
|
230
|
+
escalatedData.explanation,
|
|
231
|
+
escalatedData.approvalType,
|
|
232
|
+
status,
|
|
233
|
+
)
|
|
234
|
+
callbacks.onSegmentsUpdate?.(segments)
|
|
235
|
+
}
|
|
146
236
|
} else {
|
|
147
|
-
const segments = accumulator.updateApprovalStatus(
|
|
148
|
-
requestId,
|
|
149
|
-
approved ? 'approved' : 'rejected'
|
|
150
|
-
)
|
|
237
|
+
const segments = accumulator.updateApprovalStatus(requestId, status)
|
|
151
238
|
callbacks.onSegmentsUpdate?.(segments)
|
|
152
239
|
}
|
|
240
|
+
// approvalType from the result is informational; not consumed downstream yet.
|
|
241
|
+
void approvalType
|
|
153
242
|
break
|
|
154
243
|
}
|
|
155
244
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// Chat components exports
|
|
4
4
|
export * from './approval-request-message'
|
|
5
|
+
export * from './approval-batch-message'
|
|
5
6
|
export * from './context-compaction-display'
|
|
6
7
|
export * from './expand-chevron'
|
|
7
8
|
export * from './thinking-display'
|
|
@@ -11,11 +12,13 @@ export * from './chat-input'
|
|
|
11
12
|
export * from './slash-command-suggestions'
|
|
12
13
|
export * from './chat-message-enhanced'
|
|
13
14
|
export * from './chat-message-list'
|
|
15
|
+
export * from './chat-message-loader'
|
|
14
16
|
export * from './chat-quick-action'
|
|
15
17
|
export * from './chat-ticket-item'
|
|
16
18
|
export * from './chat-ticket-list'
|
|
17
19
|
export * from './chat-typing-indicator'
|
|
18
20
|
export * from './tool-execution-display'
|
|
21
|
+
export * from './tool-call-blocks'
|
|
19
22
|
export * from './model-display'
|
|
20
23
|
export * from './chat-sidebar'
|
|
21
24
|
export type { ChatRef } from './chat-ref.types'
|