-
Notifications
You must be signed in to change notification settings - Fork 144
Expand file tree
/
Copy pathInlineProgressUpdates.tsx
More file actions
188 lines (167 loc) · 9.49 KB
/
InlineProgressUpdates.tsx
File metadata and controls
188 lines (167 loc) · 9.49 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
/**
* Inline Progress Updates Component
*
* Displays a vertical timeline of progress updates inline within the AI response message.
* During streaming: shows full timeline with dots, spinner, and connecting line.
* After completion: collapses into "Timeline >" that can be expanded to see the full history.
*/
import React, { useState, useEffect, useRef } from "react";
import { ChevronDown, ChevronRight, ChevronUp, Loader2 } from "lucide-react";
import { Button } from "@/lib/components/ui";
import { ViewWorkflowButton } from "@/lib/components/ui/ViewWorkflowButton";
import { MarkdownWrapper } from "@/lib/components";
import type { ProgressUpdate } from "@/lib/types";
interface InlineProgressUpdatesProps {
/** Array of progress update objects accumulated during the task */
updates: ProgressUpdate[];
/** Whether the task is still in progress (shows spinner on last item) */
isActive?: boolean;
/** Callback to view the workflow/activity panel */
onViewWorkflow?: () => void;
}
/** Maximum number of updates to show before collapsing the list */
const COLLAPSE_THRESHOLD = 10;
export const InlineProgressUpdates: React.FC<InlineProgressUpdatesProps> = ({ updates, isActive = false, onViewWorkflow }) => {
const [isTimelineOpen, setIsTimelineOpen] = useState(true);
const [isListExpanded, setIsListExpanded] = useState(false);
const [expandedThinkingIds, setExpandedThinkingIds] = useState<Set<number>>(new Set());
const hasAutoCollapsed = useRef(false);
// Auto-collapse timeline when task completes
useEffect(() => {
if (!isActive && !hasAutoCollapsed.current && updates.length > 0) {
hasAutoCollapsed.current = true;
setIsTimelineOpen(false);
}
}, [isActive, updates.length]);
if (!updates || updates.length === 0) {
return null;
}
// Deduplicate consecutive identical updates (by text), but never deduplicate thinking items
const deduped = updates.filter((update, index) => update.type === "thinking" || index === 0 || update.text !== updates[index - 1].text);
const shouldCollapseList = deduped.length > COLLAPSE_THRESHOLD;
const visibleIndices = shouldCollapseList && !isListExpanded ? [0, ...Array.from({ length: 2 }, (_, i) => deduped.length - 2 + i)] : deduped.map((_, i) => i);
const visibleUpdates = visibleIndices.map(i => deduped[i]);
const hiddenCount = shouldCollapseList && !isListExpanded ? Math.max(0, deduped.length - visibleUpdates.length) : 0;
const toggleThinking = (dedupedIndex: number) => {
setExpandedThinkingIds(prev => {
const next = new Set(prev);
if (next.has(dedupedIndex)) {
next.delete(dedupedIndex);
} else {
next.add(dedupedIndex);
}
return next;
});
};
// Collapsed state: show "Timeline >"
if (!isTimelineOpen) {
return (
<div className="mb-3 -ml-2 flex items-center gap-2">
<button type="button" className="flex items-center gap-1 text-sm text-(--secondary-text-wMain) transition-colors hover:text-(--primary-text-wMain)" onClick={() => setIsTimelineOpen(true)}>
<span className="font-medium">Timeline</span>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
);
}
return (
<div className="mb-3 ml-[9px] pl-5">
{/* Collapse header when task is complete */}
{!isActive && (
<div className="mb-1 -ml-[17px]">
<button type="button" className="flex items-center gap-1 text-sm text-(--secondary-text-wMain) transition-colors hover:text-(--primary-text-wMain)" onClick={() => setIsTimelineOpen(false)}>
<span className="font-medium">Timeline</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Timeline items wrapper - line is relative to this container only */}
<div className="relative">
{/* Vertical connecting line - stops before the last dot center */}
{visibleUpdates.length > 1 && (
<div
className="absolute left-[-12px] z-0 w-[2px] rounded-full opacity-30"
style={{
top: "21px",
/* End line just touching the top of the last dot/spinner */
bottom: isActive ? "33px" : "21px",
backgroundColor: "currentColor",
}}
/>
)}
{visibleUpdates.map((update, index) => {
const dedupedIndex = visibleIndices[index];
const isLast = index === visibleUpdates.length - 1;
const isThinking = update.type === "thinking";
const isThinkingExpanded = expandedThinkingIds.has(dedupedIndex);
const isActiveStep = isLast && isActive;
// Show expand button after first item when list is collapsed
const showExpandButton = shouldCollapseList && !isListExpanded && index === 0;
return (
<React.Fragment key={`${update.timestamp}-${dedupedIndex}`}>
<div
className="relative py-3"
style={{
animation: "progressSlideIn 0.3s ease-out both",
animationDelay: `${Math.min(index * 50, 200)}ms`,
}}
>
{/* Dot or spinner indicator */}
{isActiveStep ? (
<Loader2 className="absolute top-[13px] left-[-20px] z-10 h-[16px] w-[16px] animate-spin text-(--primary-wMain)" />
) : (
<div className="absolute top-[16px] left-[-17px] z-10 h-[10px] w-[10px] rounded-full bg-(--success-wMain)" />
)}
{isThinking ? (
/* Thinking/Reasoning item - collapsible */
<div>
<button type="button" className="flex items-center gap-1 text-sm leading-relaxed text-(--secondary-text-wMain) transition-colors hover:text-(--primary-text-wMain)" onClick={() => toggleThinking(dedupedIndex)}>
<span className="font-medium">{update.text}</span>
{isThinkingExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
{/* Expandable thinking content */}
{isThinkingExpanded && update.expandableContent && (
<div className="mt-2 rounded-lg px-3 py-2">
<div className="max-h-96 overflow-y-auto text-sm text-(--secondary-text-wMain) opacity-70">
<MarkdownWrapper content={update.expandableContent} />
</div>
</div>
)}
</div>
) : (
/* Regular status text */
<span className={`text-sm leading-relaxed ${isActiveStep ? "text-(--primary-text-wMain)" : "text-(--secondary-text-wMain)"}`}>{update.text}</span>
)}
</div>
{/* Expand button between first and last items when list is collapsed */}
{showExpandButton && (
<div className="py-0.5">
<Button variant="ghost" size="sm" className="h-6 gap-1 px-1 text-xs text-(--secondary-text-wMain) hover:text-(--primary-text-wMain)" onClick={() => setIsListExpanded(true)}>
<ChevronDown className="h-3 w-3" />
{hiddenCount} more step{hiddenCount > 1 ? "s" : ""}
</Button>
</div>
)}
</React.Fragment>
);
})}
</div>
{/* Collapse list button */}
{shouldCollapseList && isListExpanded && (
<div className="py-0.5">
<Button variant="ghost" size="sm" className="h-6 gap-1 px-1 text-xs text-(--secondary-text-wMain) hover:text-(--primary-text-wMain)" onClick={() => setIsListExpanded(false)}>
<ChevronUp className="h-3 w-3" />
Show less
</Button>
</div>
)}
{/* View Workflow button during active streaming (when no header is shown) */}
{isActive && onViewWorkflow && (
<div className="mt-1">
<ViewWorkflowButton onClick={onViewWorkflow} />
</div>
)}
</div>
);
};
export default InlineProgressUpdates;