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:
bashrusakh
2026-06-18 00:43:16 +03:00
committed by GitHub
parent 077a766f94
commit 59ecd86b4b
47 changed files with 3168 additions and 1829 deletions
@@ -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}
+15 -21
View File
@@ -74,6 +74,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
import { fetchResponseStyleInstruction } from '@/lib/responseStyle';
import { wrapSystemReminder } from '@/lib/systemReminder';
import { getSyncMessages } from '@/sync/sync-refs';
import { EMPTY_REVERTED_MESSAGE_DOCK_STATE, buildRevertedMessageDockState, type RevertedMessageDockState } from './revertedMessageDockState';
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
import { isSyntheticPart } from '@/lib/messages/synthetic';
import {
@@ -89,11 +90,10 @@ import {
buildAttachmentCitationText,
findAttachmentCitationRanges,
} from './attachmentCitations';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { Part } from '@opencode-ai/sdk/v2/client';
const MAX_VISIBLE_TEXTAREA_LINES = 8;
const EMPTY_QUEUE: QueuedMessage[] = [];
const EMPTY_MESSAGES: Message[] = [];
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
// Single-line URL pasted over a selection becomes a markdown link.
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
@@ -363,34 +363,28 @@ const RevertedMessageDock: React.FC<RevertedMessageDockProps> = React.memo(({ se
const [restoringId, setRestoringId] = React.useState<string | null>(null);
const [forkingId, setForkingId] = React.useState<string | null>(null);
const [collapsed, setCollapsed] = React.useState(true);
const revertMessageID = useDirectorySync(
const revertedStateRef = React.useRef<RevertedMessageDockState>(EMPTY_REVERTED_MESSAGE_DOCK_STATE);
const revertedState = useDirectorySync(
React.useCallback((state) => {
if (!sessionId) return undefined;
const session = state.session.find((item) => item.id === sessionId);
return (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
const next = buildRevertedMessageDockState(state, sessionId, revertedStateRef.current);
revertedStateRef.current = next;
return next;
}, [sessionId]),
directory,
);
const sessionMessages = useDirectorySync(
React.useCallback((state) => (sessionId ? state.message[sessionId] ?? EMPTY_MESSAGES : EMPTY_MESSAGES), [sessionId]),
directory,
);
const partsByMessage = useDirectorySync(React.useCallback((state) => state.part, []), directory);
const revertMessageID = revertedState.revertMessageID;
const userMessages = React.useMemo(
() => sessionMessages.filter((message): message is Message & { role: 'user' } => message.role === 'user'),
[sessionMessages],
() => revertedState.records.map((record) => record.message),
[revertedState],
);
const noTextContent = t('chat.revertPopover.noTextContent');
const items = React.useMemo(() => {
if (!revertMessageID) return [];
return userMessages
.filter((message) => message.id >= revertMessageID)
.map((message) => ({
id: message.id,
text: getRevertedPreview(partsByMessage[message.id] ?? [], noTextContent),
}));
}, [noTextContent, partsByMessage, revertMessageID, userMessages]);
return revertedState.records.map((record) => ({
id: record.message.id,
text: getRevertedPreview(record.parts, noTextContent),
}));
}, [noTextContent, revertMessageID, revertedState]);
const firstRevertedMessageId = items[0]?.id;
React.useEffect(() => {
@@ -1,5 +1,4 @@
import React from 'react';
import 'katex/dist/katex.min.css';
import morphdom from 'morphdom';
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import type { Part } from '@opencode-ai/sdk/v2';
@@ -19,7 +18,7 @@ import type { EditorAPI } from '@/lib/api/types';
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
import { fallbackHtml, renderMarkdownBlocks } from './markdown/markdownCore';
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
import {
attachMarkdownInteractions,
@@ -1032,10 +1031,16 @@ const useMorphdomMarkdown = ({
// `display:contents` keeps margin-collapsing/spacing identical to a flat
// HTML body — the wrapper exists only for per-block reconciliation.
block.style.display = 'contents';
block.innerHTML = fallbackHtml(text);
block.innerHTML = renderMarkdownSync(text);
// Decorate synchronously too: wrap code blocks in their framed card,
// mark inline code, build table controls, etc. The async pass re-decorates
// its own DOM before morphing, so without this the first paint shows bare
// <pre>/tables that "snap" into their decorated form a tick later. Matching
// the structure here keeps the async morph to syntax colors only.
decorateMarkdown(block, ctx);
target.appendChild(block);
}
}, [containerRef, text]);
}, [containerRef, text, ctx]);
React.useEffect(() => {
const container = containerRef.current;
+25 -96
View File
@@ -6,19 +6,19 @@ import ChatMessage from './ChatMessage';
import { areOptionalRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
import TurnItem from './components/TurnItem';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
import { useTurnRecords } from './hooks/useTurnRecords';
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
import { buildLiveStreamingEntry } from './lib/turns/streamingTailEntry';
import { getNormalizedMessageForDisplay, hasCompactionPart } from './lib/messageDisplayNormalization';
import { useUIStore } from '@/stores/useUIStore';
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
import type { StreamPhase } from './message/types';
import { normalizeParts } from './message/partUtils';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
import { useSessionParts } from '@/sync/sync-context';
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5;
const EMPTY_STATIC_ENTRY_MESSAGES: ChatMessageEntry[] = [];
@@ -88,13 +88,6 @@ const resolveMessageRole = (message: ChatMessageEntry): string | null => {
?? null;
};
const hasCompactionPart = (message: ChatMessageEntry): boolean => {
return message.parts.some((part) => {
const type = (part as { type?: unknown } | null | undefined)?.type;
return type === 'compaction';
});
};
const getPartText = (part: Part): string => {
const text = (part as { text?: unknown }).text;
if (typeof text === 'string') {
@@ -107,40 +100,6 @@ const getPartText = (part: Part): string => {
return '';
};
const normalizeCompactionCommandMessage = (message: ChatMessageEntry): ChatMessageEntry => {
if (!hasCompactionPart(message)) {
return message;
}
let changedParts = false;
const nextParts = message.parts.map((part) => {
const type = (part as { type?: unknown } | null | undefined)?.type;
if (type !== 'compaction') {
return part;
}
changedParts = true;
return { type: 'text', text: '/compact' } as Part;
});
const info = message.info as unknown as { clientRole?: string | null | undefined };
const needsClientRole = info.clientRole !== 'user';
if (!changedParts && !needsClientRole) {
return message;
}
return {
...message,
info: needsClientRole
? ({
...(message.info as unknown as Record<string, unknown>),
clientRole: 'user',
} as unknown as typeof message.info)
: message.info,
parts: changedParts ? nextParts : message.parts,
};
};
const normalizeCompactionSummaryMessage = (
message: ChatMessageEntry,
compactionCommandIds: Set<string>,
@@ -391,39 +350,6 @@ const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeD
};
};
const normalizeMessageParts = (message: ChatMessageEntry): ChatMessageEntry => {
const parts = normalizeParts(message.parts);
if (parts.length === message.parts.length) {
return message;
}
return {
...message,
parts,
};
};
const normalizedMessageBySource = new WeakMap<ChatMessageEntry, ChatMessageEntry>();
const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
const cached = normalizedMessageBySource.get(message);
if (cached) {
return cached;
}
const normalizedPartMessage = normalizeMessageParts(message);
const normalizedCompactionMessage = normalizeCompactionCommandMessage(normalizedPartMessage);
const filteredParts = filterSyntheticParts(normalizedCompactionMessage.parts);
const normalized = filteredParts === normalizedCompactionMessage.parts
? normalizedCompactionMessage
: {
...normalizedCompactionMessage,
parts: filteredParts,
};
normalizedMessageBySource.set(message, normalized);
return normalized;
};
interface MessageListProps {
sessionKey: string;
disableStaging?: boolean;
@@ -442,6 +368,7 @@ interface MessageListProps {
isLoadingOlder: boolean;
scrollToBottom?: () => void;
scrollRef?: React.RefObject<HTMLDivElement | null>;
directory?: string;
}
export interface MessageListHandle {
@@ -1007,11 +934,10 @@ type StaticHistoryListProps = {
chatRenderMode: 'sorted' | 'live';
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingPhase?: StreamPhase | null;
reviewTransferDirection?: ReviewTransferDirection | null;
};
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingPhase, reviewTransferDirection }: StaticHistoryListProps) => {
const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, scrollRef, virtualizerRef, virtualizerKey, virtualCache, shift, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, reviewTransferDirection }: StaticHistoryListProps) => {
const renderEntry = React.useCallback((entry: RenderEntry) => {
return (
<MessageListEntry
@@ -1029,11 +955,11 @@ const StaticHistoryList = React.memo(({ entries, shouldVirtualize, contentRef, s
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={null}
activeStreamingPhase={activeStreamingPhase}
activeStreamingPhase={null}
reviewTransferDirection={reviewTransferDirection}
/>
);
}, [activeStreamingPhase, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
}, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, reviewTransferDirection, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
if (!shouldVirtualize) {
return (
@@ -1074,6 +1000,7 @@ StaticHistoryList.displayName = 'StaticHistoryList';
const StreamingTailContent: React.FC<{
entry: RenderEntry;
directory?: string;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: () => void;
@@ -1083,6 +1010,7 @@ const StreamingTailContent: React.FC<{
turnUiStates: Map<string, TurnUiState>;
onToggleTurnGroup: (turnId: string) => void;
chatRenderMode: 'sorted' | 'live';
showTurnChangedFiles: boolean;
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
onUserAnimationConsumed: (messageId: string) => void;
activeStreamingMessageId?: string | null;
@@ -1090,6 +1018,7 @@ const StreamingTailContent: React.FC<{
reviewTransferDirection?: ReviewTransferDirection | null;
}> = ({
entry,
directory,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
@@ -1099,15 +1028,24 @@ const StreamingTailContent: React.FC<{
turnUiStates,
onToggleTurnGroup,
chatRenderMode,
showTurnChangedFiles,
shouldAnimateUserMessage,
onUserAnimationConsumed,
activeStreamingMessageId,
activeStreamingPhase,
reviewTransferDirection,
}) => {
const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory);
const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
activeStreamingMessageId,
liveParts,
showTextJustificationActivity: chatRenderMode === 'sorted',
showTurnChangedFiles,
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles]);
return (
<MessageListEntry
entry={entry}
entry={liveEntry}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
@@ -1141,6 +1079,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
isLoadingOlder,
scrollToBottom,
scrollRef,
directory,
}, ref) => {
streamPerfCount('ui.message_list.render');
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
@@ -1149,18 +1088,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
const showTurnChangedFiles = useUIStore((state) => state.showTurnChangedFiles);
const defaultActivityExpanded = activityRenderMode === 'summary';
const reviewTransferDirection = useGlobalSessionsStore((state) => {
const currentSession = state.activeSessions.find((session) => session.id === sessionKey);
const direction = getReviewTransferDirection(currentSession);
if (!currentSession || !direction) return null;
const targetSessionId = direction === 'review-to-original'
? getOriginalSessionID(currentSession)
: getReviewSessionID(currentSession);
if (!targetSessionId) return null;
return state.activeSessions.some((session) => session.id === targetSessionId)
? direction
: null;
return state.reviewTransferBySessionId.get(sessionKey) ?? null;
});
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, TurnUiState>>(() => new Map());
const userAnimationRef = React.useRef<{
@@ -1665,12 +1593,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
chatRenderMode={chatRenderMode}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingPhase={activeStreamingPhase}
reviewTransferDirection={reviewTransferDirection}
/>
{trailingStreamingEntry ? (
<StreamingTailContent
entry={trailingStreamingEntry}
directory={directory}
onMessageContentChange={stableTailContentChange}
getAnimationHandlers={stableGetAnimationHandlers}
scrollToBottom={stableScrollToBottom}
@@ -1680,6 +1608,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
turnUiStates={turnUiStates}
onToggleTurnGroup={toggleTurnGroup}
chatRenderMode={chatRenderMode}
showTurnChangedFiles={showTurnChangedFiles}
shouldAnimateUserMessage={shouldAnimateUserMessage}
onUserAnimationConsumed={onUserAnimationConsumed}
activeStreamingMessageId={activeStreamingMessageId}
@@ -334,24 +334,67 @@ export const useChatTimelineController = ({
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
}, [messageListRef]);
// Tracks the timeline edges + height of the previous commit so a prepend
// that did NOT go through fetchOlderHistory (e.g. the background history
// prepend dispatched from useSync) can be compensated too. With
// overflow-anchor:none the browser leaves scrollTop unchanged when content
// is inserted above, so without this the viewport visibly jumps and
// auto-follow yanks it back on the next frame — a one-shot up/down judder.
const prependTrackingRef = React.useRef<{
oldestId: string | null;
newestId: string | null;
scrollHeight: number;
} | null>(null);
React.useLayoutEffect(() => {
const snap = prePrependScrollRef.current;
const container = scrollRef.current;
if (!snap || !container) return;
prePrependScrollRef.current = null;
if (!container) return;
// When a viewport anchor is available, delegate to MessageList
// restoreViewportAnchor which falls back to virtualizer-aware
// scrollHistoryIndexIntoView when the element is not in the DOM.
if (snap.anchor && restoreViewportAnchor(snap.anchor)) {
return;
const snap = prePrependScrollRef.current;
if (snap) {
prePrependScrollRef.current = null;
// When a viewport anchor is available, delegate to MessageList
// restoreViewportAnchor which falls back to virtualizer-aware
// scrollHistoryIndexIntoView when the element is not in the DOM.
if (!(snap.anchor && restoreViewportAnchor(snap.anchor))) {
// Fallback: height-delta compensation
const delta = container.scrollHeight - snap.height;
if (delta > 0) {
container.scrollTop = snap.top + delta;
}
}
} else {
// Auto-detect a prepend: the oldest message changed while the newest
// stayed the same (distinguishes a real prepend from a session
// switch, a bottom append, or a streaming part growing). Compensate
// synchronously by the exact height delta — for a bottom-pinned
// viewport this keeps it pinned, for a released one it preserves the
// read position, with no intermediate frame for auto-follow to fight.
const prev = prependTrackingRef.current;
const currentOldestId = renderedMessages[0]?.info?.id ?? null;
const currentNewestId = renderedMessages[renderedMessages.length - 1]?.info?.id ?? null;
const isPrepend = Boolean(
prev
&& prev.oldestId
&& currentOldestId
&& currentOldestId !== prev.oldestId
&& prev.newestId
&& currentNewestId
&& currentNewestId === prev.newestId,
);
if (isPrepend && prev) {
const delta = container.scrollHeight - prev.scrollHeight;
if (delta > 0) {
container.scrollTop = container.scrollTop + delta;
}
}
}
// Fallback: height-delta compensation
const delta = container.scrollHeight - snap.height;
if (delta > 0) {
container.scrollTop = snap.top + delta;
}
prependTrackingRef.current = {
oldestId: renderedMessages[0]?.info?.id ?? null,
newestId: renderedMessages[renderedMessages.length - 1]?.info?.id ?? null,
scrollHeight: container.scrollHeight,
};
}, [renderedMessages, scrollRef, restoreViewportAnchor]);
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => false, []);
@@ -1,6 +1,7 @@
import React from 'react';
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
import { buildProjectionCacheKey, getCachedProjection, setCachedProjection } from '../lib/turns/turnProjectionCache';
import { streamPerfMeasure } from '@/stores/utils/streamDebug';
interface UseTurnRecordsOptions {
@@ -46,6 +47,18 @@ export const useTurnRecords = (
}, [options.sessionKey, options.showTextJustificationActivity, options.showTurnChangedFiles]);
const projection = React.useMemo(() => {
const sessionKey = options.sessionKey ?? '';
const cached = getCachedProjection(
sessionKey,
messages,
options.showTextJustificationActivity,
options.showTurnChangedFiles,
);
if (cached) {
previousProjectionRef.current = cached;
return cached;
}
return streamPerfMeasure('ui.turns.projection_ms', () => {
const nextProjection = projectTurnRecords(messages, {
previousProjection: previousProjectionRef.current,
@@ -53,9 +66,18 @@ export const useTurnRecords = (
showTurnChangedFiles: options.showTurnChangedFiles,
});
previousProjectionRef.current = nextProjection;
const cacheKey = buildProjectionCacheKey(
sessionKey,
messages,
options.showTextJustificationActivity,
options.showTurnChangedFiles,
);
setCachedProjection(cacheKey, nextProjection);
return nextProjection;
});
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles]);
}, [messages, options.showTextJustificationActivity, options.showTurnChangedFiles, options.sessionKey]);
const staticTurns = React.useMemo(() => {
const nextStatic = projection.turns.length <= 1
@@ -0,0 +1,79 @@
import type { Part } from '@opencode-ai/sdk/v2';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { normalizeParts } from '../message/partUtils';
import type { ChatMessageEntry } from './turns/types';
export const hasCompactionPart = (message: ChatMessageEntry): boolean => {
return message.parts.some((part) => {
const type = (part as { type?: unknown } | null | undefined)?.type;
return type === 'compaction';
});
};
const normalizeCompactionCommandMessage = (message: ChatMessageEntry): ChatMessageEntry => {
if (!hasCompactionPart(message)) {
return message;
}
let changedParts = false;
const nextParts = message.parts.map((part) => {
const type = (part as { type?: unknown } | null | undefined)?.type;
if (type !== 'compaction') {
return part;
}
changedParts = true;
return { type: 'text', text: '/compact' } as Part;
});
const info = message.info as unknown as { clientRole?: string | null | undefined };
const needsClientRole = info.clientRole !== 'user';
if (!changedParts && !needsClientRole) {
return message;
}
return {
...message,
info: needsClientRole
? ({
...(message.info as unknown as Record<string, unknown>),
clientRole: 'user',
} as unknown as typeof message.info)
: message.info,
parts: changedParts ? nextParts : message.parts,
};
};
const normalizeMessageParts = (message: ChatMessageEntry): ChatMessageEntry => {
const parts = normalizeParts(message.parts);
if (parts.length === message.parts.length) {
return message;
}
return {
...message,
parts,
};
};
const normalizedMessageBySource = new WeakMap<ChatMessageEntry, ChatMessageEntry>();
export const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageEntry => {
const cached = normalizedMessageBySource.get(message);
if (cached) {
return cached;
}
const normalizedPartMessage = normalizeMessageParts(message);
const normalizedCompactionMessage = normalizeCompactionCommandMessage(normalizedPartMessage);
const filteredParts = filterSyntheticParts(normalizedCompactionMessage.parts);
const normalized = filteredParts === normalizedCompactionMessage.parts
? normalizedCompactionMessage
: {
...normalizedCompactionMessage,
parts: filteredParts,
};
normalizedMessageBySource.set(message, normalized);
return normalized;
};
@@ -106,6 +106,27 @@ describe('projectTurnRecords', () => {
expect(next.turns[1]).not.toBe(initial.turns[1]);
});
test('hydrates updated turns when a previous projection exists but no turn is reusable', () => {
const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
const initial = projectTurnRecords([user, assistant]);
const updatedAssistant = {
...assistant,
parts: [{ id: 'tool_1', type: 'tool', tool: 'bash', state: { status: 'completed' } } as Part],
};
const next = projectTurnRecords([user, updatedAssistant], {
previousProjection: initial,
});
expect(next.turns).toHaveLength(1);
expect(next.turns[0]).not.toBe(initial.turns[0]);
expect(next.turns[0]?.hasTools).toBe(true);
expect(next.turns[0]?.activityParts).toHaveLength(1);
expect(next.turns[0]?.stream.isStreaming).toBe(true);
expect(next.turns[0]?.stream.isRetrying).toBe(false);
});
test('reuses the whole turns array when every turn is unchanged', () => {
const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 });
const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 });
@@ -115,12 +115,43 @@ const canReusePreviousTurn = (previous: TurnRecord, next: TurnRecord): boolean =
&& areSameMessageRefs(previous.assistantMessages, next.assistantMessages);
};
const stabilizeTurnRecords = (
const hydrateTurnRecord = (
turn: TurnRecord,
effectiveOptions: ProjectTurnRecordsOptions,
): TurnRecord => {
turn.summary = projectTurnSummary(turn.assistantMessages);
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
turn.diffStats = projectTurnDiffStats(turn.userMessage);
turn.changedFiles = effectiveOptions.showTurnChangedFiles
? projectTurnChangedFiles(turn.userMessage)
: undefined;
const activity = projectTurnActivity({
turnId: turn.turnId,
assistantMessages: turn.assistantMessages,
summarySourceMessageId: turn.summary.sourceMessageId,
summarySourcePartId: turn.summary.sourcePartId,
showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
});
turn.activityParts = activity.activityParts;
turn.activitySegments = activity.activitySegments;
turn.hasTools = activity.hasTools;
turn.hasReasoning = activity.hasReasoning;
turn.stream = buildTurnStreamState(turn.userMessage, turn.assistantMessages);
turn.startedAt = turn.stream.startedAt;
turn.completedAt = turn.stream.completedAt;
turn.durationMs = turn.stream.durationMs;
return turn;
};
const hydrateStableTurnRecords = (
turns: TurnRecord[],
previousProjection?: TurnProjectionResult | null,
effectiveOptions: ProjectTurnRecordsOptions,
): TurnRecord[] => {
const previousProjection = effectiveOptions.previousProjection;
if (!previousProjection || previousProjection.turns.length === 0 || turns.length === 0) {
return turns;
return turns.map((turn) => hydrateTurnRecord(turn, effectiveOptions));
}
let canReuseTurnArray = previousProjection.turns.length === turns.length;
@@ -137,14 +168,14 @@ const stabilizeTurnRecords = (
}
canReuseTurnArray = false;
return turn;
return hydrateTurnRecord(turn, effectiveOptions);
});
if (canReuseTurnArray && reusedAnyTurn) {
return previousProjection.turns;
}
return reusedAnyTurn ? nextTurns : turns;
return nextTurns;
};
export const projectTurnRecords = (
@@ -214,33 +245,7 @@ export const projectTurnRecords = (
groupedMessageIds.add(message.info.id);
});
turns.forEach((turn) => {
turn.summary = projectTurnSummary(turn.assistantMessages);
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
turn.diffStats = projectTurnDiffStats(turn.userMessage);
turn.changedFiles = effectiveOptions.showTurnChangedFiles
? projectTurnChangedFiles(turn.userMessage)
: undefined;
const activity = projectTurnActivity({
turnId: turn.turnId,
assistantMessages: turn.assistantMessages,
summarySourceMessageId: turn.summary.sourceMessageId,
summarySourcePartId: turn.summary.sourcePartId,
showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
});
turn.activityParts = activity.activityParts;
turn.activitySegments = activity.activitySegments;
turn.hasTools = activity.hasTools;
turn.hasReasoning = activity.hasReasoning;
turn.stream = buildTurnStreamState(turn.userMessage, turn.assistantMessages);
turn.startedAt = turn.stream.startedAt;
turn.completedAt = turn.stream.completedAt;
turn.durationMs = turn.stream.durationMs;
});
const stableTurns = stabilizeTurnRecords(turns, effectiveOptions.previousProjection);
const stableTurns = hydrateStableTurnRecords(turns, effectiveOptions);
const projection = projectTurnIndexes(stableTurns);
const ungroupedMessageIds = new Set<string>();
messages.forEach((message) => {
@@ -0,0 +1,134 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { buildLiveStreamingEntry, type StreamingTailEntry } from './streamingTailEntry';
import type { ChatMessageEntry, TurnRecord } from './types';
const message = (id: string, role: 'user' | 'assistant', parentID?: string, parts: Part[] = []): ChatMessageEntry => ({
info: {
id,
role,
sessionID: 'ses_1',
...(parentID ? { parentID } : {}),
time: { created: 1 },
} as Message,
parts,
});
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const syntheticTextPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
synthetic: true,
} as Part);
const reasoningPart = (id: string, text: string): Part => ({
id,
type: 'reasoning',
text,
} as Part);
const turnEntry = (assistant: ChatMessageEntry): StreamingTailEntry => {
const user = message('user_1', 'user');
return {
kind: 'turn',
key: 'turn:user_1',
isLastTurn: true,
turn: {
turnId: 'user_1',
userMessageId: 'user_1',
userMessage: user,
headerMessageId: assistant.info.id,
messages: [],
assistantMessageIds: [assistant.info.id],
assistantMessages: [assistant],
activityParts: [],
activitySegments: [],
summary: {},
hasTools: false,
hasReasoning: false,
stream: { isStreaming: true, isRetrying: false },
} satisfies TurnRecord,
};
};
describe('buildLiveStreamingEntry', () => {
test('returns the same entry when the active message is not in the tail', () => {
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'old')]);
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_other',
liveParts: [textPart('part_live', 'live')],
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next).toBe(entry);
});
test('rebuilds only the streaming turn with live parts', () => {
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'hel')]);
const entry = turnEntry(assistant);
const liveParts = [reasoningPart('part_1_live', 'thinking')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next).not.toBe(entry);
expect(next.kind).toBe('turn');
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toBe(liveParts);
expect(next.turn.activityParts.length).toBeGreaterThan(0);
});
test('updates an ungrouped streaming message with live parts', () => {
const stale = message('assistant_1', 'assistant', undefined, [textPart('part_1', 'old')]);
const entry: StreamingTailEntry = {
kind: 'ungrouped',
key: 'msg:assistant_1',
message: stale,
};
const liveParts = [textPart('part_1_live', 'live')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
showTextJustificationActivity: false,
showTurnChangedFiles: false,
});
expect(next).not.toBe(entry);
expect(next.kind).toBe('ungrouped');
if (next.kind !== 'ungrouped') return;
expect(next.message.parts).toBe(liveParts);
});
test('normalizes live tail parts with the display filtering path', () => {
const stale = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'old')]);
const entry = turnEntry(stale);
const visible = textPart('part_visible', 'visible');
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts: [synthetic, visible],
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next.kind).toBe('turn');
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
});
});
@@ -0,0 +1,86 @@
import type { Part } from '@opencode-ai/sdk/v2';
import { getNormalizedMessageForDisplay } from '../messageDisplayNormalization';
import { projectTurnRecords } from './projectTurnRecords';
import type { ChatMessageEntry, TurnRecord } from './types';
export type StreamingTailEntry =
| {
kind: 'ungrouped';
key: string;
message: ChatMessageEntry;
previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry;
}
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
type BuildLiveStreamingEntryOptions = {
activeStreamingMessageId: string | null | undefined;
liveParts: Part[];
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
};
const withLiveParts = (
message: ChatMessageEntry,
activeStreamingMessageId: string,
liveParts: Part[],
): ChatMessageEntry => {
if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) {
return message;
}
return getNormalizedMessageForDisplay({
...message,
parts: liveParts,
});
};
export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
entry: TEntry,
options: BuildLiveStreamingEntryOptions,
): TEntry => {
const activeStreamingMessageId = options.activeStreamingMessageId;
if (!activeStreamingMessageId) {
return entry;
}
if (entry.kind === 'ungrouped') {
const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts);
if (message === entry.message) {
return entry;
}
return {
...entry,
message,
};
}
let changed = false;
const assistantMessages = entry.turn.assistantMessages.map((message) => {
const next = withLiveParts(message, activeStreamingMessageId, options.liveParts);
if (next !== message) {
changed = true;
}
return next;
});
if (!changed) {
return entry;
}
const projection = projectTurnRecords([entry.turn.userMessage, ...assistantMessages], {
showTextJustificationActivity: options.showTextJustificationActivity,
showTurnChangedFiles: options.showTurnChangedFiles,
});
const turn = projection.turns[0] ?? {
...entry.turn,
assistantMessages,
assistantMessageIds: assistantMessages.map((message) => message.info.id),
};
return {
...entry,
turn,
};
};
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { buildProjectionCacheKey } from './turnProjectionCache';
import type { ChatMessageEntry } from './types';
const createEntry = (text: string): ChatMessageEntry => ({
info: { id: 'msg_1', role: 'assistant' } as Message,
parts: [{ id: 'prt_1', type: 'text', text } as Part],
});
describe('turnProjectionCache', () => {
test('keeps the cache key stable for unchanged message and part references', () => {
const messages = [createEntry('hello')];
const first = buildProjectionCacheKey('session_1', messages, false, false);
const second = buildProjectionCacheKey('session_1', messages, false, false);
expect(second).toBe(first);
});
test('changes the cache key when streaming replaces a part with the same id and count', () => {
const before = [createEntry('hel')];
const after = [
{
info: before[0].info,
parts: [{ id: 'prt_1', type: 'text', text: 'hello' } as Part],
},
];
const beforeKey = buildProjectionCacheKey('session_1', before, false, false);
const afterKey = buildProjectionCacheKey('session_1', after, false, false);
expect(afterKey).not.toBe(beforeKey);
});
});
@@ -0,0 +1,86 @@
import type { ChatMessageEntry, TurnProjectionResult } from './types';
import { isVSCodeRuntime } from '@/lib/desktop';
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
const TURN_PROJECTION_CACHE_MAX = 30;
const VSCODE_TURN_PROJECTION_CACHE_MAX = 4;
const MOBILE_TURN_PROJECTION_CACHE_MAX = 4;
const projectionCache = new Map<string, TurnProjectionResult>();
const objectVersionByRef = new WeakMap<object, number>();
let nextObjectVersion = 1;
const getProjectionCacheMax = () => {
if (isVSCodeRuntime()) return VSCODE_TURN_PROJECTION_CACHE_MAX;
if (isMobileSurfaceRuntime()) return MOBILE_TURN_PROJECTION_CACHE_MAX;
return TURN_PROJECTION_CACHE_MAX;
};
const getObjectVersion = (value: object): number => {
const cached = objectVersionByRef.get(value);
if (cached !== undefined) return cached;
const next = nextObjectVersion;
nextObjectVersion += 1;
objectVersionByRef.set(value, next);
return next;
};
const buildMessagesVersionSignature = (messages: ChatMessageEntry[]): string => {
return messages.map((message) => {
const infoVersion = getObjectVersion(message.info as object);
const partsVersion = getObjectVersion(message.parts);
const partVersions = message.parts.map((part) => getObjectVersion(part as object)).join(',');
return `${infoVersion}:${partsVersion}:${partVersions}`;
}).join(';');
};
export const buildProjectionCacheKey = (
sessionKey: string,
messages: ChatMessageEntry[],
showTextJustificationActivity: boolean,
showTurnChangedFiles: boolean,
): string => {
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined;
const lastMessageId = lastMessage?.info?.id ?? '';
const lastMessagePartCount = lastMessage?.parts?.length ?? 0;
return [
sessionKey,
messages.length,
lastMessageId,
lastMessagePartCount,
buildMessagesVersionSignature(messages),
showTextJustificationActivity ? '1' : '0',
showTurnChangedFiles ? '1' : '0',
].join('|');
};
export const getCachedProjection = (
sessionKey: string,
messages: ChatMessageEntry[],
showTextJustificationActivity: boolean,
showTurnChangedFiles: boolean,
): TurnProjectionResult | undefined => {
const key = buildProjectionCacheKey(sessionKey, messages, showTextJustificationActivity, showTurnChangedFiles);
const cached = projectionCache.get(key);
if (cached) {
// LRU re-order: move hit to the end (most recent) so it survives
// eviction longer than entries that haven't been read recently.
projectionCache.delete(key);
projectionCache.set(key, cached);
}
return cached;
};
export const setCachedProjection = (
key: string,
projection: TurnProjectionResult,
): void => {
projectionCache.delete(key);
const max = getProjectionCacheMax();
while (projectionCache.size >= max) {
const oldest = projectionCache.keys().next().value;
if (typeof oldest !== 'string') break;
projectionCache.delete(oldest);
}
projectionCache.set(key, projection);
};
@@ -88,7 +88,11 @@ const decorateCodeBlocks = (root: HTMLElement, labels: DecorateLabels): void =>
// Already wrapped (idempotent across morphdom passes).
if (parent.closest('[data-component="markdown-code"]')) continue;
const language = pre.getAttribute('data-md-lang') ?? 'text';
// `data-md-lang` is stamped by the async highlight pass; on the synchronous
// first paint it isn't set yet, so fall back to the `language-*` class marked
// emits — keeps the card header label stable instead of flashing 'text'.
const classLang = pre.querySelector('code')?.className.match(/language-([\w+#.-]+)/)?.[1];
const language = pre.getAttribute('data-md-lang') ?? classLang ?? 'text';
const wrapper = document.createElement('div');
wrapper.setAttribute('data-component', 'markdown-code');
@@ -306,16 +306,6 @@ const sanitize = (html: string): string => {
return DOMPurify.sanitize(html, SANITIZE_CONFIG) as unknown as string;
};
const escapeHtml = (text: string): string =>
text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
export const fallbackHtml = (markdown: string): string =>
escapeHtml(markdown).replace(/\r\n?/g, '\n').replace(/\n/g, '<br>');
// ---------------------------------------------------------------------------
// Per-block HTML cache (LRU, mirrors OpenCode's checksum cache)
@@ -349,6 +339,22 @@ const parseBlock = async (block: MarkdownBlock): Promise<string> => {
return sanitize(highlighted);
};
/**
* Synchronous styled render for the first paint, before the async pipeline
* (Shiki-in-worker highlight) resolves. Produces the SAME structural HTML as
* `renderMarkdownBlocks` minus syntax coloring: paragraphs, lists, code blocks
* and bold all render at their final width, so the async pass only upgrades
* code-block colors no flash of full-width raw markdown source. `parser.parse`
* is synchronous (marked is not configured `async`), so this never blocks on a
* worker round-trip.
*/
export const renderMarkdownSync = (text: string): string => {
if (!text) return '';
const parsed = parser.parse(text) as string;
const withMath = renderMathExpressions(parsed);
return sanitize(withMath);
};
export type RenderedBlock = {
// Stable identity across renders for per-block DOM reconciliation. Encodes
// content + mode + highlight so any change forces that block (and only that
@@ -12,11 +12,7 @@ import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectorySync, useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
import { getSyncChildStores } from '@/sync/sync-refs';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionActivity } from '@/hooks/useSessionActivity';
import { opencodeClient } from '@/lib/opencode/client';
import { isVSCodeRuntime } from '@/lib/desktop';
import { sessionEvents } from '@/lib/sessionEvents';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { Button } from '@/components/ui/button';
@@ -164,17 +160,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => {
};
const MAX_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
const TASK_TOOL_POLL_FAST_MS = 1200;
const TASK_TOOL_POLL_IDLE_MS = 3200;
const TASK_TOOL_POLL_HIDDEN_MS = 6000;
const TASK_TOOL_INITIAL_FETCH_LIMIT = 500;
const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
const TASK_TOOL_IDLE_FETCH_LIMIT = 80;
const VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT = 30;
const VSCODE_TASK_TOOL_ACTIVE_FETCH_LIMIT = 30;
const VSCODE_TASK_TOOL_IDLE_FETCH_LIMIT = 30;
const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
const TASK_TOOL_SETTLE_GRACE_MS = 2500;
const TASK_TOOL_FALLBACK_RETRY_MS = 3000;
const GIT_REFRESH_MUTATING_TOOLS = new Set([
'bash',
@@ -1025,41 +1010,6 @@ const buildTaskSummaryEntriesFromSession = (messages: SessionMessageWithParts[])
return entries;
};
const buildTaskSessionMessagesSignature = (messages: SessionMessageWithParts[]): string => {
if (!Array.isArray(messages) || messages.length === 0) {
return '0';
}
const lastMessage = messages[messages.length - 1];
const lastMessageId = typeof lastMessage?.info?.id === 'string' ? lastMessage.info.id : '';
const lastMessageUpdated =
typeof lastMessage?.info?.time?.completed === 'number'
? lastMessage.info.time.completed
: typeof lastMessage?.info?.time?.created === 'number'
? lastMessage.info.time.created
: 0;
const lastParts = Array.isArray(lastMessage?.parts) ? lastMessage.parts : [];
const lastPart = lastParts[lastParts.length - 1] as Record<string, unknown> | undefined;
const tailType = typeof lastPart?.type === 'string' ? lastPart.type : '';
const tailId = typeof lastPart?.id === 'string' ? lastPart.id : '';
const tailTextLength = (() => {
const textCandidate = lastPart?.text;
if (typeof textCandidate === 'string') {
return textCandidate.length;
}
const stateCandidate = lastPart?.state;
if (stateCandidate && typeof stateCandidate === 'object') {
const stateStatus = (stateCandidate as Record<string, unknown>).status;
if (typeof stateStatus === 'string') {
return stateStatus.length;
}
}
return 0;
})();
return `${messages.length}:${lastMessageId}:${lastMessageUpdated}:${lastParts.length}:${tailType}:${tailId}:${tailTextLength}`;
};
const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => {
const title = entry.state?.title;
if (typeof title === 'string' && title.trim().length > 0) {
@@ -1117,6 +1067,144 @@ const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => {
return /^[A-Za-z0-9_-]+$/.test(baseName);
};
const getTaskSummaryEntryRenderSignature = (entry: TaskToolSummaryEntry): string => {
const toolName = normalizeToolName(entry.tool);
const status = entry.state?.status ?? '';
const label = getTaskSummaryLabel(entry);
return `${entry.id ?? ''}\u0001${toolName}\u0001${status}\u0001${label}`;
};
const areTaskSummaryEntriesRenderEqual = (
prevEntries: TaskToolSummaryEntry[],
nextEntries: TaskToolSummaryEntry[],
): boolean => {
if (prevEntries === nextEntries) return true;
if (prevEntries.length !== nextEntries.length) return false;
for (let index = 0; index < prevEntries.length; index += 1) {
if (getTaskSummaryEntryRenderSignature(prevEntries[index]) !== getTaskSummaryEntryRenderSignature(nextEntries[index])) {
return false;
}
}
return true;
};
const TaskSummaryEntryRow = React.memo(({
entry,
isMobile,
animateTailText,
showToolFileIcons,
}: {
entry: TaskToolSummaryEntry;
isMobile: boolean;
animateTailText: boolean;
showToolFileIcons: boolean;
}) => {
const normalizedToolName = normalizeToolName(entry.tool);
const toolName = normalizedToolName.length > 0 ? normalizedToolName : 'tool';
const label = getTaskSummaryLabel(entry);
const hasLabel = label.trim().length > 0;
const status = entry.state?.status;
const displayName = getToolMetadata(toolName).displayName;
return (
<ToolRevealOnMount animate={animateTailText} wipe>
<div className={cn('flex gap-2 min-w-0 w-full', isMobile ? 'items-start' : 'items-center')}>
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
<span
className="typography-meta text-foreground/80 flex-shrink-0"
style={{ color: 'var(--tools-title)' }}
title={displayName}
>
{displayName}
</span>
{hasLabel ? (
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons)
) : (
status === 'error' ? (
<span className={cn(
'typography-meta flex-1 min-w-0 text-[var(--status-error)]',
isMobile ? 'whitespace-normal break-words' : 'truncate',
)}>
{label}
</span>
) : (
<Text
variant={animateTailText ? 'generate-effect' : 'static'}
className={cn(
'typography-meta flex-1 min-w-0 text-muted-foreground/70',
isMobile ? 'whitespace-normal break-words' : 'truncate',
)}
style={{ color: 'var(--tools-description)' }}
title={label}
>
{label}
</Text>
)
)
) : null}
</div>
</ToolRevealOnMount>
);
}, (prev, next) => {
return prev.isMobile === next.isMobile
&& prev.animateTailText === next.animateTailText
&& prev.showToolFileIcons === next.showToolFileIcons
&& getTaskSummaryEntryRenderSignature(prev.entry) === getTaskSummaryEntryRenderSignature(next.entry);
});
TaskSummaryEntryRow.displayName = 'TaskSummaryEntryRow';
const TaskSummaryEntriesList = React.memo(({
entries,
isExpanded,
isMobile,
animateTailText,
showToolFileIcons,
}: {
entries: TaskToolSummaryEntry[];
isExpanded: boolean;
isMobile: boolean;
animateTailText: boolean;
showToolFileIcons: boolean;
}) => {
const visibleEntries = isExpanded ? entries : entries.slice(-6);
const hiddenCount = Math.max(0, entries.length - visibleEntries.length);
const visibleStartIndex = entries.length - visibleEntries.length;
return (
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
<div className="w-full min-w-0 space-y-1">
{hiddenCount > 0 ? (
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more</div>
) : null}
{visibleEntries.map((entry, idx) => {
const absoluteIndex = isExpanded ? idx : visibleStartIndex + idx;
const rowKey = entry.id ?? `${getTaskSummaryEntryRenderSignature(entry)}:${absoluteIndex}`;
return (
<TaskSummaryEntryRow
key={rowKey}
entry={entry}
isMobile={isMobile}
animateTailText={animateTailText}
showToolFileIcons={showToolFileIcons}
/>
);
})}
</div>
</ToolScrollableSection>
);
}, (prev, next) => {
return prev.isExpanded === next.isExpanded
&& prev.isMobile === next.isMobile
&& prev.animateTailText === next.animateTailText
&& prev.showToolFileIcons === next.showToolFileIcons
&& areTaskSummaryEntriesRenderEqual(prev.entries, next.entries);
});
TaskSummaryEntriesList.displayName = 'TaskSummaryEntriesList';
const stripTaskMetadataFromOutput = (output: string): string => {
// Strip only a trailing <task_metadata>...</task_metadata> block.
return output.replace(/\n*<task_metadata>[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd();
@@ -1233,7 +1321,6 @@ const TaskToolSummary: React.FC<{
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
const runtime = React.useContext(RuntimeAPIContext);
const displayEntries = entries;
const trimmedOutput = typeof output === 'string'
? stripTaskMetadataFromOutput(output)
@@ -1262,7 +1349,7 @@ const TaskToolSummary: React.FC<{
? input.subagent_type
: 'subagent';
if (displayEntries.length === 0 && !hasOutput && !sessionId) {
if (entries.length === 0 && !hasOutput && !sessionId) {
return (
<div className="relative pr-2 pb-2 pt-2 space-y-2 pl-[1.4375rem]">
<div className="typography-meta text-muted-foreground/70">
@@ -1272,9 +1359,6 @@ const TaskToolSummary: React.FC<{
);
}
const visibleEntries = isExpanded ? displayEntries : displayEntries.slice(-6);
const hiddenCount = Math.max(0, displayEntries.length - visibleEntries.length);
return (
<div
className={cn(
@@ -1283,65 +1367,14 @@ const TaskToolSummary: React.FC<{
'before:top-[-0.25rem] before:bottom-0'
)}
>
{displayEntries.length > 0 ? (
<ToolScrollableSection maxHeightClass={isExpanded ? 'max-h-[40vh]' : 'max-h-56'} disableHorizontal>
<div className="w-full min-w-0 space-y-1">
{hiddenCount > 0 ? (
<div className="typography-micro text-muted-foreground/70">+{hiddenCount} more</div>
) : null}
{visibleEntries.map((entry, idx) => {
const normalizedToolName = normalizeToolName(entry.tool);
const toolName = normalizedToolName.length > 0 ? normalizedToolName : 'tool';
const label = getTaskSummaryLabel(entry);
const hasLabel = label.trim().length > 0;
const status = entry.state?.status;
const displayName = getToolMetadata(toolName).displayName;
return (
<ToolRevealOnMount key={entry.id ?? `${toolName}-${idx}`} animate={animateTailText} wipe>
<div className={cn("flex gap-2 min-w-0 w-full", isMobile ? 'items-start' : 'items-center')}>
<span className="flex-shrink-0 text-foreground/80">{getToolIcon(toolName)}</span>
<span
className="typography-meta text-foreground/80 flex-shrink-0"
style={{ color: 'var(--tools-title)' }}
title={displayName}
>
{displayName}
</span>
{hasLabel ? (
status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? (
renderAnimatedPathWithIcon(label, animateTailText, true, showToolFileIcons)
) : (
status === 'error' ? (
<span className={cn(
'typography-meta flex-1 min-w-0 text-[var(--status-error)]',
isMobile ? 'whitespace-normal break-words' : 'truncate'
)}>
{label}
</span>
) : (
<Text
variant={animateTailText ? 'generate-effect' : 'static'}
className={cn(
'typography-meta flex-1 min-w-0 text-muted-foreground/70',
isMobile ? 'whitespace-normal break-words' : 'truncate'
)}
style={{ color: 'var(--tools-description)' }}
title={label}
>
{label}
</Text>
)
)
) : null}
</div>
</ToolRevealOnMount>
);
})}
</div>
</ToolScrollableSection>
{entries.length > 0 ? (
<TaskSummaryEntriesList
entries={entries}
isExpanded={isExpanded}
isMobile={isMobile}
animateTailText={animateTailText}
showToolFileIcons={showToolFileIcons}
/>
) : null}
{sessionId && (
@@ -1357,7 +1390,7 @@ const TaskToolSummary: React.FC<{
)}
{hasOutput ? (
<div className={cn('space-y-1', (displayEntries.length > 0 || sessionId) && 'pt-1')}
<div className={cn('space-y-1', (entries.length > 0 || sessionId) && 'pt-1')}
>
<button
type="button"
@@ -2147,12 +2180,11 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
isTaskTool,
parentSessionId: currentSessionId ?? undefined,
taskStartTime: taskSessionResolutionStart,
isTaskFinalized: isFinalized,
sessions: storeState.session,
sessionStatusMap: storeState.session_status,
hasRetried: taskFallbackRetried,
});
}, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, isFinalized, taskFallbackRetried]),
}, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, taskFallbackRetried]),
currentDirectory,
);
@@ -2172,49 +2204,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
return buildTaskSummaryEntriesFromSession(childSessionMessages);
}, [childSessionMessages, isTaskTool, taskSessionId]);
const childSessionHasInFlightTools = React.useMemo(() => {
if (!isTaskTool || !taskSessionId || !Array.isArray(childSessionMessages) || childSessionMessages.length === 0) {
return false;
}
for (const message of childSessionMessages) {
if (message?.info?.role !== 'assistant') {
continue;
}
const parts = Array.isArray(message.parts) ? message.parts : [];
for (const childPart of parts) {
if (childPart?.type !== 'tool') {
continue;
}
const childStatus =
typeof childPart === 'object' && childPart !== null && 'state' in childPart
? (childPart.state as { status?: string } | undefined)?.status
: undefined;
if (childStatus === 'running' || childStatus === 'pending' || childStatus === 'started') {
return true;
}
}
}
return false;
}, [childSessionMessages, isTaskTool, taskSessionId]);
const childSessionActivity = useSessionActivity(taskSessionId, currentDirectory);
const [taskChildSeenActive, setTaskChildSeenActive] = React.useState(false);
const [taskChildPollingStopped, setTaskChildPollingStopped] = React.useState(false);
const [taskPendingFinalFetch, setTaskPendingFinalFetch] = React.useState(false);
const taskPollNoChangeCountRef = React.useRef(0);
const taskPollLastSignatureRef = React.useRef<string>('');
const taskFinalFetchDoneRef = React.useRef(false);
React.useEffect(() => {
setTaskChildSeenActive(false);
setTaskChildPollingStopped(false);
setTaskPendingFinalFetch(false);
taskPollNoChangeCountRef.current = 0;
taskPollLastSignatureRef.current = '';
taskFinalFetchDoneRef.current = false;
setTaskFallbackRetried(false);
}, [taskSessionId]);
@@ -2251,148 +2241,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
taskSessionResolutionStart,
]);
React.useEffect(() => {
if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId) {
return;
}
const childSessionIsActive =
childSessionActivity.phase === 'busy'
|| childSessionActivity.phase === 'retry'
|| childSessionHasInFlightTools;
if (childSessionIsActive) {
if (!taskChildSeenActive) {
setTaskChildSeenActive(true);
}
if (taskChildPollingStopped) {
setTaskChildPollingStopped(false);
}
if (taskPendingFinalFetch) {
setTaskPendingFinalFetch(false);
}
return;
}
// Always stop polling if already done.
if (taskChildPollingStopped && taskFinalFetchDoneRef.current) {
return;
}
// Normal settle path: child went idle after we saw it active, and we have entries.
// Schedule a grace period before marking polling as stopped.
if (taskChildSeenActive && childSessionTaskSummaryEntries.length > 0 && !taskChildPollingStopped) {
if (typeof window === 'undefined') {
setTaskChildPollingStopped(true);
return;
}
const timer = window.setTimeout(() => {
setTaskChildPollingStopped(true);
}, TASK_TOOL_SETTLE_GRACE_MS);
return () => {
window.clearTimeout(timer);
};
}
// Final-fetch path: child went idle before parent saw it active, or we have no
// entries yet. First stop polling after the settle grace period. A separate
// effect performs the final fetch once polling has fully stopped, avoiding
// races with any in-flight polling response.
if (!taskChildPollingStopped && !taskFinalFetchDoneRef.current) {
if (typeof window === 'undefined') {
setTaskPendingFinalFetch(true);
setTaskChildPollingStopped(true);
return;
}
const timer = window.setTimeout(() => {
setTaskPendingFinalFetch(true);
setTaskChildPollingStopped(true);
}, TASK_TOOL_SETTLE_GRACE_MS);
return () => {
window.clearTimeout(timer);
};
}
}, [
childSessionActivity.phase,
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
currentDirectory,
hasFinalMetadataTaskSummary,
activeLatched,
isFinalized,
isTaskTool,
taskPendingFinalFetch,
taskChildPollingStopped,
taskChildSeenActive,
taskSessionId,
]);
React.useEffect(() => {
if (hasFinalMetadataTaskSummary || !isTaskTool || !taskSessionId || !taskChildPollingStopped || !taskPendingFinalFetch || taskFinalFetchDoneRef.current) {
return;
}
let cancelled = false;
const capturedSessionId = taskSessionId;
const runFinalFetch = async () => {
try {
const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory);
const response = await scopedClient.session.messages({
sessionID: capturedSessionId,
limit: isVSCodeRuntime() ? VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT : TASK_TOOL_INITIAL_FETCH_LIMIT,
});
if (cancelled) {
return;
}
const messages = response.data ?? [];
if (Array.isArray(messages) && messages.length > 0) {
const childStores = getSyncChildStores();
childStores.update(currentDirectory, (prev) => {
const records = messages as SessionMessageWithParts[];
const partPatch: Record<string, import('@opencode-ai/sdk/v2').Part[]> = { ...prev.part };
for (const rec of records) {
partPatch[rec.info.id] = rec.parts;
}
return {
message: { ...prev.message, [capturedSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] },
part: partPatch,
};
});
}
taskFinalFetchDoneRef.current = true;
setTaskPendingFinalFetch(false);
} catch {
if (cancelled) {
return;
}
setTaskPendingFinalFetch(false);
setTaskChildPollingStopped(false);
}
};
void runFinalFetch();
return () => {
cancelled = true;
};
}, [
currentDirectory,
hasFinalMetadataTaskSummary,
isTaskTool,
taskChildPollingStopped,
taskPendingFinalFetch,
taskSessionId,
]);
React.useEffect(() => {
if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') {
setLocalFinalizedAt(undefined);
@@ -2425,147 +2273,25 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}
return metadataTaskSummaryEntries;
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
const taskSummaryRenderSignature = React.useMemo(() => {
return taskSummaryEntries.map(getTaskSummaryEntryRenderSignature).join('\u0000');
}, [taskSummaryEntries]);
const lastTaskSummaryRenderSignatureRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!isTaskTool || !taskSessionId) {
return;
}
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
if (hasFinalMetadataTaskSummary) {
return;
}
const shouldPoll =
!taskChildPollingStopped
&& (childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
const shouldFetchSnapshot = !taskPendingFinalFetch && (childSessionTaskSummaryEntries.length === 0 || shouldPoll);
if (!shouldFetchSnapshot) {
return;
}
let cancelled = false;
let pollTimer: number | undefined;
const isVisible = () => {
if (typeof document === 'undefined') {
return true;
}
return document.visibilityState === 'visible';
};
const resolveFetchLimit = (isInitialFetch: boolean) => {
if (isVSCodeRuntime()) {
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
return VSCODE_TASK_TOOL_INITIAL_FETCH_LIMIT;
}
if (isActive || childSessionHasInFlightTools || childSessionActive) {
return VSCODE_TASK_TOOL_ACTIVE_FETCH_LIMIT;
}
return VSCODE_TASK_TOOL_IDLE_FETCH_LIMIT;
}
if (isInitialFetch && childSessionTaskSummaryEntries.length === 0) {
return TASK_TOOL_INITIAL_FETCH_LIMIT;
}
if (isActive || childSessionHasInFlightTools || childSessionActive) {
return TASK_TOOL_ACTIVE_FETCH_LIMIT;
}
return TASK_TOOL_IDLE_FETCH_LIMIT;
};
const resolvePollDelay = () => {
if (!isVisible()) {
return TASK_TOOL_POLL_HIDDEN_MS;
}
if (taskPollNoChangeCountRef.current >= TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS) {
return TASK_TOOL_POLL_IDLE_MS;
}
return TASK_TOOL_POLL_FAST_MS;
};
const scheduleNextPoll = () => {
if (!shouldPoll || typeof window === 'undefined' || cancelled) {
return;
}
pollTimer = window.setTimeout(() => {
pollTimer = undefined;
void fetchSessionMessages(false);
}, resolvePollDelay());
};
const fetchSessionMessages = async (isInitialFetch: boolean) => {
try {
const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory);
const response = await scopedClient.session.messages({
sessionID: taskSessionId,
limit: resolveFetchLimit(isInitialFetch),
});
const messages = response.data ?? [];
if (cancelled || !Array.isArray(messages) || messages.length === 0) {
return;
}
const nextSignature = buildTaskSessionMessagesSignature(messages as SessionMessageWithParts[]);
if (nextSignature === taskPollLastSignatureRef.current) {
taskPollNoChangeCountRef.current += 1;
return;
}
taskPollLastSignatureRef.current = nextSignature;
taskPollNoChangeCountRef.current = 0;
// Inject fetched subagent messages into sync child store
const childStores = getSyncChildStores();
childStores.update(currentDirectory, (prev) => {
const records = messages as SessionMessageWithParts[];
const partPatch: Record<string, import('@opencode-ai/sdk/v2').Part[]> = { ...prev.part };
for (const rec of records) {
partPatch[rec.info.id] = rec.parts;
}
return {
message: { ...prev.message, [taskSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] },
part: partPatch,
};
});
} catch {
// Ignore transient subagent fetch errors.
} finally {
scheduleNextPoll();
}
};
void fetchSessionMessages(true);
return () => {
cancelled = true;
if (typeof pollTimer === 'number') {
window.clearTimeout(pollTimer);
}
};
}, [
childSessionActivity.phase,
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
currentDirectory,
hasFinalMetadataTaskSummary,
isActive,
isTaskTool,
taskPendingFinalFetch,
taskChildPollingStopped,
taskSessionId,
]);
const taskSummaryLenRef = React.useRef<number>(taskSummaryEntries.length);
React.useEffect(() => {
if (!isTaskTool) {
lastTaskSummaryRenderSignatureRef.current = null;
return;
}
if (taskSummaryLenRef.current === taskSummaryEntries.length) {
const previous = lastTaskSummaryRenderSignatureRef.current;
lastTaskSummaryRenderSignatureRef.current = taskSummaryRenderSignature;
if (previous === null || previous === taskSummaryRenderSignature || taskSummaryEntries.length === 0) {
return;
}
taskSummaryLenRef.current = taskSummaryEntries.length;
onContentChange?.('structural');
}, [isTaskTool, onContentChange, taskSummaryEntries.length]);
onContentChangeRef.current?.('structural');
}, [isTaskTool, taskSummaryEntries.length, taskSummaryRenderSignature]);
const diffStats = React.useMemo(() => {
return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')
@@ -31,13 +31,16 @@ describe('resolveFallbackTaskSessionId', () => {
expect(result).toBeUndefined();
});
it('returns undefined when task is finalized', () => {
it('returns undefined when multiple idle candidates are ambiguous', () => {
const result = resolveFallbackTaskSessionId({
isTaskTool: true,
parentSessionId,
taskStartTime,
sessions: [],
isTaskFinalized: true,
sessions: [
makeSession({ id: 'child-a', parentID: parentSessionId, time: { created: taskStartTime + 100 } }),
makeSession({ id: 'child-b', parentID: parentSessionId, time: { created: taskStartTime + 200 } }),
],
sessionStatusMap: {},
});
expect(result).toBeUndefined();
});
@@ -29,8 +29,6 @@ export interface ResolveFallbackParams {
parentSessionId: string | undefined;
/** When the task tool started (ms timestamp) */
taskStartTime: number | undefined;
/** True when the task tool is finalized (completed/error/etc.) */
isTaskFinalized?: boolean;
/** Sessions from the directory store */
sessions: Session[];
/** Session status map from the sync store */
@@ -45,8 +43,8 @@ export interface ResolveFallbackParams {
*
* Returns `undefined` when:
* - Not a task tool
* - Task is finalized
* - Parent session is unknown
* - Task start time is unknown
* - No unambiguous match found
*/
export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): string | undefined {
@@ -54,7 +52,6 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
isTaskTool,
parentSessionId,
taskStartTime,
isTaskFinalized = false,
sessions,
sessionStatusMap,
hasRetried = false,
@@ -64,6 +61,10 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
return undefined;
}
if (typeof taskStartTime !== 'number') {
return undefined;
}
// Filter candidate sessions: parentID matches the current session.
let candidates = sessions.filter((session) => {
if (!session?.id || session.parentID !== parentSessionId) {
@@ -72,19 +73,14 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
return true;
});
// When the task is still running, apply no time window — late-appearing
// child sessions should still match. Once finalized, restrict to sessions
// created within a generous window around the task start to avoid binding
// to stale siblings. If taskStartTime is unavailable (cross-OpenCode
// sessions), skip the time filter entirely.
if (typeof taskStartTime === 'number' && isTaskFinalized) {
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
const latestAllowed = taskStartTime + windowMs;
candidates = candidates.filter((session) => {
const created = session.time?.created;
return typeof created === 'number' && created >= taskStartTime - 2_000 && created <= latestAllowed;
});
}
// Apply the time window even while running. Without it, a newly rendered task
// can briefly bind to the previous child session before its own child exists.
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
const latestAllowed = taskStartTime + windowMs;
candidates = candidates.filter((session) => {
const created = session.time?.created;
return typeof created === 'number' && created >= taskStartTime && created <= latestAllowed;
});
if (candidates.length === 0) {
return undefined;
@@ -105,18 +101,6 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
return liveCandidates[0].id;
}
// All idle: pick the most recently created child session.
// This handles the common case where a delegation completed and the
// user is viewing the task tool result inline.
if (liveCandidates.length === 0 && candidates.length > 1) {
const sorted = [...candidates].sort((a, b) => {
const aCreated = typeof a.time?.created === 'number' ? a.time.created : 0;
const bCreated = typeof b.time?.created === 'number' ? b.time.created : 0;
return bCreated - aCreated;
});
return sorted[0].id;
}
// Ambiguous — do not guess
return undefined;
}
@@ -0,0 +1,89 @@
import { describe, expect, test } from 'bun:test';
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from '@/sync/types';
import { EMPTY_REVERTED_MESSAGE_DOCK_STATE, buildRevertedMessageDockState } from './revertedMessageDockState';
const message = (id: string, role: 'user' | 'assistant'): Message => ({
id,
role,
sessionID: 'ses_1',
time: { created: 1 },
} as Message);
const textPart = (id: string, text: string): Part => ({
id,
type: 'text',
text,
} as Part);
const state = (partial: Partial<State>): Pick<State, 'session' | 'message' | 'part'> => ({
session: [],
message: {},
part: {},
...partial,
});
describe('buildRevertedMessageDockState', () => {
test('returns a shared empty state when the session is not reverted', () => {
const first = buildRevertedMessageDockState(state({}), 'ses_1');
const second = buildRevertedMessageDockState(
state({ part: { assistant_1: [textPart('part_1', 'streaming')] } }),
'ses_1',
first,
);
expect(first).toBe(EMPTY_REVERTED_MESSAGE_DOCK_STATE);
expect(second).toBe(EMPTY_REVERTED_MESSAGE_DOCK_STATE);
});
test('reuses the previous state when unrelated assistant parts change', () => {
const user = message('user_1', 'user');
const userParts = [textPart('part_user', 'hello')];
const first = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user, message('assistant_1', 'assistant')] },
part: { user_1: userParts, assistant_1: [textPart('part_a', 'a')] },
}),
'ses_1',
);
const second = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user, message('assistant_1', 'assistant')] },
part: { user_1: userParts, assistant_1: [textPart('part_a2', 'updated')] },
}),
'ses_1',
first,
);
expect(second).toBe(first);
});
test('updates when a reverted user message part changes', () => {
const user = message('user_1', 'user');
const first = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user] },
part: { user_1: [textPart('part_user', 'hello')] },
}),
'ses_1',
);
const second = buildRevertedMessageDockState(
state({
session: [{ id: 'ses_1', revert: { messageID: 'user_1' } } as State['session'][number]],
message: { ses_1: [user] },
part: { user_1: [textPart('part_user_updated', 'updated')] },
}),
'ses_1',
first,
);
expect(second).not.toBe(first);
expect(second.records).toHaveLength(1);
});
});
@@ -0,0 +1,73 @@
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
import type { State } from '@/sync/types';
export type RevertedMessageRecord = {
message: Message & { role: 'user' };
parts: Part[];
};
export type RevertedMessageDockState = {
revertMessageID?: string;
records: RevertedMessageRecord[];
};
const EMPTY_PARTS: Part[] = [];
const EMPTY_REVERTED_RECORDS: RevertedMessageRecord[] = [];
export const EMPTY_REVERTED_MESSAGE_DOCK_STATE: RevertedMessageDockState = {
revertMessageID: undefined,
records: EMPTY_REVERTED_RECORDS,
};
const isUserMessage = (message: Message): message is Message & { role: 'user' } => {
return message.role === 'user';
};
const areRecordsEqual = (left: RevertedMessageRecord[], right: RevertedMessageRecord[]): boolean => {
if (left === right) return true;
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index]?.message !== right[index]?.message || left[index]?.parts !== right[index]?.parts) {
return false;
}
}
return true;
};
export const buildRevertedMessageDockState = (
state: Pick<State, 'session' | 'message' | 'part'>,
sessionId: string | null,
previous: RevertedMessageDockState = EMPTY_REVERTED_MESSAGE_DOCK_STATE,
): RevertedMessageDockState => {
if (!sessionId) {
return EMPTY_REVERTED_MESSAGE_DOCK_STATE;
}
const session = state.session.find((item) => item.id === sessionId);
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
if (!revertMessageID) {
return EMPTY_REVERTED_MESSAGE_DOCK_STATE;
}
const messages = state.message[sessionId] ?? [];
const records: RevertedMessageRecord[] = [];
for (const message of messages) {
if (!isUserMessage(message) || message.id < revertMessageID) {
continue;
}
records.push({
message,
parts: state.part[message.id] ?? EMPTY_PARTS,
});
}
const next = records.length === 0 ? EMPTY_REVERTED_RECORDS : records;
if (previous.revertMessageID === revertMessageID && areRecordsEqual(previous.records, next)) {
return previous;
}
return {
revertMessageID,
records: next,
};
};