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
+135 -50
View File
@@ -8,10 +8,15 @@ import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { useTurnGrouping } from './hooks/useTurnGrouping';
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext';
interface ChatMessageEntry {
info: Message;
parts: Part[];
}
interface MessageListProps {
messages: { info: Message; parts: Part[] }[];
messages: ChatMessageEntry[];
permissions: PermissionRequest[];
questions: QuestionRequest[];
onMessageContentChange: (reason?: ContentChangeReason) => void;
@@ -20,8 +25,97 @@ interface MessageListProps {
isLoadingOlder: boolean;
onLoadOlder: () => void;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
scrollRef?: React.RefObject<HTMLDivElement | null>;
}
interface MessageRowProps {
message: ChatMessageEntry;
onContentChange: (reason?: ContentChangeReason) => void;
animationHandlers: AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
}
// Static MessageRow - does NOT subscribe to dynamic context
// Used for messages NOT in the last turn - no re-renders during streaming
const StaticMessageRow = React.memo<MessageRowProps>(({
message,
onContentChange,
animationHandlers,
scrollToBottom,
}) => {
const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id);
const turnGroupingContext = useTurnGroupingContextStatic(message.info.id);
return (
<ChatMessage
message={message}
previousMessage={previousMessage}
nextMessage={nextMessage}
onContentChange={onContentChange}
animationHandlers={animationHandlers}
scrollToBottom={scrollToBottom}
turnGroupingContext={turnGroupingContext}
/>
);
});
StaticMessageRow.displayName = 'StaticMessageRow';
// Dynamic MessageRow - subscribes to dynamic context for streaming state
// Used for messages in the LAST turn only
const DynamicMessageRow = React.memo<MessageRowProps>(({
message,
onContentChange,
animationHandlers,
scrollToBottom,
}) => {
const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id);
const turnGroupingContext = useTurnGroupingContextForMessage(message.info.id);
return (
<ChatMessage
message={message}
previousMessage={previousMessage}
nextMessage={nextMessage}
onContentChange={onContentChange}
animationHandlers={animationHandlers}
scrollToBottom={scrollToBottom}
turnGroupingContext={turnGroupingContext}
/>
);
});
DynamicMessageRow.displayName = 'DynamicMessageRow';
// Inner component that renders messages with access to context hooks
const MessageListContent: React.FC<{
displayMessages: ChatMessageEntry[];
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
}> = ({ displayMessages, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => {
const lastTurnMessageIds = useLastTurnMessageIds();
return (
<>
{displayMessages.map((message) => {
const isInLastTurn = lastTurnMessageIds.has(message.info.id);
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
return (
<RowComponent
key={message.info.id}
message={message}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
})}
</>
);
};
const MessageList: React.FC<MessageListProps> = ({
messages,
permissions,
@@ -67,58 +161,49 @@ const MessageList: React.FC<MessageListProps> = ({
});
}, [messages]);
const { getContextForMessage } = useTurnGrouping(displayMessages);
return (
<div>
{hasMoreAbove && (
<div className="flex justify-center py-3">
{isLoadingOlder ? (
<span className="text-xs uppercase tracking-wide text-muted-foreground/80">
Loading…
</span>
) : (
<button
type="button"
onClick={onLoadOlder}
className="text-xs uppercase tracking-wide text-muted-foreground/80 hover:text-foreground"
>
Load older messages
</button>
)}
</div>
)}
<TurnGroupingProvider messages={displayMessages}>
<div>
{hasMoreAbove && (
<div className="flex justify-center py-3">
{isLoadingOlder ? (
<span className="text-xs uppercase tracking-wide text-muted-foreground/80">
Loading…
</span>
) : (
<button
type="button"
onClick={onLoadOlder}
className="text-xs uppercase tracking-wide text-muted-foreground/80 hover:text-foreground"
>
Load older messages
</button>
)}
</div>
)}
<div className="flex flex-col">
{displayMessages.map((message, index) => (
<ChatMessage
key={message.info.id}
message={message}
previousMessage={index > 0 ? displayMessages[index - 1] : undefined}
nextMessage={index < displayMessages.length - 1 ? displayMessages[index + 1] : undefined}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
turnGroupingContext={getContextForMessage(message.info.id)}
/>
))}
<MessageListContent
displayMessages={displayMessages}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
/>
{(questions.length > 0 || permissions.length > 0) && (
<div>
{questions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{permissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
)}
{/* Bottom spacer - always 10% of viewport height */}
<div className="flex-shrink-0" style={{ height: '10vh' }} aria-hidden="true" />
</div>
{(questions.length > 0 || permissions.length > 0) && (
<div>
{questions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{permissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
)}
{/* Bottom spacer - always 10% of viewport height */}
<div className="flex-shrink-0" style={{ height: '10vh' }} aria-hidden="true" />
</div>
</TurnGroupingProvider>
);
};