@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.172",
3
+ "version": "0.0.173",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -137,8 +137,8 @@
137
137
  "test:ui": "vitest --ui",
138
138
  "test:run": "vitest run",
139
139
  "test:coverage": "vitest run --coverage",
140
- "yalc:watch": "NODE_OPTIONS='--max-old-space-size=8192' nodemon --watch src --watch tsconfig.json --watch tsup.config.ts --watch tailwind.config.ts -e ts,tsx,json,css --ignore dist --ignore node_modules --ignore '*.lock' --ignore .yalc --ignore yalc.lock --exec 'tsup && yalc push --changed'",
141
- "yalc:push": "NODE_OPTIONS='--max-old-space-size=8192' npm run build && yalc push",
140
+ "yalc:watch": "NODE_OPTIONS='--max-old-space-size=4096' nodemon --watch src --watch tsconfig.json --watch tsup.config.ts --watch tailwind.config.ts -e ts,tsx,json,css --ignore dist --ignore node_modules --ignore '*.lock' --ignore .yalc --ignore yalc.lock --exec 'tsup && yalc push --changed'",
141
+ "yalc:push": "yalc push",
142
142
  "yalc:publish": "yalc publish",
143
143
  "generate:icons": "node scripts/generate-icons.mjs",
144
144
  "storybook": "storybook dev -p 6006",
