@flamingo-stack/openframe-frontend-core 0.0.172 → 0.0.173

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-2MEBOTV4.cjs} +2263 -1698
  2. package/dist/chunk-2MEBOTV4.cjs.map +1 -0
  3. package/dist/{chunk-FEEPEOW2.js → chunk-J3ZCNPDM.js} +6128 -5563
  4. package/dist/chunk-J3ZCNPDM.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 +22 -6
  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
@@ -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'
@@ -4,21 +4,75 @@ import { forwardRef, useState } from "react"
4
4
 
5
5
  import { cn } from "../../utils/cn"
6
6
  import { PulseDots } from "../ui/pulse-dots"
7
+ import { SimpleMarkdownRenderer } from "../ui/simple-markdown-renderer"
7
8
  import { ExpandChevron } from "./expand-chevron"
8
9
  import { useCollapsible } from "./hooks/use-collapsible"
9
10
  import type { ThinkingDisplayProps } from "./types"
10
11
 
12
+ // Tame SimpleMarkdownRenderer for the muted "thought" aesthetic:
13
+ // - all text in secondary tone with small size
14
+ // - bold pops back to primary so emphasis still reads
15
+ // - tighter paragraph/list/heading spacing (renderer ships with mt-8 mb-4)
16
+ // - flatter horizontal rules
17
+ // - inline-code visually quieter inside thoughts
18
+ // - fenced code blocks (`.code-block-container`) lose the renderer's `my-6`
19
+ // and inner `p-4` so they sit flush with surrounding paragraphs
20
+ const thoughtMdClasses = [
21
+ // Body & lists — muted grey, never white.
22
+ "[&_p]:!text-sm [&_p]:!text-ods-text-secondary [&_p]:!leading-snug [&_p]:!my-2 [&_p:first-child]:!mt-0 [&_p:last-child]:!mb-0",
23
+ "[&_li]:!text-sm [&_li]:!text-ods-text-secondary [&_li]:!leading-snug",
24
+ "[&_ul]:!my-2 [&_ol]:!my-2 [&_ul]:!pl-5 [&_ol]:!pl-5",
25
+ // Inline emphasis stays grey too. Bold pops via weight (no color shift),
26
+ // italic via slant — neither is allowed to brighten into white.
27
+ "[&_strong]:!text-ods-text-secondary [&_strong]:!font-semibold",
28
+ "[&_em]:!text-ods-text-secondary",
29
+ // Headings — SimpleMarkdownRenderer paints them `text-ods-text-primary`
30
+ // by default. Override every level back to secondary grey so headings
31
+ // don't suddenly turn white inside a thought block.
32
+ "[&_h1]:!text-sm [&_h1]:!text-ods-text-secondary [&_h1]:!font-semibold [&_h1]:!mt-3 [&_h1]:!mb-1 [&_h1]:!pb-0 [&_h1]:!border-0",
33
+ "[&_h2]:!text-sm [&_h2]:!text-ods-text-secondary [&_h2]:!font-semibold [&_h2]:!mt-3 [&_h2]:!mb-1 [&_h2]:!pb-0 [&_h2]:!border-0",
34
+ "[&_h3]:!text-sm [&_h3]:!text-ods-text-secondary [&_h3]:!font-semibold [&_h3]:!mt-3 [&_h3]:!mb-1",
35
+ "[&_h4]:!text-sm [&_h4]:!text-ods-text-secondary [&_h4]:!font-semibold [&_h4]:!mt-2 [&_h4]:!mb-1",
36
+ "[&_h5]:!text-sm [&_h5]:!text-ods-text-secondary [&_h5]:!font-semibold [&_h5]:!mt-2 [&_h5]:!mb-1",
37
+ "[&_h6]:!text-sm [&_h6]:!text-ods-text-secondary [&_h6]:!font-semibold [&_h6]:!mt-2 [&_h6]:!mb-1",
38
+ // Rules
39
+ "[&_hr]:!my-3 [&_hr]:!border-ods-border [&_hr]:!opacity-40",
40
+ // Inline code — Cursor-style chip: no border, background a notch
41
+ // LIGHTER than the surrounding thought card (`bg-ods-border` is the
42
+ // brightest neutral grey in the palette and sits above `bg-ods-card`),
43
+ // small rounded corners, tight padding. Reads as a clearly raised token
44
+ // without the loud bordered chip of the default renderer.
45
+ "[&_code]:!text-[12px] [&_code]:!text-ods-text-secondary",
46
+ "[&_code]:!border-0 [&_code]:!bg-ods-border [&_code]:!rounded",
47
+ "[&_code]:!px-1.5 [&_code]:!py-0.5",
48
+ "[&_.code-block-container]:!my-2",
49
+ "[&_.code-block-container_.code-header]:!py-1 [&_.code-block-container_.code-header]:!px-3",
50
+ "[&_.code-block-container_>div:last-child]:!p-3",
51
+ ].join(" ")
52
+
11
53
  const ThinkingDisplay = forwardRef<HTMLDivElement, ThinkingDisplayProps>(
12
54
  ({ className, text, isStreaming = false, ...props }, ref) => {
13
55
  const [expanded, setExpanded] = useState(false)
56
+ // 26px ≈ one line of `text-sm leading-snug` (~19px) + space for the
57
+ // bordered/padded `<code>` badges that markdown renders inline. With
58
+ // exact 1lh the bottom of those badges gets clipped.
14
59
  const { innerRef, isOverflowing, containerStyle } = useCollapsible({
15
60
  expanded,
16
- collapsedHeight: "1lh",
61
+ collapsedHeight: 19,
17
62
  disableTransition: isStreaming,
18
63
  })
19
64
  const label = isStreaming ? "Thinking" : "Thought"
20
65
  const canToggle = isOverflowing || expanded
21
66
 
67
+ const toggle = () => canToggle && setExpanded(!expanded)
68
+ const handleKeyDown = (e: React.KeyboardEvent) => {
69
+ if (!canToggle) return
70
+ if (e.key === "Enter" || e.key === " ") {
71
+ e.preventDefault()
72
+ setExpanded(!expanded)
73
+ }
74
+ }
75
+
22
76
  return (
23
77
  <div
24
78
  ref={ref}
@@ -28,29 +82,39 @@ const ThinkingDisplay = forwardRef<HTMLDivElement, ThinkingDisplayProps>(
28
82
  )}
29
83
  {...props}
30
84
  >
31
- <button
32
- type="button"
85
+ <div
86
+ role="button"
87
+ tabIndex={canToggle ? 0 : -1}
88
+ aria-expanded={expanded}
89
+ onClick={toggle}
90
+ onKeyDown={handleKeyDown}
33
91
  className={cn(
34
92
  "w-full flex items-start gap-3 text-left",
35
93
  canToggle ? "cursor-pointer" : "cursor-default"
36
94
  )}
37
- onClick={() => canToggle && setExpanded(!expanded)}
38
95
  >
39
- <div className="flex items-center gap-1 shrink-0 pt-0.5">
40
- <span className="text-sm font-medium text-ods-text-secondary">
41
- {label}
96
+ <div className="flex items-center gap-1 shrink-0 h-[19px]">
97
+ <span className="text-sm font-medium leading-snug text-ods-text-secondary">
98
+ {label}:
42
99
  </span>
43
100
  {isStreaming && <PulseDots size="sm" />}
44
101
  </div>
45
102
 
46
- <div className="flex-1 min-w-0 pt-0.5" style={containerStyle}>
47
- <div ref={innerRef} className="text-sm text-ods-text-secondary whitespace-pre-wrap">
48
- {text}
103
+ <div className="flex-1 min-w-0" style={containerStyle}>
104
+ <div
105
+ ref={innerRef}
106
+ className={cn("text-sm leading-snug text-ods-text-secondary", thoughtMdClasses)}
107
+ >
108
+ <SimpleMarkdownRenderer content={text} textSize="compact" />
49
109
  </div>
50
110
  </div>
51
111
 
52
- {canToggle && <ExpandChevron expanded={expanded} className="mt-1" />}
53
- </button>
112
+ {canToggle && (
113
+ <div className="h-[19px] flex items-center shrink-0">
114
+ <ExpandChevron expanded={expanded} />
115
+ </div>
116
+ )}
117
+ </div>
54
118
  </div>
55
119
  )
56
120
  }
