@redocly/theme 0.70.0-next.5 → 0.70.0-next.7

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 (99) hide show
  1. package/lib/components/Buttons/ConnectMCPButton.js +8 -13
  2. package/lib/components/Catalog/CatalogEntity/CatalogEntityGraph/CatalogEntityRelationsGraph.lazy.js +1 -1
  3. package/lib/components/Catalog/CatalogFilter/CatalogFilterDateRange.js +2 -1
  4. package/lib/components/Dropdown/Dropdown.d.ts +4 -2
  5. package/lib/components/Dropdown/Dropdown.js +28 -8
  6. package/lib/components/Filter/FilterDateRange.js +36 -2
  7. package/lib/components/Portal/Portal.js +37 -5
  8. package/lib/components/Search/SearchAiActionButtons.js +8 -3
  9. package/lib/components/Search/SearchAiActivity.d.ts +10 -0
  10. package/lib/components/Search/SearchAiActivity.js +278 -0
  11. package/lib/components/Search/SearchAiDialog.js +5 -1
  12. package/lib/components/Search/SearchAiMessage.d.ts +2 -1
  13. package/lib/components/Search/SearchAiMessage.js +20 -108
  14. package/lib/components/Search/SearchDialog.js +10 -0
  15. package/lib/components/Search/SearchItem.js +1 -8
  16. package/lib/components/Search/variables.js +15 -2
  17. package/lib/components/Select/SelectInput.js +2 -8
  18. package/lib/components/SplitButton/SplitButton.js +37 -3
  19. package/lib/core/constants/mcp.d.ts +2 -2
  20. package/lib/core/constants/mcp.js +3 -8
  21. package/lib/core/constants/search.js +5 -1
  22. package/lib/core/contexts/PortalContainerContext.d.ts +2 -0
  23. package/lib/core/contexts/PortalContainerContext.js +7 -0
  24. package/lib/core/hooks/index.d.ts +2 -0
  25. package/lib/core/hooks/index.js +2 -0
  26. package/lib/core/hooks/menu/use-collapse.js +1 -1
  27. package/lib/core/hooks/search/use-persisted-quick-filter.d.ts +21 -0
  28. package/lib/core/hooks/search/use-persisted-quick-filter.js +92 -0
  29. package/lib/core/hooks/search/use-search-ai-activity.d.ts +11 -0
  30. package/lib/core/hooks/search/use-search-ai-activity.js +15 -0
  31. package/lib/core/hooks/use-connect-mcp-button.js +2 -1
  32. package/lib/core/hooks/use-full-height.d.ts +1 -1
  33. package/lib/core/hooks/use-full-height.js +8 -8
  34. package/lib/core/styles/global.d.ts +2 -0
  35. package/lib/core/styles/global.js +80 -76
  36. package/lib/core/styles/index.d.ts +1 -0
  37. package/lib/core/styles/index.js +3 -1
  38. package/lib/core/styles/search-ai-appear.d.ts +1 -0
  39. package/lib/core/styles/search-ai-appear.js +18 -0
  40. package/lib/core/types/dropdown.d.ts +1 -0
  41. package/lib/core/types/dropdown.js +3 -0
  42. package/lib/core/types/index.d.ts +1 -0
  43. package/lib/core/types/l10n.d.ts +1 -1
  44. package/lib/core/types/mcp.d.ts +2 -1
  45. package/lib/core/types/search.d.ts +23 -0
  46. package/lib/core/utils/assign-ref.d.ts +2 -0
  47. package/lib/core/utils/assign-ref.js +12 -0
  48. package/lib/core/utils/content-segments.d.ts +12 -1
  49. package/lib/core/utils/content-segments.js +90 -5
  50. package/lib/core/utils/dropdown.d.ts +2 -0
  51. package/lib/core/utils/dropdown.js +12 -0
  52. package/lib/core/utils/index.d.ts +2 -0
  53. package/lib/core/utils/index.js +2 -0
  54. package/lib/core/utils/mcp.d.ts +2 -1
  55. package/lib/core/utils/mcp.js +5 -0
  56. package/lib/core/utils/search-ai-activity.d.ts +4 -0
  57. package/lib/core/utils/search-ai-activity.js +83 -0
  58. package/lib/markdoc/tags/connect-mcp.js +15 -0
  59. package/package.json +3 -2
  60. package/src/components/Buttons/ConnectMCPButton.tsx +8 -14
  61. package/src/components/Catalog/CatalogEntity/CatalogEntityGraph/CatalogEntityRelationsGraph.lazy.tsx +3 -1
  62. package/src/components/Catalog/CatalogFilter/CatalogFilterDateRange.tsx +3 -2
  63. package/src/components/Dropdown/Dropdown.tsx +28 -6
  64. package/src/components/Filter/FilterDateRange.tsx +3 -2
  65. package/src/components/Portal/Portal.tsx +4 -2
  66. package/src/components/Search/SearchAiActionButtons.tsx +31 -19
  67. package/src/components/Search/SearchAiActivity.tsx +318 -0
  68. package/src/components/Search/SearchAiDialog.tsx +4 -3
  69. package/src/components/Search/SearchAiMessage.tsx +27 -135
  70. package/src/components/Search/SearchDialog.tsx +11 -0
  71. package/src/components/Search/SearchItem.tsx +2 -9
  72. package/src/components/Search/variables.ts +15 -2
  73. package/src/components/Select/SelectInput.tsx +2 -8
  74. package/src/components/SplitButton/SplitButton.tsx +8 -2
  75. package/src/core/constants/mcp.ts +3 -9
  76. package/src/core/constants/search.ts +5 -1
  77. package/src/core/contexts/PortalContainerContext.tsx +4 -0
  78. package/src/core/hooks/__mocks__/index.ts +2 -0
  79. package/src/core/hooks/index.ts +2 -0
  80. package/src/core/hooks/menu/use-collapse.ts +1 -1
  81. package/src/core/hooks/search/use-persisted-quick-filter.ts +125 -0
  82. package/src/core/hooks/search/use-search-ai-activity.ts +31 -0
  83. package/src/core/hooks/use-connect-mcp-button.ts +7 -2
  84. package/src/core/hooks/use-full-height.ts +8 -8
  85. package/src/core/styles/global.ts +80 -75
  86. package/src/core/styles/index.ts +2 -1
  87. package/src/core/styles/search-ai-appear.ts +16 -0
  88. package/src/core/types/dropdown.ts +1 -0
  89. package/src/core/types/index.ts +1 -0
  90. package/src/core/types/l10n.ts +4 -1
  91. package/src/core/types/mcp.ts +3 -1
  92. package/src/core/types/search.ts +21 -0
  93. package/src/core/utils/assign-ref.ts +9 -0
  94. package/src/core/utils/content-segments.ts +98 -6
  95. package/src/core/utils/dropdown.ts +14 -0
  96. package/src/core/utils/index.ts +2 -0
  97. package/src/core/utils/mcp.ts +7 -1
  98. package/src/core/utils/search-ai-activity.ts +110 -0
  99. package/src/markdoc/tags/connect-mcp.ts +16 -1