@@ -0,0 +1,237 @@
1
+ "use client"
2
+
3
+ import { forwardRef, useState } from "react"
4
+ import { CheckCircle, XCircle } from "lucide-react"
5
+ import { cn } from "../../utils/cn"
6
+ import { Button } from "../ui/button"
7
+ import { Tag } from "../ui/tag"
8
+ import { ToolType } from "../platform"
9
+ import { ToolIcon } from "../tool-icon"
10
+ import { CheckCircleIcon, XmarkCircleIcon } from "../icons-v2-generated"
11
+ import { PulseDots } from "../ui/pulse-dots"
12
+ import { ExpandChevron } from "./expand-chevron"
13
+ import { useCollapsible } from "./hooks/use-collapsible"
14
+ import { ArgRow, ResultBlock } from "./tool-call-blocks"
15
+ import type {
16
+ ApprovalBatchExecutionState,
17
+ ApprovalBatchSegment,
18
+ PendingToolCallData,
19
+ } from "./types"
20
+ import {
21
+ COMMAND_BODY_ARG_KEYS,
22
+ getCommandText,
23
+ } from "./utils/tool-call-helpers"
24
+
25
+ export interface ApprovalBatchMessageProps extends React.HTMLAttributes<HTMLDivElement> {
26
+ data: ApprovalBatchSegment["data"]
27
+ status?: ApprovalBatchSegment["status"]
28
+ onApprove?: (requestId?: string) => void | Promise<void>
29
+ onReject?: (requestId?: string) => void | Promise<void>
30
+ }
31
+
32
+ const COMMAND_BODY_KEYS = new Set<string>(COMMAND_BODY_ARG_KEYS)
33
+
34
+ function getArgEntries(call: PendingToolCallData): Array<[string, unknown]> {
35
+ const args = call.toolCallArguments
36
+ if (!args || typeof args !== "object") return []
37
+ return Object.entries(args).filter(([k, v]) => !COMMAND_BODY_KEYS.has(k) && v !== null && v !== undefined && v !== "")
38
+ }
39
+
40
+ /**
41
+ * Status icon for one tool call inside an approved/done batch.
42
+ * - pending batch → null (chevron-only row, no status icon)
43
+ * - approved, no exec → PulseDots (queued / waiting for backend)
44
+ * - executing → PulseDots
45
+ * - done + success → green check
46
+ * - done + failure → red cross
47
+ */
48
+ function ExecutionStatusIcon({
49
+ batchStatus,
50
+ execution,
51
+ }: {
52
+ batchStatus: ApprovalBatchSegment["status"]
53
+ execution: ApprovalBatchExecutionState | undefined
54
+ }) {
55
+ if (batchStatus !== "approved") return null
56
+ if (!execution || execution.status === "executing") return <PulseDots size="sm" />
57
+ if (execution.success === false) return <XmarkCircleIcon className="w-4 h-4 text-ods-error" />
58
+ return <CheckCircleIcon className="w-4 h-4 text-ods-success" />
59
+ }
60
+
61
+ interface ToolCallRowProps {
62
+ call: PendingToolCallData
63
+ expanded: boolean
64
+ onToggle: () => void
65
+ batchStatus: ApprovalBatchSegment["status"]
66
+ execution: ApprovalBatchExecutionState | undefined
67
+ }
68
+
69
+ function ToolCallRow({ call, expanded, onToggle, batchStatus, execution }: ToolCallRowProps) {
70
+ const command = getCommandText(call)
71
+ const args = getArgEntries(call)
72
+ const toolType = (call.toolType as ToolType) || ("OPENFRAME" as ToolType)
73
+ const { innerRef, containerStyle } = useCollapsible({ expanded })
74
+ const result = execution?.status === "done" ? execution.result : undefined
75
+ const hasExpandableBody = args.length > 0 || (typeof result === "string" && result.length > 0)
76
+
77
+ return (
78
+ <div className="bg-ods-card border-b border-ods-border last:border-b-0 flex flex-col items-start w-full">
79
+ <button
80
+ type="button"
81
+ onClick={onToggle}
82
+ className="flex gap-2 items-start w-full p-3 cursor-pointer text-left"
83
+ >
84
+ <div className="flex items-center justify-center shrink-0 w-5 h-5">
85
+ <ToolIcon toolType={toolType} size={16} />
86
+ </div>
87
+ <div
88
+ className={cn(
89
+ "flex-1 min-w-0 font-medium text-sm leading-5",
90
+ expanded
91
+ ? "text-ods-text-primary whitespace-pre-wrap break-all"
92
+ : "text-ods-text-secondary line-clamp-2 max-h-10 break-all",
93
+ )}
94
+ >
95
+ {command}
96
+ </div>
97
+ <div className="flex items-center justify-center shrink-0 w-5 h-5">
98
+ <ExecutionStatusIcon batchStatus={batchStatus} execution={execution} />
99
+ </div>
100
+ <div className="flex items-center justify-center shrink-0 w-5 h-5">
101
+ <ExpandChevron expanded={expanded} />
102
+ </div>
103
+ </button>
104
+
105
+ <div className="w-full" style={containerStyle}>
106
+ <div ref={innerRef}>
107
+ {hasExpandableBody && (
108
+ <div className="flex flex-col gap-2 items-start w-full text-sm font-medium leading-5 p-3 bg-ods-card">
109
+ {args.map(([key, value]) => (
110
+ <ArgRow key={key} argKey={key} value={value} />
111
+ ))}
112
+ {result && <ResultBlock result={result} />}
113
+ </div>
114
+ )}
115
+ </div>
116
+ </div>
117
+ </div>
118
+ )
119
+ }
120
+
121
+ const ApprovalBatchMessage = forwardRef<HTMLDivElement, ApprovalBatchMessageProps>(
122
+ ({ className, data, onApprove, onReject, status = "pending", ...props }, ref) => {
123
+ const [expandedId, setExpandedId] = useState<string | null>(null)
124
+ const [isProcessing, setIsProcessing] = useState(false)
125
+
126
+ const explanations = data.toolCalls
127
+ .map((c) => c.toolExplanation?.trim())
128
+ .filter((s): s is string => !!s)
129
+
130
+ const handleApprove = async () => {
131
+ setIsProcessing(true)
132
+ try {
133
+ await onApprove?.(data.approvalRequestId)
134
+ } finally {
135
+ setIsProcessing(false)
136
+ }
137
+ }
138
+
139
+ const handleReject = async () => {
140
+ setIsProcessing(true)
141
+ try {
142
+ await onReject?.(data.approvalRequestId)
143
+ } finally {
144
+ setIsProcessing(false)
145
+ }
146
+ }
147
+
148
+ const showFooterBlock = explanations.length > 0 || status !== undefined
149
+
150
+ return (
151
+ <div
152
+ ref={ref}
153
+ className={cn(
154
+ "bg-ods-card border border-ods-border rounded-md overflow-hidden flex flex-col mb-2",
155
+ className,
156
+ )}
157
+ {...props}
158
+ >
159
+ {data.toolCalls.map((call) => (
160
+ <ToolCallRow
161
+ key={call.toolExecutionRequestId}
162
+ call={call}
163
+ expanded={expandedId === call.toolExecutionRequestId}
164
+ onToggle={() =>
165
+ setExpandedId((prev) =>
166
+ prev === call.toolExecutionRequestId ? null : call.toolExecutionRequestId,
167
+ )
168
+ }
169
+ batchStatus={status}
170
+ execution={data.executions?.[call.toolExecutionRequestId]}
171
+ />
172
+ ))}
173
+
174
+ {showFooterBlock && (
175
+ <div className="bg-ods-card flex flex-col gap-2 items-start justify-center p-3">
176
+ {explanations.length > 0 && (
177
+ <ul className="list-disc pl-5 text-sm font-medium text-ods-text-primary leading-5 w-full">
178
+ {explanations.map((expl, i) => (
179
+ <li key={i}>{expl}</li>
180
+ ))}
181
+ </ul>
182
+ )}
183
+
184
+ {status === "pending" ? (
185
+ <div className="flex gap-2 items-center">
186
+ <Button
187
+ size="small-legacy"
188
+ variant="accent"
189
+ onClick={handleApprove}
190
+ disabled={isProcessing}
191
+ className={cn(
192
+ "bg-ods-accent hover:bg-ods-accent/90",
193
+ "font-mono font-medium md:!text-sm text-ods-bg uppercase tracking-[-0.28px]",
194
+ "px-2 py-1 h-auto",
195
+ )}
196
+ >
197
+ Approve
198
+ </Button>
199
+ <Button
200
+ size="small-legacy"
201
+ variant="outline"
202
+ onClick={handleReject}
203
+ disabled={isProcessing}
204
+ className={cn(
205
+ "bg-ods-card border-ods-border",
206
+ "font-mono font-medium md:!text-sm text-ods-text-primary uppercase tracking-[-0.28px]",
207
+ "hover:bg-ods-bg px-2 py-1 h-auto",
208
+ )}
209
+ >
210
+ Reject
211
+ </Button>
212
+ </div>
213
+ ) : (
214
+ <div className="flex">
215
+ <Tag
216
+ label={status === "approved" ? "Approved" : "Rejected"}
217
+ variant={status === "approved" ? "success" : "error"}
218
+ icon={
219
+ status === "approved" ? (
220
+ <CheckCircle className="w-4 h-4" />
221
+ ) : (
222
+ <XCircle className="w-4 h-4" />
223
+ )
224
+ }
225
+ />
226
+ </div>
227
+ )}
228
+ </div>
229
+ )}
230
+ </div>
231
+ )
232
+ },
233
+ )
234
+
235
+ ApprovalBatchMessage.displayName = "ApprovalBatchMessage"
236
+
237
+ export { ApprovalBatchMessage }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState, useRef, useImperativeHandle, forwardRef, useCallback, useEffect, useMemo, type KeyboardEvent, type ChangeEvent } from "react"
4
4
  import { cn } from "../../utils/cn"