@@ -0,0 +1,58 @@
1
+ "use client"
2
+
3
+ import { cn } from "../../utils/cn"
4
+ import { formatToolArgValue, formatToolResult } from "./utils/tool-call-helpers"
5
+
6
+ /**
7
+ * Single arg row: inline `key: value` or labeled `<pre>` block.
8
+ * Shared between ApprovalBatchMessage and ToolExecutionDisplay so both
9
+ * surfaces render long script bodies / JSON the same way.
10
+ */
11
+ export function ArgRow({ argKey, value }: { argKey: string; value: unknown }) {
12
+ const formatted = formatToolArgValue(value)
13
+
14
+ if (formatted.kind === "inline") {
15
+ return (
16
+ <div className="flex gap-1 items-start w-full">
17
+ <span className="shrink-0 text-ods-text-secondary">{argKey}:</span>
18
+ <span className="flex-1 min-w-0 text-ods-text-primary break-all">{formatted.text}</span>
19
+ </div>
20
+ )
21
+ }
22
+
23
+ return (
24
+ <div className="flex flex-col gap-1 items-start w-full">
25
+ <span className="text-ods-text-secondary">{argKey}:</span>
26
+ <pre className="bg-ods-bg border border-ods-border rounded-md p-3 w-full max-h-64 overflow-auto text-xs leading-5 text-ods-text-primary font-mono whitespace-pre">
27
+ <code>{formatted.text}</code>
28
+ </pre>
29
+ </div>
30
+ )
31
+ }
32
+
33
+ /**
34
+ * Result block for a tool execution. Renders inline for short single-line
35
+ * output and as a scrollable `<pre>` for code / JSON / multiline output.
36
+ */
37
+ export function ResultBlock({ result, className }: { result: string | undefined | null; className?: string }) {
38
+ const formatted = formatToolResult(result)
39
+ if (!formatted.text) return null
40
+
41
+ if (formatted.kind === "inline") {
42
+ return (
43
+ <div className={cn("flex flex-col gap-1 items-start w-full", className)}>
44
+ <span className="text-ods-text-secondary">Result:</span>
45
+ <span className="text-ods-text-primary break-all">{formatted.text}</span>
46
+ </div>
47
+ )
48
+ }
49
+
50
+ return (
51
+ <div className={cn("flex flex-col gap-1 items-start w-full", className)}>
52
+ <span className="text-ods-text-secondary">Result:</span>
53
+ <pre className="bg-ods-bg border border-ods-border rounded-md p-3 w-full max-h-64 overflow-auto text-xs leading-5 text-ods-text-primary font-mono whitespace-pre">
54
+ <code>{formatted.text}</code>
55
+ </pre>
56
+ </div>
57
+ )
58
+ }
@@ -9,119 +9,98 @@ import { ToolIcon } from "../tool-icon"
9
9
  import { PulseDots } from "../ui/pulse-dots"
