@flamingo-stack/openframe-frontend-core 0.0.187 → 0.0.188

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-FMWHOUFE.js → chunk-6XYXWVYY.js} +2 -2
  2. package/dist/{chunk-ALW3D72O.cjs → chunk-HIMEIPED.cjs} +9 -9
  3. package/dist/{chunk-ALW3D72O.cjs.map → chunk-HIMEIPED.cjs.map} +1 -1
  4. package/dist/{chunk-RZ3HHPQH.js → chunk-J2C2TI5Z.js} +2174 -2119
  5. package/dist/chunk-J2C2TI5Z.js.map +1 -0
  6. package/dist/{chunk-TMD5LDX4.cjs → chunk-VJTFBYVG.cjs} +57 -2
  7. package/dist/{chunk-TMD5LDX4.cjs.map → chunk-VJTFBYVG.cjs.map} +1 -1
  8. package/dist/{chunk-EUTOT74J.cjs → chunk-W5AWCFKE.cjs} +762 -760
  9. package/dist/chunk-W5AWCFKE.cjs.map +1 -0
  10. package/dist/{chunk-M36UJN3T.js → chunk-YOMHP4V3.js} +4091 -4089
  11. package/dist/chunk-YOMHP4V3.js.map +1 -0
  12. package/dist/components/chat/approval-batch-message.d.ts.map +1 -1
  13. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  14. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  15. package/dist/components/chat/context-compaction-display.d.ts.map +1 -1
  16. package/dist/components/chat/tool-execution-display.d.ts.map +1 -1
  17. package/dist/components/chat/types/api.types.d.ts +2 -6
  18. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  19. package/dist/components/chat/types/message.types.d.ts +20 -0
  20. package/dist/components/chat/types/message.types.d.ts.map +1 -1
  21. package/dist/components/chat/types/network.types.d.ts +2 -0
  22. package/dist/components/chat/types/network.types.d.ts.map +1 -1
  23. package/dist/components/chat/types/processing.types.d.ts +2 -6
  24. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  25. package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -1
  26. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +2 -6
  27. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  28. package/dist/components/chat/utils/message-segment-accumulator.d.ts +2 -6
  29. package/dist/components/chat/utils/message-segment-accumulator.d.ts.map +1 -1
  30. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  31. package/dist/components/chat/utils/tool-call-helpers.d.ts +21 -2
  32. package/dist/components/chat/utils/tool-call-helpers.d.ts.map +1 -1
  33. package/dist/components/features/index.cjs +4 -4
  34. package/dist/components/features/index.js +3 -3
  35. package/dist/components/icons-v2-generated/index.cjs +4 -2
  36. package/dist/components/icons-v2-generated/index.cjs.map +1 -1
  37. package/dist/components/icons-v2-generated/index.d.ts +1 -0
  38. package/dist/components/icons-v2-generated/index.d.ts.map +1 -1
  39. package/dist/components/icons-v2-generated/index.js +3 -1
  40. package/dist/components/icons-v2-generated/loaders/dots-loader-icon.d.ts +8 -0
  41. package/dist/components/icons-v2-generated/loaders/dots-loader-icon.d.ts.map +1 -0
  42. package/dist/components/icons-v2-generated/loaders/index.d.ts +2 -0
  43. package/dist/components/icons-v2-generated/loaders/index.d.ts.map +1 -0
  44. package/dist/components/index.cjs +4 -6
  45. package/dist/components/index.cjs.map +1 -1
  46. package/dist/components/index.js +3 -5
  47. package/dist/components/navigation/index.cjs +4 -4
  48. package/dist/components/navigation/index.js +3 -3
  49. package/dist/components/ui/index.cjs +4 -6
  50. package/dist/components/ui/index.cjs.map +1 -1
  51. package/dist/components/ui/index.d.ts +0 -1
  52. package/dist/components/ui/index.d.ts.map +1 -1
  53. package/dist/components/ui/index.js +3 -5
  54. package/dist/hooks/index.cjs +3 -3
  55. package/dist/hooks/index.js +2 -2
  56. package/dist/index.cjs +4 -6
  57. package/dist/index.cjs.map +1 -1
  58. package/dist/index.js +3 -5
  59. package/package.json +1 -1
  60. package/src/components/chat/approval-batch-message.tsx +4 -5
  61. package/src/components/chat/chat-message-enhanced.tsx +0 -37
  62. package/src/components/chat/chat-message-list.tsx +57 -0
  63. package/src/components/chat/context-compaction-display.tsx +2 -3
  64. package/src/components/chat/thinking-display.tsx +2 -2
  65. package/src/components/chat/tool-execution-display.tsx +14 -11
  66. package/src/components/chat/types/api.types.ts +2 -2
  67. package/src/components/chat/types/message.types.ts +21 -0
  68. package/src/components/chat/types/network.types.ts +2 -0
  69. package/src/components/chat/types/processing.types.ts +2 -6
  70. package/src/components/chat/utils/chunk-parser.ts +2 -0
  71. package/src/components/chat/utils/extract-incomplete-message-state.ts +4 -2
  72. package/src/components/chat/utils/message-segment-accumulator.ts +11 -2
  73. package/src/components/chat/utils/process-historical-messages.ts +2 -0
  74. package/src/components/chat/utils/tool-call-helpers.ts +97 -14
  75. package/src/components/icons-v2/loaders/dots-loader.svg +1 -0
  76. package/src/components/icons-v2-generated/index.ts +1 -0
  77. package/src/components/icons-v2-generated/loaders/dots-loader-icon.tsx +53 -0
  78. package/src/components/icons-v2-generated/loaders/index.ts +1 -0
  79. package/src/components/ui/index.ts +0 -2
  80. package/src/stories/DotsLoaderIcon.stories.tsx +103 -0
  81. package/dist/chunk-EUTOT74J.cjs.map +0 -1
  82. package/dist/chunk-M36UJN3T.js.map +0 -1
  83. package/dist/chunk-RZ3HHPQH.js.map +0 -1
  84. package/dist/components/ui/pulse-dots.d.ts +0 -7
  85. package/dist/components/ui/pulse-dots.d.ts.map +0 -1
  86. package/src/components/ui/pulse-dots.tsx +0 -56
  87. /package/dist/{chunk-FMWHOUFE.js.map → chunk-6XYXWVYY.js.map} +0 -0