@@ -0,0 +1,318 @@
1
+ import React, { useId, useState, type ComponentType, type JSX } from 'react';
2
+ import styled, { css, keyframes } from 'styled-components';
3
+
4
+ import type { ToolCall, SearchAiActivityKind } from '@redocly/theme/core/types';
5
+ import type { IconProps } from '@redocly/theme/icons/types';
6
+
7
+ import { useSearchAiActivity } from '@redocly/theme/core/hooks';
8
+ import { searchAiAppearAnimation } from '@redocly/theme/core/styles';
9
+ import { AiStarsIcon } from '@redocly/theme/icons/AiStarsIcon/AiStarsIcon';
10
+ import { ChatIcon } from '@redocly/theme/icons/ChatIcon/ChatIcon';
11
+ import { ChevronDownIcon } from '@redocly/theme/icons/ChevronDownIcon/ChevronDownIcon';
12
+ import { CodeIcon } from '@redocly/theme/icons/CodeIcon/CodeIcon';
13
+ import { SearchIcon } from '@redocly/theme/icons/SearchIcon/SearchIcon';
14
+
15
+ const ACTIVITY_KIND_ICONS: Record<SearchAiActivityKind, ComponentType<IconProps>> = {
16
+ thinking: AiStarsIcon,
17
+ search: SearchIcon,
18
+ support: ChatIcon,
19
+ code: CodeIcon,
20
+ };
21
+
22
+ export type SearchAiActivityProps = {
23
+ /** One run of consecutive tool calls; an empty list means the assistant has not called a tool yet. */
24
+ toolCalls: ToolCall[];
25
+ /** The assistant works on this group now; once it moves on, a call without a result counts as finished. */
26
+ isActive: boolean;
27
+ className?: string;
28
+ };
29
+
30
+ export function SearchAiActivity({
31
+ toolCalls,
32
+ isActive,
33
+ className,
34
+ }: SearchAiActivityProps): JSX.Element {
35
+ const [isExpanded, setIsExpanded] = useState(false);
36
+ const listId = useId();
37
+ const { status, steps } = useSearchAiActivity({ toolCalls, isActive });
38
+ const StatusIcon = ACTIVITY_KIND_ICONS[status.kind];
39
+
40
+ const header = (
41
+ <>
42
+ <IconWrapper aria-hidden>
43
+ <StatusIcon size="var(--search-ai-activity-icon-size)" />
44
+ </IconWrapper>
45
+ <StatusLabel
46
+ key={`${status.label}${status.detail ?? ''}`}
47
+ title={status.detail ? `${status.label} “${status.detail}”` : status.label}
48
+ $isRunning={status.isRunning}
49
+ >
50
+ {status.label}
51
+ {status.detail && <StatusDetail> “{status.detail}”</StatusDetail>}
52
+ </StatusLabel>
53
+ {steps.length > 1 && (
54
+ <>
55
+ {' '}
56
+ <ToolCallCount>{steps.length}</ToolCallCount>
57
+ </>
58
+ )}
59
+ {steps.length > 0 && (
60
+ <ExpandIcon size="var(--search-ai-activity-icon-size)" $isExpanded={isExpanded} />
61
+ )}
62
+ </>
63
+ );
64
+
65
+ return (
66
+ <SearchAiActivityWrapper
67
+ data-component-name="Search/SearchAiActivity"
68
+ data-testid="tool-calls-info"
69
+ className={className}
70
+ >
71
+ {steps.length > 0 ? (
72
+ <>
73
+ <HeaderButton
74
+ type="button"
75
+ aria-expanded={isExpanded}
76
+ aria-controls={listId}
77
+ onClick={() => setIsExpanded((expanded) => !expanded)}
78
+ >
79
+ {header}
80
+ </HeaderButton>
81
+ <StepsWrapper id={listId} $isExpanded={isExpanded}>
82
+ <StepList>
83
+ {steps.map((step) => {
84
+ const StepIcon = ACTIVITY_KIND_ICONS[step.kind];
85
+ return (
86
+ <StepItem key={step.id}>
87
+ <StepIconWrapper aria-hidden>
88
+ <StepIcon size="var(--search-ai-activity-icon-size)" />
89
+ </StepIconWrapper>
90
+ <StepText $isRunning={step.isRunning && isExpanded}>{step.label}</StepText>
91
+ </StepItem>
92
+ );
93
+ })}
94
+ </StepList>
95
+ </StepsWrapper>
96
+ </>
97
+ ) : (
98
+ <HeaderWrapper>{header}</HeaderWrapper>
99
+ )}
100
+ </SearchAiActivityWrapper>
101
+ );
102
+ }
103
+
104
+ const labelIn = keyframes`
105
+ from {
106
+ opacity: 0.35;
107
+ }
108
+ `;
109
+
110
+ const shimmerSweep = keyframes`
111
+ from {
112
+ background-position: 100% 0;
113
+ }
114
+ to {
115
+ background-position: 0 0;
116
+ }
117
+ `;
118
+
119
+ const shimmer = css`
120
+ @supports (background-clip: text) or (-webkit-background-clip: text) {
121
+ @media (prefers-reduced-motion: no-preference) {
122
+ background:
123
+ linear-gradient(
124
+ 90deg,
125
+ transparent 0%,
126
+ transparent 40%,
127
+ var(--search-ai-activity-shimmer-color) 50%,
128
+ transparent 60%,
129
+ transparent 100%
130
+ )
131
+ 100% 0 / 250% 100% no-repeat,
132
+ linear-gradient(currentColor, currentColor);
133
+ -webkit-background-clip: text;
134
+ background-clip: text;
135
+ -webkit-text-fill-color: transparent;
136
+ animation:
137
+ ${labelIn} var(--search-ai-activity-fade-duration) ease-out,
138
+ ${shimmerSweep} var(--search-ai-activity-shimmer-duration) linear
139
+ infinite;
140
+ }
141
+ }
142
+ `;
143
+
144
+ const SearchAiActivityWrapper = styled.div`
145
+ font-size: var(--search-ai-activity-font-size);
146
+ line-height: var(--search-ai-activity-line-height);
147
+ color: var(--search-ai-activity-text-color);
148
+ `;
149
+
150
+ const headerStyles = css`
151
+ display: flex;
152
+ align-items: center;
153
+ gap: var(--spacing-xs);
154
+ max-width: 100%;
155
+ min-height: var(--search-ai-activity-line-height);
156
+ white-space: nowrap;
157
+ `;
158
+
159
+ const HeaderWrapper = styled.div`
160
+ ${headerStyles}
161
+ `;
162
+
163
+ const HeaderButton = styled.button`
164
+ ${headerStyles}
165
+ margin: 0;
166
+ padding: 0;
167
+ border: 0;
168
+ border-radius: var(--border-radius);
169
+ background: none;
170
+ font: inherit;
171
+ color: inherit;
172
+ text-align: start;
173
+ cursor: pointer;
174
+ transition: color var(--search-ai-activity-fade-duration) ease-out;
175
+
176
+ &:hover {
177
+ color: var(--search-ai-activity-text-color-hover);
178
+ }
179
+
180
+ &:focus-visible {
181
+ outline: 2px solid var(--button-border-color-focused);
182
+ outline-offset: 2px;
183
+ }
184
+ `;
185
+
186
+ const IconWrapper = styled.span`
187
+ display: inline-flex;
188
+ flex-shrink: 0;
189
+ width: var(--search-ai-activity-icon-size);
190
+ height: var(--search-ai-activity-icon-size);
191
+ `;
192
+
193
+ const StatusLabel = styled.span<{ $isRunning: boolean }>`
194
+ min-width: 0;
195
+ overflow: hidden;
196
+ text-overflow: ellipsis;
197
+ font-weight: var(--font-weight-regular);
198
+ animation: ${labelIn} var(--search-ai-activity-fade-duration) ease-out;
199
+
200
+ @media (prefers-reduced-motion: reduce) {
201
+ animation: none;
202
+ }
203
+
204
+ ${({ $isRunning }) => $isRunning && shimmer}
205
+ `;
206
+
207
+ const StatusDetail = styled.span`
208
+ color: var(--search-ai-activity-detail-color);
209
+ `;
210
+
211
+ const ToolCallCount = styled.span`
212
+ flex-shrink: 0;
213
+ min-width: calc(var(--search-ai-activity-line-height) - 4px);
214
+ padding: 0 var(--spacing-xxs);
215
+ border-radius: calc(var(--search-ai-activity-line-height) / 2);
216
+ background: var(--search-ai-activity-count-bg-color);
217
+ font-size: var(--search-ai-activity-count-font-size);
218
+ line-height: calc(var(--search-ai-activity-line-height) - 4px);
219
+ font-variant-numeric: tabular-nums;
220
+ text-align: center;
221
+ `;
222
+
223
+ const ExpandIcon = styled(ChevronDownIcon)<{ $isExpanded: boolean }>`
224
+ flex-shrink: 0;
225
+ margin-inline-start: calc(var(--spacing-xxs) * -1);
226
+ transform: rotate(${({ $isExpanded }) => ($isExpanded ? '180deg' : '0deg')});
227
+ transition: transform var(--search-ai-activity-expand-duration)
228
+ var(--search-ai-activity-easing);
229
+
230
+ @media (prefers-reduced-motion: reduce) {
231
+ transition: none;
232
+ }
233
+ `;
234
+
235
+ const StepsWrapper = styled.div<{ $isExpanded: boolean }>`
236
+ display: grid;
237
+ grid-template-rows: ${({ $isExpanded }) => ($isExpanded ? '1fr' : '0fr')};
238
+ opacity: ${({ $isExpanded }) => ($isExpanded ? 1 : 0)};
239
+ visibility: ${({ $isExpanded }) => ($isExpanded ? 'visible' : 'hidden')};
240
+ ${({ $isExpanded }) =>
241
+ !$isExpanded &&
242
+ css`
243
+ contain: inline-size;
244
+ `}
245
+ transition:
246
+ grid-template-rows var(--search-ai-activity-expand-duration) var(--search-ai-activity-easing),
247
+ opacity var(--search-ai-activity-expand-duration) ease-out,
248
+ visibility var(--search-ai-activity-expand-duration);
249
+
250
+ @media (prefers-reduced-motion: reduce) {
251
+ transition: none;
252
+ }
253
+ `;
254
+
255
+ const STEP_GAP = 'var(--spacing-xs)';
256
+ const ICON_OFFSET =
257
+ 'calc((var(--search-ai-activity-line-height) - var(--search-ai-activity-icon-size)) / 2)';
258
+ const RAIL_GAP = '3px';
259
+ const RAIL_INSET = 'calc(var(--search-ai-activity-icon-size) / 2 - 0.5px)';
260
+
261
+ const StepList = styled.ol`
262
+ min-height: 0;
263
+ overflow: hidden;
264
+ margin: 0;
265
+ padding: 0;
266
+ list-style: none;
267
+
268
+ /* Padding would stay visible while the list is collapsed; the spacer collapses with it. */
269
+ &::before {
270
+ content: "";
271
+ display: block;
272
+ height: ${STEP_GAP};
273
+ }
274
+ `;
275
+
276
+ const StepItem = styled.li`
277
+ position: relative;
278
+ display: flex;
279
+ gap: var(--spacing-xs);
280
+ padding-bottom: ${STEP_GAP};
281
+ ${searchAiAppearAnimation}
282
+
283
+ &:last-child {
284
+ padding-bottom: 0;
285
+ }
286
+
287
+ &:first-child::before,
288
+ &:not(:last-child)::after {
289
+ content: "";
290
+ position: absolute;
291
+ inset-inline-start: ${RAIL_INSET};
292
+ width: 1px;
293
+ background: var(--search-ai-activity-rail-color);
294
+ }
295
+
296
+ &:first-child::before {
297
+ top: calc(${STEP_GAP} * -1 - ${ICON_OFFSET} + ${RAIL_GAP});
298
+ height: calc(${STEP_GAP} + ${ICON_OFFSET} * 2 - ${RAIL_GAP} * 2);
299
+ }
300
+
301
+ &:not(:last-child)::after {
302
+ top: calc(
303
+ ${ICON_OFFSET} + var(--search-ai-activity-icon-size) + ${RAIL_GAP}
304
+ );
305
+ bottom: calc(${RAIL_GAP} - ${ICON_OFFSET});
306
+ }
307
+ `;
308
+
309
+ const StepIconWrapper = styled(IconWrapper)`
310
+ margin-top: ${ICON_OFFSET};
311
+ `;
312
+
313
+ const StepText = styled.span<{ $isRunning: boolean }>`
314
+ min-width: 0;
315
+ overflow-wrap: anywhere;
316
+
317
+ ${({ $isRunning }) => $isRunning && shimmer}
318
+ `;
@@ -119,6 +119,10 @@ export function SearchAiDialog({
119
119
 
120
120
  // oxlint-disable-next-line react-you-might-not-need-an-effect/no-pass-data-to-parent -- merges the streamed assistant response into the lifted conversation state as chunks arrive
121
121
  setConversation((prev) => {
122
+ // A reset can land between this effect's render and its update.
123
+ if (prev.length === 0) {
124
+ return prev;
125
+ }
122
126
  const lastMessage = prev[prev.length - 1];
123
127
  const content: string = response || '';
124
128
 
@@ -214,9 +218,6 @@ export function SearchAiDialog({
214
218
  messageId={item.messageId}
215
219
  feedback={item.feedback}
216
220
  onFeedbackChange={handleFeedbackChange}
217
- toolCalls={
218
- item.toolCalls || (index === conversation.length - 1 ? toolCalls : undefined)
219
- }
220
221
  contentSegments={item.contentSegments}
221
222
  />
222
223
  ))}
@@ -6,9 +6,9 @@ import {
6
6
  type SearchAiMessageResource,
7
7
  type ToolCall,
8
8
  type ContentSegment,
9
- ToolCallName,
10
9
  } from '@redocly/theme/core/types';
11
- import { TOOL_CALL_DISPLAY_TEXT, AiSearchConversationRole } from '@redocly/theme/core/constants';
10
+ import { AiSearchConversationRole } from '@redocly/theme/core/constants';
11
+ import { groupToolCallSegments, hasToolCallsAndText } from '@redocly/theme/core/utils';
12
12
  import { Link } from '@redocly/theme/components/Link/Link';
13
13
  import { Tag } from '@redocly/theme/components/Tag/Tag';
14
14
  import { useThemeHooks } from '@redocly/theme/core/hooks';
@@ -18,43 +18,14 @@ import { AiStarsIcon } from '@redocly/theme/icons/AiStarsIcon/AiStarsIcon';
18
18
  import { CheckmarkOutlineIcon } from '@redocly/theme/icons/CheckmarkOutlineIcon/CheckmarkOutlineIcon';
19
19
  import { SearchAiActionButtons } from '@redocly/theme/components/Search/SearchAiActionButtons';
20
20
  import { SearchAiNegativeFeedbackForm } from '@redocly/theme/components/Search/SearchAiNegativeFeedbackForm';
21
+ import { SearchAiActivity } from '@redocly/theme/components/Search/SearchAiActivity';
22
+ import { searchAiAppearAnimation } from '@redocly/theme/core/styles';
21
23
 
22
24
  function MarkdownSegment({ text }: { text: string }): JSX.Element {
23
25
  const { useMarkdownText } = useThemeHooks();
24
26
  const markdown = useMarkdownText(text);
25
27
  return <ResponseText as="div" children={markdown} data-testid="response-text" />;
26
28
  }
27
- // The codemode `execute` tool passes a human-readable `description` of what its code
28
- // does; show that instead of a generic "Executing execute..." label.
29
- function getExecuteDescription(args: unknown): string | undefined {
30
- if (args && typeof args === 'object' && 'description' in args) {
31
- const { description } = args as { description?: unknown };
32
- if (typeof description === 'string' && description.trim().length > 0) {
33
- return description.trim();
34
- }
35
- }
36
- return undefined;
37
- }
38
-
39
- function getToolCallDisplayText(toolCall: ToolCall): {
40
- inProgressText: string;
41
- completedText: string;
42
- } {
43
- if (toolCall.name === ToolCallName.Execute) {
44
- const description = getExecuteDescription(toolCall.args);
45
- if (description) {
46
- return { inProgressText: description, completedText: description };
47
- }
48
- }
49
-
50
- return (
51
- TOOL_CALL_DISPLAY_TEXT[toolCall.name] ?? {
52
- inProgressText: `Executing ${toolCall.name}...`,
53
- completedText: `${toolCall.name} executed`,
54
- }
55
- );
56
- }
57
-
58
29
  export type SearchAiMessageProps = {
59
30
  role: AiSearchConversationRole;
60
31
  content: string;
@@ -64,6 +35,7 @@ export type SearchAiMessageProps = {
64
35
  messageId?: string;
65
36
  feedback?: FeedbackType;
66
37
  onFeedbackChange: (messageId: string, feedback: FeedbackType | undefined) => void;
38
+ /** @deprecated Not used; the message reads its tool calls from `contentSegments`. */
67
39
  toolCalls?: ToolCall[];
68
40
  contentSegments?: ContentSegment[];
69
41
  };
@@ -77,7 +49,6 @@ function SearchAiMessageComponent({
77
49
  messageId,
78
50
  feedback,
79
51
  onFeedbackChange,
80
- toolCalls = [],
81
52
  contentSegments = [{ type: 'text' as const, text: content }],
82
53
  }: SearchAiMessageProps): JSX.Element {
83
54
  const { useTranslate, useTelemetry } = useThemeHooks();
@@ -90,7 +61,8 @@ function SearchAiMessageComponent({
90
61
 
91
62
  const showSuccessMessage = feedbackSent && feedback;
92
63
 
93
- const isLoading = isThinking && content.length === 0 && toolCalls.length === 0;
64
+ const groupedSegments = groupToolCallSegments(contentSegments, isThinking);
65
+ const isFullWidth = hasToolCallsAndText(contentSegments);
94
66
 
95
67
  const sendFeedbackTelemetry = (feedbackValue: FeedbackType, dislikeReason?: string) => {
96
68
  if (!messageId) return;
@@ -143,29 +115,18 @@ function SearchAiMessageComponent({
143
115
  margin="0 var(--spacing-xs) 0 0"
144
116
  />
145
117
  )}
146
- <MessageContentWrapper>
118
+ <MessageContentWrapper $isFullWidth={isFullWidth}>
147
119
  {role === AiSearchConversationRole.ASSISTANT ? (
148
120
  <>
149
- <MessageWrapper $role={role}>
150
- {contentSegments.map((segment, index) => {
151
- if (segment.type === 'tool') {
152
- const toolCallCompleted = Boolean(segment.toolCall.result);
153
-
154
- const { inProgressText, completedText } = getToolCallDisplayText(
155
- segment.toolCall,
156
- );
157
-
158
- const toolCallDisplayText = toolCallCompleted ? completedText : inProgressText;
159
-
121
+ <MessageWrapper $role={role} $isFullWidth={isFullWidth}>
122
+ {groupedSegments.map((segment, index) => {
123
+ if (segment.type === 'tools') {
160
124
  return (
161
- <ToolCallsInfoWrapper key={`tool-${index}`} data-testid="tool-calls-info">
162
- <ToolCallInfoItem>
163
- <DocumentIcon size="14px" color="--search-ai-text-color" />
164
- <ToolCallText $isSearching={!toolCallCompleted}>
165
- {toolCallDisplayText}
166
- </ToolCallText>
167
- </ToolCallInfoItem>
168
- </ToolCallsInfoWrapper>
125
+ <ToolCallsActivity
126
+ key={`tools-${index}`}
127
+ toolCalls={segment.toolCalls}
128
+ isActive={segment.isActive}
129
+ />
169
130
  );
170
131
  }
171
132
 
@@ -193,14 +154,7 @@ function SearchAiMessageComponent({
193
154
  </ResourcesWrapper>
194
155
  </>
195
156
  )}
196
- {isLoading && (
197
- <ThinkingDotsWrapper data-testid="thinking-dots-wrapper">
198
- <ThinkingDot />
199
- <ThinkingDot />
200
- <ThinkingDot />
201
- </ThinkingDotsWrapper>
202
- )}
203
- {content.length > 0 && (
157
+ {!isThinking && content.length > 0 && (
204
158
  <FeedbackWrapper>
205
159
  <SearchAiActionButtons
206
160
  content={content}
@@ -261,7 +215,6 @@ export const SearchAiMessage = memo(SearchAiMessageComponent, (prevProps, nextPr
261
215
  prevProps.feedback === nextProps.feedback &&
262
216
  prevProps.onFeedbackChange === nextProps.onFeedbackChange &&
263
217
  areResourcesEqual(prevProps.resources, nextProps.resources) &&
264
- prevProps.toolCalls?.length === nextProps.toolCalls?.length &&
265
218
  prevProps.contentSegments === nextProps.contentSegments
266
219
  );
267
220
  });
@@ -275,9 +228,10 @@ const SearchAiMessageWrapper = styled.div<{ $role: string }>`
275
228
  $role === AiSearchConversationRole.USER ? 'flex-end' : 'flex-start'};
276
229
  `;
277
230
 
278
- const MessageContentWrapper = styled.div`
231
+ const MessageContentWrapper = styled.div<{ $isFullWidth: boolean }>`
279
232
  display: flex;
280
233
  flex-direction: column;
234
+ flex-grow: ${({ $isFullWidth }) => ($isFullWidth ? 1 : 0)};
281
235
  gap: var(--spacing-sm);
282
236
  max-width: 80%;
283
237
  min-width: 0;
@@ -300,13 +254,13 @@ const ResponseText = styled(Markdown)`
300
254
  }
301
255
  `;
302
256
 
303
- const MessageWrapper = styled.div<{ $role: string }>`
257
+ const MessageWrapper = styled.div<{ $role: string; $isFullWidth?: boolean }>`
304
258
  padding: ${({ $role }) =>
305
259
  $role === AiSearchConversationRole.USER
306
260
  ? 'var(--spacing-sm)'
307
261
  : 'var(--spacing-sm) var(--spacing-sm) var(--spacing-xs) var(--spacing-sm)'};
308
262
  border-radius: var(--border-radius-xl);
309
- width: fit-content;
263
+ width: ${({ $isFullWidth }) => ($isFullWidth ? 'auto' : 'fit-content')};
310
264
  max-width: 100%;
311
265
  word-wrap: break-word;
312
266
  white-space: pre-wrap;
@@ -327,12 +281,14 @@ const ResourcesWrapper = styled.div`
327
281
  display: flex;
328
282
  flex-direction: column;
329
283
  margin: 0;
284
+ ${searchAiAppearAnimation}
330
285
  `;
331
286
  const FeedbackWrapper = styled.div`
332
287
  display: flex;
333
288
  flex-direction: row;
334
289
  gap: var(--search-ai-feedback-gap);
335
290
  margin-top: var(--spacing-sm);
291
+ ${searchAiAppearAnimation}
336
292
  `;
337
293
 
338
294
  const ResourcesTitle = styled.div`
@@ -367,43 +323,6 @@ const ResourceTag = styled(Tag).attrs({ textTransform: 'none' })`
367
323
  }
368
324
  `;
369
325
 
370
- const ThinkingDotsWrapper = styled.div`
371
- display: flex;
372
- gap: var(--search-ai-thinking-dots-gap);
373
- padding: var(--search-ai-thinking-dots-padding);
374
- `;
375
-
376
- const ThinkingDot = styled.div`
377
- width: var(--search-ai-thinking-dot-size);
378
- height: var(--search-ai-thinking-dot-size);
379
- border-radius: 50%;
380
- background: var(--search-ai-thinking-dot-color);
381
- animation: bounce 1.4s infinite ease-in-out;
382
-
383
- &:nth-child(1) {
384
- animation-delay: -0.32s;
385
- }
386
- &:nth-child(2) {
387
- animation-delay: -0.16s;
388
- }
389
- &:nth-child(3) {
390
- animation-delay: 0s;
391
- }
392
-
393
- @keyframes bounce {
394
- 0%,
395
- 80%,
396
- 100% {
397
- opacity: 0.2;
398
- transform: scale(0.8);
399
- }
400
- 40% {
401
- opacity: 1;
402
- transform: scale(1);
403
- }
404
- }
405
- `;
406
-
407
326
  const SuccessMessageWrapper = styled.div`
408
327
  max-width: fit-content;
409
328
  display: flex;
@@ -420,37 +339,10 @@ const SuccessMessageText = styled.div`
420
339
  color: var(--color-success-darker);
421
340
  `;
422
341
 
423
- const ToolCallsInfoWrapper = styled.div`
424
- display: flex;
425
- flex-direction: column;
426
- gap: var(--spacing-xxs);
342
+ const ToolCallsActivity = styled(SearchAiActivity)`
427
343
  margin: 0 0 var(--spacing-sm) 0;
428
- font-size: var(--font-size-xs);
429
- color: var(--search-ai-text-color);
430
- opacity: 0.6;
431
- `;
432
-
433
- const ToolCallInfoItem = styled.div`
434
- display: flex;
435
- align-items: center;
436
- gap: var(--spacing-xxs);
437
- `;
438
-
439
- const ToolCallText = styled.span<{ $isSearching?: boolean }>`
440
- font-weight: var(--font-weight-regular);
441
344
 
442
- ${({ $isSearching }) =>
443
- $isSearching &&
444
- `
445
- animation: pulse 1.5s ease-in-out infinite;
446
-
447
- @keyframes pulse {
448
- 0%, 100% {
449
- opacity: 1;
450
- }
451
- 50% {
452
- opacity: 0.4;
453
- }
454
- }
455
- `}
345
+ &:last-child {
346
+ margin-bottom: var(--spacing-xxs);
347
+ }
456
348
  `;
@@ -19,6 +19,7 @@ import {
19
19
  useSearchFilter,
20
20
  useRecentSearches,
21
21
  useModalScrollLock,
22
+ usePersistedQuickFilter,
22
23
  } from '@redocly/theme/core/hooks';
23
24
  import { Tag } from '@redocly/theme/components/Tag/Tag';
24
25
  import { CloseIcon } from '@redocly/theme/icons/CloseIcon/CloseIcon';
@@ -81,6 +82,16 @@ export function SearchDialog({
81
82
  onFacetReset,
82
83
  onQuickFilterReset,
83
84
  } = useSearchFilter(filter, setFilter);
85
+ usePersistedQuickFilter({
86
+ filter,
87
+ setFilter,
88
+ facets,
89
+ groupField,
90
+ query,
91
+ isSearchLoading,
92
+ searchError,
93
+ enabled: initialMode === 'search',
94
+ });
84
95
  const { addSearchHistoryItem } = useRecentSearches();
85
96
  const aiSearch = useAiSearch({ filter: filter, product: product?.name });
86
97
  useModalScrollLock(true);
@@ -9,7 +9,7 @@ import { Link } from '@redocly/theme/components/Link/Link';
9
9
  import { Image } from '@redocly/theme/components/Image/Image';
10
10
  import { searchHighlight } from '@redocly/theme/components/Search/SearchHighlight';
11
11
  import { Badge } from '@redocly/theme/components/Badge/Badge';
12
- import { trimText, getOperationColor } from '@redocly/theme/core/utils';
12
+ import { assignRef, trimText, getOperationColor } from '@redocly/theme/core/utils';
13
13
  import { ReturnKeyIcon } from '@redocly/theme/icons/ReturnKeyIcon/ReturnKeyIcon';
14
14
  import { useThemeHooks } from '@redocly/theme/core/hooks';
15
15
 
@@ -61,14 +61,7 @@ export function SearchItem({
61
61
  tabIndex={0}
62
62
  innerRef={(el: HTMLAnchorElement) => {
63
63
  ref.current = el;
64
-
65
- if (!innerRef) return;
66
-
67
- if (typeof innerRef === 'function') {
68
- innerRef(el);
69
- } else {
70
- innerRef.current = el;
71
- }
64
+ assignRef(innerRef, el);
72
65
  }}
73
66
  data-component-name="Search/SearchItem"
74
67
  >
@@ -199,8 +199,21 @@ export const search = css`
199
199
  --search-ai-suggestions-title-font-weight: var(--font-weight-light);
200
200
  --search-ai-suggestions-text-color: var(--text-color-description);
201
201
 
202
- --search-ai-thinking-dots-gap: 4px;
203
- --search-ai-thinking-dots-padding: 4px 0;
202
+ --search-ai-activity-font-size: var(--font-size-base);
203
+ --search-ai-activity-line-height: var(--line-height-sm);
204
+ --search-ai-activity-count-font-size: var(--font-size-sm);
205
+ --search-ai-activity-count-bg-color: var(--border-color-secondary);
206
+ --search-ai-activity-text-color: var(--text-color-description);
207
+ --search-ai-activity-text-color-hover: var(--text-color-primary);
208
+ --search-ai-activity-detail-color: var(--text-color-secondary);
209
+ --search-ai-activity-shimmer-color: var(--text-color-primary);
210
+ --search-ai-activity-rail-color: var(--border-color-primary);
211
+ --search-ai-activity-icon-size: 14px;
212
+ --search-ai-activity-fade-duration: 200ms;
213
+ --search-ai-activity-expand-duration: 250ms;
214
+ --search-ai-activity-shimmer-duration: 2s;
215
+ --search-ai-activity-easing: cubic-bezier(0.32, 0.72, 0, 1);
216
+
204
217
  --search-ai-thinking-dot-size: 6px;
205
218
  --search-ai-thinking-dot-color: var(--search-ai-gradient);
206
219