fix: stop stale chat renders after session switches
Roll back aggressive chat reuse paths that froze message parts, tool state, and completed assistant bodies when sessions were inactive or backgrounded.
This commit is contained in:
@@ -50,42 +50,6 @@ const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
|
|||||||
const CHAT_SCROLL_STYLE = { overflowAnchor: 'none' } as const;
|
const CHAT_SCROLL_STYLE = { overflowAnchor: 'none' } as const;
|
||||||
type SessionMessageRecord = { info: Message; parts: Part[] };
|
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 = {
|
type HydratingToolSkeletonRow = {
|
||||||
id: string;
|
id: string;
|
||||||
titleWidth: string;
|
titleWidth: string;
|
||||||
@@ -310,12 +274,8 @@ export const ChatContainer: React.FC = () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
|
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '');
|
||||||
const [suspendDetachedTailUpdates, setSuspendDetachedTailUpdates] = React.useState(false);
|
|
||||||
const [forceLiveViewport, setForceLiveViewport] = React.useState(false);
|
|
||||||
// Messages from sync system
|
// Messages from sync system
|
||||||
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', undefined, {
|
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
|
||||||
suspendPartUpdates: suspendDetachedTailUpdates,
|
|
||||||
});
|
|
||||||
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
||||||
|
|
||||||
// Sessions from sync system
|
// Sessions from sync system
|
||||||
@@ -513,36 +473,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
onActiveTurnChange: handleActiveTurnChange,
|
onActiveTurnChange: handleActiveTurnChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
const viewportMessages = sessionMessages;
|
||||||
const next = Boolean(currentSessionId && streamingMessageId && !isPinned && !forceLiveViewport);
|
|
||||||
setSuspendDetachedTailUpdates((previous) => (previous === next ? previous : next));
|
|
||||||
}, [currentSessionId, forceLiveViewport, isPinned, streamingMessageId]);
|
|
||||||
|
|
||||||
const viewportMessagesRef = React.useRef<SessionMessageRecord[]>(EMPTY_MESSAGES);
|
|
||||||
const viewportSessionIdRef = React.useRef<string | null>(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 timelineController = useChatTimelineController({
|
const timelineController = useChatTimelineController({
|
||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
@@ -559,22 +490,11 @@ export const ChatContainer: React.FC = () => {
|
|||||||
const { loadEarlier, resumeToBottomInstant } = timelineController;
|
const { loadEarlier, resumeToBottomInstant } = timelineController;
|
||||||
|
|
||||||
const runLatestInstantResume = React.useCallback(async () => {
|
const runLatestInstantResume = React.useCallback(async () => {
|
||||||
setForceLiveViewport(true);
|
if (!currentSessionId) {
|
||||||
try {
|
scrollToBottom({ instant: true, force: true });
|
||||||
if (!currentSessionId) {
|
return;
|
||||||
scrollToBottom({ instant: true, force: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await resumeToBottomInstant();
|
|
||||||
} finally {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
setForceLiveViewport(false);
|
|
||||||
} else {
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
setForceLiveViewport(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
await resumeToBottomInstant();
|
||||||
}, [currentSessionId, resumeToBottomInstant, scrollToBottom]);
|
}, [currentSessionId, resumeToBottomInstant, scrollToBottom]);
|
||||||
|
|
||||||
const resumeToLatestInstant = React.useCallback(() => {
|
const resumeToLatestInstant = React.useCallback(() => {
|
||||||
@@ -658,7 +578,6 @@ export const ChatContainer: React.FC = () => {
|
|||||||
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
||||||
|
|
||||||
const hasHistoryMetadata = Boolean(historyMeta);
|
const hasHistoryMetadata = Boolean(historyMeta);
|
||||||
const lastHydratedSessionRef = React.useRef<string | null>(null);
|
|
||||||
const lastScrolledSessionRef = React.useRef<string | null>(null);
|
const lastScrolledSessionRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
const isSessionHydrating =
|
const isSessionHydrating =
|
||||||
@@ -696,15 +615,12 @@ export const ChatContainer: React.FC = () => {
|
|||||||
if (!currentSessionId) return;
|
if (!currentSessionId) return;
|
||||||
if (hasSessionMessagesEntry && hasHistoryMetadata) return;
|
if (hasSessionMessagesEntry && hasHistoryMetadata) return;
|
||||||
|
|
||||||
const isSessionSwitch = lastHydratedSessionRef.current !== currentSessionId;
|
|
||||||
lastHydratedSessionRef.current = currentSessionId;
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
await loadMessages(currentSessionId).finally(() => {
|
await loadMessages(currentSessionId).finally(() => {
|
||||||
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
||||||
const isActivePhase = statusType === 'busy' || statusType === 'retry';
|
const isActivePhase = statusType === 'busy' || statusType === 'retry';
|
||||||
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
||||||
const shouldSkipScroll = hasHashTarget || (isActivePhase && isPinned && !isSessionSwitch);
|
const shouldSkipScroll = hasHashTarget || (isActivePhase && isPinned);
|
||||||
|
|
||||||
if (!shouldSkipScroll) {
|
if (!shouldSkipScroll) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
|
|||||||
@@ -373,13 +373,6 @@ const getNormalizedMessageForDisplay = (message: ChatMessageEntry): ChatMessageE
|
|||||||
return normalized;
|
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 {
|
interface MessageListProps {
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
turnStart: number;
|
turnStart: number;
|
||||||
@@ -1222,19 +1215,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
previousOrder: string[];
|
previousOrder: string[];
|
||||||
animatedIds: Set<string>;
|
animatedIds: Set<string>;
|
||||||
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
|
}>({ sessionKey: undefined, previousOrder: [], animatedIds: new Set() });
|
||||||
const baseDisplayCacheRef = React.useRef<{
|
|
||||||
input: ChatMessageEntry[];
|
|
||||||
output: ChatMessageEntry[];
|
|
||||||
outputIndexById: Map<string, number>;
|
|
||||||
} | null>(null);
|
|
||||||
const staticRenderEntriesCacheRef = React.useRef<{
|
|
||||||
input: ChatMessageEntry[];
|
|
||||||
output: RenderEntry[];
|
|
||||||
staticTurns: TurnRecord[];
|
|
||||||
lastTurnId: string | null;
|
|
||||||
ungroupedMessageIds: Set<string>;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
|
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
|
||||||
const stableOnLoadOlder = useStableEvent(onLoadOlder);
|
const stableOnLoadOlder = useStableEvent(onLoadOlder);
|
||||||
const stableScrollToBottom = useStableEvent((options?: { instant?: boolean; force?: boolean }) => {
|
const stableScrollToBottom = useStableEvent((options?: { instant?: boolean; force?: boolean }) => {
|
||||||
@@ -1256,50 +1236,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
|
|
||||||
|
|
||||||
const baseDisplayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.base_display_ms', () => {
|
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<string>();
|
const seenIdsFromTail = new Set<string>();
|
||||||
const dedupedMessages: ChatMessageEntry[] = [];
|
const dedupedMessages: ChatMessageEntry[] = [];
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
@@ -1344,19 +1280,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
output.push(currentWithRole);
|
output.push(currentWithRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputIndexById = new Map<string, number>();
|
|
||||||
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;
|
return output;
|
||||||
}), [messages]);
|
}), [messages]);
|
||||||
|
|
||||||
@@ -1386,48 +1309,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
showTextJustificationActivity: chatRenderMode === 'sorted',
|
showTextJustificationActivity: chatRenderMode === 'sorted',
|
||||||
});
|
});
|
||||||
const staticRenderEntries = React.useMemo<RenderEntry[]>(() => streamPerfMeasure('ui.message_list.render_entries_ms', () => {
|
const staticRenderEntries = React.useMemo<RenderEntry[]>(() => 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) => ({
|
const turnEntries = staticTurns.map((turn) => ({
|
||||||
kind: 'turn' as const,
|
kind: 'turn' as const,
|
||||||
key: `turn:${turn.turnId}`,
|
key: `turn:${turn.turnId}`,
|
||||||
@@ -1465,16 +1346,8 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
staticRenderEntriesCacheRef.current = {
|
|
||||||
input: displayMessages,
|
|
||||||
output: orderedEntries,
|
|
||||||
staticTurns,
|
|
||||||
lastTurnId: projection.lastTurnId,
|
|
||||||
ungroupedMessageIds: projection.ungroupedMessageIds,
|
|
||||||
};
|
|
||||||
|
|
||||||
return orderedEntries;
|
return orderedEntries;
|
||||||
}), [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, staticTurns, streamingTurn]);
|
}), [displayMessages, projection.lastTurnId, projection.ungroupedMessageIds, staticTurns]);
|
||||||
|
|
||||||
const trailingStreamingEntry = React.useMemo<RenderEntry | undefined>(() => {
|
const trailingStreamingEntry = React.useMemo<RenderEntry | undefined>(() => {
|
||||||
if (streamingTurn) {
|
if (streamingTurn) {
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionPermissions, useSessionStatus } from '@/sync/sync-context';
|
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { StatusRow } from './StatusRow';
|
import { StatusRow } from './StatusRow';
|
||||||
|
|
||||||
const DEFAULT_WORKING_STATUS = 'working';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coarse status wrapper.
|
* Status row wrapper.
|
||||||
* Avoids subscribing to live assistant parts so the row doesn't rerender on every text delta.
|
* 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(() => {
|
export const StatusRowContainer: React.FC = React.memo(() => {
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
@@ -22,50 +20,20 @@ export const StatusRowContainer: React.FC = React.memo(() => {
|
|||||||
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
|
return state.sessionAbortFlags?.get(currentSessionId) ?? null;
|
||||||
}, [currentSessionId]),
|
}, [currentSessionId]),
|
||||||
);
|
);
|
||||||
const permissions = useSessionPermissions(currentSessionId ?? '');
|
const { working } = useAssistantStatus();
|
||||||
const sessionStatus = useSessionStatus(currentSessionId ?? '');
|
|
||||||
const { phase, isWorking } = useCurrentSessionActivity();
|
|
||||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
|
|
||||||
const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged);
|
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 (
|
return (
|
||||||
<StatusRow
|
<StatusRow
|
||||||
isWorking={isWorking}
|
isWorking={working.isWorking}
|
||||||
statusText={statusText}
|
statusText={working.statusText}
|
||||||
isGenericStatus={true}
|
isGenericStatus={working.isGenericStatus}
|
||||||
isWaitingForPermission={isWaitingForPermission}
|
isWaitingForPermission={working.isWaitingForPermission}
|
||||||
wasAborted={wasAborted}
|
wasAborted={wasAborted || working.wasAborted}
|
||||||
abortActive={wasAborted}
|
abortActive={wasAborted || working.abortActive}
|
||||||
retryInfo={retryInfo}
|
retryInfo={working.retryInfo}
|
||||||
showAssistantStatus
|
showAssistantStatus
|
||||||
showTodos={false}
|
showTodos={false}
|
||||||
agentName={currentAgentName}
|
agentName={currentAgentName}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
|
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
|
||||||
import { projectTurnIndexes } from '../lib/turns/projectTurnIndexes';
|
|
||||||
import { stabilizeTurnProjection } from '../lib/turns/stabilizeTurnProjection';
|
|
||||||
import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
|
import type { ChatMessageEntry, TurnProjectionResult, TurnRecord } from '../lib/turns/types';
|
||||||
import { streamPerfMeasure } from '@/stores/utils/streamDebug';
|
import { streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||||
|
|
||||||
@@ -16,125 +14,36 @@ export interface TurnRecordsResult {
|
|||||||
streamingTurn: TurnProjectionResult['turns'][number] | undefined;
|
streamingTurn: TurnProjectionResult['turns'][number] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildTailOnlyProjection = (
|
|
||||||
previousProjection: TurnProjectionResult | null,
|
|
||||||
previousMessages: ChatMessageEntry[] | null,
|
|
||||||
nextMessages: ChatMessageEntry[],
|
|
||||||
showTextJustificationActivity: boolean,
|
|
||||||
): TurnProjectionResult | null => {
|
|
||||||
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<string>();
|
|
||||||
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 = (
|
export const useTurnRecords = (
|
||||||
messages: ChatMessageEntry[],
|
messages: ChatMessageEntry[],
|
||||||
options: UseTurnRecordsOptions,
|
options: UseTurnRecordsOptions,
|
||||||
): TurnRecordsResult => {
|
): TurnRecordsResult => {
|
||||||
const previousProjectionRef = React.useRef<TurnProjectionResult | null>(null);
|
const previousProjectionRef = React.useRef<TurnProjectionResult | null>(null);
|
||||||
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
|
|
||||||
const staticTurnsRef = React.useRef<TurnRecord[]>([]);
|
const staticTurnsRef = React.useRef<TurnRecord[]>([]);
|
||||||
const streamingTurnRef = React.useRef<TurnRecord | undefined>(undefined);
|
const streamingTurnRef = React.useRef<TurnRecord | undefined>(undefined);
|
||||||
|
const previousSessionKeyRef = React.useRef<string | undefined>(options.sessionKey);
|
||||||
|
|
||||||
|
if (previousSessionKeyRef.current !== options.sessionKey) {
|
||||||
|
previousSessionKeyRef.current = options.sessionKey;
|
||||||
|
previousProjectionRef.current = null;
|
||||||
|
staticTurnsRef.current = [];
|
||||||
|
streamingTurnRef.current = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
previousProjectionRef.current = null;
|
previousProjectionRef.current = null;
|
||||||
previousMessagesRef.current = null;
|
|
||||||
staticTurnsRef.current = [];
|
staticTurnsRef.current = [];
|
||||||
streamingTurnRef.current = undefined;
|
streamingTurnRef.current = undefined;
|
||||||
}, [options.sessionKey, options.showTextJustificationActivity]);
|
}, [options.sessionKey, options.showTextJustificationActivity]);
|
||||||
|
|
||||||
const projection = React.useMemo(() => {
|
const projection = React.useMemo(() => {
|
||||||
return streamPerfMeasure('ui.turns.projection_ms', () => {
|
return streamPerfMeasure('ui.turns.projection_ms', () => {
|
||||||
const tailOnlyProjection = buildTailOnlyProjection(
|
const nextProjection = projectTurnRecords(messages, {
|
||||||
previousProjectionRef.current,
|
|
||||||
previousMessagesRef.current,
|
|
||||||
messages,
|
|
||||||
options.showTextJustificationActivity,
|
|
||||||
);
|
|
||||||
const rawProjection = tailOnlyProjection ?? projectTurnRecords(messages, {
|
|
||||||
previousProjection: previousProjectionRef.current,
|
previousProjection: previousProjectionRef.current,
|
||||||
showTextJustificationActivity: options.showTextJustificationActivity,
|
showTextJustificationActivity: options.showTextJustificationActivity,
|
||||||
});
|
});
|
||||||
const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current);
|
previousProjectionRef.current = nextProjection;
|
||||||
previousProjectionRef.current = stabilizedProjection;
|
return nextProjection;
|
||||||
previousMessagesRef.current = messages;
|
|
||||||
return stabilizedProjection;
|
|
||||||
});
|
});
|
||||||
}, [messages, options.showTextJustificationActivity]);
|
}, [messages, options.showTextJustificationActivity]);
|
||||||
|
|
||||||
|
|||||||
@@ -32,99 +32,6 @@ const getMessageCompletedAt = (message: ChatMessageEntry): number | undefined =>
|
|||||||
return typeof completed === 'number' ? completed : 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 getUserSummaryBody = (message: ChatMessageEntry): string | undefined => {
|
||||||
const summaryBody = (message.info as { summary?: { body?: unknown } | null | undefined })?.summary?.body;
|
const summaryBody = (message.info as { summary?: { body?: unknown } | null | undefined })?.summary?.body;
|
||||||
if (typeof summaryBody !== 'string') {
|
if (typeof summaryBody !== 'string') {
|
||||||
@@ -194,7 +101,6 @@ export const projectTurnRecords = (
|
|||||||
|
|
||||||
const turns: TurnRecord[] = [];
|
const turns: TurnRecord[] = [];
|
||||||
const turnByUserId = new Map<string, TurnRecord>();
|
const turnByUserId = new Map<string, TurnRecord>();
|
||||||
const previousTurnsById = new Map((effectiveOptions.previousProjection?.turns ?? []).map((turn) => [turn.turnId, turn]));
|
|
||||||
const groupedMessageIds = new Set<string>();
|
const groupedMessageIds = new Set<string>();
|
||||||
let currentTurn: TurnRecord | undefined;
|
let currentTurn: TurnRecord | undefined;
|
||||||
|
|
||||||
@@ -254,43 +160,6 @@ export const projectTurnRecords = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
turns.forEach((turn) => {
|
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.summary = projectTurnSummary(turn.assistantMessages);
|
||||||
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
|
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
|
||||||
turn.diffStats = projectTurnDiffStats(turn.userMessage);
|
turn.diffStats = projectTurnDiffStats(turn.userMessage);
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import { projectTurnIndexes } from './projectTurnIndexes';
|
import { projectTurnIndexes } from './projectTurnIndexes';
|
||||||
import type { TurnProjectionResult, TurnRecord } from './types';
|
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 buildTurnSignature = (turn: TurnRecord): string => {
|
||||||
const assistantIds = turn.assistantMessageIds.join(',');
|
const assistantIds = turn.assistantMessageIds.join(',');
|
||||||
return [
|
return [
|
||||||
@@ -40,6 +58,10 @@ export const stabilizeTurnProjection = (
|
|||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!areTurnMessagesReferenceStable(previousTurn, turn)) {
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
|
|
||||||
reused = true;
|
reused = true;
|
||||||
return previousTurn;
|
return previousTurn;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2024,8 +2024,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
const childSessionIsActive =
|
const childSessionIsActive =
|
||||||
childSessionActivity.phase === 'busy'
|
childSessionActivity.phase === 'busy'
|
||||||
|| childSessionActivity.phase === 'retry'
|
|| childSessionActivity.phase === 'retry'
|
||||||
|| childSessionHasInFlightTools
|
|| childSessionHasInFlightTools;
|
||||||
|| (!isFinalized && activeLatched);
|
|
||||||
|
|
||||||
if (childSessionIsActive) {
|
if (childSessionIsActive) {
|
||||||
if (!taskChildSeenActive) {
|
if (!taskChildSeenActive) {
|
||||||
@@ -2106,7 +2105,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
|
const childSessionActive = childSessionActivity.phase === 'busy' || childSessionActivity.phase === 'retry';
|
||||||
const shouldPoll =
|
const shouldPoll =
|
||||||
!taskChildPollingStopped
|
!taskChildPollingStopped
|
||||||
&& (isActive || childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
|
&& (childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
|
||||||
const shouldFetchSnapshot = childSessionTaskSummaryEntries.length === 0 || shouldPoll;
|
const shouldFetchSnapshot = childSessionTaskSummaryEntries.length === 0 || shouldPoll;
|
||||||
if (!shouldFetchSnapshot) {
|
if (!shouldFetchSnapshot) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ const readToolStatus = (part: Part | undefined): string | null => {
|
|||||||
return typeof status === 'string' ? status : 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 readPartTime = (part: Part | undefined) => {
|
||||||
const time = (part as { time?: { start?: unknown; end?: unknown } } | undefined)?.time;
|
const time = (part as { time?: { start?: unknown; end?: unknown } } | undefined)?.time;
|
||||||
return {
|
return {
|
||||||
@@ -53,6 +61,12 @@ export const areRenderRelevantPartsEqual = (left: Part[], right: Part[]): boolea
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (leftPart.type === 'tool') {
|
if (leftPart.type === 'tool') {
|
||||||
|
if (readToolStateRef(leftPart) !== readToolStateRef(rightPart)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (readPartMetadataRef(leftPart) !== readPartMetadataRef(rightPart)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (readToolStatus(leftPart) !== readToolStatus(rightPart)) {
|
if (readToolStatus(leftPart) !== readToolStatus(rightPart)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,18 @@ export function applyDirectoryEvent(
|
|||||||
return true
|
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": {
|
case "message.updated": {
|
||||||
const info = (event.properties as { info: Message }).info
|
const info = (event.properties as { info: Message }).info
|
||||||
const messages = draft.message[info.sessionID]
|
const messages = draft.message[info.sessionID]
|
||||||
|
|||||||
@@ -765,6 +765,8 @@ function handleEvent(
|
|||||||
draft.session_diff = { ...current.session_diff }
|
draft.session_diff = { ...current.session_diff }
|
||||||
break
|
break
|
||||||
case "session.status":
|
case "session.status":
|
||||||
|
case "session.idle":
|
||||||
|
case "session.error":
|
||||||
draft.session_status = { ...(current.session_status ?? {}) }
|
draft.session_status = { ...(current.session_status ?? {}) }
|
||||||
break
|
break
|
||||||
case "todo.updated":
|
case "todo.updated":
|
||||||
@@ -812,6 +814,11 @@ function handleEvent(
|
|||||||
setGlobalSessionStatus(props.sessionID, props.status)
|
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") {
|
if (payload.type === "permission.asked") {
|
||||||
const nd = normalizeDirectory(resolvedDirectory)
|
const nd = normalizeDirectory(resolvedDirectory)
|
||||||
if (!nd) {
|
if (!nd) {
|
||||||
@@ -1268,8 +1275,6 @@ export function useChildStoreManager() {
|
|||||||
return useSyncSystem().childStores
|
return useSyncSystem().childStores
|
||||||
}
|
}
|
||||||
|
|
||||||
const MESSAGE_PART_SNAPSHOT_THROTTLE_MS = 100
|
|
||||||
|
|
||||||
export type SessionTextMessage = {
|
export type SessionTextMessage = {
|
||||||
id: string
|
id: string
|
||||||
role: string | null
|
role: string | null
|
||||||
@@ -1304,12 +1309,7 @@ function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string,
|
|||||||
const [partsSnapshot, setPartsSnapshot] = React.useState<Record<string, Part[]>>({})
|
const [partsSnapshot, setPartsSnapshot] = React.useState<Record<string, Part[]>>({})
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let pending = false
|
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
timer = null
|
|
||||||
pending = false
|
|
||||||
const state = store.getState()
|
const state = store.getState()
|
||||||
const prev = prevPartsRef.current
|
const prev = prevPartsRef.current
|
||||||
let changed = false
|
let changed = false
|
||||||
@@ -1328,28 +1328,13 @@ function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string,
|
|||||||
flush()
|
flush()
|
||||||
|
|
||||||
if (suspendUpdates) {
|
if (suspendUpdates) {
|
||||||
return () => {
|
return
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = store.subscribe(() => {
|
const unsub = store.subscribe(flush)
|
||||||
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)
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsub()
|
unsub()
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
}
|
}
|
||||||
}, [messageIds, store, suspendUpdates])
|
}, [messageIds, store, suspendUpdates])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user