@vertesia/ui 1.4.0-dev.20260629.130134Z → 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.
- package/lib/core/components/SidePanel.js +1 -1
- package/lib/core/components/SidePanel.js.map +1 -1
- package/lib/core/components/shadcn/button.d.ts +10 -3
- package/lib/core/components/shadcn/button.d.ts.map +1 -1
- package/lib/core/components/shadcn/button.js +41 -9
- package/lib/core/components/shadcn/button.js.map +1 -1
- package/lib/core/components/shadcn/modal/dialog.js +1 -1
- package/lib/core/components/shadcn/modal/dialog.js.map +1 -1
- package/lib/core/components/shadcn/resizeable.d.ts +1 -1
- package/lib/core/components/shadcn/resizeable.d.ts.map +1 -1
- package/lib/core/components/shadcn/resizeable.js +12 -2
- package/lib/core/components/shadcn/resizeable.js.map +1 -1
- package/lib/core/components/shadcn/tabs.d.ts +12 -1
- package/lib/core/components/shadcn/tabs.d.ts.map +1 -1
- package/lib/core/components/shadcn/tabs.js +12 -4
- package/lib/core/components/shadcn/tabs.js.map +1 -1
- package/lib/core/components/shadcn/theme/ThemeSwitcher.js +1 -1
- package/lib/core/components/shadcn/theme/ThemeSwitcher.js.map +1 -1
- package/lib/core/hooks/useIntersectionObserver.d.ts.map +1 -1
- package/lib/core/hooks/useIntersectionObserver.js +3 -1
- package/lib/core/hooks/useIntersectionObserver.js.map +1 -1
- package/lib/features/agent/chat/AgentChatPlaybackControls.d.ts.map +1 -1
- package/lib/features/agent/chat/AgentChatPlaybackControls.js +2 -2
- package/lib/features/agent/chat/AgentChatPlaybackControls.js.map +1 -1
- package/lib/features/agent/chat/AgentRequestInputOverlay.d.ts.map +1 -1
- package/lib/features/agent/chat/AgentRequestInputOverlay.js +2 -2
- package/lib/features/agent/chat/AgentRequestInputOverlay.js.map +1 -1
- package/lib/features/agent/chat/AgentRightPanel.js +1 -1
- package/lib/features/agent/chat/AgentRightPanel.js.map +1 -1
- package/lib/features/agent/chat/AskUserWidget.d.ts.map +1 -1
- package/lib/features/agent/chat/AskUserWidget.js +4 -3
- package/lib/features/agent/chat/AskUserWidget.js.map +1 -1
- package/lib/features/agent/chat/ImageLightbox.d.ts.map +1 -1
- package/lib/features/agent/chat/ImageLightbox.js +1 -1
- package/lib/features/agent/chat/ImageLightbox.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentConversation.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentConversation.js +57 -12
- package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts +8 -1
- package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js +45 -36
- package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/Header.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/Header.js +4 -2
- package/lib/features/agent/chat/ModernAgentOutput/Header.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageInput.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageInput.js +7 -3
- package/lib/features/agent/chat/ModernAgentOutput/MessageInput.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageItem.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js +2 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/WorkstreamTabs.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/WorkstreamTabs.js +158 -8
- package/lib/features/agent/chat/ModernAgentOutput/WorkstreamTabs.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/requestInputMessages.d.ts +6 -0
- package/lib/features/agent/chat/ModernAgentOutput/requestInputMessages.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/requestInputMessages.js +15 -1
- package/lib/features/agent/chat/ModernAgentOutput/requestInputMessages.js.map +1 -1
- package/lib/features/agent/chat/hooks/useFileProcessing.d.ts +1 -0
- package/lib/features/agent/chat/hooks/useFileProcessing.d.ts.map +1 -1
- package/lib/features/agent/chat/hooks/useFileProcessing.js +37 -3
- package/lib/features/agent/chat/hooks/useFileProcessing.js.map +1 -1
- package/lib/features/magic-pdf/AnnotatedImageSlider.js +13 -13
- package/lib/features/magic-pdf/AnnotatedImageSlider.js.map +1 -1
- package/lib/features/magic-pdf/DownloadPopover.js +2 -2
- package/lib/features/magic-pdf/DownloadPopover.js.map +1 -1
- package/lib/features/magic-pdf/MagicPdfView.js +3 -3
- package/lib/features/magic-pdf/MagicPdfView.js.map +1 -1
- package/lib/features/oauth/McpConnectionsButton.d.ts.map +1 -1
- package/lib/features/oauth/McpConnectionsButton.js +2 -2
- package/lib/features/oauth/McpConnectionsButton.js.map +1 -1
- package/lib/features/oauth/McpConnectionsDialog.d.ts.map +1 -1
- package/lib/features/oauth/McpConnectionsDialog.js +1 -1
- package/lib/features/oauth/McpConnectionsDialog.js.map +1 -1
- package/lib/features/oauth/RemoteMcpConnectionButton.d.ts.map +1 -1
- package/lib/features/oauth/RemoteMcpConnectionButton.js +1 -1
- package/lib/features/oauth/RemoteMcpConnectionButton.js.map +1 -1
- package/lib/features/pdf-viewer/PdfPageSlider.js +2 -2
- package/lib/features/pdf-viewer/PdfPageSlider.js.map +1 -1
- package/lib/features/pdf-viewer/SimplePdfViewer.d.ts.map +1 -1
- package/lib/features/pdf-viewer/SimplePdfViewer.js +3 -3
- package/lib/features/pdf-viewer/SimplePdfViewer.js.map +1 -1
- package/lib/features/store/objects/DocumentQuickFilter.d.ts.map +1 -1
- package/lib/features/store/objects/DocumentQuickFilter.js +5 -5
- package/lib/features/store/objects/DocumentQuickFilter.js.map +1 -1
- package/lib/features/store/objects/DocumentSearchResults.d.ts.map +1 -1
- package/lib/features/store/objects/DocumentSearchResults.js +2 -2
- package/lib/features/store/objects/DocumentSearchResults.js.map +1 -1
- package/lib/features/store/objects/components/ContentDispositionButton.d.ts.map +1 -1
- package/lib/features/store/objects/components/ContentDispositionButton.js +1 -1
- package/lib/features/store/objects/components/ContentDispositionButton.js.map +1 -1
- package/lib/features/store/objects/components/ContentOverview.js +3 -3
- package/lib/features/store/objects/components/ContentOverview.js.map +1 -1
- package/lib/features/store/objects/components/DocumentIcon.js +1 -1
- package/lib/features/store/objects/components/VectorSearchWidget.js +1 -1
- package/lib/features/store/objects/components/VectorSearchWidget.js.map +1 -1
- package/lib/features/store/objects/layout/renderers.d.ts.map +1 -1
- package/lib/features/store/objects/layout/renderers.js +2 -2
- package/lib/features/store/objects/layout/renderers.js.map +1 -1
- package/lib/features/store/objects/selection/SelectionActions.js +1 -1
- package/lib/features/store/objects/selection/SelectionActions.js.map +1 -1
- package/lib/features/store/objects/selection/actions/StartWorkflowComponent.js +4 -4
- package/lib/features/store/objects/selection/actions/StartWorkflowComponent.js.map +1 -1
- package/lib/features/store/types/ContentObjectTypesTable.d.ts.map +1 -1
- package/lib/features/store/types/ContentObjectTypesTable.js +4 -4
- package/lib/features/store/types/ContentObjectTypesTable.js.map +1 -1
- package/lib/features/store/types/SelectContentTypeModal.js +1 -1
- package/lib/features/store/types/SelectContentTypeModal.js.map +1 -1
- package/lib/features/user/UserInfo.js +7 -3
- package/lib/features/user/UserInfo.js.map +1 -1
- package/lib/router/HistoryNavigator.d.ts.map +1 -1
- package/lib/router/HistoryNavigator.js +5 -0
- package/lib/router/HistoryNavigator.js.map +1 -1
- package/lib/router/Nav.d.ts.map +1 -1
- package/lib/router/Nav.js +9 -1
- package/lib/router/Nav.js.map +1 -1
- package/lib/vertesia-ui-core.js +1 -1
- package/lib/vertesia-ui-core.js.map +1 -1
- package/lib/vertesia-ui-features.js +1 -1
- package/lib/vertesia-ui-features.js.map +1 -1
- package/lib/vertesia-ui-router.js +1 -1
- package/lib/vertesia-ui-router.js.map +1 -1
- package/lib/vertesia-ui-widgets.js +1 -1
- package/lib/vertesia-ui-widgets.js.map +1 -1
- package/lib/widgets/form/Form.js +1 -1
- package/lib/widgets/form/Form.js.map +1 -1
- package/lib/widgets/json-view/JSONSwitcher.js +1 -1
- package/lib/widgets/json-view/JSONSwitcher.js.map +1 -1
- package/lib/widgets/json-view/JSONView.js +6 -6
- package/lib/widgets/json-view/JSONView.js.map +1 -1
- package/lib/widgets/monacoEditor/MonacoEditor.d.ts.map +1 -1
- package/lib/widgets/monacoEditor/MonacoEditor.js +29 -0
- package/lib/widgets/monacoEditor/MonacoEditor.js.map +1 -1
- package/lib/widgets/xml-viewer/hooks/useXMLViewer.d.ts +9 -0
- package/lib/widgets/xml-viewer/hooks/useXMLViewer.d.ts.map +1 -1
- package/lib/widgets/xml-viewer/hooks/useXMLViewer.js +20 -17
- package/lib/widgets/xml-viewer/hooks/useXMLViewer.js.map +1 -1
- package/package.json +29 -30
- package/src/__tests__/a11y.test.tsx +73 -0
- package/src/core/components/SidePanel.tsx +1 -1
- package/src/core/components/shadcn/button.tsx +67 -12
- package/src/core/components/shadcn/modal/dialog.tsx +1 -1
- package/src/core/components/shadcn/resizeable.test.tsx +77 -0
- package/src/core/components/shadcn/resizeable.tsx +28 -2
- package/src/core/components/shadcn/tabs.test.tsx +65 -0
- package/src/core/components/shadcn/tabs.tsx +25 -3
- package/src/core/components/shadcn/theme/ThemeSwitcher.tsx +1 -1
- package/src/core/hooks/useIntersectionObserver.tsx +3 -1
- package/src/features/agent/chat/AgentChatPlaybackControls.tsx +0 -5
- package/src/features/agent/chat/AgentRequestInputOverlay.test.tsx +31 -0
- package/src/features/agent/chat/AgentRequestInputOverlay.tsx +6 -2
- package/src/features/agent/chat/AgentRightPanel.tsx +1 -1
- package/src/features/agent/chat/AskUserWidget.test.tsx +49 -0
- package/src/features/agent/chat/AskUserWidget.tsx +32 -22
- package/src/features/agent/chat/ImageLightbox.tsx +0 -1
- package/src/features/agent/chat/ModernAgentConversation.test.tsx +246 -1
- package/src/features/agent/chat/ModernAgentConversation.tsx +62 -17
- package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.test.tsx +6 -1
- package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.tsx +81 -49
- package/src/features/agent/chat/ModernAgentOutput/Header.tsx +3 -3
- package/src/features/agent/chat/ModernAgentOutput/MessageInput.test.tsx +25 -0
- package/src/features/agent/chat/ModernAgentOutput/MessageInput.tsx +5 -3
- package/src/features/agent/chat/ModernAgentOutput/MessageItem.tsx +4 -1
- package/src/features/agent/chat/ModernAgentOutput/WorkstreamTabs.tsx +328 -42
- package/src/features/agent/chat/ModernAgentOutput/requestInputMessages.test.ts +80 -0
- package/src/features/agent/chat/ModernAgentOutput/requestInputMessages.ts +21 -1
- package/src/features/agent/chat/hooks/useFileProcessing.test.tsx +70 -0
- package/src/features/agent/chat/hooks/useFileProcessing.ts +37 -6
- package/src/features/magic-pdf/AnnotatedImageSlider.tsx +62 -66
- package/src/features/magic-pdf/DownloadPopover.tsx +2 -2
- package/src/features/magic-pdf/MagicPdfView.tsx +3 -3
- package/src/features/oauth/McpConnectionsButton.tsx +0 -2
- package/src/features/oauth/McpConnectionsDialog.tsx +0 -1
- package/src/features/oauth/RemoteMcpConnectionButton.tsx +0 -1
- package/src/features/pdf-viewer/PdfPageSlider.tsx +5 -5
- package/src/features/pdf-viewer/SimplePdfViewer.tsx +11 -12
- package/src/features/store/objects/DocumentQuickFilter.tsx +13 -15
- package/src/features/store/objects/DocumentSearchResults.tsx +3 -6
- package/src/features/store/objects/components/ContentDispositionButton.tsx +5 -1
- package/src/features/store/objects/components/ContentOverview.tsx +11 -11
- package/src/features/store/objects/components/DocumentIcon.tsx +1 -1
- package/src/features/store/objects/components/VectorSearchWidget.tsx +2 -2
- package/src/features/store/objects/layout/renderers.tsx +1 -2
- package/src/features/store/objects/selection/SelectionActions.tsx +1 -1
- package/src/features/store/objects/selection/actions/StartWorkflowComponent.tsx +6 -5
- package/src/features/store/types/ContentObjectTypesTable.tsx +12 -14
- package/src/features/store/types/SelectContentTypeModal.tsx +2 -2
- package/src/features/user/UserInfo.tsx +7 -7
- package/src/router/HistoryNavigator.ts +5 -0
- package/src/router/Nav.tsx +12 -3
- package/src/widgets/form/Form.tsx +1 -1
- package/src/widgets/json-view/JSONSwitcher.tsx +2 -2
- package/src/widgets/json-view/JSONView.tsx +7 -7
- package/src/widgets/monacoEditor/MonacoEditor.tsx +29 -0
- package/src/widgets/xml-viewer/hooks/useXMLViewer.test.ts +26 -0
- package/src/widgets/xml-viewer/hooks/useXMLViewer.ts +23 -18
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AgentMessage } from '@vertesia/common';
|
|
2
|
-
import {
|
|
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
|
-
<
|
|
62
|
-
{sortedWorkstreams
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
}
|