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
174 changes: 81 additions & 93 deletions ui/src/components/ChatMessageList/ChatMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,11 @@ export function ChatMessageList({
enabled: messageGroups.length > 0,
});

// Don't adjust scroll position when the actively-streaming item grows —
// the default correction pushes the user further down on every token.
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
!(item.index === instance.options.count - 1 && hasStreamingResponses);

// Track message count to detect new user messages
const prevMessagesLengthRef = useRef(messages.length);

Expand Down Expand Up @@ -347,15 +352,32 @@ export function ChatMessageList({
<div
className="relative"
style={{
// Use max of virtualizer size and estimated size to prevent layout jumps
height:
Math.max(virtualizer.getTotalSize(), messageGroups.length * 200) +
(hasStreamingResponses ? 200 : 0),
height: Math.max(virtualizer.getTotalSize(), messageGroups.length * 200),
}}
>
{/* Virtualized message groups */}
{virtualizer.getVirtualItems().map((virtualItem) => {
const group = messageGroups[virtualItem.index];
const isLastGroup = virtualItem.index === messageGroups.length - 1;
const activeStreamingIds =
isLastGroup && hasStreamingResponses
? new Set(filteredModelResponses.map((r) => r.instanceId ?? r.model))
: null;
const committedInstanceIds = new Set(
group.assistantResponses
.filter((r) => !activeStreamingIds?.has(r.instanceId ?? r.model ?? ""))
.map((r) => r.instanceId ?? r.model ?? "")
);
const showStreaming =
isLastGroup &&
hasStreamingResponses &&
filteredModelResponses.some(
(r) => !committedInstanceIds.has(r.instanceId ?? r.model)
);
const committedResponses = activeStreamingIds
? group.assistantResponses.filter(
(r) => !activeStreamingIds.has(r.instanceId ?? r.model ?? "")
)
: group.assistantResponses;
return (
<div
key={group.id}
Expand All @@ -370,7 +392,58 @@ export function ChatMessageList({
onSaveEdit={onEditAndRerun}
onRegenerate={onRegenerateAll}
/>
{group.assistantResponses.length > 0 && (
{showStreaming && (
<>
<RoutingDecision />
<ChainProgress
models={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
<SynthesisProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
<RefinementProgress />
<CritiqueProgress />
<ElectedProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
<TournamentProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
<ConsensusProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
<DebateProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
<CouncilProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
<HierarchicalProgress />
<ScattershotProgress />
<ExplainerProgress />
<ConfidenceProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
<div
key={streamingSessionIdRef.current}
className="animate-slide-up-bounce"
>
<MultiModelResponse
responses={filteredModelResponses.map((r) => {
const instanceId = r.instanceId ?? r.model;
return {
...r,
instanceId,
label: instanceLabels.get(instanceId),
};
})}
timestamp={streamingTimestampRef.current}
actionConfig={actionConfig}
/>
</div>
</>
)}
{committedResponses.length > 0 && (
<>
{/* Show persisted mode indicators for chained/routed messages */}
{group.assistantResponses[0].modeMetadata?.mode === "routed" && (
Expand Down Expand Up @@ -543,7 +616,7 @@ export function ChatMessageList({
</div>
)}
<MultiModelResponse
responses={group.assistantResponses.map((m) => {
responses={committedResponses.map((m) => {
// Use instanceId if set, otherwise fall back to model for backwards compat
const instanceId = m.instanceId ?? m.model ?? "unknown";
return {
Expand All @@ -560,6 +633,7 @@ export function ChatMessageList({
citations: m.citations,
artifacts: m.artifacts,
toolExecutionRounds: m.toolExecutionRounds,
completedRounds: m.completedRounds,
debugMessageId: m.debugMessageId,
};
})}
Expand Down Expand Up @@ -587,92 +661,6 @@ export function ChatMessageList({
</div>
);
})}

{/*
STREAMING SECTION - Outside Virtualization

Active streaming responses render here, positioned absolutely at the bottom.
This is intentionally outside the virtualized list because:
1. Streaming content height changes constantly (every token)
2. Virtualization re-measures heights, which would cause jank
3. The streaming section should always be visible (no virtualization cutoff)

The key={streamingSessionIdRef.current} ensures animation only plays once
per streaming session, not on every content update.
*/}
{/* Show streaming section when we have streaming responses */}
{hasStreamingResponses && (
<div
className="absolute left-0 right-0"
style={{
// Use virtualizer total size, with fallback to estimated size for unmeasured groups
transform: `translateY(${Math.max(virtualizer.getTotalSize(), messageGroups.length * 200)}px)`,
}}
>
{/* Routing decision indicator for routed mode */}
<RoutingDecision />
{/* Chain progress indicator for chained mode */}
<ChainProgress
models={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
{/* Synthesis progress indicator for synthesized mode */}
<SynthesisProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
{/* Refinement progress indicator for refined mode */}
<RefinementProgress />
{/* Critique progress indicator for critiqued mode */}
<CritiqueProgress />
{/* Election progress indicator for elected mode */}
<ElectedProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
{/* Tournament progress indicator for tournament mode */}
<TournamentProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
{/* Consensus progress indicator for consensus mode */}
<ConsensusProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
{/* Debate progress indicator for debated mode */}
<DebateProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
{/* Council progress indicator for council mode */}
<CouncilProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
{/* Hierarchical progress indicator for hierarchical mode */}
<HierarchicalProgress />
{/* Scattershot progress indicator for scattershot mode */}
<ScattershotProgress />
{/* Explainer progress indicator for explainer mode */}
<ExplainerProgress />
{/* Confidence-weighted progress indicator for confidence-weighted mode */}
<ConfidenceProgress
allModels={selectedModels.filter((m) => !disabledModels.includes(m))}
/>
{/* Key ensures animation only plays once per streaming session */}
{hasStreamingResponses && (
<div key={streamingSessionIdRef.current} className="animate-slide-up-bounce">
<MultiModelResponse
responses={filteredModelResponses.map((r) => {
// Use instanceId if set, otherwise fall back to model
const instanceId = r.instanceId ?? r.model;
return {
...r,
instanceId,
label: instanceLabels.get(instanceId),
};
})}
timestamp={streamingTimestampRef.current}
actionConfig={actionConfig}
/>
</div>
)}
</div>
)}
</div>
)}
</div>
Expand Down
38 changes: 21 additions & 17 deletions ui/src/components/ChatView/ChatView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,23 +100,27 @@ const meta: Meta<typeof ChatView> = {
},
},
decorators: [
(Story) => (
<QueryClientProvider client={queryClient}>
<ConfigProvider>
<AuthProvider>
<PreferencesProvider>
<ToastProvider>
<TooltipProvider>
<div className="h-screen">
<Story />
</div>
</TooltipProvider>
</ToastProvider>
</PreferencesProvider>
</AuthProvider>
</ConfigProvider>
</QueryClientProvider>
),
(Story) => {
// Show reasoning & tools in tests
useChatUIStore.setState({ compactMode: false });
return (
<QueryClientProvider client={queryClient}>
<ConfigProvider>
<AuthProvider>
<PreferencesProvider>
<ToastProvider>
<TooltipProvider>
<div className="h-screen">
<Story />
</div>
</TooltipProvider>
</ToastProvider>
</PreferencesProvider>
</AuthProvider>
</ConfigProvider>
</QueryClientProvider>
);
},
],
};

Expand Down
131 changes: 131 additions & 0 deletions ui/src/components/MultiModelResponse/ContentRound.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { memo, useState, useCallback, useMemo } from "react";

Check warning on line 1 in ui/src/components/MultiModelResponse/ContentRound.tsx

View workflow job for this annotation

GitHub Actions / Frontend

Component 'ContentRound' is missing a Storybook story. Create src/components/MultiModelResponse/ContentRound.stories.tsx
import type { ToolExecutionRound, Artifact, DisplaySelectionData } from "@/components/chat-types";
import { Artifact as ArtifactComponent } from "@/components/Artifact";
import { ReasoningSection } from "@/components/ReasoningSection/ReasoningSection";
import { StreamingMarkdown } from "@/components/StreamingMarkdown/StreamingMarkdown";
import { ExecutionSummaryBar, ExecutionTimeline } from "@/components/ToolExecution";
import { useCompactMode } from "@/stores/chatUIStore";

interface ContentRoundProps {
/** Round's reasoning content */
reasoning?: string | null;
/** Round's text content */
content?: string | null;
/** Whether text content is actively streaming */
isStreaming?: boolean;
/** Whether reasoning is actively streaming */
isReasoningStreaming?: boolean;
/** Token count for reasoning (shown in ReasoningSection) */
reasoningTokenCount?: number;
/** Tool execution round for this round (if tools were called) */
toolExecutionRound?: ToolExecutionRound;
/** Whether tool execution is still in progress for this round */
isToolsStreaming?: boolean;
/** Artifact click handler for tool execution timeline */
onArtifactClick?: (artifact: Artifact) => void;
/** Display selection if display_artifacts was called in this round */
displaySelection?: DisplaySelectionData | null;
/** All output artifacts across all rounds (for resolving display selection IDs) */
allOutputArtifacts?: Artifact[];
}

/**
* A single round of model output: reasoning → content → tool execution summary.
*
* Used in multi-round tool calling to render each iteration as a distinct block
* with consistent spacing, replacing raw `<hr>` separators.
*/
function ContentRoundComponent({
reasoning,
content,
isStreaming = false,
isReasoningStreaming = false,
reasoningTokenCount,
toolExecutionRound,
isToolsStreaming = false,
onArtifactClick,
displaySelection,
allOutputArtifacts,
}: ContentRoundProps) {
const [toolsExpanded, setToolsExpanded] = useState(false);
const handleToggleTools = useCallback(() => setToolsExpanded((p) => !p), []);
const compactMode = useCompactMode();

// Resolve display selection to actual artifacts
const displayedArtifacts = useMemo(() => {
if (!displaySelection?.artifactIds.length || !allOutputArtifacts) return [];
const displayed: Artifact[] = [];
for (const id of displaySelection.artifactIds) {
const artifact = allOutputArtifacts.find((a) => a.id === id);
if (artifact) displayed.push(artifact);
}
return displayed;
}, [displaySelection, allOutputArtifacts]);

const hasContent = !!content?.trim();
const hasReasoning = !!reasoning;
const hasTools = !!toolExecutionRound;
const hasDisplayedArtifacts = displayedArtifacts.length > 0;

if (!hasContent && !hasReasoning && !hasTools && !hasDisplayedArtifacts) return null;

if (compactMode) {
// Compact: show content + artifacts only; collapse reasoning/tool-only rounds
if (hasContent || hasDisplayedArtifacts) {
const layoutClass =
displaySelection?.layout === "gallery" ? "grid grid-cols-2 gap-3" : "space-y-3";
return (
<div className="space-y-1 border-l-2 border-transparent pl-3 transition-colors hover:border-zinc-200 dark:hover:border-zinc-700">
{hasContent && <StreamingMarkdown content={content!} isStreaming={isStreaming} />}
{hasDisplayedArtifacts && (
<div className={layoutClass}>
{displayedArtifacts.map((artifact) => (
<ArtifactComponent key={artifact.id} artifact={artifact} />
))}
</div>
)}
</div>
);
}
// No content in compact mode — parent manages status indicators
return null;
}

const layoutClass =
displaySelection?.layout === "gallery" ? "grid grid-cols-2 gap-3" : "space-y-3";

return (
<div className="space-y-1 border-l-2 border-transparent pl-3 transition-colors hover:border-zinc-200 dark:hover:border-zinc-700">
{hasReasoning && (
<ReasoningSection
content={reasoning!}
isStreaming={isReasoningStreaming}
tokenCount={reasoningTokenCount}
/>
)}
{hasContent && <StreamingMarkdown content={content!} isStreaming={isStreaming} />}
{hasTools && (
<div className="mt-1.5">
<ExecutionSummaryBar
rounds={[toolExecutionRound!]}
isExpanded={toolsExpanded}
onToggle={handleToggleTools}
isStreaming={isToolsStreaming}
/>
{toolsExpanded && (
<ExecutionTimeline rounds={[toolExecutionRound!]} onArtifactClick={onArtifactClick} />
)}
</div>
)}
{hasDisplayedArtifacts && (
<div className={layoutClass}>
{displayedArtifacts.map((artifact) => (
<ArtifactComponent key={artifact.id} artifact={artifact} />
))}
</div>
)}
</div>
);
}

export const ContentRound = memo(ContentRoundComponent);
Loading
Loading