@vertesia/ui 1.4.0 → 1.4.1

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 (196) hide show
  1. package/lib/core/components/SidePanel.js +1 -1
  2. package/lib/core/components/SidePanel.js.map +1 -1
  3. package/lib/core/components/shadcn/button.d.ts +10 -3
  4. package/lib/core/components/shadcn/button.d.ts.map +1 -1
  5. package/lib/core/components/shadcn/button.js +41 -9
  6. package/lib/core/components/shadcn/button.js.map +1 -1
  7. package/lib/core/components/shadcn/modal/dialog.js +1 -1
  8. package/lib/core/components/shadcn/modal/dialog.js.map +1 -1
  9. package/lib/core/components/shadcn/resizeable.d.ts +1 -1
  10. package/lib/core/components/shadcn/resizeable.d.ts.map +1 -1
  11. package/lib/core/components/shadcn/resizeable.js +12 -2
  12. package/lib/core/components/shadcn/resizeable.js.map +1 -1
  13. package/lib/core/components/shadcn/tabs.d.ts +12 -1
  14. package/lib/core/components/shadcn/tabs.d.ts.map +1 -1
  15. package/lib/core/components/shadcn/tabs.js +12 -4
  16. package/lib/core/components/shadcn/tabs.js.map +1 -1
  17. package/lib/core/components/shadcn/theme/ThemeSwitcher.js +1 -1
  18. package/lib/core/components/shadcn/theme/ThemeSwitcher.js.map +1 -1
  19. package/lib/core/hooks/useIntersectionObserver.d.ts.map +1 -1
  20. package/lib/core/hooks/useIntersectionObserver.js +3 -1
  21. package/lib/core/hooks/useIntersectionObserver.js.map +1 -1
  22. package/lib/features/agent/chat/AgentChatPlaybackControls.d.ts.map +1 -1
  23. package/lib/features/agent/chat/AgentChatPlaybackControls.js +2 -2
  24. package/lib/features/agent/chat/AgentChatPlaybackControls.js.map +1 -1
  25. package/lib/features/agent/chat/AgentRequestInputOverlay.d.ts.map +1 -1
  26. package/lib/features/agent/chat/AgentRequestInputOverlay.js +2 -2
  27. package/lib/features/agent/chat/AgentRequestInputOverlay.js.map +1 -1
  28. package/lib/features/agent/chat/AgentRightPanel.js +1 -1
  29. package/lib/features/agent/chat/AgentRightPanel.js.map +1 -1
  30. package/lib/features/agent/chat/AskUserWidget.d.ts.map +1 -1
  31. package/lib/features/agent/chat/AskUserWidget.js +4 -3
  32. package/lib/features/agent/chat/AskUserWidget.js.map +1 -1
  33. package/lib/features/agent/chat/ImageLightbox.d.ts.map +1 -1
  34. package/lib/features/agent/chat/ImageLightbox.js +1 -1
  35. package/lib/features/agent/chat/ImageLightbox.js.map +1 -1
  36. package/lib/features/agent/chat/ModernAgentConversation.d.ts.map +1 -1
  37. package/lib/features/agent/chat/ModernAgentConversation.js +57 -12
  38. package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
  39. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts +8 -1
  40. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts.map +1 -1
  41. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js +45 -36
  42. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js.map +1 -1
  43. package/lib/features/agent/chat/ModernAgentOutput/Header.d.ts.map +1 -1
  44. package/lib/features/agent/chat/ModernAgentOutput/Header.js +4 -2
  45. package/lib/features/agent/chat/ModernAgentOutput/Header.js.map +1 -1
  46. package/lib/features/agent/chat/ModernAgentOutput/MessageInput.d.ts.map +1 -1
  47. package/lib/features/agent/chat/ModernAgentOutput/MessageInput.js +7 -3
  48. package/lib/features/agent/chat/ModernAgentOutput/MessageInput.js.map +1 -1
  49. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.d.ts.map +1 -1
  50. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js +2 -1
  51. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js.map +1 -1
  52. package/lib/features/agent/chat/ModernAgentOutput/WorkstreamTabs.d.ts.map +1 -1
  53. package/lib/features/agent/chat/ModernAgentOutput/WorkstreamTabs.js +158 -8
  54. package/lib/features/agent/chat/ModernAgentOutput/WorkstreamTabs.js.map +1 -1
  55. package/lib/features/agent/chat/ModernAgentOutput/requestInputMessages.d.ts +6 -0
  56. package/lib/features/agent/chat/ModernAgentOutput/requestInputMessages.d.ts.map +1 -1
  57. package/lib/features/agent/chat/ModernAgentOutput/requestInputMessages.js +15 -1
  58. package/lib/features/agent/chat/ModernAgentOutput/requestInputMessages.js.map +1 -1
  59. package/lib/features/agent/chat/hooks/useFileProcessing.d.ts +1 -0
  60. package/lib/features/agent/chat/hooks/useFileProcessing.d.ts.map +1 -1
  61. package/lib/features/agent/chat/hooks/useFileProcessing.js +37 -3
  62. package/lib/features/agent/chat/hooks/useFileProcessing.js.map +1 -1
  63. package/lib/features/magic-pdf/AnnotatedImageSlider.js +13 -13
  64. package/lib/features/magic-pdf/AnnotatedImageSlider.js.map +1 -1
  65. package/lib/features/magic-pdf/DownloadPopover.js +2 -2
  66. package/lib/features/magic-pdf/DownloadPopover.js.map +1 -1
  67. package/lib/features/magic-pdf/MagicPdfView.js +3 -3
  68. package/lib/features/magic-pdf/MagicPdfView.js.map +1 -1
  69. package/lib/features/oauth/McpConnectionsButton.d.ts.map +1 -1
  70. package/lib/features/oauth/McpConnectionsButton.js +2 -2
  71. package/lib/features/oauth/McpConnectionsButton.js.map +1 -1
  72. package/lib/features/oauth/McpConnectionsDialog.d.ts.map +1 -1
  73. package/lib/features/oauth/McpConnectionsDialog.js +1 -1
  74. package/lib/features/oauth/McpConnectionsDialog.js.map +1 -1
  75. package/lib/features/oauth/RemoteMcpConnectionButton.d.ts.map +1 -1
  76. package/lib/features/oauth/RemoteMcpConnectionButton.js +1 -1
  77. package/lib/features/oauth/RemoteMcpConnectionButton.js.map +1 -1
  78. package/lib/features/pdf-viewer/PdfPageSlider.js +2 -2
  79. package/lib/features/pdf-viewer/PdfPageSlider.js.map +1 -1
  80. package/lib/features/pdf-viewer/SimplePdfViewer.d.ts.map +1 -1
  81. package/lib/features/pdf-viewer/SimplePdfViewer.js +3 -3
  82. package/lib/features/pdf-viewer/SimplePdfViewer.js.map +1 -1
  83. package/lib/features/store/objects/DocumentQuickFilter.d.ts.map +1 -1
  84. package/lib/features/store/objects/DocumentQuickFilter.js +5 -5
  85. package/lib/features/store/objects/DocumentQuickFilter.js.map +1 -1
  86. package/lib/features/store/objects/DocumentSearchResults.d.ts.map +1 -1
  87. package/lib/features/store/objects/DocumentSearchResults.js +2 -2
  88. package/lib/features/store/objects/DocumentSearchResults.js.map +1 -1
  89. package/lib/features/store/objects/components/ContentDispositionButton.d.ts.map +1 -1
  90. package/lib/features/store/objects/components/ContentDispositionButton.js +1 -1
  91. package/lib/features/store/objects/components/ContentDispositionButton.js.map +1 -1
  92. package/lib/features/store/objects/components/ContentOverview.js +3 -3
  93. package/lib/features/store/objects/components/ContentOverview.js.map +1 -1
  94. package/lib/features/store/objects/components/DocumentIcon.js +1 -1
  95. package/lib/features/store/objects/components/VectorSearchWidget.js +1 -1
  96. package/lib/features/store/objects/components/VectorSearchWidget.js.map +1 -1
  97. package/lib/features/store/objects/layout/renderers.d.ts.map +1 -1
  98. package/lib/features/store/objects/layout/renderers.js +2 -2
  99. package/lib/features/store/objects/layout/renderers.js.map +1 -1
  100. package/lib/features/store/objects/selection/SelectionActions.js +1 -1
  101. package/lib/features/store/objects/selection/SelectionActions.js.map +1 -1
  102. package/lib/features/store/objects/selection/actions/StartWorkflowComponent.js +4 -4
  103. package/lib/features/store/objects/selection/actions/StartWorkflowComponent.js.map +1 -1
  104. package/lib/features/store/types/ContentObjectTypesTable.d.ts.map +1 -1
  105. package/lib/features/store/types/ContentObjectTypesTable.js +4 -4
  106. package/lib/features/store/types/ContentObjectTypesTable.js.map +1 -1
  107. package/lib/features/store/types/SelectContentTypeModal.js +1 -1
  108. package/lib/features/store/types/SelectContentTypeModal.js.map +1 -1
  109. package/lib/features/user/UserInfo.js +7 -3
  110. package/lib/features/user/UserInfo.js.map +1 -1
  111. package/lib/router/HistoryNavigator.d.ts.map +1 -1
  112. package/lib/router/HistoryNavigator.js +5 -0
  113. package/lib/router/HistoryNavigator.js.map +1 -1
  114. package/lib/router/Nav.d.ts.map +1 -1
  115. package/lib/router/Nav.js +9 -1
  116. package/lib/router/Nav.js.map +1 -1
  117. package/lib/vertesia-ui-core.js +1 -1
  118. package/lib/vertesia-ui-core.js.map +1 -1
  119. package/lib/vertesia-ui-features.js +1 -1
  120. package/lib/vertesia-ui-features.js.map +1 -1
  121. package/lib/vertesia-ui-router.js +1 -1
  122. package/lib/vertesia-ui-router.js.map +1 -1
  123. package/lib/vertesia-ui-widgets.js +1 -1
  124. package/lib/vertesia-ui-widgets.js.map +1 -1
  125. package/lib/widgets/form/Form.js +1 -1
  126. package/lib/widgets/form/Form.js.map +1 -1
  127. package/lib/widgets/json-view/JSONSwitcher.js +1 -1
  128. package/lib/widgets/json-view/JSONSwitcher.js.map +1 -1
  129. package/lib/widgets/json-view/JSONView.js +6 -6
  130. package/lib/widgets/json-view/JSONView.js.map +1 -1
  131. package/lib/widgets/monacoEditor/MonacoEditor.d.ts.map +1 -1
  132. package/lib/widgets/monacoEditor/MonacoEditor.js +29 -0
  133. package/lib/widgets/monacoEditor/MonacoEditor.js.map +1 -1
  134. package/lib/widgets/xml-viewer/hooks/useXMLViewer.d.ts +9 -0
  135. package/lib/widgets/xml-viewer/hooks/useXMLViewer.d.ts.map +1 -1
  136. package/lib/widgets/xml-viewer/hooks/useXMLViewer.js +20 -17
  137. package/lib/widgets/xml-viewer/hooks/useXMLViewer.js.map +1 -1
  138. package/package.json +29 -30
  139. package/src/__tests__/a11y.test.tsx +73 -0
  140. package/src/core/components/SidePanel.tsx +1 -1
  141. package/src/core/components/shadcn/button.tsx +67 -12
  142. package/src/core/components/shadcn/modal/dialog.tsx +1 -1
  143. package/src/core/components/shadcn/resizeable.test.tsx +77 -0
  144. package/src/core/components/shadcn/resizeable.tsx +28 -2
  145. package/src/core/components/shadcn/tabs.test.tsx +65 -0
  146. package/src/core/components/shadcn/tabs.tsx +25 -3
  147. package/src/core/components/shadcn/theme/ThemeSwitcher.tsx +1 -1
  148. package/src/core/hooks/useIntersectionObserver.tsx +3 -1
  149. package/src/features/agent/chat/AgentChatPlaybackControls.tsx +0 -5
  150. package/src/features/agent/chat/AgentRequestInputOverlay.test.tsx +31 -0
  151. package/src/features/agent/chat/AgentRequestInputOverlay.tsx +6 -2
  152. package/src/features/agent/chat/AgentRightPanel.tsx +1 -1
  153. package/src/features/agent/chat/AskUserWidget.test.tsx +49 -0
  154. package/src/features/agent/chat/AskUserWidget.tsx +32 -22
  155. package/src/features/agent/chat/ImageLightbox.tsx +0 -1
  156. package/src/features/agent/chat/ModernAgentConversation.test.tsx +246 -1
  157. package/src/features/agent/chat/ModernAgentConversation.tsx +62 -17
  158. package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.test.tsx +6 -1
  159. package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.tsx +81 -49
  160. package/src/features/agent/chat/ModernAgentOutput/Header.tsx +3 -3
  161. package/src/features/agent/chat/ModernAgentOutput/MessageInput.test.tsx +25 -0
  162. package/src/features/agent/chat/ModernAgentOutput/MessageInput.tsx +5 -3
  163. package/src/features/agent/chat/ModernAgentOutput/MessageItem.tsx +4 -1
  164. package/src/features/agent/chat/ModernAgentOutput/WorkstreamTabs.tsx +328 -42
  165. package/src/features/agent/chat/ModernAgentOutput/requestInputMessages.test.ts +80 -0
  166. package/src/features/agent/chat/ModernAgentOutput/requestInputMessages.ts +21 -1
  167. package/src/features/agent/chat/hooks/useFileProcessing.test.tsx +70 -0
  168. package/src/features/agent/chat/hooks/useFileProcessing.ts +37 -6
  169. package/src/features/magic-pdf/AnnotatedImageSlider.tsx +62 -66
  170. package/src/features/magic-pdf/DownloadPopover.tsx +2 -2
  171. package/src/features/magic-pdf/MagicPdfView.tsx +3 -3
  172. package/src/features/oauth/McpConnectionsButton.tsx +0 -2
  173. package/src/features/oauth/McpConnectionsDialog.tsx +0 -1
  174. package/src/features/oauth/RemoteMcpConnectionButton.tsx +0 -1
  175. package/src/features/pdf-viewer/PdfPageSlider.tsx +5 -5
  176. package/src/features/pdf-viewer/SimplePdfViewer.tsx +11 -12
  177. package/src/features/store/objects/DocumentQuickFilter.tsx +13 -15
  178. package/src/features/store/objects/DocumentSearchResults.tsx +3 -6
  179. package/src/features/store/objects/components/ContentDispositionButton.tsx +5 -1
  180. package/src/features/store/objects/components/ContentOverview.tsx +11 -11
  181. package/src/features/store/objects/components/DocumentIcon.tsx +1 -1
  182. package/src/features/store/objects/components/VectorSearchWidget.tsx +2 -2
  183. package/src/features/store/objects/layout/renderers.tsx +1 -2
  184. package/src/features/store/objects/selection/SelectionActions.tsx +1 -1
  185. package/src/features/store/objects/selection/actions/StartWorkflowComponent.tsx +6 -5
  186. package/src/features/store/types/ContentObjectTypesTable.tsx +12 -14
  187. package/src/features/store/types/SelectContentTypeModal.tsx +2 -2
  188. package/src/features/user/UserInfo.tsx +7 -7
  189. package/src/router/HistoryNavigator.ts +5 -0
  190. package/src/router/Nav.tsx +12 -3
  191. package/src/widgets/form/Form.tsx +1 -1
  192. package/src/widgets/json-view/JSONSwitcher.tsx +2 -2
  193. package/src/widgets/json-view/JSONView.tsx +7 -7
  194. package/src/widgets/monacoEditor/MonacoEditor.tsx +29 -0
  195. package/src/widgets/xml-viewer/hooks/useXMLViewer.test.ts +26 -0
  196. package/src/widgets/xml-viewer/hooks/useXMLViewer.ts +23 -18
