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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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')
|
||||
|
||||
+6
-3
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user