@@ -3,14 +3,13 @@
3
3
  import { forwardRef, useMemo, useState } from "react"
4
4
 
5
5
  import { cn } from "../../utils/cn"
6
- import { CheckCircleIcon, XmarkCircleIcon } from "../icons-v2-generated"
6
+ import { CheckCircleIcon, DotsLoaderIcon, XmarkCircleIcon } from "../icons-v2-generated"
7
7
  import { ToolType } from "../platform"
8
8
  import { ToolIcon } from "../tool-icon"
9
- import { PulseDots } from "../ui/pulse-dots"
10
9
  import { ExpandChevron } from "./expand-chevron"
11
10
  import { useCollapsible } from "./hooks/use-collapsible"
12
11
  import { ArgRow, ResultBlock } from "./tool-call-blocks"
13
- import { COMMAND_BODY_ARG_KEYS } from "./utils/tool-call-helpers"
12
+ import { COMMAND_BODY_ARG_KEYS, getToolCallTitle } from "./utils/tool-call-helpers"
14
13
  import type { ToolExecutionDisplayProps } from "./types"
15
14
 
16
15
  const COMMAND_BODY_KEYS = new Set<string>(COMMAND_BODY_ARG_KEYS)
@@ -24,11 +23,15 @@ const ToolExecutionDisplay = forwardRef<HTMLDivElement, ToolExecutionDisplayProp
24
23
  const isExecuted = message.type === "EXECUTED_TOOL"
25
24
  const integratedToolType = (message.integratedToolType as ToolType) || ("OPENFRAME" as ToolType)
26
25
 
27
- const previewText = useMemo(() => {
28
- const command = message.parameters?.command ?? message.parameters?.query
29
- if (command) return String(command)
30
- return message.toolFunction ?? ""
31
- }, [message.toolFunction, message.parameters])
26
+ const previewText = useMemo(
27
+ () =>
28
+ getToolCallTitle({
29
+ args: message.parameters,
30
+ title: message.toolTitle,
31
+ name: message.toolFunction,
32
+ }),
33
+ [message.parameters, message.toolTitle, message.toolFunction],
34
+ )
32
35
 
