@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
@@ -7,13 +7,16 @@ import {
7
7
  MESSAGE_TYPE,
8
8
  OWNER_TYPE,
9
9
  type AuthorType,
10
+ type ChatApprovalStatus,
10
11
  type HistoricalMessage,
12
+ type PendingToolCallData,
11
13
  type ProcessedMessage,
12
14
  type MessageProcessingOptions,
13
15
  type MessageData,
14
16
  type MessageOwner,
15
17
  } from '../types'
16
18
  import { MessageSegmentAccumulator, createMessageSegmentAccumulator } from './message-segment-accumulator'
19
+ import { getCommandText } from './tool-call-helpers'
17
20
 
18
21
  function getOwnerDisplayName(owner?: MessageOwner): string {
19
22
  if (owner?.type === OWNER_TYPE.ADMIN && owner.user) {
@@ -48,7 +51,10 @@ function pushStandaloneMessages(
48
51
  */
49
52
  export interface ProcessHistoricalMessagesResult {
50
53
  messages: ProcessedMessage[]
51
- escalatedApprovals: Map<string, { command: string; explanation?: string; approvalType: string }>
54
+ escalatedApprovals: Map<
55
+ string,
56
+ { command: string; explanation?: string; approvalType: string; toolCalls?: PendingToolCallData[] }
57
+ >
52
58
  }
53
59
 
54
60
  /**
@@ -71,7 +77,10 @@ export function processHistoricalMessages(
71
77
 
72
78
  const processedMessages: ProcessedMessage[] = []
73
79
  const accumulator = createMessageSegmentAccumulator({ onApprove, onReject })
74
- const escalatedApprovals = new Map<string, { command: string; explanation?: string; approvalType: string }>()
80
+ const escalatedApprovals = new Map<
81
+ string,
82
+ { command: string; explanation?: string; approvalType: string; toolCalls?: PendingToolCallData[] }
83
+ >()
75
84
 
76
85
  let currentAssistantId: string | null = null
77
86
  let currentAssistantTimestamp: Date | null = null
@@ -192,9 +201,15 @@ function processMessageData(
192
201
  accumulator: MessageSegmentAccumulator,
193
202
  approvalStatuses: Record<string, string>,
194
203
  options: MessageProcessingOptions = {},
195
- escalatedApprovals?: Map<string, { command: string; explanation?: string; approvalType: string }>
204
+ escalatedApprovals?: Map<
205
+ string,
206
+ { command: string; explanation?: string; approvalType: string; toolCalls?: PendingToolCallData[] }
207
+ >
196
208
  ): void {
197
- const { displayApprovalTypes } = options
209
+ // batchApprovalsEnabled is owned by the consumer (oss-tenant chat client /
210
+ // openframe-frontend tickets). Defaults to ON so consumers that haven't
211
+ // wired the flag yet get the batch UI; pass `false` to force legacy.
212
+ const { displayApprovalTypes, batchApprovalsEnabled = true } = options
198
213
  switch (data.type) {
199
214
  case MESSAGE_TYPE.TEXT:
200
215
  if ('text' in data && data.text) {
@@ -217,6 +232,7 @@ function processMessageData(
217
232
  integratedToolType: data.integratedToolType || '',
218
233
  toolFunction: data.toolFunction || '',
219
234
  parameters: data.parameters,
235
+ toolExecutionRequestId: data.toolExecutionRequestId,
220
236
  },
221
237
  })
222
238
  }
@@ -233,6 +249,7 @@ function processMessageData(
233
249
  parameters: data.parameters,
234
250
  result: data.result,
235
251
  success: data.success,
252
+ toolExecutionRequestId: data.toolExecutionRequestId,
236
253
  },
237
254
  })
238
255
  }
@@ -241,18 +258,42 @@ function processMessageData(
241
258
  case MESSAGE_TYPE.APPROVAL_REQUEST:
242
259
  if ('approvalRequestId' in data && data.approvalRequestId) {
243
260
  const approvalType = data.approvalType || 'CLIENT'
261
+ const toolCalls: PendingToolCallData[] | undefined = Array.isArray(data.toolCalls)
262
+ ? data.toolCalls
263
+ : undefined
264
+ const isBatch = !!toolCalls && toolCalls.length > 0
244
265
 
245
266
  if (!displayApprovalTypes || displayApprovalTypes.includes(approvalType)) {
246
- accumulator.trackApprovalRequest(data.approvalRequestId, {
247
- command: data.command || '',
248
- explanation: data.explanation,
249
- approvalType,
250
- })
267
+ if (isBatch) {
268
+ const status = (approvalStatuses[data.approvalRequestId] as ChatApprovalStatus) || 'pending'
269
+ if (batchApprovalsEnabled) {
270
+ accumulator.addApprovalBatch(data.approvalRequestId, approvalType, toolCalls!, status)
271
+ } else {
272
+ // Flag OFF — unfold batch into N legacy approval cards (same id).
273
+ for (const call of toolCalls!) {
274
+ if (!call.requiresApproval) continue
275
+ accumulator.addApprovalRequest(
276
+ data.approvalRequestId,
277
+ getCommandText(call),
278
+ call.toolExplanation,
279
+ approvalType,
280
+ status,
281
+ )
282
+ }
283
+ }
284
+ } else {
285
+ accumulator.trackApprovalRequest(data.approvalRequestId, {
286
+ command: data.command || '',
287
+ explanation: data.explanation,
288
+ approvalType,
289
+ })
290
+ }
251
291
  } else {
252
292
  escalatedApprovals?.set(data.approvalRequestId, {
253
293
  command: data.command || '',
254
294
  explanation: data.explanation,
255
295
  approvalType,
296
+ ...(isBatch ? { toolCalls } : {}),
256
297
  })
257
298
  }
258
299
  }
@@ -260,10 +301,34 @@ function processMessageData(
260
301
 
261
302
  case MESSAGE_TYPE.APPROVAL_RESULT:
262
303
  if ('approvalRequestId' in data && data.approvalRequestId) {
263
- const existingStatus = approvalStatuses[data.approvalRequestId]
264
- const status = existingStatus || (data.approved ? 'approved' : 'rejected')
304
+ const existingStatus = approvalStatuses[data.approvalRequestId] as ChatApprovalStatus | undefined
305
+ const status: ChatApprovalStatus = existingStatus || (data.approved ? 'approved' : 'rejected')
265
306
  const escalatedData = escalatedApprovals?.get(data.approvalRequestId)
266
307
 
308
+ if (escalatedData?.toolCalls && escalatedData.toolCalls.length > 0) {
309
+ if (batchApprovalsEnabled) {
310
+ accumulator.addApprovalBatch(
311
+ data.approvalRequestId,
312
+ escalatedData.approvalType,
313
+ escalatedData.toolCalls,
314
+ status,
315
+ )
316
+ } else {
317
+ for (const call of escalatedData.toolCalls) {
318
+ if (!call.requiresApproval) continue
319
+ accumulator.addApprovalRequest(
320
+ data.approvalRequestId,
321
+ getCommandText(call),
322
+ call.toolExplanation,
323
+ escalatedData.approvalType,
324
+ status,
325
+ )
326
+ }
327
+ }
328
+ escalatedApprovals?.delete(data.approvalRequestId)
329
+ break
330
+ }
331
+
267
332
  if (escalatedData) {
268
333
  accumulator.trackApprovalRequest(data.approvalRequestId, {
269
334
  command: escalatedData.command,
@@ -272,7 +337,14 @@ function processMessageData(
272
337
  })
273
338
  escalatedApprovals?.delete(data.approvalRequestId)
274
339
  }
275
-
340
+
341
+ // If a segment with this id is already present (batch or legacy), just flip its status.
342
+ // updateApprovalStatus matches both `approval_batch` and `approval_request` segments.
343
+ const before = accumulator.getSegments()
344
+ const after = accumulator.updateApprovalStatus(data.approvalRequestId, status)
345
+ const updatedExisting = before.some((s, i) => after[i] !== s)
346
+ if (updatedExisting) break
347
+
276
348
  accumulator.processApprovalResult(
277
349
  data.approvalRequestId,
278
350
  status === 'approved',
@@ -364,7 +436,10 @@ export function processHistoricalMessagesWithErrors(
364
436
 
365
437
  const processedMessages: ProcessedMessage[] = []
366
438
  const accumulator = createMessageSegmentAccumulator({ onApprove, onReject })
367
- const escalatedApprovals = new Map<string, { command: string; explanation?: string; approvalType: string }>()
439
+ const escalatedApprovals = new Map<
440
+ string,
441
+ { command: string; explanation?: string; approvalType: string; toolCalls?: PendingToolCallData[] }
442
+ >()
368
443
 
369
444
  let currentAssistantId: string | null = null
370
445
  let currentAssistantTimestamp: Date | null = null
@@ -0,0 +1,105 @@
1
+ import type { PendingToolCallData } from '../types'
2
+
3
+ /**
4
+ * Keys in `toolCallArguments` whose value is the actual command/query/script
5
+ * body — anything that should be rendered as the tool's "title" line in the
6
+ * approval card. Order matters: first match wins.
7
+ */
8
+ export const COMMAND_BODY_ARG_KEYS = ['command', 'query', 'script', 'scriptContent', 'code'] as const
9
+
10
+ /**
11
+ * Extract a human-readable command string from a tool call.
12
+ * Order: command/query/script/scriptContent/code arg → toolTitle → toolName → fallback.
13
+ */
14
+ export function getCommandText(call: PendingToolCallData): string {
15
+ const args = call.toolCallArguments
16
+ if (args && typeof args === 'object') {
17
+ const a = args as Record<string, unknown>
18
+ for (const key of COMMAND_BODY_ARG_KEYS) {
19
+ const candidate = a[key]
20
+ if (typeof candidate === 'string' && candidate.trim()) return candidate
21
+ }
22
+ }
23
+ return call.toolTitle || call.toolName || 'Tool call'
24
+ }
25
+
26
+ export type FormattedArgValue =
27
+ | { kind: 'inline'; text: string }
28
+ | { kind: 'block'; text: string; language: 'json' | 'text' }
29
+
30
+ const INLINE_STRING_MAX = 80
31
+
32
+ /**
33
+ * Decide how to render a single `toolCallArguments` value:
34
+ * - objects/arrays → pretty-printed JSON block
35
+ * - strings that parse as JSON → pretty-printed JSON block
36
+ * - multi-line or long strings → preserved-whitespace text block
37
+ * - everything else → inline single line
38
+ */
39
+ export function formatToolArgValue(value: unknown): FormattedArgValue {
40
+ if (value === null || value === undefined) return { kind: 'inline', text: '' }
41
+
42
+ if (typeof value === 'object') {
43
+ return { kind: 'block', text: safeStringify(value), language: 'json' }
44
+ }
45
+
46
+ if (typeof value === 'string') {
47
+ const trimmed = value.trim()
48
+ if (
49
+ (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
50
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))
51
+ ) {
52
+ try {
53
+ return { kind: 'block', text: JSON.stringify(JSON.parse(trimmed), null, 2), language: 'json' }
54
+ } catch {
55
+ // not valid JSON — fall through
56
+ }
57
+ }
58
+ if (value.includes('\n') || value.length > INLINE_STRING_MAX) {
59
+ return { kind: 'block', text: value, language: 'text' }
60
+ }
61
+ return { kind: 'inline', text: value }
62
+ }
63
+
64
+ return { kind: 'inline', text: String(value) }
65
+ }
66
+
67
+ function safeStringify(value: unknown): string {
68
+ try {
69
+ return JSON.stringify(value, null, 2)
70
+ } catch {
71
+ return String(value)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Decide how to render a tool execution `result` string.
77
+ * - Strips a single surrounding markdown code fence (``` … ```), since the
78
+ * backend often wraps Bash/PowerShell output that way.
79
+ * - If the body parses as JSON, returns a pretty-printed JSON block.
80
+ * - If the body is multi-line / long, returns a preserved-whitespace block.
81
+ * - Otherwise inline.
82
+ */
83
+ export function formatToolResult(value: string | undefined | null): FormattedArgValue {
84
+ if (typeof value !== 'string') return { kind: 'inline', text: '' }
85
+
86
+ const fenced = value.match(/^\s*```(?:[a-zA-Z0-9_-]*)\n([\s\S]*?)\n```\s*$/)
87
+ const body = fenced ? fenced[1] : value
88
+ const trimmed = body.trim()
89
+
90
+ if (
91
+ (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
92
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))
93
+ ) {
94
+ try {
95
+ return { kind: 'block', text: JSON.stringify(JSON.parse(trimmed), null, 2), language: 'json' }
96
+ } catch {
97
+ // fall through
98
+ }
99
+ }
100
+
101
+ if (body.includes('\n') || body.length > INLINE_STRING_MAX) {
102
+ return { kind: 'block', text: body, language: 'text' }
103
+ }
104
+ return { kind: 'inline', text: body }
105
+ }
@@ -12,40 +12,122 @@ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextArea
12
12
  label?: string;
13
13
  /** Error message displayed below the textarea */
14
14
  error?: string;
15
+ /** Element rendered at the right edge of the field (e.g. send icon). */
16
+ endIcon?: React.ReactNode;
17
+ /**
18
+ * When true, `endIcon` is wrapped in a real `<button>` with hover / active
19
+ * / focus-visible styling matching the rest of the design system. Pass any
20
+ * extra button attributes (`onClick`, `disabled`, `aria-label`, …) via
21
+ * `endIconButtonProps`.
22
+ */
23
+ endIconAsButton?: boolean;
24
+ /** Extra attributes for the end-icon button. */
25
+ endIconButtonProps?: Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'>;
15
26
  }
16
27
 
17
28
  const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
18
- ({ className, invalid = false, label, error, ...props }, ref) => {
29
+ ({ className, invalid = false, label, error, endIcon, endIconAsButton = false, endIconButtonProps, ...props }, ref) => {
19
30
  const isInvalid = invalid || !!error
31
+ const hasEndIcon = !!endIcon
20
32
 
33
+ // Without an end icon we keep the historical layout: bare textarea, the
34
+ // border/bg/hover all live on the textarea itself.
35
+ if (!hasEndIcon) {
36
+ return (
37
+ <FieldWrapper label={label} error={error}>
38
+ <textarea
39
+ className={cn(
40
+ "flex min-h-[96px] w-full rounded-[6px] border p-3",
41
+ "text-h4",
42
+ "focus-visible:outline-none focus:border-ods-accent",
43
+ "transition-colors duration-200 touch-manipulation",
44
+ "bg-ods-card border-ods-border text-ods-text-primary placeholder:text-ods-text-secondary",
45
+ !props.disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover active:bg-ods-bg-active active:border-ods-border-active",
46
+ props.disabled && "!cursor-not-allowed bg-ods-bg disabled:placeholder:text-ods-border",
47
+ "cursor-text relative z-10",
48
+ "resize-y",
49
+ isInvalid && "border-ods-error hover:border-ods-error focus:border-ods-error",
50
+ className,
51
+ )}
52
+ ref={ref}
53
+ {...props}
54
+ />
55
+ </FieldWrapper>
56
+ )
57
+ }
58
+
59
+ // With an end icon we lift border/bg onto a `<label>` wrapper so the icon
60
+ // sits visually inside the same field. The textarea becomes a transparent
61
+ // child. Wrapper classes mirror `Input` 1:1 (same items-center, same
62
+ // `has-[:focus]` selectors, same active state, same outline reset) so
63
+ // hover / focus / cursor behaviour matches the standard input exactly.
21
64
  const content = (
22
- <textarea
65
+ <label
66
+ data-invalid={isInvalid || undefined}
23
67
  className={cn(
24
- // Layout & spacing - match Input
25
- "flex min-h-[96px] w-full rounded-[6px] border p-3",
26
- // Typography - match Input exactly
27
- "text-h4",
28
- // Focus states
29
- "focus-visible:outline-none focus:border-ods-accent",
30
- // Animations & touch UX
31
- "transition-colors duration-200 touch-manipulation",
32
- // Theme palette
33
- "bg-ods-card border-ods-border text-ods-text-primary placeholder:text-ods-text-secondary",
34
- // Hover & active (not disabled)
68
+ // Wrapper mirrors `Input`, but uses `items-end` so the icon sticks
69
+ // to the bottom-right when the textarea grows multi-line. Vertical
70
+ // padding lives here (not on the textarea) so both children share
71
+ // the same bottom baseline at any height.
72
+ "flex w-full items-end gap-2 rounded-[6px] border px-3 py-2.5 md:py-3 min-h-11 md:min-h-12 cursor-text",
73
+ "has-[:focus-visible]:outline-none",
74
+ "group",
75
+ "transition-colors duration-200",
76
+ "bg-ods-card border-ods-border has-[:focus]:border-ods-accent",
35
77
  !props.disabled && "hover:bg-ods-bg-hover hover:border-ods-border-hover active:bg-ods-bg-active active:border-ods-border-active",
36
- // Disabled
37
- props.disabled && "!cursor-not-allowed bg-ods-bg disabled:placeholder:text-ods-border",
38
- // Ensure proper cursor/stacking
39
- "cursor-text relative z-10",
40
- // Textarea-specific
41
- "resize-y",
42
- // Invalid state
43
- isInvalid && "border-ods-error hover:border-ods-error focus:border-ods-error",
44
- className,
78
+ props.disabled && "!cursor-not-allowed bg-ods-bg",
79
+ isInvalid && "border-ods-error hover:border-ods-error has-[:focus]:border-ods-error",
80
+ )}
81
+ >
82
+ <textarea
83
+ ref={ref}
84
+ rows={1}
85
+ className={cn(
86
+ "flex-1 min-w-0 resize-none bg-transparent border-none outline-none p-0 m-0 box-border",
87
+ // Native CSS auto-grow (Chrome 123+, FF 124+, Safari 16.4+) — no
88
+ // JS needed to size to content. Falls back to `rows={1}` natural
89
+ // height on older browsers.
90
+ "[field-sizing:content]",
91
+ "text-h4 text-ods-text-primary placeholder:text-ods-text-secondary leading-6",
92
+ // Hard cap on growth: beyond `max-h` the textarea scrolls
93
+ // internally instead of pushing the icon out of frame.
94
+ "max-h-[160px] overflow-y-auto",
95
+ "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-ods-border/30 hover:scrollbar-thumb-ods-text-secondary/30",
96
+ "disabled:cursor-not-allowed disabled:placeholder:text-ods-border",
97
+ className,
98
+ )}
99
+ {...props}
100
+ />
101
+
102
+ {endIconAsButton ? (
103
+ <button
104
+ type="button"
105
+ aria-label={endIconButtonProps?.['aria-label'] ?? 'Submit'}
106
+ {...endIconButtonProps}
107
+ className={cn(
108
+ "flex shrink-0 items-center text-ods-text-secondary transition-colors duration-200",
109
+ "group-has-[:focus]:text-ods-accent group-data-[invalid]:text-ods-error",
110
+ "[&_svg]:size-4 md:[&_svg]:size-6",
111
+ "cursor-pointer hover:text-ods-text-primary",
112
+ "focus-visible:outline-none focus-visible:text-ods-accent",
113
+ "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-ods-text-secondary",
114
+ endIconButtonProps?.className,
115
+ )}
116
+ >
117
+ {endIcon}
118
+ </button>
119
+ ) : (
120
+ <span
121
+ className={cn(
122
+ "flex shrink-0 items-center text-ods-text-secondary transition-colors duration-200",
123
+ "group-has-[:focus]:text-ods-accent group-data-[invalid]:text-ods-error",
124
+ "[&_svg]:size-4 md:[&_svg]:size-6",
125
+ )}
126
+ >
127
+ {endIcon}
128
+ </span>
45
129
  )}
46
- ref={ref}
47
- {...props}
48
- />
130
+ </label>
49
131
  )
50
132
 
51
133
  return (