@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.
Files changed (87) hide show
  1. package/dist/{chunk-AY3E5FKM.cjs → chunk-TX2C3PJM.cjs} +2227 -1649
  2. package/dist/chunk-TX2C3PJM.cjs.map +1 -0
  3. package/dist/{chunk-FEEPEOW2.js → chunk-X7AOT3WQ.js} +5770 -5192
  4. package/dist/chunk-X7AOT3WQ.js.map +1 -0
  5. package/dist/components/chat/approval-batch-message.d.ts +10 -0
  6. package/dist/components/chat/approval-batch-message.d.ts.map +1 -0
  7. package/dist/components/chat/chat-input.d.ts.map +1 -1
  8. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  9. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  10. package/dist/components/chat/chat-message-loader.d.ts +23 -0
  11. package/dist/components/chat/chat-message-loader.d.ts.map +1 -0
  12. package/dist/components/chat/hooks/index.d.ts +1 -0
  13. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  14. package/dist/components/chat/hooks/use-collapsible.d.ts.map +1 -1
  15. package/dist/components/chat/hooks/use-delayed-flag.d.ts +25 -0
  16. package/dist/components/chat/hooks/use-delayed-flag.d.ts.map +1 -0
  17. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -1
  18. package/dist/components/chat/index.d.ts +3 -0
  19. package/dist/components/chat/index.d.ts.map +1 -1
  20. package/dist/components/chat/thinking-display.d.ts.map +1 -1
  21. package/dist/components/chat/tool-call-blocks.d.ts +18 -0
  22. package/dist/components/chat/tool-call-blocks.d.ts.map +1 -0
  23. package/dist/components/chat/tool-execution-display.d.ts +1 -1
  24. package/dist/components/chat/tool-execution-display.d.ts.map +1 -1
  25. package/dist/components/chat/types/api.types.d.ts +16 -1
  26. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  27. package/dist/components/chat/types/component.types.d.ts +2 -2
  28. package/dist/components/chat/types/component.types.d.ts.map +1 -1
  29. package/dist/components/chat/types/message.types.d.ts +54 -1
  30. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  31. package/dist/components/chat/types/network.types.d.ts +2 -0
  32. package/dist/components/chat/types/network.types.d.ts.map +1 -1
  33. package/dist/components/chat/types/processing.types.d.ts +14 -1
  34. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  35. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -1
  36. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  37. package/dist/components/chat/utils/index.d.ts +1 -0
  38. package/dist/components/chat/utils/index.d.ts.map +1 -1
  39. package/dist/components/chat/utils/message-segment-accumulator.d.ts +32 -4
  40. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  41. package/dist/components/chat/utils/process-historical-messages.d.ts +2 -1
  42. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  43. package/dist/components/chat/utils/tool-call-helpers.d.ts +38 -0
  44. package/dist/components/chat/utils/tool-call-helpers.d.ts.map +1 -0
  45. package/dist/components/features/index.cjs +2 -2
  46. package/dist/components/features/index.js +1 -1
  47. package/dist/components/index.cjs +14 -2
  48. package/dist/components/index.cjs.map +1 -1
  49. package/dist/components/index.js +13 -1
  50. package/dist/components/navigation/index.cjs +2 -2
  51. package/dist/components/navigation/index.js +1 -1
  52. package/dist/components/ui/index.cjs +14 -2
  53. package/dist/components/ui/index.cjs.map +1 -1
  54. package/dist/components/ui/index.js +13 -1
  55. package/dist/components/ui/textarea.d.ts +11 -0
  56. package/dist/components/ui/textarea.d.ts.map +1 -1
  57. package/dist/index.cjs +14 -2
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.js +13 -1
  60. package/package.json +3 -3
  61. package/src/components/chat/approval-batch-message.tsx +237 -0
  62. package/src/components/chat/chat-input.tsx +34 -106
  63. package/src/components/chat/chat-message-enhanced.tsx +11 -0
  64. package/src/components/chat/chat-message-list.tsx +100 -37
  65. package/src/components/chat/chat-message-loader.tsx +67 -0
  66. package/src/components/chat/hooks/index.ts +1 -0
  67. package/src/components/chat/hooks/use-collapsible.ts +10 -1
  68. package/src/components/chat/hooks/use-delayed-flag.ts +56 -0
  69. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +109 -20
  70. package/src/components/chat/index.ts +3 -0
  71. package/src/components/chat/thinking-display.tsx +76 -12
  72. package/src/components/chat/tool-call-blocks.tsx +58 -0
  73. package/src/components/chat/tool-execution-display.tsx +60 -81
  74. package/src/components/chat/types/api.types.ts +22 -3
  75. package/src/components/chat/types/component.types.ts +2 -2
  76. package/src/components/chat/types/message.types.ts +58 -1
  77. package/src/components/chat/types/network.types.ts +2 -0
  78. package/src/components/chat/types/processing.types.ts +13 -2
  79. package/src/components/chat/utils/chunk-parser.ts +40 -5
  80. package/src/components/chat/utils/extract-incomplete-message-state.ts +19 -1
  81. package/src/components/chat/utils/index.ts +3 -0
  82. package/src/components/chat/utils/message-segment-accumulator.ts +136 -12
  83. package/src/components/chat/utils/process-historical-messages.ts +88 -13
  84. package/src/components/chat/utils/tool-call-helpers.ts +105 -0
  85. package/src/components/ui/textarea.tsx +107 -25
  86. package/dist/chunk-AY3E5FKM.cjs.map +0 -1
  87. 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 { ChatMessageListSkeleton } from "./chat-message-skeleton"
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: 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.
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: false,
77
+ initial: 'instant',
74
78
  })
75
79
 
76
80
  // ---- Prepend / load-more state (NOT owned by the library) --------
77
- const sentinelRef = useRef<HTMLDivElement>(null)
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
- useEffect(() => {
104
- if (!autoScroll) return
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 = scrollRef.current
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, scrollRef])
207
+ }, [messages, scrollEl])
184
208
 
185
209
  // ---- Load-more (infinite-scroll UP) ------------------------------
186
- // Distinct from stick-to-bottom; uses its own IntersectionObserver
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 = scrollRef.current
190
- const sentinelElement = sentinelRef.current
191
- if (!scrollContainer || !sentinelElement || !hasNextPage) return
223
+ const scrollContainer = scrollEl
224
+ const sentinelElement = sentinelEl
225
+ if (!scrollContainer || !hasNextPage) return
192
226
 
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])
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
- if (isLoading) {
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
- <ChatMessageListSkeleton
267
+ <ChatMessageListLoader
215
268
  className={className}
216
- showAvatars={showAvatars}
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={sentinelRef} className="h-px" />
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
+ }
@@ -2,5 +2,6 @@
2
2
 
3
3
  export * from './use-chunk-catchup'
4
4
  export * from './use-collapsible'
5
+ export * from './use-delayed-flag'
5
6
  export * from './use-nats-dialog-subscription'
6
7
  export * from './use-realtime-chunk-processor'
@@ -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 : collapsedHeight,
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 { callbacks, displayApprovalTypes = ['CLIENT'], approvalStatuses = {}, initialState, enableThinking = false } = options
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<Map<string, { command: string; explanation?: string; approvalType: string }>>(
55
- new Map()
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, escalatedData)
137
-
138
- const segments = accumulator.addApprovalRequest(
139
- requestId,
140
- escalatedData.command,
141
- escalatedData.explanation,
142
- escalatedData.approvalType,
143
- approved ? 'approved' : 'rejected'
144
- )
145
- callbacks.onSegmentsUpdate?.(segments)
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'