33
36
  const argEntries = useMemo<Array<[string, unknown]>>(() => {
34
37
  if (!message.parameters || typeof message.parameters !== "object") return []
@@ -41,7 +44,7 @@ const ToolExecutionDisplay = forwardRef<HTMLDivElement, ToolExecutionDisplayProp
41
44
  const hasBody = argEntries.length > 0 || hasResult || isExecuting
42
45
 
43
46
  const renderStatusIcon = () => {
44
- if (isExecuting) return <PulseDots size="sm" />
47
+ if (isExecuting) return <DotsLoaderIcon size={16} className="text-ods-text-secondary" />
45
48
  if (isExecuted && message.success === true) return <CheckCircleIcon className="w-4 h-4 text-ods-success" />
46
49
  if (isExecuted && message.success === false) return <XmarkCircleIcon className="w-4 h-4 text-ods-error" />
47
50
  return null
@@ -72,7 +75,7 @@ const ToolExecutionDisplay = forwardRef<HTMLDivElement, ToolExecutionDisplayProp
72
75
  : "text-ods-text-secondary line-clamp-2 max-h-10 break-all",
73
76
  )}
74
77
  >
75
- {expanded ? message.toolFunction || previewText : previewText}
78
+ {previewText}
76
79
  </div>
77
80
  <div className="flex items-center justify-center shrink-0 w-5 h-5">{renderStatusIcon()}</div>
78
81
  <div className="flex items-center justify-center shrink-0 w-5 h-5">
@@ -91,7 +94,7 @@ const ToolExecutionDisplay = forwardRef<HTMLDivElement, ToolExecutionDisplayProp
91
94
  {isExecuting && (
92
95
  <div className="flex flex-col gap-1 items-start w-full">
93
96
  <span className="text-ods-text-secondary">Result:</span>
94
- <PulseDots size="sm" />
97
+ <DotsLoaderIcon size={16} className="text-ods-text-secondary" />
95
98
  </div>
96
99
  )}
97
100
  </div>
@@ -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, PendingToolCallData, TokenUsageData } from './message.types'
8
+ import type { MessageSegment, PendingToolCallData, TokenUsageData, ExecutingToolState } from './message.types'
9
9
 
10
10
  // ========== Hook Options ==========
11
11
 