5
- import { Send01Icon, StopIcon } from "../icons-v2-generated"
5
+ import { Send01Icon, StopCircleIcon } from "../icons-v2-generated"
6
6
  import { Textarea } from "../ui/textarea"
7
7
  import { ChatTypingIndicator } from "./chat-typing-indicator"
8
8
  import { SlashCommandSuggestions } from "./slash-command-suggestions"
@@ -71,27 +71,18 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
71
71
  (): ChatInputRef => ({
72
72
  focus: () => focusTextarea(),
73
73
  blur: () => textareaRef.current?.blur(),
74
- clear: () => {
75
- setValue('')
76
- if (textareaRef.current) {
77
- textareaRef.current.style.height = 'auto'
78
- }
79
- },
74
+ clear: () => setValue(''),
80
75
  setValue: (next: string) => {
81
76
  setValue(next)
82
- // Defer until React commits the new value so scrollHeight reflects
83
- // the updated DOM. requestAnimationFrame is the standard escape
84
- // hatch. After focus, set selection to the END of the new value —
77
+ // After focus, set selection to the END of the new value
85
78
  // programmatic `.focus()` on a textarea defaults to caret-at-0
86
79
  // (browser-standard), which would land the cursor at the start of
87
80
  // the prefilled `/cmd ` and force users to arrow-right past every
88
- // character before typing.
81
+ // character before typing. Auto-grow is handled by the Textarea's
82
+ // native `field-sizing: content` — no manual height mgmt needed.
89
83
  requestAnimationFrame(() => {
90
84
  const el = textareaRef.current
91
- if (!el) return
92
- el.style.height = 'auto'
93
- el.style.height = `${el.scrollHeight}px`
94
- if (disabled || el.disabled) return
85
+ if (!el || disabled || el.disabled) return
95
86
  el.focus()
96
87
  el.setSelectionRange(next.length, next.length)
97
88
  })
@@ -105,12 +96,9 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
105
96
  const clamped = Math.max(0, Math.min(cursorOffset, next.length))
106
97
  requestAnimationFrame(() => {
107
98
  const el = textareaRef.current
108
- if (el) {
109
- el.style.height = 'auto'
110
- el.style.height = `${el.scrollHeight}px`
111
- el.focus()
112
- el.setSelectionRange(clamped, clamped)
113
- }
99
+ if (!el) return
100
+ el.focus()
101
+ el.setSelectionRange(clamped, clamped)
114
102
  })
115
103
  },
116
104
  submit: (next: string) => {
@@ -123,9 +111,6 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
123
111
  if (!trimmed) return
124
112
  onSend(trimmed)
125
113
  setValue('')
126
- if (textareaRef.current) {
127
- textareaRef.current.style.height = 'auto'
128
- }
129
114
  shouldRefocusRef.current = true
130
115
  focusTextarea()
131
116
  },
@@ -143,11 +128,6 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
143
128
  if (message && !sending && !disabled && onSend) {
144
129
  onSend(message)
145
130
  setValue('')
146
-
147
- if (textareaRef.current) {
148
- textareaRef.current.style.height = 'auto'
149
- }
150
-
151
131
  shouldRefocusRef.current = true
152
132
  focusTextarea()
153
133
  }
@@ -263,11 +243,6 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
263
243
 
264
244
  const handleChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
265
245
  setValue(e.target.value)
266
-
267
- if (textareaRef.current) {
268
- textareaRef.current.style.height = 'auto'
269
- textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
270
- }
271
246
  }, [])
