perf: isolate chat streaming renders and reduce sidebar render cost (#1672)
Reworks the chat and session-sidebar render paths to cut render cascades, memory
churn, and UI jank on large sessions and big session trees. Behavior is preserved;
the changes are about *when* and *how much* the UI re-renders.
## Chat streaming
- Freeze the streaming message's parts in the bulk turn projection during streaming,
and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
no longer re-runs the whole-session projection or re-renders unrelated rows.
session with referential reuse of unchanged turns.
- Memoize message rows with field-aware comparators instead of reference equality.
- Replace the manual child-session polling in the task tool with the live SSE
stream + a one-shot load, removing a fetch/settle state machine.
## History loading & scroll
- Load an initial page fast, then prepend one older page in the background so the
scroll container has headroom and "load older on scroll-up" fires before the user
hits the absolute top.
- Compensate scroll synchronously (in a layout effect, before paint) for prepends —
including background prepends that don't originate from a user scroll — so the
viewport stays stable instead of judder-correcting on the next frame.
## Markdown rendering
- Render markdown synchronously *styled* on first paint (paragraphs, lists, code
cards, tables, inline code) instead of raw escaped text; the async pass then only
upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
- Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
chunk, avoiding a late stylesheet injection on first render.
## Sidebar
- Hoist per-row recursive tree walks out of row comparators into per-group
precomputed sets/keys; batch live-session lookups into a single map; add a
group-level memo boundary.
- Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.
## Sync layer
- Add a staleness guard so a slow message fetch can't repopulate a session the user
navigated away from.
- Throw on fetch failure for authoritative loaders so a transient blip can't read as
an empty server response.
## Cleanup
- Remove dead code (unused hooks, params, duplicated inline types) surfaced while
reworking the above.
## Known issue
- A rare, purely cosmetic first-paint width flash can still appear on large sessions;
it has no behavioral or data impact and is tracked for a follow-up runtime trace.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
|
||||
import type { PermissionRequest } from '@/types/permission';
|
||||
import type { QuestionRequest } from '@/types/question';
|
||||
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { DraftPresetChips } from './DraftPresetChips';
|
||||
@@ -23,14 +25,8 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { PermissionRequest } from '@/types/permission';
|
||||
import type { QuestionRequest } from '@/types/question';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import {
|
||||
collectVisibleSessionIdsForBlockingRequests,
|
||||
flattenBlockingRequests,
|
||||
} from './lib/blockingRequests';
|
||||
|
||||
// New sync system imports
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
@@ -38,22 +34,21 @@ import { useStreamingStore } from '@/sync/streaming';
|
||||
import {
|
||||
useSessionMessageCount,
|
||||
useSessionMessageRecords,
|
||||
useSessions,
|
||||
useDirectorySync,
|
||||
useSyncDirectory,
|
||||
useDirectorySync,
|
||||
useSessionStatus,
|
||||
useScopedBlockingPermissions,
|
||||
useScopedBlockingQuestions,
|
||||
useParentSession,
|
||||
} from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache';
|
||||
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
||||
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
|
||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
|
||||
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
|
||||
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
|
||||
@@ -140,6 +135,7 @@ type ChatViewportProps = {
|
||||
isDesktopExpandedInput: boolean;
|
||||
isMobile: boolean;
|
||||
stickyUserHeader: boolean;
|
||||
directory?: string;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
messageListRef: React.RefObject<MessageListHandle | null>;
|
||||
pendingRevealWork: boolean;
|
||||
@@ -168,6 +164,7 @@ const ChatViewport = React.memo(({
|
||||
isDesktopExpandedInput,
|
||||
isMobile,
|
||||
stickyUserHeader,
|
||||
directory,
|
||||
scrollRef,
|
||||
messageListRef,
|
||||
pendingRevealWork,
|
||||
@@ -235,6 +232,7 @@ const ChatViewport = React.memo(({
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
scrollRef={scrollRef}
|
||||
directory={directory}
|
||||
/>
|
||||
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
|
||||
<div>
|
||||
@@ -263,6 +261,7 @@ const ChatViewport = React.memo(({
|
||||
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.stickyUserHeader === next.stickyUserHeader
|
||||
&& prev.directory === next.directory
|
||||
&& prev.scrollRef === next.scrollRef
|
||||
&& prev.messageListRef === next.messageListRef
|
||||
&& prev.pendingRevealWork === next.pendingRevealWork
|
||||
@@ -407,7 +406,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
effectiveSessionDirectory,
|
||||
);
|
||||
// Messages from sync system
|
||||
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory);
|
||||
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, {
|
||||
suspendPartUpdates: Boolean(streamingMessageId),
|
||||
suspendPartUpdatesForMessageId: streamingMessageId,
|
||||
});
|
||||
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
||||
const sessionPrefetchInfo = React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
@@ -423,55 +425,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
React.useCallback(() => undefined, []),
|
||||
);
|
||||
|
||||
// Sessions from sync system
|
||||
const sessions = useSessions(effectiveSessionDirectory);
|
||||
|
||||
// Plan detection - watches messages for plan creation and signals store
|
||||
usePlanDetection(currentSessionId ?? '', sessionMessages);
|
||||
|
||||
// Session status from sync system
|
||||
const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '', effectiveSessionDirectory) ?? IDLE_SESSION_STATUS;
|
||||
|
||||
// Permissions & questions from sync system
|
||||
const allPermissions = useDirectorySync(
|
||||
React.useCallback((s) => s.permission ?? {}, []),
|
||||
effectiveSessionDirectory,
|
||||
);
|
||||
const allQuestions = useDirectorySync(
|
||||
React.useCallback((s) => s.question ?? {}, []),
|
||||
effectiveSessionDirectory,
|
||||
);
|
||||
// Scoped blocking requests — only subscribe to permissions/questions for
|
||||
// the current session + descendant subagent sessions, not all sessions in
|
||||
// the directory.
|
||||
const sessionPermissions = useScopedBlockingPermissions(currentSessionId, effectiveSessionDirectory);
|
||||
const sessionQuestions = useScopedBlockingQuestions(currentSessionId, effectiveSessionDirectory);
|
||||
|
||||
// Convert Record → Map for blockingRequests helpers
|
||||
const permissionsMap = React.useMemo(() => {
|
||||
const m = new Map<string, PermissionRequest[]>();
|
||||
for (const [k, v] of Object.entries(allPermissions)) m.set(k, v as PermissionRequest[]);
|
||||
return m;
|
||||
}, [allPermissions]);
|
||||
|
||||
const questionsMap = React.useMemo(() => {
|
||||
const m = new Map<string, QuestionRequest[]>();
|
||||
for (const [k, v] of Object.entries(allQuestions)) m.set(k, v as QuestionRequest[]);
|
||||
return m;
|
||||
}, [allQuestions]);
|
||||
|
||||
const scopedSessionIds = React.useMemo(
|
||||
() => collectVisibleSessionIdsForBlockingRequests(
|
||||
sessions.map((session) => ({ id: session.id, parentID: session.parentID })),
|
||||
currentSessionId,
|
||||
),
|
||||
[sessions, currentSessionId],
|
||||
);
|
||||
|
||||
const sessionPermissions = React.useMemo(() => {
|
||||
if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS;
|
||||
return flattenBlockingRequests(permissionsMap, scopedSessionIds);
|
||||
}, [permissionsMap, scopedSessionIds]);
|
||||
|
||||
const sessionQuestions = React.useMemo(() => {
|
||||
if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
|
||||
return flattenBlockingRequests(questionsMap, scopedSessionIds);
|
||||
}, [questionsMap, scopedSessionIds]);
|
||||
const sessionIsWorking = React.useMemo(() => {
|
||||
if (!currentSessionId || sessionPermissions.length > 0 || sessionQuestions.length > 0) {
|
||||
return false;
|
||||
@@ -561,15 +526,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
return project ? getProjectDisplayLabel(project) : null;
|
||||
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
|
||||
|
||||
const parentSession = React.useMemo(() => {
|
||||
if (!currentSessionId) return null;
|
||||
const current = sessions.find((session) => session.id === currentSessionId);
|
||||
const parentID = current?.parentID;
|
||||
if (!parentID) return null;
|
||||
return sessions.find((session) => session.id === parentID)
|
||||
?? getAllSyncSessions().find((session) => session.id === parentID)
|
||||
?? null;
|
||||
}, [currentSessionId, sessions]);
|
||||
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
|
||||
|
||||
const handleReturnToParentSession = React.useCallback(() => {
|
||||
if (!parentSession) return;
|
||||
@@ -943,6 +900,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||
isMobile={isMobile}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
directory={effectiveSessionDirectory}
|
||||
scrollRef={scrollRef}
|
||||
messageListRef={messageListRef}
|
||||
pendingRevealWork={timelineController.pendingRevealWork}
|
||||
|
||||
Reference in New Issue
Block a user