@@ -150,7 +150,7 @@ export interface UseRealtimeChunkProcessorOptions {
150
150
  /** Pending approvals that haven't been resolved */
151
151
  pendingApprovals?: Map<string, { command: string; explanation?: string; approvalType: string }>
152
152
  /** Executing tools waiting for completion */
153
- executingTools?: Map<string, { integratedToolType: string; toolFunction: string; parameters?: Record<string, any> }>
153
+ executingTools?: Map<string, ExecutingToolState>
154
154
  /** Escalated approvals */
155
155
  escalatedApprovals?: Map<
156
156
  string,
@@ -35,6 +35,8 @@ export interface ToolExecutionData {
35
35
  type: 'EXECUTING_TOOL' | 'EXECUTED_TOOL'
36
36
  integratedToolType: string
37
37
  toolFunction: string
38
+ /** Backend-issued human-readable title (mirrors `PendingToolCallData.toolTitle`). */
39
+ toolTitle?: string
38
40
  parameters?: Record<string, any>
39
41
  result?: string
40
42
  success?: boolean
@@ -46,6 +48,21 @@ export interface ToolExecutionData {
46
48
  toolExecutionRequestId?: string
47
49
  }
48
50
 
51
+ /**
52
+ * Snapshot of an in-flight tool kept between the `EXECUTING_TOOL` and
53
+ * `EXECUTED_TOOL` events. The backend only sends `toolTitle` on
54
+ * `EXECUTING_TOOL`; carrying this state lets the accumulator restore it onto
55
+ * the merged `EXECUTED_TOOL` segment instead of falling back to the raw
56
+ * `toolFunction`.
57
+ */
58
+ export interface ExecutingToolState {
59
+ integratedToolType: string
60
+ toolFunction: string
61
+ /** Mirrors {@link ToolExecutionData.toolTitle}; absent on `EXECUTED_TOOL`. */
62
+ toolTitle?: string
63
+ parameters?: Record<string, any>
64
+ }
65
+
49
66
  // ========== Approval Request Types ==========
50
67
 
51
68
  export interface ApprovalRequestData {
@@ -171,6 +188,8 @@ export interface ExecutingToolMessageData extends MessageDataBase {
171
188
  type: 'EXECUTING_TOOL'
172
189
  integratedToolType?: string
173
190
  toolFunction?: string
191
+ /** Backend-issued human-readable title (wire field, mirrors `ChunkData.title`). */
192
+ title?: string
174
193
  parameters?: Record<string, any>
175
194
  toolExecutionRequestId?: string
176
195
  }
@@ -179,6 +198,8 @@ export interface ExecutedToolMessageData extends MessageDataBase {
179
198
  type: 'EXECUTED_TOOL'
180
199
  integratedToolType?: string
181
200
  toolFunction?: string
201
+ /** Backend-issued human-readable title (wire field, mirrors `ChunkData.title`). */
202
+ title?: string
182
203
  parameters?: Record<string, any>
183
204
  result?: string
184
205
  success?: boolean
@@ -38,6 +38,8 @@ export interface ChunkData {
38
38
  text?: string
39
39
  integratedToolType?: string
40
40
  toolFunction?: string
41
+ /** Execution chunks carry the human-readable title as `title`. */
42
+ title?: string
41
43
  parameters?: Record<string, any>
42
44
  result?: string
43
45
  success?: boolean
@@ -3,7 +3,7 @@
3
3
  * Contains types for message parsing, accumulation, and processing
4
4
  */
5
5
 
6
- import type { MessageSegment, ProcessedMessage, ToolExecutionSegment, TokenUsageData, PendingToolCallData } from './message.types'
6
+ import type { MessageSegment, ProcessedMessage, ToolExecutionSegment, TokenUsageData, PendingToolCallData, ExecutingToolState } from './message.types'
7
7
  import type { ChatApprovalStatus, AssistantType } from './chat.types'
8
8
  import type { ChunkData, NatsMessageType } from './network.types'
9
9
 
@@ -39,11 +39,7 @@ export interface PendingApproval {
39
39
  export interface AccumulatorState {
40
40
  segments: MessageSegment[]
41
41
  pendingApprovals: Map<string, PendingApproval>
42
- executingTools: Map<string, {
43
- integratedToolType: string
44
- toolFunction: string
45
- parameters?: Record<string, any>
46
- }>
42
+ executingTools: Map<string, ExecutingToolState>
47
43
  escalatedApprovals?: Map<
48
44
  string,
49
45
  { command: string; explanation?: string; approvalType: string; toolCalls?: PendingToolCallData[] }
@@ -74,6 +74,7 @@ export function parseChunkToAction(chunk: unknown): ParsedChunkAction | null {
74
74
  type: 'EXECUTING_TOOL',
75
75
  integratedToolType: data.integratedToolType || '',
76
76
  toolFunction: data.toolFunction || '',
77
+ toolTitle: typeof data.title === 'string' ? data.title : undefined,
77
78
  parameters: data.parameters,
78
79
  toolExecutionRequestId: typeof data.toolExecutionRequestId === 'string' ? data.toolExecutionRequestId : undefined,
79
80
  }
@@ -89,6 +90,7 @@ export function parseChunkToAction(chunk: unknown): ParsedChunkAction | null {
89
90
  type: 'EXECUTED_TOOL',
90
91
  integratedToolType: data.integratedToolType || '',
91
92
  toolFunction: data.toolFunction || '',
93
+ toolTitle: typeof data.title === 'string' ? data.title : undefined,
92
94
  parameters: data.parameters,
93
95
  result: data.result,
94
96
  success: data.success,
@@ -7,6 +7,7 @@ import type {
7
7
  ProcessedMessage,
8
8
  MessageSegment,
9
9
  PendingApproval,
10
+ ExecutingToolState,
10
11
  } from '../types'
11
12
 
12
13
  /**
@@ -18,7 +19,7 @@ export function extractIncompleteMessageState(
18
19
  ): {
19
20
  existingSegments?: MessageSegment[]
20
21
  pendingApprovals?: Map<string, PendingApproval>
21
- executingTools?: Map<string, { integratedToolType: string; toolFunction: string; parameters?: Record<string, any> }>
22
+ executingTools?: Map<string, ExecutingToolState>
22
23
  } | undefined {
23
24
  if (!lastMessage || lastMessage.role !== 'assistant' || typeof lastMessage.content === 'string') {
24
25
  return undefined
@@ -26,7 +27,7 @@ export function extractIncompleteMessageState(
26
27
 
27
28
  const segments = lastMessage.content as MessageSegment[]
28
29
  const pendingApprovals = new Map<string, PendingApproval>()
29
- const executingTools = new Map<string, { integratedToolType: string; toolFunction: string; parameters?: Record<string, any> }>()
30
+ const executingTools = new Map<string, ExecutingToolState>()
30
31
  let hasIncompleteState = false
31
32
 
32
33
  segments.forEach(segment => {
@@ -39,6 +40,7 @@ export function extractIncompleteMessageState(
39
40
  executingTools.set(toolKey, {
40
41
  integratedToolType: segment.data.integratedToolType,
41
42
  toolFunction: segment.data.toolFunction,
43
+ toolTitle: segment.data.toolTitle,
42
44
  parameters: segment.data.parameters,
43
45
  })
44
46
  hasIncompleteState = true
@@ -20,6 +20,7 @@ import type {
20
20
  PendingToolCallData,
21
21
  AccumulatorState,
22
22
  ChatApprovalStatus,
23
+ ExecutingToolState,
23
24
  } from '../types'
24
25
 
25
26
  export interface AccumulatorCallbacks {
@@ -34,7 +35,7 @@ export interface AccumulatorCallbacks {
34
35
  export class MessageSegmentAccumulator {
35
36
  private segments: MessageSegment[] = []
36
37
  private pendingApprovals: Map<string, PendingApproval> = new Map()
37
- private executingTools: Map<string, { integratedToolType: string; toolFunction: string; parameters?: Record<string, any> }> = new Map()
38
+ private executingTools: Map<string, ExecutingToolState> = new Map()
38
39
  private callbacks: AccumulatorCallbacks = {}
39
40
 
40
41
  constructor(callbacks?: AccumulatorCallbacks) {
@@ -57,7 +58,7 @@ export class MessageSegmentAccumulator {
57
58
  initializeWithState(state: {
58
59
  existingSegments?: MessageSegment[]
59
60
  pendingApprovals?: Map<string, PendingApproval>
60
- executingTools?: Map<string, { integratedToolType: string; toolFunction: string; parameters?: Record<string, any> }>
61
+ executingTools?: Map<string, ExecutingToolState>
61
62
  }): void {
62
63
  if (state.existingSegments) {
63
64
  this.segments = [...state.existingSegments]
@@ -164,6 +165,7 @@ export class MessageSegmentAccumulator {
164
165
  this.executingTools.set(toolKey, {
165
166
  integratedToolType: toolData.integratedToolType,
166
167
  toolFunction: toolData.toolFunction,
168
+ toolTitle: toolData.toolTitle,
167
169
  parameters: toolData.parameters,
168
170
  })
169
171
  this.segments.push(segment)
@@ -179,10 +181,17 @@ export class MessageSegmentAccumulator {
179
181
  )
180
182
 
181
183
  const executingTool = this.executingTools.get(toolKey)
184
+ // The backend omits `toolTitle` on EXECUTED_TOOL; restore it from the
185
+ // paired EXECUTING segment (or its tracked state) so the completed
186
+ // segment keeps the human-readable title instead of falling back to the
187
+ // raw `toolFunction`.
188
+ const existingExecuting =
189
+ existingIndex !== -1 ? (this.segments[existingIndex] as ToolExecutionSegment) : undefined
182
190
  const mergedSegment: ToolExecutionSegment = {
183
191
  type: 'tool_execution',
184
192
  data: {
185
193
  ...toolData,
194
+ toolTitle: toolData.toolTitle ?? existingExecuting?.data.toolTitle ?? executingTool?.toolTitle,
186
195
  parameters: toolData.parameters || executingTool?.parameters,
187
196
  }
188
197
  }
@@ -231,6 +231,7 @@ function processMessageData(
231
231
  type: 'EXECUTING_TOOL',
232
232
  integratedToolType: data.integratedToolType || '',
233
233
  toolFunction: data.toolFunction || '',
234
+ toolTitle: typeof data.title === 'string' ? data.title : undefined,
234
235
  parameters: data.parameters,
235
236
  toolExecutionRequestId: data.toolExecutionRequestId,
236
237
  },
@@ -246,6 +247,7 @@ function processMessageData(
246
247
  type: 'EXECUTED_TOOL',
247
248
  integratedToolType: data.integratedToolType || '',
248
249
  toolFunction: data.toolFunction || '',
250
+ toolTitle: typeof data.title === 'string' ? data.title : undefined,
249
251
  parameters: data.parameters,
250
252
  result: data.result,
251
253
  success: data.success,
@@ -8,19 +8,38 @@ import type { PendingToolCallData } from '../types'
8
8
  export const COMMAND_BODY_ARG_KEYS = ['command', 'query', 'script', 'scriptContent', 'code'] as const
9
9
 
10
10
  /**
11
- * Extract a human-readable command string from a tool call.
12
- * Order: command/query/script/scriptContent/code arg → toolTitletoolName → fallback.
11
+ * Resolve the human-readable title/preview line for a tool call.
12
+ * Order: command/query/script/scriptContent/code arg → titlename → fallback.
13
+ *
14
+ * Shape-agnostic so both the approval card (`PendingToolCallData`:
15
+ * `toolCallArguments`/`toolName`) and the execution card (`ToolExecutionData`:
16
+ * `parameters`/`toolFunction`) can share one source of truth.
13
17
  */
14
- export function getCommandText(call: PendingToolCallData): string {
15
- const args = call.toolCallArguments
18
+ export function getToolCallTitle(opts: {
19
+ args?: Record<string, unknown> | null
20
+ title?: string | null
21
+ name?: string | null
22
+ }): string {
23
+ const { args, title, name } = opts
16
24
  if (args && typeof args === 'object') {
17
- const a = args as Record<string, unknown>
18
25
  for (const key of COMMAND_BODY_ARG_KEYS) {
19
- const candidate = a[key]
26
+ const candidate = args[key]
20
27
  if (typeof candidate === 'string' && candidate.trim()) return candidate
21
28
  }
22
29
  }
23
- return call.toolTitle || call.toolName || 'Tool call'
30
+ return title || name || 'Tool call'
31
+ }
32
+
33
+ /**
34
+ * Extract a human-readable command string from a batch tool call.
35
+ * Thin adapter over {@link getToolCallTitle}.
36
+ */
37
+ export function getCommandText(call: PendingToolCallData): string {
38
+ return getToolCallTitle({
39
+ args: call.toolCallArguments,
40
+ title: call.toolTitle,
41
+ name: call.toolName,
42
+ })
24
43
  }
25
44
 
26
45
  export type FormattedArgValue =
@@ -29,6 +48,74 @@ export type FormattedArgValue =
29
48
 
30
49
  const INLINE_STRING_MAX = 80
31
50
 
51
+ /**
52
+ * Pretty-print like `JSON.stringify(value, null, 2)`, but render multi-line
53
+ * string values across real lines instead of escaped `\n` sequences.
54
+ *
55
+ * Trade-off: the output is optimised for human reading, not for being
56
+ * re-parsed as JSON (multi-line string bodies lose their surrounding quotes
57
+ * and escaping). Single-line strings keep normal JSON quoting/escaping.
58
+ *
59
+ * `indent` is the nesting level of this value's content lines / closing
60
+ * bracket (matching the 2-space step of `JSON.stringify(_, null, 2)`).
61
+ */
62
+ function expandJson(value: unknown, indent: number): string {
63
+ const pad = (n: number) => ' '.repeat(n)
64
+
65
+ if (value === null) return 'null'
66
+ const t = typeof value
67
+ if (t === 'number' || t === 'boolean' || t === 'bigint') return String(value)
68
+
69
+ if (t === 'string') {
70
+ const s = (value as string).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
71
+ if (!s.includes('\n')) return JSON.stringify(s)
72
+ return s
73
+ .replace(/^\n+/, '')
74
+ .replace(/\n+$/, '')
75
+ .split('\n')
76
+ .map((line) => pad(indent) + line)
77
+ .join('\n')
78
+ }
79
+
80
+ if (Array.isArray(value)) {
81
+ if (value.length === 0) return '[]'
82
+ const items = value.map((v) =>
83
+ typeof v === 'string' && /[\r\n]/.test(v)
84
+ ? expandJson(v, indent + 1)
85
+ : pad(indent + 1) + expandJson(v, indent + 1),
86
+ )
87
+ return '[\n' + items.join(',\n') + '\n' + pad(indent) + ']'
88
+ }
89
+
90
+ if (t === 'object') {
91
+ const entries = Object.entries(value as Record<string, unknown>)
92
+ if (entries.length === 0) return '{}'
93
+ const items = entries.map(([k, v]) => {
94
+ const key = JSON.stringify(k)
95
+ if (typeof v === 'string' && /[\r\n]/.test(v)) {
96
+ return pad(indent + 1) + key + ':\n' + expandJson(v, indent + 2)
97
+ }
98
+ return pad(indent + 1) + key + ': ' + expandJson(v, indent + 1)
99
+ })
100
+ return '{\n' + items.join(',\n') + '\n' + pad(indent) + '}'
101
+ }
102
+
103
+ return JSON.stringify(value)
104
+ }
105
+
106
+ /**
107
+ * Entry point for {@link expandJson}: readable pretty-print where multi-line
108
+ * string fields (script bodies, command output, …) are shown with real line
109
+ * breaks instead of literal `\n`.
110
+ */
111
+ export function expandedJsonStringify(value: unknown): string {
112
+ try {
113
+ return expandJson(value, 0)
114
+ } catch {
115
+ return String(value)
116
+ }
117
+ }
118
+
32
119
  /**
33
120
  * Decide how to render a single `toolCallArguments` value:
34
121
  * - objects/arrays → pretty-printed JSON block
@@ -50,7 +137,7 @@ export function formatToolArgValue(value: unknown): FormattedArgValue {
50
137
  (trimmed.startsWith('[') && trimmed.endsWith(']'))
51
138
  ) {
52
139
  try {
53
- return { kind: 'block', text: JSON.stringify(JSON.parse(trimmed), null, 2), language: 'json' }
140
+ return { kind: 'block', text: expandedJsonStringify(JSON.parse(trimmed)), language: 'json' }
54
141
  } catch {
55
142
  // not valid JSON — fall through
56
143
  }
@@ -65,11 +152,7 @@ export function formatToolArgValue(value: unknown): FormattedArgValue {
65
152
  }
66
153
 
67
154
  function safeStringify(value: unknown): string {
68
- try {
69
- return JSON.stringify(value, null, 2)
70
- } catch {
71
- return String(value)
72
- }
155
+ return expandedJsonStringify(value)
73
156
  }
74
157
 
75
158
  /**
@@ -92,7 +175,7 @@ export function formatToolResult(value: string | undefined | null): FormattedArg
92
175
  (trimmed.startsWith('[') && trimmed.endsWith(']'))
93
176
  ) {
94
177
  try {
95
- return { kind: 'block', text: JSON.stringify(JSON.parse(trimmed), null, 2), language: 'json' }
178
+ return { kind: 'block', text: expandedJsonStringify(JSON.parse(trimmed)), language: 'json' }
96
179
  } catch {
97
180
  // fall through
98
181
  }
@@ -0,0 +1 @@
1
+ <svg fill="#888" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="4" cy="12" r="3"><animate attributeName="r" dur="0.75s" values="3;.2;3" repeatCount="indefinite" begin="0s"/></circle><circle cx="12" cy="12" r="3"><animate attributeName="r" dur="0.75s" values="3;.2;3" repeatCount="indefinite" begin="0.15s"/></circle><circle cx="20" cy="12" r="3"><animate attributeName="r" dur="0.75s" values="3;.2;3" repeatCount="indefinite" begin="0.3s"/></circle></svg>
@@ -16,6 +16,7 @@ export * from './food-and-drinks';
16
16
  export * from './health';
17
17
  export * from './household';
18
18
  export * from './interface';
19
+ export * from './loaders';
19
20
  export * from './map-and-travel';
20
21
  export * from './media-playback';
21
22
  export * from './number';
@@ -0,0 +1,53 @@
1
+ import type { SVGProps } from "react";
2
+ export interface DotsLoaderIconProps
3
+ extends Omit<SVGProps<SVGSVGElement>, "width" | "height"> {
4
+ className?: string;
5
+ size?: number;
6
+ color?: string;
7
+ }
8
+ export function DotsLoaderIcon({
9
+ className = "",
10
+ size = 24,
11
+ color = "currentColor",
12
+ ...props
13
+ }: DotsLoaderIconProps) {
14
+ return (
15
+ <svg
16
+ xmlns="http://www.w3.org/2000/svg"
17
+ fill={color}
18
+ viewBox="0 0 24 24"
19
+ width={size}
20
+ height={size}
21
+ className={className}
22
+ {...props}
23
+ >
24
+ <circle cx={4} cy={12} r={3}>
25
+ <animate
26
+ attributeName="r"
27
+ dur="0.75s"
28
+ values="3;.2;3"
29
+ repeatCount="indefinite"
30
+ begin="0s"
31
+ />
32
+ </circle>
33
+ <circle cx={12} cy={12} r={3}>
34
+ <animate
35
+ attributeName="r"
36
+ dur="0.75s"
37
+ values="3;.2;3"
38
+ repeatCount="indefinite"
39
+ begin="0.15s"
40
+ />
41
+ </circle>
42
+ <circle cx={20} cy={12} r={3}>
43
+ <animate
44
+ attributeName="r"
45
+ dur="0.75s"
46
+ values="3;.2;3"
47
+ repeatCount="indefinite"
48
+ begin="0.3s"
49
+ />
50
+ </circle>
51
+ </svg>
52
+ );
53
+ }
@@ -0,0 +1 @@
1
+ export { DotsLoaderIcon } from './dots-loader-icon';
@@ -44,8 +44,6 @@ export * from './menubar'
44
44
  export * from './navigation-menu'
45
45
  export * from './tab-content'
46
46
  export * from './tab-navigation'
47
- // Animation components
48
- export * from './pulse-dots'
49
47
  // Feedback components
50
48
  export * from './alert'
51
49
  export * from './badge'
@@ -0,0 +1,103 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2
+ import { DotsLoaderIcon } from '../components/icons-v2-generated/loaders/dots-loader-icon';
3
+
4
+ const meta = {
5
+ title: 'UI/DotsLoaderIcon',
6
+ component: DotsLoaderIcon,
7
+ argTypes: {
8
+ size: {
9
+ control: { type: 'number', min: 12, max: 128, step: 4 },
10
+ },
11
+ color: {
12
+ control: 'text',
13
+ description: 'CSS color. Defaults to `currentColor` — pass an ODS token like `var(--ods-accent)`.',
14
+ },
15
+ className: { control: 'text' },
16
+ },
17
+ parameters: {
18
+ layout: 'centered',
19
+ },
20
+ } satisfies Meta<typeof DotsLoaderIcon>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ /**
26
+ * Default — 24px, inherits text color via `currentColor`.
27
+ * The three dots animate in an infinite loop (SMIL chain).
28
+ */
29
+ export const Default: Story = {
30
+ args: {
31
+ size: 24,
32
+ },
33
+ };
34
+
35
+ /**
36
+ * Large size for full-page / blocking loading states.
37
+ */
38
+ export const Large: Story = {
39
+ args: {
40
+ size: 64,
41
+ },
42
+ };
43
+
44
+ /**
45
+ * Inherits the surrounding text color through `currentColor`.
46
+ */
47
+ export const InheritsTextColor: Story = {
48
+ args: {
49
+ size: 40,
50
+ },
51
+ render: (args) => (
52
+ <div className="text-ods-accent">
53
+ <DotsLoaderIcon {...args} />
54
+ </div>
55
+ ),
56
+ };
57
+
58
+ /**
59
+ * Explicit ODS color tokens — never hardcode hex.
60
+ */
61
+ export const OdsColors: Story = {
62
+ args: {
63
+ size: 40,
64
+ },
65
+ render: (args) => (
66
+ <div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
67
+ <DotsLoaderIcon {...args} color="var(--ods-text-primary)" />
68
+ <DotsLoaderIcon {...args} color="var(--ods-text-secondary)" />
69
+ <DotsLoaderIcon {...args} color="var(--ods-accent)" />
70
+ <DotsLoaderIcon {...args} color="var(--ods-error)" />
71
+ </div>
72
+ ),
73
+ };
74
+
75
+ /**
76
+ * Size scale.
77
+ */
78
+ export const Sizes: Story = {
79
+ args: {},
80
+ render: () => (
81
+ <div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
82
+ <DotsLoaderIcon size={16} />
83
+ <DotsLoaderIcon size={24} />
84
+ <DotsLoaderIcon size={40} />
85
+ <DotsLoaderIcon size={64} />
86
+ <DotsLoaderIcon size={96} />
87
+ </div>
88
+ ),
89
+ };
90
+
91
+ /**
92
+ * In context — centered inside a card, as used for inline loading.
93
+ */
94
+ export const InCard: Story = {
95
+ args: {
96
+ size: 32,
97
+ },
98
+ render: (args) => (
99
+ <div className="flex items-center justify-center bg-ods-card border border-ods-border rounded-lg w-64 h-40 text-ods-text-secondary">
100
+ <DotsLoaderIcon {...args} />
101
+ </div>
102
+ ),
103
+ };