@@ -1,7 +1,8 @@
1
1
  import type { AgentMessage } from '@vertesia/common';
2
- import { Button, cn } from '@vertesia/ui/core';
2
+ import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@vertesia/ui/core';
3
3
  import { i18nInstance, NAMESPACE, useUITranslation } from '@vertesia/ui/i18n';
4
- import { CheckCircle, Clock } from 'lucide-react';
4
+ import { CheckCircle, ChevronDown, Clock } from 'lucide-react';
5
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
5
6
  import { getWorkstreamId } from './utils';
6
7
 
7
8
  interface WorkstreamTabsProps {
@@ -12,6 +13,324 @@ interface WorkstreamTabsProps {
12
13
  completionStatus?: Map<string, boolean>; // Optional completion status per workstream
13
14
  }
14
15
 
16
+ // Tab-item styling for the header workstream bar: filled `bg-info` active pill with
17
+ // count badges and completion icons (distinct from the right panel's underline tabs).
18
+ const TAB_ITEM_BASE =
19
+ 'flex items-center gap-1.5 px-2 py-1 text-xs font-medium whitespace-nowrap transition-colors border-b-2 shrink-0 cursor-pointer';
20
+ const TAB_ITEM_INACTIVE = 'border-transparent text-muted hover:bg-muted';
21
+ const TAB_ITEM_ACTIVE = 'border-info bg-info text-info';
22
+ const TAB_GAP_PX = 4; // matches the `gap-1` between tab items
23
+
24
+ // Shorten long workstream names for the tab row.
25
+ function truncateName(name: string) {
26
+ return name.length > 20 ? `${name.substring(0, 18)}...` : name;
27
+ }
28
+
29
+ type WorkstreamEntry = [id: string, name: string];
30
+
31
+ interface WorkstreamMoreMenuProps {
32
+ /** The overflowed workstream entries to list inside the menu. */
33
+ items: WorkstreamEntry[];
34
+ current: string;
35
+ onSelect: (id: string) => void;
36
+ label: string;
37
+ /** Whether the active workstream is one of the overflowed entries. */
38
+ active: boolean;
39
+ count?: Map<string, number>;
40
+ completionStatus?: Map<string, boolean>;
41
+ }
42
+
43
+ /** Trailing "More" dropdown of overflowed workstream tabs; opens on hover, click, or keyboard. */
44
+ function WorkstreamMoreMenu({
45
+ items,
46
+ current,
47
+ onSelect,
48
+ label,
49
+ active,
50
+ count,
51
+ completionStatus,
52
+ }: WorkstreamMoreMenuProps) {
53
+ const [open, setOpen] = useState(false);
54
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
55
+ const openedByHover = useRef(false);
56
+
57
+ const cancelClose = () => {
58
+ if (closeTimer.current) {
59
+ clearTimeout(closeTimer.current);
60
+ closeTimer.current = null;
61
+ }
62
+ };
63
+ const openOnHover = () => {
64
+ cancelClose();
65
+ openedByHover.current = true;
66
+ setOpen(true);
67
+ };
68
+ // Delay the close so the pointer can travel across the gap onto the menu.
69
+ const closeAfterDelay = () => {
70
+ cancelClose();
71
+ closeTimer.current = setTimeout(() => setOpen(false), 150);
72
+ };
73
+
74
+ // Clear any pending close timer on unmount.
75
+ useEffect(() => () => clearTimeout(closeTimer.current ?? undefined), []);
76
+
77
+ return (
78
+ // Non-modal: a modal menu sets body `pointer-events: none` while open, which makes
79
+ // the hover open/close flap. It still closes on outside-click / Escape.
80
+ <DropdownMenu
81
+ modal={false}
82
+ open={open}
83
+ onOpenChange={(next) => {
84
+ // Fired by click / keyboard / dismiss (not by hover); keep our state in sync.
85
+ cancelClose();
86
+ if (next) openedByHover.current = false;
87
+ setOpen(next);
88
+ }}
89
+ >
90
+ <DropdownMenuTrigger asChild>
91
+ {/* Tab-bar primitive: raw button is the menu trigger (asChild). */}
92
+ <button
93
+ type="button"
94
+ onMouseEnter={openOnHover}
95
+ onMouseLeave={closeAfterDelay}
96
+ className={cn(TAB_ITEM_BASE, active ? TAB_ITEM_ACTIVE : TAB_ITEM_INACTIVE)}
97
+ >
98
+ {label}
99
+ <ChevronDown className="ms-0.5 size-3.5" />
100
+ </button>
101
+ </DropdownMenuTrigger>
102
+ <DropdownMenuContent
103
+ align="end"
104
+ className="w-max"
105
+ onMouseEnter={cancelClose}
106
+ onMouseLeave={closeAfterDelay}
107
+ onCloseAutoFocus={(e) => {
108
+ // Keep hover-opens from returning the focus ring to the trigger.
109
+ if (openedByHover.current) e.preventDefault();
110
+ }}
111
+ >
112
+ {items.map(([id, name]) => {
113
+ // biome-ignore lint/style/noNonNullAssertion: guarded by count?.has(id).
114
+ const showCount = count?.has(id) && count.get(id)! > 0;
115
+ return (
116
+ <DropdownMenuItem
117
+ key={id}
118
+ onClick={() => onSelect(id)}
119
+ className={cn('flex items-center gap-2', id === current && 'text-info')}
120
+ >
121
+ <span className="truncate">{name}</span>
122
+ {showCount && (
123
+ <span className="ms-auto inline-flex items-center gap-1">
124
+ <span className="inline-flex items-center justify-center rounded-full bg-muted px-1.5 text-[10px] text-muted">
125
+ {count?.get(id)}
126
+ </span>
127
+ {completionStatus &&
128
+ id !== 'all' &&
129
+ (completionStatus.get(id) ? (
130
+ <CheckCircle className="size-3 text-success" />
131
+ ) : (
132
+ <Clock className="size-3 text-attention" />
133
+ ))}
134
+ </span>
135
+ )}
136
+ </DropdownMenuItem>
137
+ );
138
+ })}
139
+ </DropdownMenuContent>
140
+ </DropdownMenu>
141
+ );
142
+ }
143
+
144
+ interface WorkstreamOverflowBarProps {
145
+ entries: WorkstreamEntry[];
146
+ activeWorkstream: string;
147
+ onSelectWorkstream: (id: string) => void;
148
+ count?: Map<string, number>;
149
+ completionStatus?: Map<string, boolean>;
150
+ }
151
+
152
+ /**
153
+ * Workstream tabs as a horizontal row; any that don't fit collapse into a trailing
154
+ * "More" dropdown. The visible/overflow split is measured from a hidden full-width row,
155
+ * and the active tab is always promoted into view so it can never hide in the menu.
156
+ */
157
+ function WorkstreamOverflowBar({
158
+ entries,
159
+ activeWorkstream,
160
+ onSelectWorkstream,
161
+ count,
162
+ completionStatus,
163
+ }: WorkstreamOverflowBarProps) {
164
+ const { t } = useUITranslation();
165
+ const containerRef = useRef<HTMLDivElement | null>(null);
166
+ const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
167
+ const moreRef = useRef<HTMLButtonElement | null>(null);
168
+ // `count` leading tabs are shown. When `promote` is set, the active tab is pulled
169
+ // into the last slot (before More) and `count` counts the tabs shown before it.
170
+ const [layout, setLayout] = useState<{ count: number; promote: boolean }>({
171
+ count: entries.length,
172
+ promote: false,
173
+ });
174
+
175
+ const recompute = () => {
176
+ const container = containerRef.current;
177
+ if (!container) return;
178
+ const containerWidth = container.clientWidth;
179
+ const widths = entries.map((_, i) => itemRefs.current[i]?.offsetWidth ?? 0);
180
+ const totalAll = widths.reduce((sum, w) => sum + w, 0) + TAB_GAP_PX * Math.max(0, entries.length - 1);
181
+
182
+ // How many tabs (in order, optionally skipping one) fit within `available`.
183
+ const fitCount = (available: number, skipIndex: number) => {
184
+ let used = 0;
185
+ let fitted = 0;
186
+ for (let i = 0; i < entries.length; i++) {
187
+ if (i === skipIndex) continue;
188
+ const cand = used + (fitted > 0 ? TAB_GAP_PX : 0) + widths[i];
189
+ if (cand > available) break;
190
+ used = cand;
191
+ fitted += 1;
192
+ }
193
+ return fitted;
194
+ };
195
+
196
+ let next: { count: number; promote: boolean };
197
+ if (totalAll <= containerWidth) {
198
+ next = { count: entries.length, promote: false };
199
+ } else {
200
+ const moreWidth = moreRef.current?.offsetWidth ?? 0;
201
+ const naturalCount = Math.max(1, fitCount(containerWidth - moreWidth - TAB_GAP_PX, -1));
202
+ const activeIndex = entries.findIndex(([id]) => id === activeWorkstream);
203
+ if (activeIndex < 0 || activeIndex < naturalCount) {
204
+ next = { count: naturalCount, promote: false };
205
+ } else {
206
+ // Active tab overflowed: reserve its slot at the end, fit leading tabs before it.
207
+ const leadAvailable = containerWidth - moreWidth - widths[activeIndex] - TAB_GAP_PX * 2;
208
+ next = { count: fitCount(leadAvailable, activeIndex), promote: true };
209
+ }
210
+ }
211
+ setLayout((prev) => (prev.count === next.count && prev.promote === next.promote ? prev : next));
212
+ };
213
+
214
+ // Re-measure after every render (tab/label changes) and on container resize.
215
+ const recomputeRef = useRef(recompute);
216
+ recomputeRef.current = recompute;
217
+ useLayoutEffect(() => {
218
+ recomputeRef.current();
219
+ });
220
+ useEffect(() => {
221
+ const container = containerRef.current;
222
+ if (!container || typeof ResizeObserver === 'undefined') return;
223
+ const observer = new ResizeObserver(() => recomputeRef.current());
224
+ observer.observe(container);
225
+ return () => observer.disconnect();
226
+ }, []);
227
+
228
+ const activeIndex = entries.findIndex(([id]) => id === activeWorkstream);
229
+ let visible: WorkstreamEntry[];
230
+ let overflow: WorkstreamEntry[];
231
+ if (layout.promote && activeIndex >= 0) {
232
+ // Pull the active tab into the last visible slot; the tab it displaces overflows.
233
+ const others = entries.filter((_, i) => i !== activeIndex);
234
+ visible = [...others.slice(0, layout.count), entries[activeIndex]];
235
+ overflow = others.slice(layout.count);
236
+ } else {
237
+ visible = entries.slice(0, layout.count);
238
+ overflow = entries.slice(layout.count);
239
+ }
240
+ const activeInOverflow = overflow.some(([id]) => id === activeWorkstream);
241
+ const moreLabel = t('agent.moreTabs');
242
+
243
+ // Inner tab content (name + count badge + completion icon), shared by the hidden
244
+ // measurement row and the visible row so their measured widths match.
245
+ const renderTabInner = (id: string, name: string, isActive: boolean) => {
246
+ // biome-ignore lint/style/noNonNullAssertion: guarded by count?.has(id).
247
+ const showCount = count?.has(id) && count.get(id)! > 0;
248
+ return (
249
+ <>
250
+ {truncateName(name)}
251
+ {showCount && (
252
+ <span className="flex items-center gap-1">
253
+ <span
254
+ className={cn(
255
+ 'inline-flex items-center justify-center p-1 text-xs rounded-full',
256
+ isActive ? 'bg-info text-info' : 'bg-muted text-muted',
257
+ )}
258
+ >
259
+ {count?.get(id)}
260
+ </span>
261
+ {/* Show completion status indicator if we have it and it's not 'all' */}
262
+ {completionStatus &&
263
+ id !== 'all' &&
264
+ (completionStatus.get(id) ? (
265
+ <CheckCircle className="size-3 text-success" />
266
+ ) : (
267
+ <Clock className="size-3 text-attention" />
268
+ ))}
269
+ </span>
270
+ )}
271
+ </>
272
+ );
273
+ };
274
+
275
+ return (
276
+ <div ref={containerRef} className="relative mb-1 bg-muted border-b border-muted/20">
277
+ {/* Hidden measurement row: all tabs + More at natural width, kept separate
278
+ from the visible row so measuring can't feed back into the layout. */}
279
+ <div aria-hidden className="pointer-events-none invisible absolute start-0 top-0 flex w-max gap-1">
280
+ {entries.map(([id, name], i) => (
281
+ <button
282
+ type="button"
283
+ key={id}
284
+ tabIndex={-1}
285
+ ref={(el) => {
286
+ itemRefs.current[i] = el;
287
+ }}
288
+ className={cn(TAB_ITEM_BASE, TAB_ITEM_INACTIVE)}
289
+ >
290
+ {renderTabInner(id, name, false)}
291
+ </button>
292
+ ))}
293
+ <button type="button" tabIndex={-1} ref={moreRef} className={cn(TAB_ITEM_BASE, TAB_ITEM_INACTIVE)}>
294
+ {moreLabel}
295
+ <ChevronDown className="ms-0.5 size-3.5" />
296
+ </button>
297
+ </div>
298
+
299
+ {/* Visible row */}
300
+ <div className="flex gap-1 overflow-hidden">
301
+ {visible.map(([id, name]) => {
302
+ const isActive = activeWorkstream === id;
303
+ return (
304
+ // Tab-bar primitive: raw button mirrors the filled active-pill styling.
305
+ <button
306
+ type="button"
307
+ key={id}
308
+ aria-current={isActive ? 'page' : undefined}
309
+ onClick={() => onSelectWorkstream(id)}
310
+ title={name.length > 20 ? name : undefined}
311
+ className={cn(TAB_ITEM_BASE, isActive ? TAB_ITEM_ACTIVE : TAB_ITEM_INACTIVE)}
312
+ >
313
+ {renderTabInner(id, name, isActive)}
314
+ </button>
315
+ );
316
+ })}
317
+
318
+ {overflow.length > 0 && (
319
+ <WorkstreamMoreMenu
320
+ items={overflow}
321
+ current={activeWorkstream}
322
+ onSelect={onSelectWorkstream}
323
+ label={moreLabel}
324
+ active={activeInOverflow}
325
+ count={count}
326
+ completionStatus={completionStatus}
327
+ />
328
+ )}
329
+ </div>
330
+ </div>
331
+ );
332
+ }
333
+
15
334
  /**
16
335
  * Component that displays tabs for different workstreams
17
336
  */
@@ -58,46 +377,13 @@ export default function WorkstreamTabs({
58
377
  }
59
378
 
60
379
  return (
61
- <div className="flex overflow-x-auto gap-1 mb-1 bg-muted border-b border-muted/20 sticky top-0 z-10">
62
- {sortedWorkstreams.map(([id, name]) => (
63
- <Button
64
- variant="unstyled"
65
- key={id}
66
- className={cn(
67
- 'px-2 py-1 text-xs font-medium whitespace-nowrap transition-colors flex items-center gap-1.5',
68
- activeWorkstream === id
69
- ? 'bg-info text-info border-b-2 border-info'
70
- : 'text-muted hover:bg-muted border-b-2 border-transparent',
71
- )}
72
- onClick={() => onSelectWorkstream(id)}
73
- title={name.length > 20 ? name : undefined}
74
- >
75
- {/* Shorten long names for better UI */}
76
- {name.length > 20 ? `${name.substring(0, 18)}...` : name}
77
- {/* biome-ignore lint/style/noNonNullAssertion: intentional non-null assertion; TS can't prove narrowing here */}
78
- {count?.has(id) && count.get(id)! > 0 && (
79
- <div className="flex items-center gap-1">
80
- <span
81
- className={cn(
82
- 'inline-flex items-center justify-center p-1 text-xs rounded-full',
83
- activeWorkstream === id ? 'bg-info text-info' : 'bg-muted text-muted',
84
- )}
85
- >
86
- {count.get(id)}
87
- </span>
88
- {/* Show completion status indicator if we have it and it's not 'all' */}
89
- {completionStatus &&
90
- id !== 'all' &&
91
- (completionStatus.get(id) ? (
92
- <CheckCircle className="size-3 text-success" />
93
- ) : (
94
- <Clock className="size-3 text-attention" />
95
- ))}
96
- </div>
97
- )}
98
- </Button>
99
- ))}
100
- </div>
380
+ <WorkstreamOverflowBar
381
+ entries={sortedWorkstreams}
382
+ activeWorkstream={activeWorkstream}
383
+ onSelectWorkstream={onSelectWorkstream}
384
+ count={count}
385
+ completionStatus={completionStatus}
386
+ />
101
387
  );
102
388
  }
103
389
 
@@ -0,0 +1,80 @@
1
+ import { type AgentMessage, AgentMessageType } from '@vertesia/common';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ getAnsweredToolApprovalRequestInputKeys,
5
+ getRequestInputMessageKey,
6
+ getToolApprovalResponseMetadata,
7
+ } from './requestInputMessages';
8
+
9
+ const APPROVAL_KEY = 'create_interaction:name:JDE AR Payment Processing Agent';
10
+
11
+ function makeApprovalRequest(): AgentMessage {
12
+ return {
13
+ timestamp: 1,
14
+ type: AgentMessageType.REQUEST_INPUT,
15
+ message: 'Approve Create a new draft interaction: JDE AR Payment Processing Agent?',
16
+ workstream_id: 'main',
17
+ details: {
18
+ tool_approval: { tool_name: 'create_interaction', approval_key: APPROVAL_KEY },
19
+ ux: { options: [{ id: 'allow_once' }, { id: 'allow_for_run' }, { id: 'deny' }] },
20
+ },
21
+ } as unknown as AgentMessage;
22
+ }
23
+
24
+ function makeUserAnswer(message: string, metadata?: Record<string, unknown>): AgentMessage {
25
+ return {
26
+ timestamp: 2,
27
+ type: AgentMessageType.QUESTION,
28
+ message,
29
+ workstream_id: 'main',
30
+ details: metadata ? { metadata } : undefined,
31
+ } as unknown as AgentMessage;
32
+ }
33
+
34
+ describe('getToolApprovalResponseMetadata', () => {
35
+ it('builds structured response metadata for approval options', () => {
36
+ expect(getToolApprovalResponseMetadata(makeApprovalRequest(), 'allow_for_run')).toEqual({
37
+ tool_approval_response: { decision: 'allow_for_run', approval_key: APPROVAL_KEY },
38
+ });
39
+ });
40
+
41
+ it('returns undefined for prompts that are not tool approvals', () => {
42
+ const plainPrompt = {
43
+ timestamp: 1,
44
+ type: AgentMessageType.REQUEST_INPUT,
45
+ message: 'Which environment?',
46
+ workstream_id: 'main',
47
+ details: { ux: { options: [{ id: 'staging' }, { id: 'production' }] } },
48
+ } as unknown as AgentMessage;
49
+ expect(getToolApprovalResponseMetadata(plainPrompt, 'staging')).toBeUndefined();
50
+ });
51
+
52
+ it('returns undefined for unknown option ids', () => {
53
+ expect(getToolApprovalResponseMetadata(makeApprovalRequest(), 'staging')).toBeUndefined();
54
+ });
55
+ });
56
+
57
+ describe('getAnsweredToolApprovalRequestInputKeys', () => {
58
+ it('treats a bare option answer as answered', () => {
59
+ const request = makeApprovalRequest();
60
+ const answer = makeUserAnswer('allow_for_run');
61
+ const answered = getAnsweredToolApprovalRequestInputKeys([request, answer]);
62
+ expect(answered.has(getRequestInputMessageKey(request))).toBe(true);
63
+ });
64
+
65
+ it('treats a metadata-carrying answer as answered regardless of its text', () => {
66
+ const request = makeApprovalRequest();
67
+ const answer = makeUserAnswer('Allow this action for this run\n\nUploaded artifacts:\n[a.png](artifact:a)', {
68
+ tool_approval_response: { decision: 'allow_for_run', approval_key: APPROVAL_KEY },
69
+ });
70
+ const answered = getAnsweredToolApprovalRequestInputKeys([request, answer]);
71
+ expect(answered.has(getRequestInputMessageKey(request))).toBe(true);
72
+ });
73
+
74
+ it('does not treat unrelated prose as an approval answer', () => {
75
+ const request = makeApprovalRequest();
76
+ const answer = makeUserAnswer('please proceed\n\nUploaded artifacts:\n[a.png](artifact:a)');
77
+ const answered = getAnsweredToolApprovalRequestInputKeys([request, answer]);
78
+ expect(answered.size).toBe(0);
79
+ });
80
+ });
@@ -169,6 +169,22 @@ export function isToolApprovalRequestInput(message: AgentMessage): boolean {
169
169
  return message.type === AgentMessageType.REQUEST_INPUT && getToolApprovalKey(message) !== undefined;
170
170
  }