272
247
 
273
248
  const [isStopping, setIsStopping] = useState(false)
@@ -307,7 +282,6 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
307
282
  }
308
283
  }, [onStop, isStopping])
309
284
 
310
- // Show awaiting response state
311
285
  if (awaitingResponse) {
312
286
  return (
313
287
  <div
@@ -315,34 +289,28 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
315
289
  "mx-auto w-full max-w-3xl items-end gap-6",
316
290
  reserveAvatarOffset ? "grid grid-cols-[32px_1fr]" : "grid grid-cols-[1fr]",
317
291
  "flex-shrink-0",
318
- className
292
+ className,
319
293
  )}
320
294
  >
321
295
  {reserveAvatarOffset && <div className="invisible h-8 w-8" aria-hidden />}
322
- <div
323
- className={cn(
324
- "relative flex items-center justify-center gap-2",
325
- "rounded-md bg-ods-card border border-ods-border",
326
- "px-3 py-3",
327
- "transition-colors",
328
- )}
329
- >
296
+ <div className="relative flex items-center justify-center gap-2 rounded-md bg-ods-card border border-ods-border px-3 py-3 transition-colors">
330
297
  <ChatTypingIndicator size="sm" dotClassName="bg-ods-text-primary" />
331
- <p className="text-h4 text-ods-text-secondary">
332
- Waiting for Technician Response
333
- </p>
298
+ <p className="text-h4 text-ods-text-secondary">Waiting for Technician Response</p>
334
299
  </div>
