Skip to content
Merged
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
Loading
Loading