171
171
 
172
+ const TOOL_APPROVAL_OPTION_DECISIONS = new Set(['allow_once', 'allow_for_run', 'deny']);
173
+
174
+ /**
175
+ * Metadata to attach when the user picks an option on a tool-approval prompt, so the workflow
176
+ * reads the decision structurally instead of from the message text (which clients may decorate,
177
+ * e.g. with an attachments footer). Returns undefined for non-approval prompts or unknown options.
178
+ */
179
+ export function getToolApprovalResponseMetadata(
180
+ message: AgentMessage,
181
+ optionId: string,
182
+ ): Record<string, unknown> | undefined {
183
+ const approvalKey = getToolApprovalKey(message);
184
+ if (!approvalKey || !TOOL_APPROVAL_OPTION_DECISIONS.has(optionId)) return undefined;
185
+ return { tool_approval_response: { decision: optionId, approval_key: approvalKey } };
186
+ }
187
+
172
188
  function isToolApprovalOptionResponse(message: AgentMessage): boolean {
173
189
  if (message.type !== AgentMessageType.QUESTION) return false;
174
190
  const normalized = getAgentMessageText(message).trim().toLowerCase();
@@ -181,7 +197,11 @@ function isToolApprovalMetadataResponse(message: AgentMessage): boolean {
181
197
  const metadata = getRecord(details?.metadata);
182
198
  const toolApprovalResponse =
183
199
  getRecord(details?.tool_approval_response) ?? getRecord(metadata?.tool_approval_response);
184
- return toolApprovalResponse?.decision === 'deny_with_feedback';
200
+ const decision = toolApprovalResponse?.decision;
201
+ return (
202
+ decision === 'deny_with_feedback' ||
203
+ (typeof decision === 'string' && TOOL_APPROVAL_OPTION_DECISIONS.has(decision))
204
+ );
185
205
  }
186
206
 
187
207
  function isToolApprovalResponse(message: AgentMessage): boolean {
@@ -50,6 +50,76 @@ describe('useFileProcessing', () => {
50
50
  expect(result.current.processingFiles.has('file-1')).toBe(false);
51
51
  });
52
52
 
53
+ it('clears files on send without signaling FileRemoved and suppresses the server echo', async () => {
54
+ const client = createClient();
55
+ const toast = vi.fn();
56
+ const serverFileUpdates = new Map([['file-1', createReadyFile('file-1')]]);
57
+ const { result, rerender } = renderHook(
58
+ ({ updates }) => useFileProcessing(client, 'agent-run-1', updates, toast),
59
+ {
60
+ initialProps: { updates: serverFileUpdates },
61
+ },
62
+ );
63
+
64
+ expect(result.current.processingFiles.has('file-1')).toBe(true);
65
+
66
+ act(() => {
67
+ result.current.clearProcessingFiles();
68
+ });
69
+
70
+ // The agent already received the file via FileUploaded, so sending the message
71
+ // consumes it rather than retracting it — no FileRemoved signal.
72
+ expect(client.agents.sendSignal).not.toHaveBeenCalled();
73
+ expect(result.current.processingFiles.has('file-1')).toBe(false);
74
+
75
+ // A subsequent server echo for the same file must not re-add the chip.
76
+ rerender({ updates: new Map([['file-1', createReadyFile('file-1')]]) });
77
+ expect(result.current.processingFiles.has('file-1')).toBe(false);
78
+ });
79
+
80
+ it('preserves local artifact_path/reference when a server update omits them', async () => {
81
+ const client = createClient();
82
+ const toast = vi.fn();
83
+ const { result, rerender } = renderHook(
84
+ ({ updates }) => useFileProcessing(client, 'agent-run-1', updates, toast),
85
+ {
86
+ initialProps: { updates: new Map<string, ConversationFile>() },
87
+ },
88
+ );
89
+
90
+ const file = new File(['pdf'], 'report.pdf', { type: 'application/pdf' });
91
+ await act(async () => {
92
+ await result.current.handleFileUpload([file]);
93
+ });
94
+
95
+ const [fileId, local] = Array.from(result.current.processingFiles.entries())[0];
96
+ expect(local.artifact_path).toBe('files/report.pdf');
97
+ expect(local.reference).toBe('artifact:files/report.pdf');
98
+
99
+ // Server echoes READY but drops artifact_path/reference — the merge must backfill
100
+ // them from the local optimistic entry so the sent-message embed still resolves.
101
+ rerender({
102
+ updates: new Map<string, ConversationFile>([
103
+ [
104
+ fileId,
105
+ {
106
+ id: fileId,
107
+ name: 'report.pdf',
108
+ content_type: 'application/pdf',
109
+ size: 0,
110
+ status: FileProcessingStatus.READY,
111
+ started_at: 1_000,
112
+ },
113
+ ],
114
+ ]),
115
+ });
116
+
117
+ const merged = result.current.processingFiles.get(fileId);
118
+ expect(merged?.status).toBe(FileProcessingStatus.READY);
119
+ expect(merged?.artifact_path).toBe('files/report.pdf');
120
+ expect(merged?.reference).toBe('artifact:files/report.pdf');
121
+ });
122
+
53
123
  it('does not signal an upload that was removed before upload completion', async () => {
54
124
  const client = createClient();
55
125
  const uploadArtifact = vi.mocked(client.agents.uploadArtifact);
@@ -8,6 +8,7 @@ export interface UseFileProcessingResult {
8
8
  hasProcessingFiles: boolean;
9
9
  handleFileUpload: (files: File[]) => Promise<void>;
10
10
  removeProcessingFile: (fileId: string) => Promise<void>;
11
+ clearProcessingFiles: () => void;
11
12
  }
12
13
 
13
14
  type ToastFn = (opts: {
@@ -58,6 +59,12 @@ export function useFileProcessing(
58
59
  const removedFileIdsRef = useRef<Set<string>>(new Set());
59
60
  const previewUrlsRef = useRef<Map<string, string>>(new Map());
60
61
  const previousAgentRunId = useRef(agentRunId);
62
+ // Kept current so clearProcessingFiles() can read the latest tracked ids
63
+ // imperatively without being re-created on every render.
64
+ const localFilesRef = useRef(localFiles);
65
+ localFilesRef.current = localFiles;
66
+ const serverFileUpdatesRef = useRef(serverFileUpdates);
67
+ serverFileUpdatesRef.current = serverFileUpdates;
61
68
 
62
69
  const revokePreviewUrl = useCallback((fileId: string) => {
63
70
  const previewUrl = previewUrlsRef.current.get(fileId);
@@ -97,11 +104,17 @@ export function useFileProcessing(
97
104
  });
98
105
  serverFileUpdates.forEach((file, id) => {
99
106
  if (!removedFileIds.has(id)) {
100
- const localPreviewUrl = (localFiles.get(id) as LocalConversationFile | undefined)?.preview_url;
101
- merged.set(
102
- id,
103
- localPreviewUrl ? ({ ...file, preview_url: localPreviewUrl } as LocalConversationFile) : file,
104
- );
107
+ // Server updates are authoritative for status, but may omit fields the local
108
+ // optimistic entry already knows: preview_url is never sent by the server, and
109
+ // artifact_path/reference can be absent on some updates. Backfill them from local
110
+ // so the sent-message embed and thumbnail resolution keep working.
111
+ const local = localFiles.get(id) as LocalConversationFile | undefined;
112
+ merged.set(id, {
113
+ ...file,
114
+ artifact_path: file.artifact_path ?? local?.artifact_path,
115
+ reference: file.reference ?? local?.reference,
116
+ ...(local?.preview_url ? { preview_url: local.preview_url } : {}),
117
+ } as LocalConversationFile);
105
118
  }
106
119
  });
107
120
  return merged;
@@ -224,5 +237,23 @@ export function useFileProcessing(
224
237
  [agentRunId, client, revokePreviewUrl, t, toast],
225
238
  );
226
239
 
227
- return { processingFiles, hasProcessingFiles, handleFileUpload, removeProcessingFile };
240
+ // Clears the composer's uploaded-file chips after a message is sent. Unlike
241
+ // removeProcessingFile, this does NOT signal FileRemoved — the agent already
242
+ // received these files via FileUploaded, so the message consumes them rather
243
+ // than retracting them. The ids are added to removedFileIds so the server echo
244
+ // in the merge above cannot re-add a chip once the message has been sent.
245
+ const clearProcessingFiles = useCallback(() => {
246
+ previewUrlsRef.current.forEach(revokePreviewUrlValue);
247
+ previewUrlsRef.current.clear();
248
+ setRemovedFileIds((prev) => {
249
+ const next = new Set(prev);
250
+ for (const id of serverFileUpdatesRef.current.keys()) next.add(id);
251
+ for (const id of localFilesRef.current.keys()) next.add(id);
252
+ removedFileIdsRef.current = next;
253
+ return next;
254
+ });
255
+ setLocalFiles(new Map());
256
+ }, []);
257
+
258
+ return { processingFiles, hasProcessingFiles, handleFileUpload, removeProcessingFile, clearProcessingFiles };
228
259
  }