335
300
  </div>
336
301
  )
337
302
  }
338
303
 
304
+ const isStopMode = sending && !!onStop
305
+ const sendDisabled = sending || disabled || !value.trim()
306
+
339
307
  return (
340
308
  <div
341
309
  className={cn(
342
310
  "mx-auto w-full max-w-3xl items-end gap-6",
343
311
  reserveAvatarOffset ? "grid grid-cols-[32px_1fr]" : "grid grid-cols-[1fr]",
344
312
  "flex-shrink-0",
345
- className
313
+ className,
346
314
  )}
347
315
  >
348
316
  {reserveAvatarOffset && <div className="invisible h-8 w-8" aria-hidden />}
@@ -355,67 +323,27 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
355
323
  resolveSourceIcon={slashCommands?.resolveSourceIcon}
356
324
  onAction={slashCommands?.onAction}
357
325
  />
358
- <div
359
- className={cn(
360
- "flex items-center gap-2",
361
- "rounded-md bg-ods-card border border-ods-border",
362
- "transition-colors",
363
- "text-left text-ods-text-primary",
364
- )}
365
- >
366
- <Textarea
367
- ref={textareaRef}
368
- value={value}
369
- onChange={handleChange}
370
- onKeyDown={handleKeyDown}
371
- placeholder={disabled ? "Connection lost. Waiting to reconnect..." : placeholder}
372
- disabled={sending || disabled}
373
- rows={1}
374
- className={cn(
375
- "flex-1 resize-none bg-transparent px-3 border-none focus-visible:ring-0",
376
- "font-dm-sans text-[18px] font-medium leading-[24px]",
377
- "placeholder:text-ods-text-secondary",
378
- "overflow-hidden text-ellipsis",
379
- "min-h-[20px] max-h-[160px] focus:outline-none",
380
- "disabled:opacity-50 disabled:cursor-not-allowed"
381
- )}
382
- {...inputProps}
383
- />
384
-
385
- {sending && onStop ? (
386
- <button
387
- type="button"
388
- onClick={handleStop}
389
- disabled={isStopping}
390
- className={cn(
391
- "rounded-md px-3 text-ods-text-secondary transition-all",
392
- isStopping ? "cursor-not-allowed opacity-40" : "hover:text-ods-accent active:scale-95",
393
- "focus:outline-none"
394
- )}
395
- aria-label="Stop generation"
396
- >
397
- <StopIcon size={24} />
398
- </button>
399
- ) : (
400
- <button
401
- type="button"
402
- onClick={handleSubmit}
403
- disabled={sending || disabled || !value.trim()}
404
- className={cn(
405
- "rounded-md px-3 text-ods-text-secondary transition-all",
406
- sending || disabled || !value.trim() ? "cursor-not-allowed opacity-40" : "hover:text-ods-text-primary active:scale-95",
407
- "focus:outline-none"
408
- )}
409
- aria-label="Send message"
410
- >
411
- <Send01Icon size={24} />
412
- </button>
413
- )}
414
- </div>
326
+ <Textarea
327
+ ref={textareaRef}
328
+ value={value}
329
+ onChange={handleChange}
330
+ onKeyDown={handleKeyDown}
331
+ placeholder={disabled ? "Connection lost. Waiting to reconnect..." : placeholder}
332
+ disabled={sending || disabled}
333
+ rows={1}
334
+ endIcon={isStopMode ? <StopCircleIcon size={20} /> : <Send01Icon size={20} />}
335
+ endIconAsButton
336
+ endIconButtonProps={{
337
+ onClick: isStopMode ? handleStop : handleSubmit,
338
+ disabled: isStopMode ? isStopping : sendDisabled,
339
+ 'aria-label': isStopMode ? 'Stop generation' : 'Send message',
340
+ }}
341
+ {...inputProps}
342
+ />
415
343
  </div>
416
344
  </div>
417
345
  )
