diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 50836781..d7bffc48 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -50,42 +50,6 @@ const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.'; const CHAT_SCROLL_STYLE = { overflowAnchor: 'none' } as const; type SessionMessageRecord = { info: Message; parts: Part[] }; -const getSessionMessageId = (message: SessionMessageRecord | undefined): string | null => { - const id = message?.info?.id; - return typeof id === 'string' && id.trim().length > 0 ? id : null; -}; - -const canFreezeDetachedViewport = ( - previous: SessionMessageRecord[], - next: SessionMessageRecord[], - streamingMessageId: string | null, -): boolean => { - if (!streamingMessageId || previous.length === 0 || next.length === 0) { - return false; - } - - if (next.length < previous.length) { - return false; - } - - if (next.length === previous.length) { - for (let index = 0; index < next.length - 1; index += 1) { - if (previous[index] !== next[index]) { - return false; - } - } - return getSessionMessageId(previous[previous.length - 1]) === getSessionMessageId(next[next.length - 1]); - } - - for (let index = 0; index < previous.length; index += 1) { - if (previous[index] !== next[index]) { - return false; - } - } - - return true; -}; - type HydratingToolSkeletonRow = { id: string; titleWidth: string; @@ -310,12 +274,8 @@ export const ChatContainer: React.FC = () => { ), ); const sessionMessageCount = useSessionMessageCount(currentSessionId ?? ''); - const [suspendDetachedTailUpdates, setSuspendDetachedTailUpdates] = React.useState(false); - const [forceLiveViewport, setForceLiveViewport] = React.useState(false); // Messages from sync system - const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', undefined, { - suspendPartUpdates: suspendDetachedTailUpdates, - }); + const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; // Sessions from sync system @@ -513,36 +473,7 @@ export const ChatContainer: React.FC = () => { onActiveTurnChange: handleActiveTurnChange, }); - React.useEffect(() => { - const next = Boolean(currentSessionId && streamingMessageId && !isPinned && !forceLiveViewport); - setSuspendDetachedTailUpdates((previous) => (previous === next ? previous : next)); - }, [currentSessionId, forceLiveViewport, isPinned, streamingMessageId]); - - const viewportMessagesRef = React.useRef(EMPTY_MESSAGES); - const viewportSessionIdRef = React.useRef(null); - const viewportMessages = React.useMemo(() => { - if (viewportSessionIdRef.current !== currentSessionId) { - viewportSessionIdRef.current = currentSessionId; - viewportMessagesRef.current = sessionMessages; - return sessionMessages; - } - - const shouldFreezeViewport = Boolean( - currentSessionId - && streamingMessageId - && !isPinned - && !forceLiveViewport - && historyMeta?.loading !== true - && canFreezeDetachedViewport(viewportMessagesRef.current, sessionMessages, streamingMessageId), - ); - - if (shouldFreezeViewport) { - return viewportMessagesRef.current; - } - - viewportMessagesRef.current = sessionMessages; - return sessionMessages; - }, [currentSessionId, forceLiveViewport, historyMeta?.loading, isPinned, sessionMessages, streamingMessageId]); + const viewportMessages = sessionMessages; const timelineController = useChatTimelineController({ sessionId: currentSessionId, @@ -559,22 +490,11 @@ export const ChatContainer: React.FC = () => { const { loadEarlier, resumeToBottomInstant } = timelineController; const runLatestInstantResume = React.useCallback(async () => { - setForceLiveViewport(true); - try { - if (!currentSessionId) { - scrollToBottom({ instant: true, force: true }); - return; - } - await resumeToBottomInstant(); - } finally { - if (typeof window === 'undefined') { - setForceLiveViewport(false); - } else { - window.requestAnimationFrame(() => { - setForceLiveViewport(false); - }); - } + if (!currentSessionId) { + scrollToBottom({ instant: true, force: true }); + return; } + await resumeToBottomInstant(); }, [currentSessionId, resumeToBottomInstant, scrollToBottom]); const resumeToLatestInstant = React.useCallback(() => { @@ -658,7 +578,6 @@ export const ChatContainer: React.FC = () => { }, [currentSessionId, isDesktopExpandedInput, scrollRef]); const hasHistoryMetadata = Boolean(historyMeta); - const lastHydratedSessionRef = React.useRef(null); const lastScrolledSessionRef = React.useRef(null); const isSessionHydrating = @@ -696,15 +615,12 @@ export const ChatContainer: React.FC = () => { if (!currentSessionId) return; if (hasSessionMessagesEntry && hasHistoryMetadata) return; - const isSessionSwitch = lastHydratedSessionRef.current !== currentSessionId; - lastHydratedSessionRef.current = currentSessionId; - const load = async () => { await loadMessages(currentSessionId).finally(() => { const statusType = sessionStatusForCurrent.type ?? 'idle'; const isActivePhase = statusType === 'busy' || statusType === 'retry'; const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0; - const shouldSkipScroll = hasHashTarget || (isActivePhase && isPinned && !isSessionSwitch); + const shouldSkipScroll = hasHashTarget || (isActivePhase && isPinned); if (!shouldSkipScroll) { if (typeof window === 'undefined') { diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 1a9e525e..01ba1ca0 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -373,13 +373,6 @@ const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageE return normalized; }; -const isAssistantTextOnlyMessage = (message: ChatMessageEntry): boolean => { - if (resolveMessageRole(message) !== 'assistant') { - return false; - } - return message.parts.length > 0 && message.parts.every((part) => part?.type === 'text'); -}; - interface MessageListProps { sessionKey: string; turnStart: number; @@ -1222,19 +1215,6 @@ const MessageList = React.forwardRef(({ previousOrder: string[]; animatedIds: Set; }>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() }); - const baseDisplayCacheRef = React.useRef<{ - input: ChatMessageEntry[]; - output: ChatMessageEntry[]; - outputIndexById: Map; - } | null>(null); - const staticRenderEntriesCacheRef = React.useRef<{ - input: ChatMessageEntry[]; - output: RenderEntry[]; - staticTurns: TurnRecord[]; - lastTurnId: string | null; - ungroupedMessageIds: Set; - } | null>(null); - const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers); const stableOnLoadOlder = useStableEvent(onLoadOlder); const stableScrollToBottom = useStableEvent((options?: { instant?: boolean; force?: boolean }) => { @@ -1256,50 +1236,6 @@ const MessageList = React.forwardRef(({ const baseDisplayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.base_display_ms', () => { - const cached = baseDisplayCacheRef.current; - const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined; - const canUseTailFastPath = Boolean(lastMessage && isAssistantTextOnlyMessage(lastMessage)); - - if (cached && canUseTailFastPath && cached.input.length === messages.length && messages.length > 0) { - let changedCount = 0; - let changedIndex = -1; - let idsStable = true; - - for (let index = 0; index < messages.length; index += 1) { - if (messages[index]?.info?.id !== cached.input[index]?.info?.id) { - idsStable = false; - break; - } - if (messages[index] !== cached.input[index]) { - changedCount += 1; - changedIndex = index; - if (changedCount > 1) { - break; - } - } - } - - if (idsStable && changedCount === 1 && changedIndex === messages.length - 1) { - const changedMessage = messages[changedIndex]; - const previousMessage = changedIndex > 0 ? messages[changedIndex - 1] : undefined; - const bridgeSensitive = isUserSubtaskMessage(previousMessage) || isUserShellMarkerMessage(previousMessage); - - if (changedMessage && isAssistantTextOnlyMessage(changedMessage) && !bridgeSensitive) { - const outputIndex = cached.outputIndexById.get(changedMessage.info.id); - if (outputIndex !== undefined) { - const nextOutput = [...cached.output]; - nextOutput[outputIndex] = getNormalizedMessageForDisplay(changedMessage); - baseDisplayCacheRef.current = { - input: messages, - output: nextOutput, - outputIndexById: cached.outputIndexById, - }; - return nextOutput; - } - } - } - } - const seenIdsFromTail = new Set(); const dedupedMessages: ChatMessageEntry[] = []; for (let index = messages.length - 1; index >= 0; index -= 1) { @@ -1344,19 +1280,6 @@ const MessageList = React.forwardRef(({ output.push(currentWithRole); } - const outputIndexById = new Map(); - output.forEach((message, index) => { - const id = message.info?.id; - if (typeof id === 'string' && id.length > 0) { - outputIndexById.set(id, index); - } - }); - baseDisplayCacheRef.current = { - input: messages, - output, - outputIndexById, - }; - return output; }), [messages]); @@ -1386,48 +1309,6 @@ const MessageList = React.forwardRef(({ showTextJustificationActivity: chatRenderMode === 'sorted', }); const staticRenderEntries = React.useMemo(() => streamPerfMeasure('ui.message_list.render_entries_ms', () => { - const cached = staticRenderEntriesCacheRef.current; - const lastMessage = displayMessages.length > 0 ? displayMessages[displayMessages.length - 1] : undefined; - const hasTrailingCandidate = Boolean(lastMessage) && ( - (streamingTurn - ? (streamingTurn.userMessage.info.id === lastMessage?.info.id - || streamingTurn.assistantMessages.some((assistant) => assistant.info.id === lastMessage?.info.id)) - : false) - || (lastMessage ? projection.ungroupedMessageIds.has(lastMessage.info.id) : false) - ); - - if ( - cached - && hasTrailingCandidate - && cached.input.length === displayMessages.length - && cached.staticTurns === staticTurns - && cached.lastTurnId === projection.lastTurnId - && cached.ungroupedMessageIds === projection.ungroupedMessageIds - && displayMessages.length > 0 - ) { - let changedCount = 0; - let changedIndex = -1; - let idsStable = true; - - for (let index = 0; index < displayMessages.length; index += 1) { - if (displayMessages[index]?.info?.id !== cached.input[index]?.info?.id) { - idsStable = false; - break; - } - if (displayMessages[index] !== cached.input[index]) { - changedCount += 1; - changedIndex = index; - if (changedCount > 1) { - break; - } - } - } - - if (idsStable && changedCount === 1 && changedIndex === displayMessages.length - 1) { - return cached.output; - } - } - const turnEntries = staticTurns.map((turn) => ({ kind: 'turn' as const, key: `turn:${turn.turnId}`, @@ -1465,16 +1346,8 @@ const MessageList = React.forwardRef(({ }); }); - staticRenderEntriesCacheRef.current = { - input: displayMessages, - output: orderedEntries, - staticTurns, - lastTurnId: projection.lastTurnId, - ungroupedMessageIds: projection.ungroupedMessageIds, - }; - return orderedEntries; - }), [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, staticTurns, streamingTurn]); + }), [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, staticTurns]); const trailingStreamingEntry = React.useMemo(() => { if (streamingTurn) { diff --git a/packages/ui/src/components/chat/StatusRowContainer.tsx b/packages/ui/src/components/chat/StatusRowContainer.tsx index 33872ac0..7f33a78b 100644 --- a/packages/ui/src/components/chat/StatusRowContainer.tsx +++ b/packages/ui/src/components/chat/StatusRowContainer.tsx @@ -1,16 +1,14 @@ import React from 'react'; -import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; +import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useSessionPermissions, useSessionStatus } from '@/sync/sync-context'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { StatusRow } from './StatusRow'; -const DEFAULT_WORKING_STATUS = 'working'; - /** - * Coarse status wrapper. - * Avoids subscribing to live assistant parts so the row doesn't rerender on every text delta. + * Status row wrapper. + * Uses the dedicated assistant status hook so the row keeps accurate live activity + * labels while still limiting subscriptions to the active assistant message. */ export const StatusRowContainer: React.FC = React.memo(() => { const currentSessionId = useSessionUIStore((state) => state.currentSessionId); @@ -22,50 +20,20 @@ export const StatusRowContainer: React.FC = React.memo(() => { return state.sessionAbortFlags?.get(currentSessionId) ?? null; }, [currentSessionId]), ); - const permissions = useSessionPermissions(currentSessionId ?? ''); - const sessionStatus = useSessionStatus(currentSessionId ?? ''); - const { phase, isWorking } = useCurrentSessionActivity(); + const { working } = useAssistantStatus(); const currentAgentName = useConfigStore((state) => state.currentAgentName); const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged); - const isWaitingForPermission = permissions.length > 0; - const isRetry = sessionStatus?.type === 'retry'; - - const statusText = React.useMemo(() => { - if (isWaitingForPermission) { - return 'waiting for permission'; - } - if (isRetry) { - return 'retrying'; - } - if (!isWorking) { - return null; - } - if (phase === 'busy') { - return 'composing'; - } - return DEFAULT_WORKING_STATUS; - }, [isRetry, isWaitingForPermission, isWorking, phase]); - - const retryInfo = React.useMemo(() => { - if (!isRetry) { - return null; - } - return { - attempt: (sessionStatus as { attempt?: number } | undefined)?.attempt, - next: (sessionStatus as { next?: number } | undefined)?.next, - }; - }, [isRetry, sessionStatus]); return ( { - if (!previousProjection || !previousMessages || previousProjection.turns.length === 0) { - return null; - } - - if (previousMessages.length !== nextMessages.length || nextMessages.length === 0) { - return null; - } - - let changedCount = 0; - let changedIndex = -1; - for (let index = 0; index < nextMessages.length; index += 1) { - if (previousMessages[index]?.info.id !== nextMessages[index]?.info.id) { - return null; - } - if (previousMessages[index] !== nextMessages[index]) { - changedCount += 1; - changedIndex = index; - if (changedCount > 1) { - return null; - } - } - } - - if (changedCount !== 1 || changedIndex !== nextMessages.length - 1) { - return null; - } - - const previousLastTurn = previousProjection.turns[previousProjection.turns.length - 1]; - if (!previousLastTurn) { - return null; - } - - const lastTurnStartIndex = previousMessages.findIndex((message) => message.info.id === previousLastTurn.userMessageId); - if (lastTurnStartIndex < 0) { - return null; - } - - const previousTailMessages = previousMessages.slice(lastTurnStartIndex); - const nextTailMessages = nextMessages.slice(lastTurnStartIndex); - if (nextTailMessages[0]?.info.id !== previousLastTurn.userMessageId) { - return null; - } - - const previousStaticTurns = previousProjection.turns.slice(0, -1); - const previousStaticUngrouped = new Set(previousProjection.ungroupedMessageIds); - previousTailMessages.forEach((message) => { - previousStaticUngrouped.delete(message.info.id); - }); - - const previousTailUngrouped = new Set(); - previousTailMessages.forEach((message) => { - if (previousProjection.ungroupedMessageIds.has(message.info.id)) { - previousTailUngrouped.add(message.info.id); - } - }); - - const previousTailProjection: TurnProjectionResult = { - ...projectTurnIndexes([previousLastTurn]), - ungroupedMessageIds: previousTailUngrouped, - }; - - const rawTailProjection = projectTurnRecords(nextTailMessages, { - previousProjection: previousTailProjection, - showTextJustificationActivity, - }); - const stabilizedTailProjection = stabilizeTurnProjection(rawTailProjection, previousTailProjection); - const turns = previousStaticTurns.length > 0 - ? [...previousStaticTurns, ...stabilizedTailProjection.turns] - : stabilizedTailProjection.turns; - const projection = projectTurnIndexes(turns); - const ungroupedMessageIds = new Set(previousStaticUngrouped); - stabilizedTailProjection.ungroupedMessageIds.forEach((messageId) => { - ungroupedMessageIds.add(messageId); - }); - - return { - ...projection, - ungroupedMessageIds, - }; -}; - export const useTurnRecords = ( messages: ChatMessageEntry[], options: UseTurnRecordsOptions, ): TurnRecordsResult => { const previousProjectionRef = React.useRef(null); - const previousMessagesRef = React.useRef(null); const staticTurnsRef = React.useRef([]); const streamingTurnRef = React.useRef(undefined); + const previousSessionKeyRef = React.useRef(options.sessionKey); + + if (previousSessionKeyRef.current !== options.sessionKey) { + previousSessionKeyRef.current = options.sessionKey; + previousProjectionRef.current = null; + staticTurnsRef.current = []; + streamingTurnRef.current = undefined; + } React.useEffect(() => { previousProjectionRef.current = null; - previousMessagesRef.current = null; staticTurnsRef.current = []; streamingTurnRef.current = undefined; }, [options.sessionKey, options.showTextJustificationActivity]); const projection = React.useMemo(() => { return streamPerfMeasure('ui.turns.projection_ms', () => { - const tailOnlyProjection = buildTailOnlyProjection( - previousProjectionRef.current, - previousMessagesRef.current, - messages, - options.showTextJustificationActivity, - ); - const rawProjection = tailOnlyProjection ?? projectTurnRecords(messages, { + const nextProjection = projectTurnRecords(messages, { previousProjection: previousProjectionRef.current, showTextJustificationActivity: options.showTextJustificationActivity, }); - const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current); - previousProjectionRef.current = stabilizedProjection; - previousMessagesRef.current = messages; - return stabilizedProjection; + previousProjectionRef.current = nextProjection; + return nextProjection; }); }, [messages, options.showTextJustificationActivity]); diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts index 145b6ea3..d4faaa5b 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.ts @@ -32,99 +32,6 @@ const getMessageCompletedAt = (message: ChatMessageEntry): number | undefined => return typeof completed === 'number' ? completed : undefined; }; -const getMessageFinish = (message: ChatMessageEntry): string | undefined => { - const finish = (message.info as { finish?: unknown }).finish; - return typeof finish === 'string' ? finish : undefined; -}; - -const getMessageStatus = (message: ChatMessageEntry): string | undefined => { - const status = (message.info as { status?: unknown }).status; - return typeof status === 'string' ? status : undefined; -}; - -const getPartText = (part: ChatMessageEntry['parts'][number]): string | undefined => { - const text = (part as { text?: unknown }).text; - if (typeof text === 'string') { - return text; - } - const content = (part as { content?: unknown }).content; - return typeof content === 'string' ? content : undefined; -}; - -const arePartsEquivalentForReuse = ( - previousPart: ChatMessageEntry['parts'][number], - nextPart: ChatMessageEntry['parts'][number], -): boolean => { - if (previousPart === nextPart) { - return true; - } - - if (previousPart.type !== nextPart.type) { - return false; - } - - if (previousPart.id && nextPart.id && previousPart.id !== nextPart.id) { - return false; - } - - if (previousPart.type === 'text' || previousPart.type === 'reasoning') { - return getPartText(previousPart) === getPartText(nextPart); - } - - if (previousPart.type === 'tool') { - const previousTool = previousPart as { - tool?: unknown; - callID?: unknown; - state?: { status?: unknown }; - }; - const nextTool = nextPart as { - tool?: unknown; - callID?: unknown; - state?: { status?: unknown }; - }; - - return previousTool.tool === nextTool.tool - && previousTool.callID === nextTool.callID - && previousTool.state?.status === nextTool.state?.status; - } - - return true; -}; - -const areMessagesEquivalentForReuse = (previousMessage: ChatMessageEntry, nextMessage: ChatMessageEntry): boolean => { - if (previousMessage === nextMessage) { - return true; - } - - if (previousMessage.info.id !== nextMessage.info.id) { - return false; - } - - if (getMessageCompletedAt(previousMessage) !== getMessageCompletedAt(nextMessage)) { - return false; - } - - if (getMessageFinish(previousMessage) !== getMessageFinish(nextMessage)) { - return false; - } - - if (getMessageStatus(previousMessage) !== getMessageStatus(nextMessage)) { - return false; - } - - if (previousMessage.parts.length !== nextMessage.parts.length) { - return false; - } - - for (let index = 0; index < previousMessage.parts.length; index += 1) { - if (!arePartsEquivalentForReuse(previousMessage.parts[index], nextMessage.parts[index])) { - return false; - } - } - - return true; -}; - const getUserSummaryBody = (message: ChatMessageEntry): string | undefined => { const summaryBody = (message.info as { summary?: { body?: unknown } | null | undefined })?.summary?.body; if (typeof summaryBody !== 'string') { @@ -194,7 +101,6 @@ export const projectTurnRecords = ( const turns: TurnRecord[] = []; const turnByUserId = new Map(); - const previousTurnsById = new Map((effectiveOptions.previousProjection?.turns ?? []).map((turn) => [turn.turnId, turn])); const groupedMessageIds = new Set(); let currentTurn: TurnRecord | undefined; @@ -254,43 +160,6 @@ export const projectTurnRecords = ( }); turns.forEach((turn) => { - const previousTurn = previousTurnsById.get(turn.turnId); - const canReuseComputed = (() => { - if (!previousTurn) { - return false; - } - if (previousTurn.stream.isStreaming) { - return false; - } - if (!areMessagesEquivalentForReuse(previousTurn.userMessage, turn.userMessage)) { - return false; - } - if (previousTurn.assistantMessages.length !== turn.assistantMessages.length) { - return false; - } - for (let index = 0; index < turn.assistantMessages.length; index += 1) { - if (!areMessagesEquivalentForReuse(previousTurn.assistantMessages[index], turn.assistantMessages[index])) { - return false; - } - } - return true; - })(); - - if (canReuseComputed && previousTurn) { - turn.summary = previousTurn.summary; - turn.summaryText = previousTurn.summaryText; - turn.diffStats = previousTurn.diffStats; - turn.activityParts = previousTurn.activityParts; - turn.activitySegments = previousTurn.activitySegments; - turn.hasTools = previousTurn.hasTools; - turn.hasReasoning = previousTurn.hasReasoning; - turn.stream = previousTurn.stream; - turn.startedAt = previousTurn.startedAt; - turn.completedAt = previousTurn.completedAt; - turn.durationMs = previousTurn.durationMs; - return; - } - turn.summary = projectTurnSummary(turn.assistantMessages); turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage); turn.diffStats = projectTurnDiffStats(turn.userMessage); diff --git a/packages/ui/src/components/chat/lib/turns/stabilizeTurnProjection.ts b/packages/ui/src/components/chat/lib/turns/stabilizeTurnProjection.ts index 7537a321..8a0d4685 100644 --- a/packages/ui/src/components/chat/lib/turns/stabilizeTurnProjection.ts +++ b/packages/ui/src/components/chat/lib/turns/stabilizeTurnProjection.ts @@ -1,6 +1,24 @@ import { projectTurnIndexes } from './projectTurnIndexes'; import type { TurnProjectionResult, TurnRecord } from './types'; +const areTurnMessagesReferenceStable = (previousTurn: TurnRecord, nextTurn: TurnRecord): boolean => { + if (previousTurn.userMessage !== nextTurn.userMessage) { + return false; + } + + if (previousTurn.assistantMessages.length !== nextTurn.assistantMessages.length) { + return false; + } + + for (let index = 0; index < previousTurn.assistantMessages.length; index += 1) { + if (previousTurn.assistantMessages[index] !== nextTurn.assistantMessages[index]) { + return false; + } + } + + return true; +}; + const buildTurnSignature = (turn: TurnRecord): string => { const assistantIds = turn.assistantMessageIds.join(','); return [ @@ -40,6 +58,10 @@ export const stabilizeTurnProjection = ( return turn; } + if (!areTurnMessagesReferenceStable(previousTurn, turn)) { + return turn; + } + reused = true; return previousTurn; }); diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index fe29b71e..5496bb4f 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -2024,8 +2024,7 @@ const ToolPart: React.FC = ({ const childSessionIsActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry' - || childSessionHasInFlightTools - || (!isFinalized && activeLatched); + || childSessionHasInFlightTools; if (childSessionIsActive) { if (!taskChildSeenActive) { @@ -2106,7 +2105,7 @@ const ToolPart: React.FC = ({ const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry'; const shouldPoll = !taskChildPollingStopped - && (isActive || childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0); + && (childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0); const shouldFetchSnapshot = childSessionTaskSummaryEntries.length === 0 || shouldPoll; if (!shouldFetchSnapshot) { return; diff --git a/packages/ui/src/components/chat/message/renderCompare.ts b/packages/ui/src/components/chat/message/renderCompare.ts index d3cc71c3..e06064a1 100644 --- a/packages/ui/src/components/chat/message/renderCompare.ts +++ b/packages/ui/src/components/chat/message/renderCompare.ts @@ -17,6 +17,14 @@ const readToolStatus = (part: Part | undefined): string | null => { return typeof status === 'string' ? status : null; }; +const readToolStateRef = (part: Part | undefined): unknown => { + return (part as { state?: unknown } | undefined)?.state; +}; + +const readPartMetadataRef = (part: Part | undefined): unknown => { + return (part as { metadata?: unknown } | undefined)?.metadata; +}; + const readPartTime = (part: Part | undefined) => { const time = (part as { time?: { start?: unknown; end?: unknown } } | undefined)?.time; return { @@ -53,6 +61,12 @@ export const areRenderRelevantPartsEqual = (left: Part[], right: Part[]): boolea } if (leftPart.type === 'tool') { + if (readToolStateRef(leftPart) !== readToolStateRef(rightPart)) { + return false; + } + if (readPartMetadataRef(leftPart) !== readPartMetadataRef(rightPart)) { + return false; + } if (readToolStatus(leftPart) !== readToolStatus(rightPart)) { return false; } diff --git a/packages/ui/src/sync/event-reducer.ts b/packages/ui/src/sync/event-reducer.ts index 6d710e7c..296a6bff 100644 --- a/packages/ui/src/sync/event-reducer.ts +++ b/packages/ui/src/sync/event-reducer.ts @@ -133,6 +133,18 @@ export function applyDirectoryEvent( return true } + case "session.idle": { + const props = event.properties as { sessionID: string } + draft.session_status[props.sessionID] = { type: "idle" } + return true + } + + case "session.error": { + const props = event.properties as { sessionID: string } + draft.session_status[props.sessionID] = { type: "idle" } + return true + } + case "message.updated": { const info = (event.properties as { info: Message }).info const messages = draft.message[info.sessionID] diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 2f866ad3..b64f042d 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -765,6 +765,8 @@ function handleEvent( draft.session_diff = { ...current.session_diff } break case "session.status": + case "session.idle": + case "session.error": draft.session_status = { ...(current.session_status ?? {}) } break case "todo.updated": @@ -812,6 +814,11 @@ function handleEvent( setGlobalSessionStatus(props.sessionID, props.status) } + if (payload.type === "session.idle" || payload.type === "session.error") { + const props = payload.properties as { sessionID: string } + setGlobalSessionStatus(props.sessionID, { type: "idle" }) + } + if (payload.type === "permission.asked") { const nd = normalizeDirectory(resolvedDirectory) if (!nd) { @@ -1268,8 +1275,6 @@ export function useChildStoreManager() { return useSyncSystem().childStores } -const MESSAGE_PART_SNAPSHOT_THROTTLE_MS = 100 - export type SessionTextMessage = { id: string role: string | null @@ -1304,12 +1309,7 @@ function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string, const [partsSnapshot, setPartsSnapshot] = React.useState>({}) React.useEffect(() => { - let timer: ReturnType | null = null - let pending = false - const flush = () => { - timer = null - pending = false const state = store.getState() const prev = prevPartsRef.current let changed = false @@ -1328,28 +1328,13 @@ function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string, flush() if (suspendUpdates) { - return () => { - if (timer) clearTimeout(timer) - } + return } - const unsub = store.subscribe(() => { - if (timer) { - pending = true - return - } - timer = setTimeout(() => { - flush() - if (pending) { - pending = false - timer = setTimeout(flush, MESSAGE_PART_SNAPSHOT_THROTTLE_MS) - } - }, MESSAGE_PART_SNAPSHOT_THROTTLE_MS) - }) + const unsub = store.subscribe(flush) return () => { unsub() - if (timer) clearTimeout(timer) } }, [messageIds, store, suspendUpdates])