Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion client/webui/frontend/src/lib/components/chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { CompactionNotification, type CompactionNotificationData } from "./Compa
import { SelectableMessageContent } from "./selection";
import { MessageHoverButtons } from "./MessageHoverButtons";
import { MessageAttribution } from "./MessageAttribution";
import { InlineProgressUpdates } from "./InlineProgressUpdates";

/**
* Returns true if a user message is from another user (not the current viewer).
Expand Down Expand Up @@ -708,7 +709,8 @@ const getChatBubble = (
}

const hasContent = groupedParts.some(p => (p.kind === "text" && p.text.trim()) || p.kind === "file" || p.kind === "artifact");
if (!hasContent) {
const hasProgressUpdates = message.progressUpdates && message.progressUpdates.length > 0;
if (!hasContent && !hasProgressUpdates) {
return null;
}

Expand Down Expand Up @@ -785,6 +787,12 @@ const getChatBubble = (

return (
<div key={message.metadata?.messageId} className="space-y-6">
{/* Render inline progress updates at the top of AI messages */}
{!message.isUser && message.progressUpdates && message.progressUpdates.length > 0 && (
<div className="pl-4">
<InlineProgressUpdates updates={message.progressUpdates} isActive={!message.isComplete} onViewWorkflow={message.taskId ? handleViewWorkflowClick : undefined} />
</div>
)}
{/* Render context quote above user message if present */}
{message.isUser && message.contextQuote && (
<div className="flex justify-end pr-4">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,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;
1 change: 1 addition & 0 deletions client/webui/frontend/src/lib/components/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ export { UserPresenceAvatars } from "./UserPresenceAvatars";
export { ShareNotificationMessage } from "./ShareNotificationMessage";
export { MessageAttribution } from "./MessageAttribution";
export { CollaborativeUserMessage } from "./CollaborativeUserMessage";
export { InlineProgressUpdates } from "./InlineProgressUpdates";
export * from "./file";
export * from "./selection";
27 changes: 1 addition & 26 deletions client/webui/frontend/src/lib/components/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import type { ImperativePanelHandle } from "react-resizable-panels";
import { Header } from "@/lib/components/header";
import { useChatContext, useTaskContext, useTitleAnimation, useConfigContext, useIsChatSharingEnabled } from "@/lib/hooks";
import { useProjectContext } from "@/lib/providers";
import type { TextPart } from "@/lib/types";
import type { CollaborativeUser } from "@/lib/types/collaboration";
import { ChatInputArea, ChatMessage, ChatSessionDialog, ChatSessionDeleteDialog, ChatSidePanel, LoadingMessageRow, ProjectBadge, SessionSidePanel, UserPresenceAvatars, ShareNotificationMessage } from "@/lib/components/chat";
import { ChatInputArea, ChatMessage, ChatSessionDialog, ChatSessionDeleteDialog, ChatSidePanel, ProjectBadge, SessionSidePanel, UserPresenceAvatars, ShareNotificationMessage } from "@/lib/components/chat";
import { Button, ChatMessageList, CHAT_STYLES, ResizablePanelGroup, ResizablePanel, ResizableHandle, Spinner, Tooltip, TooltipContent, TooltipTrigger } from "@/lib/components/ui";
import type { ChatMessageListRef } from "@/lib/components/ui/chat/chat-message-list";
import { useShareLink, useShareUsers } from "@/lib/api/share";
Expand Down Expand Up @@ -51,10 +50,7 @@ export function ChatPage() {
messages,
isSidePanelCollapsed,
setIsSidePanelCollapsed,
openSidePanelTab,
setTaskIdInSidePanel,
isResponding,
latestStatusText,
isLoadingSession,
sessionToDelete,
closeSessionDeleteModal,
Expand Down Expand Up @@ -391,26 +387,6 @@ export function ChatPage() {
return map;
}, [messages]);

const loadingMessage = useMemo(() => {
return messages.find(message => message.isStatusBubble);
}, [messages]);

const backendStatusText = useMemo(() => {
if (!loadingMessage || !loadingMessage.parts) return null;
const textPart = loadingMessage.parts.find(p => p.kind === "text") as TextPart | undefined;
return textPart?.text || null;
}, [loadingMessage]);

const handleViewProgressClick = useMemo(() => {
// Use currentTaskId directly instead of relying on loadingMessage
if (!currentTaskId) return undefined;

return () => {
setTaskIdInSidePanel(currentTaskId);
openSidePanelTab("activity");
};
}, [currentTaskId, setTaskIdInSidePanel, openSidePanelTab]);

// Handle navigation state (e.g., from SharedChatViewPage returning to /chat)
useEffect(() => {
const state = location.state as {
Expand Down Expand Up @@ -564,7 +540,6 @@ export function ChatPage() {
})}
</ChatMessageList>
<div style={CHAT_STYLES}>
{isResponding && <LoadingMessageRow statusText={(backendStatusText || latestStatusText.current) ?? undefined} onViewWorkflow={handleViewProgressClick} />}
<ChatInputArea agents={agents} scrollToBottom={chatMessageListRef.current?.scrollToBottom} />
</div>
</>
Expand Down
6 changes: 6 additions & 0 deletions client/webui/frontend/src/lib/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
animation: pulse-generating 1.5s ease-in-out infinite;
}

/* Animation for inline progress timeline items */
@keyframes progressSlideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}

@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
Expand Down
Loading
Loading