10
10
  import { ExpandChevron } from "./expand-chevron"
11
11
  import { useCollapsible } from "./hooks/use-collapsible"
12
+ import { ArgRow, ResultBlock } from "./tool-call-blocks"
13
+ import { COMMAND_BODY_ARG_KEYS } from "./utils/tool-call-helpers"
12
14
  import type { ToolExecutionDisplayProps } from "./types"
13
15
 
14
- const ToolExecutionDisplay = forwardRef<HTMLButtonElement, ToolExecutionDisplayProps>(
16
+ const COMMAND_BODY_KEYS = new Set<string>(COMMAND_BODY_ARG_KEYS)
17
+
18
+ const ToolExecutionDisplay = forwardRef<HTMLDivElement, ToolExecutionDisplayProps>(
15
19
  ({ className, message, ...props }, ref) => {
16
20
  const [expanded, setExpanded] = useState(false)
17
21
  const { innerRef, containerStyle } = useCollapsible({ expanded })
18
22
 
19
23
  const isExecuting = message.type === "EXECUTING_TOOL"
20
24
  const isExecuted = message.type === "EXECUTED_TOOL"
21
- const integratedToolType = message.integratedToolType || 'OPENFRAME'
25
+ const integratedToolType = (message.integratedToolType as ToolType) || ("OPENFRAME" as ToolType)
22
26
 
23
27
  const previewText = useMemo(() => {
24
28
  const command = message.parameters?.command ?? message.parameters?.query
25
29
  if (command) return String(command)
26
- return message.toolFunction ?? ''
30
+ return message.toolFunction ?? ""
27
31
  }, [message.toolFunction, message.parameters])
28
32
 
29
- const formatValue = (value: unknown): string => {
30
- if (typeof value === 'object' && value !== null) {
31
- return JSON.stringify(value)
32
- }
33
- return String(value)
34
- }
33
+ const argEntries = useMemo<Array<[string, unknown]>>(() => {
34
+ if (!message.parameters || typeof message.parameters !== "object") return []
35
+ return Object.entries(message.parameters).filter(
36
+ ([k, v]) => !COMMAND_BODY_KEYS.has(k) && v !== null && v !== undefined && v !== "",
37
+ )
38
+ }, [message.parameters])
35
39
 
36
- const hasParams = message.parameters && Object.keys(message.parameters).length > 0
37
- const hasStatus = isExecuting || isExecuted
40
+ const hasResult = isExecuted && typeof message.result === "string" && message.result.length > 0
41
+ const hasBody = argEntries.length > 0 || hasResult || isExecuting
38
42
 
39
43
  const renderStatusIcon = () => {
40
- if (isExecuting) {
41
- return <PulseDots size="sm" />
42
- }
43
- if (isExecuted && message.success === true) {
44
- return <CheckCircleIcon className="w-4 h-4 text-ods-success" />
45
- }
46
- if (isExecuted && message.success === false) {
47
- return <XmarkCircleIcon className="w-4 h-4 text-ods-error" />
48
- }
44
+ if (isExecuting) return <PulseDots size="sm" />
45
+ if (isExecuted && message.success === true) return <CheckCircleIcon className="w-4 h-4 text-ods-success" />
46
+ if (isExecuted && message.success === false) return <XmarkCircleIcon className="w-4 h-4 text-ods-error" />
49
47
  return null
50
48
  }
51
49
 
52
50
  return (
53
- <button
51
+ <div
54
52
  ref={ref}
55
- type="button"
56
53
  className={cn(
57
- "bg-ods-card border border-ods-border rounded-[6px] flex gap-[var(--spacing-system-xs)] items-start p-[var(--spacing-system-s)] cursor-pointer text-left w-full",
58
- className
54
+ "bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full flex flex-col",
55
+ className,
59
56
  )}
60
- onClick={() => setExpanded(!expanded)}
61
57
  {...props}
62
58
  >
63
- <div className="flex items-center justify-center shrink-0 w-5 h-5">
64
- <ToolIcon toolType={integratedToolType as ToolType} size={16} />
65
- </div>
66
-
67
- <div className="flex-1 min-w-0 flex flex-col">
68
- <div className="flex gap-2 items-start w-full">
69
- <div
70
- className={cn(
71
- "flex-1 min-w-0 text-sm font-medium leading-5",
72
- expanded
73
- ? "text-ods-text-primary whitespace-pre-wrap break-all"
74
- : "text-ods-text-secondary line-clamp-2 max-h-10 break-all"
75
- )}
76
- >
77
- {expanded ? message.toolFunction : previewText}
78
- </div>
79
- {hasStatus && (
80
- <div className="flex items-center justify-center shrink-0 w-5 h-5">
81
- {renderStatusIcon()}
82
- </div>
59
+ <button
60
+ type="button"
61
+ className="flex gap-[var(--spacing-system-xs)] items-start p-[var(--spacing-system-s)] cursor-pointer text-left w-full"
62
+ onClick={() => setExpanded((prev) => !prev)}
63
+ >
64
+ <div className="flex items-center justify-center shrink-0 w-5 h-5">
65
+ <ToolIcon toolType={integratedToolType} size={16} />
66
+ </div>
67
+ <div
68
+ className={cn(
69
+ "flex-1 min-w-0 text-sm font-medium leading-5",
70
+ expanded
71
+ ? "text-ods-text-primary whitespace-pre-wrap break-all"
72
+ : "text-ods-text-secondary line-clamp-2 max-h-10 break-all",
83
73
  )}
84
- <div className="flex items-center justify-center shrink-0 w-5 h-5">
85
- <ExpandChevron expanded={expanded} />
86
- </div>
74
+ >
75
+ {expanded ? message.toolFunction || previewText : previewText}
87
76
  </div>
77
+ <div className="flex items-center justify-center shrink-0 w-5 h-5">{renderStatusIcon()}</div>
78
+ <div className="flex items-center justify-center shrink-0 w-5 h-5">
79
+ <ExpandChevron expanded={expanded} />
80
+ </div>
81
+ </button>
88
82
 
89
- <div className="w-full" style={containerStyle}>
90
- <div ref={innerRef} className="flex flex-col gap-2 pt-2">
91
- {hasParams && (
92
- <div className="flex flex-col items-start w-full text-sm font-medium leading-5">
93
- {Object.entries(message.parameters ?? {}).map(([key, value]) => (
94
- <div key={key} className="flex gap-1 items-start w-full">
95
- <span className="shrink-0 text-ods-text-secondary overflow-hidden text-ellipsis">
96
- {key}:
97
- </span>
98
- <span className="flex-1 min-w-0 text-ods-text-primary break-all">
99
- {formatValue(value)}
100
- </span>
101
- </div>
102
- ))}
103
- </div>
104
- )}
105
-
106
- {(isExecuting || (isExecuted && message.result)) && (
107
- <div className="flex flex-col items-start w-full">
108
- <p className="text-sm font-medium leading-5 text-ods-text-secondary">
109
- Result:
110
- </p>
111
- {isExecuted && message.result && (
112
- <div className="text-sm font-medium leading-5 text-ods-text-primary whitespace-pre-wrap break-all">
113
- {message.result}
114
- </div>
115
- )}
116
- {isExecuting && <PulseDots size="sm" />}
117
- </div>
118
- )}
119
- </div>
83
+ <div className="w-full" style={containerStyle}>
84
+ <div ref={innerRef}>
85
+ {hasBody && (
86
+ <div className="flex flex-col gap-2 items-start w-full text-sm font-medium leading-5 p-3 bg-ods-card">
87
+ {argEntries.map(([key, value]) => (
88
+ <ArgRow key={key} argKey={key} value={value} />
89
+ ))}
90
+ {hasResult && <ResultBlock result={message.result} />}
91
+ {isExecuting && (
92
+ <div className="flex flex-col gap-1 items-start w-full">
93
+ <span className="text-ods-text-secondary">Result:</span>
94
+ <PulseDots size="sm" />
95
+ </div>
96
+ )}
97
+ </div>
98
+ )}
120
99
  </div>
121
100
  </div>
122
- </button>
101
+ </div>
123
102
  )
124
- }
103
+ },
125
104
  )
126
105
 
127
106
  ToolExecutionDisplay.displayName = "ToolExecutionDisplay"
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { ChunkData, NatsMessageType, FetchChunksFunction } from './network.types'
7
7
  import type { ChatType, ChatApprovalStatus } from './chat.types'
8
- import type { MessageSegment, TokenUsageData } from './message.types'
8
+ import type { MessageSegment, PendingToolCallData, TokenUsageData } from './message.types'
9
9
 
10
10
  // ========== Hook Options ==========
11
11
 
@@ -152,7 +152,10 @@ export interface UseRealtimeChunkProcessorOptions {
152
152
  /** Executing tools waiting for completion */
153
153
  executingTools?: Map<string, { integratedToolType: string; toolFunction: string; parameters?: Record<string, any> }>
154
154
  /** Escalated approvals */
155
- escalatedApprovals?: Map<string, { command: string; explanation?: string; approvalType: string }>
155
+ escalatedApprovals?: Map<
156
+ string,
157
+ { command: string; explanation?: string; approvalType: string; toolCalls?: PendingToolCallData[] }
158
+ >
156
159
  }
157
160
  /**
158
161
  * When true, THINKING chunks are processed into thinking segments. When false
@@ -160,6 +163,19 @@ export interface UseRealtimeChunkProcessorOptions {
160
163
  * accumulator or store.
161
164
  */
162
165
  enableThinking?: boolean
166
+ /**
167
+ * Consumer-owned (e.g. set in `openframe-oss-tenant` chat client via the
168
+ * `'batch-approvals'` feature flag and forwarded here). The lib does NOT
169
+ * default this to a batch-on behavior — when omitted it falls back to the
170
+ * legacy single-card rendering.
171
+ *
172
+ * When true: `APPROVAL_REQUEST` chunks containing `toolCalls[]` are rendered
173
+ * as a single batch card. When false / omitted: the batch is split into N
174
+ * legacy approval cards (one per tool that requires approval), all sharing
175
+ * the same `approvalRequestId`. Tools with `requiresApproval=false` are
176
+ * dropped from the UI in the unfolded mode.
177
+ */
178
+ batchApprovalsEnabled?: boolean
163
179
  }
164
180
 
165
181
  export interface UseRealtimeChunkProcessorReturn {
@@ -172,7 +188,10 @@ export interface UseRealtimeChunkProcessorReturn {
172
188
  /** Update approval status for a request */
173
189
  updateApprovalStatus: (requestId: string, status: ChatApprovalStatus) => MessageSegment[]
174
190
  /** Get pending approval requests */
175
- getPendingApprovals: () => Map<string, { command: string; explanation?: string; approvalType: string }>
191
+ getPendingApprovals: () => Map<
192
+ string,
193
+ { command: string; explanation?: string; approvalType: string; toolCalls?: PendingToolCallData[] }
194
+ >
176
195
  }
177
196
 
178
197
  // ========== API Request Types ==========