Optimize chat rendering & add web session activity tracking (#214)

* feat: add chat virtualization and turn grouping infrastructure

Implement VirtualMessageList with virtualization and overscan for smooth scrolling
Refactor MessageList to render only visible messages and pass scroll refs
Introduce TurnGroupingContext and provider to stabilize per-turn rendering and reduce re-renders

* feat: add web server session activity endpoint for visibility restore

Add /api/session-activity endpoint to expose tracked session activity
Use web server activity first when restoring visibility, with fallback to global status
Update server SSE to set session phase on activity events

* feat(chat): split TurnGroupingContext into UI/Streaming contexts

Add separate UI state and streaming contexts to reduce re-renders.
Skip animations for items visible in collapsed view when expanding.
Track shown parts in collapsed mode to fade in progressively

* feat(ui): cache turn groups with expand state and guard web activity

Add isExpanded to the turn cache key to trigger updates correctly
Expose isGroupExpanded from UI state so groups reflect expand/collapse
Limit web server session activity fetch to web runtime only
This commit is contained in:
Bohdan Triapitsyn
2026-01-25 19:16:26 +02:00
committed by GitHub
parent adbc5af95f
commit f34027df10
20 changed files with 1000 additions and 170 deletions
@@ -9,11 +9,23 @@ interface FadeInOnRevealProps {
const FADE_ANIMATION_ENABLED = true;
// Context to allow parent components (like VirtualMessageList) to disable animations
// for items entering the viewport due to scrolling rather than new content
const FadeInDisabledContext = React.createContext(false);
export const FadeInDisabledProvider: React.FC<{ disabled: boolean; children: React.ReactNode }> = ({ disabled, children }) => (
<FadeInDisabledContext.Provider value={disabled}>
{children}
</FadeInDisabledContext.Provider>
);
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, className, skipAnimation }) => {
const [visible, setVisible] = React.useState(skipAnimation ?? false);
const contextDisabled = React.useContext(FadeInDisabledContext);
const shouldSkip = skipAnimation || contextDisabled;
const [visible, setVisible] = React.useState(shouldSkip);
React.useEffect(() => {
if (!FADE_ANIMATION_ENABLED || skipAnimation) {
if (!FADE_ANIMATION_ENABLED || shouldSkip) {
return;
}
@@ -36,9 +48,9 @@ export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, classN
window.cancelAnimationFrame(frame);
}
};
}, [skipAnimation]);
}, [shouldSkip]);
if (!FADE_ANIMATION_ENABLED || skipAnimation) {
if (!FADE_ANIMATION_ENABLED || shouldSkip) {
return <>{children}</>;
}
@@ -80,6 +80,9 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
const [expansionKey, setExpansionKey] = React.useState(0);
// Track which parts have already been shown in collapsed view (for fade-in animation)
const shownInCollapsedRef = React.useRef<Set<string>>(new Set());
React.useEffect(() => {
if (previousExpandedRef.current === isExpanded) return;
const wasCollapsed = previousExpandedRef.current === false;
@@ -89,6 +92,8 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
if (isExpanded && wasCollapsed) {
setExpansionKey((k) => k + 1);
setJustExpandedFromCollapsed(true);
// Clear collapsed tracking when expanding (will restart when collapsed again)
shownInCollapsedRef.current.clear();
// Reset after a short delay (after animations would have started)
const timer = setTimeout(() => setJustExpandedFromCollapsed(false), 50);
return () => clearTimeout(timer);
@@ -216,11 +221,24 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
const animationKey = `${partId}-exp${expansionKey}`;
// Skip animation if:
// - We just expanded from collapsed AND
// - This part was already visible in collapsed state
// Determine if animation should be skipped:
// 1. When expanding from collapsed: skip for items that were already visible
// 2. When collapsed: skip for items already shown before (track in ref)
const wasVisibleInCollapsed = activity.part.id ? visibleInCollapsedIds.has(activity.part.id) : false;
const skipAnimation = justExpandedFromCollapsed && wasVisibleInCollapsed;
let skipAnimation = false;
if (justExpandedFromCollapsed && wasVisibleInCollapsed) {
// Expanding: don't animate items that were already visible in collapsed state
skipAnimation = true;
} else if (!isExpanded && activity.part.id) {
// Collapsed: animate only items that haven't been shown yet
if (shownInCollapsedRef.current.has(activity.part.id)) {
skipAnimation = true;
} else {
// Mark as shown for future renders
shownInCollapsedRef.current.add(activity.part.id);
}
}
switch (activity.kind) {
case 'tool':
@@ -407,7 +407,7 @@ interface DiffPreviewProps {
input?: ToolStateWithMetadata['input'];
}
const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) => (
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, syntaxTheme, input }) => (
<div className="typography-code px-1 pb-1 pt-0 space-y-0">
{parseDiffToUnified(diff).map((hunk, hunkIdx) => (
<div key={hunkIdx} className="-mx-1 px-1 border-b border-border/20 last:border-b-0">
@@ -468,7 +468,9 @@ const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, syntaxTheme, input }) =
</div>
))}
</div>
);
));
DiffPreview.displayName = 'DiffPreview';
interface WriteInputPreviewProps {
content: string;
@@ -477,9 +479,12 @@ interface WriteInputPreviewProps {
displayPath: string;
}
const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTheme, filePath, displayPath }) => {
const lines = content.split('\n');
const language = getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined);
const WriteInputPreview: React.FC<WriteInputPreviewProps> = React.memo(({ content, syntaxTheme, filePath, displayPath }) => {
const lines = React.useMemo(() => content.split('\n'), [content]);
const language = React.useMemo(
() => getLanguageFromExtension(filePath ?? '') || detectLanguageFromOutput(content, 'write', filePath ? { filePath } : undefined),
[content, filePath]
);
const lineCount = Math.max(lines.length, 1);
const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`;
@@ -526,7 +531,9 @@ const WriteInputPreview: React.FC<WriteInputPreviewProps> = ({ content, syntaxTh
</div>
</div>
);
};
});
WriteInputPreview.displayName = 'WriteInputPreview';
interface ImagePreviewProps {
content: string;
@@ -534,7 +541,7 @@ interface ImagePreviewProps {
displayPath: string;
}
const ImagePreview: React.FC<ImagePreviewProps> = ({ content, filePath, displayPath }) => {
const ImagePreview: React.FC<ImagePreviewProps> = React.memo(({ content, filePath, displayPath }) => {
const mimeType = getImageMimeType(filePath);
const isSvg = filePath.toLowerCase().endsWith('.svg');
@@ -566,7 +573,9 @@ const ImagePreview: React.FC<ImagePreviewProps> = ({ content, filePath, displayP
</div>
</div>
);
};
});
ImagePreview.displayName = 'ImagePreview';
interface ToolExpandedContentProps {
part: ToolPartType;
@@ -578,7 +587,7 @@ interface ToolExpandedContentProps {
hasNextTool: boolean;
}
const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
part,
state,
syntaxTheme,
@@ -950,7 +959,9 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = ({
)}
</div>
);
};
});
ToolExpandedContent.displayName = 'ToolExpandedContent';
const ToolPart: React.FC<ToolPartProps> = ({ part, isExpanded, onToggle, syntaxTheme, isMobile, onContentChange, hasPrevTool = false, hasNextTool = false }) => {
const state = part.state;