418
- }
346
+ },
419
347
  )
420
348
 
421
349
  ChatInput.displayName = "ChatInput"
@@ -6,6 +6,7 @@ import { SquareAvatar } from "../ui/square-avatar"
6
6
  import { ChatTypingIndicator } from "./chat-typing-indicator"
7
7
  import { ToolExecutionDisplay } from "./tool-execution-display"
8
8
  import { ApprovalRequestMessage } from "./approval-request-message"
9
+ import { ApprovalBatchMessage } from "./approval-batch-message"
9
10
  import { ErrorMessageDisplay } from "./error-message-display"
10
11
  import { ContextCompactionDisplay } from "./context-compaction-display"
11
12
  import { ThinkingDisplay } from "./thinking-display"
@@ -203,6 +204,16 @@ const ChatMessageEnhanced = forwardRef<HTMLDivElement, ChatMessageEnhancedProps>
203
204
  onReject={segment.onReject}
204
205
  />
205
206
  )
207
+ } else if (segment.type === 'approval_batch') {
208
+ return (
209
+ <ApprovalBatchMessage
210
+ key={index}
211
+ data={segment.data}
212
+ status={segment.status}
213
+ onApprove={segment.onApprove}
214
+ onReject={segment.onReject}
215
+ />
216
+ )
206
217
  } else if (segment.type === 'error') {
207
218
  return (
208
219
  <ErrorMessageDisplay
@@ -4,7 +4,9 @@ import { useRef, useEffect, useLayoutEffect, useImperativeHandle, forwardRef } f
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
  /*
@@ -209,14 +211,17 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
209
211
  // MutableRefObject<HTMLElement> so we cast to the public type.
210
212
  useImperativeHandle(ref, () => scrollRef.current as HTMLDivElement, [scrollRef])
211
213
 
212
- if (isLoading) {
214
+ // Gate the loader: only show after 200ms (fast loads never flicker),
215
+ // and once shown, hold for at least 400ms (no sub-frame flash if data
216
+ // arrives a moment later).
217
+ const showLoader = useDelayedFlag(isLoading, { delay: 200, minDuration: 400 })
218
+
219
+ if (showLoader) {
213
220
  return (
214
- <ChatMessageListSkeleton
221
+ <ChatMessageListLoader
215
222
  className={className}
216
- showAvatars={showAvatars}
223
+ assistantIcon={assistantIcon}
217
224
  assistantType={assistantType}
218
- contentClassName={contentClassName}
219
- messageCount={6}
220
225
  />
221
226
  )
222
227
  }
@@ -262,9 +267,20 @@ const ChatMessageList = forwardRef<HTMLDivElement, ChatMessageListProps>(
262
267
  )}
263
268
  style={{ minHeight: '100%' }}
264
269
  >
270
+ {/* Infinite scroll sentinel + loader for older pages */}
265
271
  {hasNextPage && (
266
272
  <div ref={sentinelRef} className="h-px" />
267
273
  )}
274
+ {isFetchingNextPage && (
275
+ <div
276
+ className="flex justify-center py-3 animate-in fade-in duration-200"
277
+ role="status"
278
+ aria-live="polite"
279
+ aria-busy="true"
280
+ >
281
+ <PulseDots size="sm" />
282
+ </div>
283
+ )}
268
284
  <div className="flex-1" />
269
285
  {messages.map((message, index) => (
270
286
  <ChatMessageEnhanced
@@ -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