refactor: isolate chat hot paths and lazy server watchers
Split static history from live tail, stabilize chat data identities, and reduce scroll-shell churn so old viewport content stops waking on stream deltas. Also defer watcher startup and health polling so idle runtime work better matches actual usage.
This commit is contained in:
@@ -7,9 +7,12 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import ChatEmptyState from './ChatEmptyState';
|
||||
import MessageList, { type MessageListHandle } from './MessageList';
|
||||
import { PermissionCard } from './PermissionCard';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import { StatusRowContainer } from './StatusRowContainer';
|
||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
||||
import { useChatScrollManager, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||
import { useTimelineStaging } from '@/hooks/useTimelineStaging';
|
||||
@@ -42,6 +45,45 @@ const EMPTY_PERMISSIONS: PermissionRequest[] = [];
|
||||
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected';
|
||||
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;
|
||||
@@ -49,6 +91,144 @@ type HydratingToolSkeletonRow = {
|
||||
detailWidth: string;
|
||||
};
|
||||
|
||||
type ChatViewportProps = {
|
||||
currentSessionId: string;
|
||||
isDesktopExpandedInput: boolean;
|
||||
isMobile: boolean;
|
||||
stickyUserHeader: boolean;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
messageListRef: React.RefObject<MessageListHandle | null>;
|
||||
turnStart: number;
|
||||
pendingRevealWork: boolean;
|
||||
renderedMessages: SessionMessageRecord[];
|
||||
hasMoreAboveTurns: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
sessionIsWorking: boolean;
|
||||
streamingMessageId: string | null;
|
||||
retryOverlay: {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
confirmedAt?: number;
|
||||
fallbackTimestamp?: number;
|
||||
} | null;
|
||||
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
handleLoadOlder: () => void;
|
||||
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
sessionQuestions: QuestionRequest[];
|
||||
sessionPermissions: PermissionRequest[];
|
||||
isProgrammaticFollowActive: boolean;
|
||||
};
|
||||
|
||||
const ChatViewport = React.memo(({
|
||||
currentSessionId,
|
||||
isDesktopExpandedInput,
|
||||
isMobile,
|
||||
stickyUserHeader,
|
||||
scrollRef,
|
||||
messageListRef,
|
||||
turnStart,
|
||||
pendingRevealWork,
|
||||
renderedMessages,
|
||||
hasMoreAboveTurns,
|
||||
isLoadingOlder,
|
||||
sessionIsWorking,
|
||||
streamingMessageId,
|
||||
retryOverlay,
|
||||
handleMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
handleLoadOlder,
|
||||
scrollToBottom,
|
||||
sessionQuestions,
|
||||
sessionPermissions,
|
||||
isProgrammaticFollowActive,
|
||||
}: ChatViewportProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
||||
: 'flex-1'
|
||||
)}
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<ScrollShadow
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||
ref={scrollRef}
|
||||
style={CHAT_SCROLL_STYLE}
|
||||
observeMutations={false}
|
||||
hideTopShadow={isMobile && stickyUserHeader}
|
||||
data-scroll-shadow="true"
|
||||
data-scrollbar="chat"
|
||||
>
|
||||
<div className="relative z-0 min-h-full">
|
||||
<MessageList
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
turnStart={turnStart}
|
||||
disableStaging={pendingRevealWork}
|
||||
messages={renderedMessages}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
activeStreamingMessageId={streamingMessageId}
|
||||
retryOverlay={retryOverlay}
|
||||
onMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
hasMoreAbove={hasMoreAboveTurns}
|
||||
isLoadingOlder={isLoadingOlder}
|
||||
onLoadOlder={handleLoadOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
|
||||
<div>
|
||||
{sessionQuestions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{sessionPermissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
return prev.currentSessionId === next.currentSessionId
|
||||
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.stickyUserHeader === next.stickyUserHeader
|
||||
&& prev.scrollRef === next.scrollRef
|
||||
&& prev.messageListRef === next.messageListRef
|
||||
&& prev.turnStart === next.turnStart
|
||||
&& prev.pendingRevealWork === next.pendingRevealWork
|
||||
&& prev.renderedMessages === next.renderedMessages
|
||||
&& prev.hasMoreAboveTurns === next.hasMoreAboveTurns
|
||||
&& prev.isLoadingOlder === next.isLoadingOlder
|
||||
&& prev.sessionIsWorking === next.sessionIsWorking
|
||||
&& prev.streamingMessageId === next.streamingMessageId
|
||||
&& prev.retryOverlay === next.retryOverlay
|
||||
&& prev.handleMessageContentChange === next.handleMessageContentChange
|
||||
&& prev.getAnimationHandlers === next.getAnimationHandlers
|
||||
&& prev.handleLoadOlder === next.handleLoadOlder
|
||||
&& prev.scrollToBottom === next.scrollToBottom
|
||||
&& prev.sessionQuestions === next.sessionQuestions
|
||||
&& prev.sessionPermissions === next.sessionPermissions
|
||||
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive;
|
||||
});
|
||||
|
||||
ChatViewport.displayName = 'ChatViewport';
|
||||
|
||||
const HYDRATING_SKELETON_ITEMS: Array<{
|
||||
id: number;
|
||||
toolRows: HydratingToolSkeletonRow[];
|
||||
@@ -163,6 +343,64 @@ export const ChatContainer: React.FC = () => {
|
||||
if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
|
||||
return flattenBlockingRequests(questionsMap, scopedSessionIds);
|
||||
}, [questionsMap, scopedSessionIds]);
|
||||
const sessionIsWorking = React.useMemo(() => {
|
||||
if (!currentSessionId || sessionPermissions.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
||||
if (statusType === 'busy' || statusType === 'retry') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastMessage = sessionMessages[sessionMessages.length - 1]?.info as Message | undefined;
|
||||
return Boolean(
|
||||
lastMessage
|
||||
&& lastMessage.role === 'assistant'
|
||||
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
|
||||
);
|
||||
}, [currentSessionId, sessionMessages, sessionPermissions.length, sessionStatusForCurrent.type]);
|
||||
const activeRetryStatus = React.useMemo(() => {
|
||||
if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawMessage = typeof (sessionStatusForCurrent as { message?: string }).message === 'string'
|
||||
? (((sessionStatusForCurrent as { message?: string }).message) ?? '').trim()
|
||||
: '';
|
||||
|
||||
return {
|
||||
sessionId: currentSessionId,
|
||||
message: rawMessage || DEFAULT_RETRY_MESSAGE,
|
||||
confirmedAt: (sessionStatusForCurrent as { confirmedAt?: number }).confirmedAt,
|
||||
};
|
||||
}, [currentSessionId, sessionStatusForCurrent]);
|
||||
const [retryFallbackTimestamp, setRetryFallbackTimestamp] = React.useState<number>(0);
|
||||
const retryFallbackSessionRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeRetryStatus || typeof activeRetryStatus.confirmedAt === 'number') {
|
||||
retryFallbackSessionRef.current = null;
|
||||
setRetryFallbackTimestamp(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (retryFallbackSessionRef.current !== activeRetryStatus.sessionId) {
|
||||
retryFallbackSessionRef.current = activeRetryStatus.sessionId;
|
||||
setRetryFallbackTimestamp(Date.now());
|
||||
}
|
||||
}, [activeRetryStatus]);
|
||||
|
||||
const retryOverlay = React.useMemo(() => {
|
||||
if (!activeRetryStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...activeRetryStatus,
|
||||
fallbackTimestamp: retryFallbackTimestamp,
|
||||
};
|
||||
}, [activeRetryStatus, retryFallbackTimestamp]);
|
||||
|
||||
// History metadata — use sync's hasMore/isLoading
|
||||
const historyMeta = React.useMemo(() => {
|
||||
@@ -248,11 +486,36 @@ export const ChatContainer: React.FC = () => {
|
||||
onActiveTurnChange: handleActiveTurnChange,
|
||||
});
|
||||
|
||||
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
|
||||
&& historyMeta?.loading !== true
|
||||
&& canFreezeDetachedViewport(viewportMessagesRef.current, sessionMessages, streamingMessageId),
|
||||
);
|
||||
|
||||
if (shouldFreezeViewport) {
|
||||
return viewportMessagesRef.current;
|
||||
}
|
||||
|
||||
viewportMessagesRef.current = sessionMessages;
|
||||
return sessionMessages;
|
||||
}, [currentSessionId, historyMeta?.loading, isPinned, sessionMessages, streamingMessageId]);
|
||||
|
||||
// Deferred timeline staging — renders 1 message on first paint,
|
||||
// adds 3 per rAF frame to avoid blocking.
|
||||
const { stagedMessages } = useTimelineStaging({
|
||||
sessionKey: currentSessionId ?? '',
|
||||
messages: sessionMessages,
|
||||
messages: viewportMessages,
|
||||
});
|
||||
|
||||
const timelineController = useChatTimelineController({
|
||||
@@ -266,12 +529,23 @@ export const ChatContainer: React.FC = () => {
|
||||
isPinned,
|
||||
isOverflowing,
|
||||
});
|
||||
const { resumeToBottomInstant } = timelineController;
|
||||
const { loadEarlier, resumeToBottomInstant } = timelineController;
|
||||
|
||||
React.useEffect(() => {
|
||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||
}, [timelineController.handleActiveTurnChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (sessionPermissions.length === 0 && sessionQuestions.length === 0) {
|
||||
return;
|
||||
}
|
||||
handleMessageContentChange('permission');
|
||||
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
|
||||
|
||||
const handleLoadOlder = React.useCallback(() => {
|
||||
void loadEarlier();
|
||||
}, [loadEarlier]);
|
||||
|
||||
const navigation = useChatTurnNavigation({
|
||||
sessionId: currentSessionId,
|
||||
turnIds: timelineController.turnIds,
|
||||
@@ -505,49 +779,29 @@ export const ChatContainer: React.FC = () => {
|
||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||
>
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-0',
|
||||
isDesktopExpandedInput
|
||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
||||
: 'flex-1'
|
||||
)}
|
||||
aria-hidden={isDesktopExpandedInput}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<ScrollShadow
|
||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||
ref={scrollRef}
|
||||
style={{ overflowAnchor: 'none' }}
|
||||
observeMutations={false}
|
||||
hideTopShadow={isMobile && stickyUserHeader}
|
||||
data-scroll-shadow="true"
|
||||
data-scrollbar="chat"
|
||||
>
|
||||
<div className="relative z-0 min-h-full">
|
||||
<MessageList
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
turnStart={timelineController.turnStart}
|
||||
disableStaging={timelineController.pendingRevealWork}
|
||||
messages={timelineController.renderedMessages}
|
||||
permissions={sessionPermissions}
|
||||
questions={sessionQuestions}
|
||||
onMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
hasMoreAbove={timelineController.historySignals.hasMoreAboveTurns}
|
||||
isLoadingOlder={timelineController.isLoadingOlder}
|
||||
onLoadOlder={() => {
|
||||
void timelineController.loadEarlier();
|
||||
}}
|
||||
scrollToBottom={scrollToBottom}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
</div>
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly />
|
||||
</div>
|
||||
</div>
|
||||
<ChatViewport
|
||||
currentSessionId={currentSessionId}
|
||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||
isMobile={isMobile}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
scrollRef={scrollRef}
|
||||
messageListRef={messageListRef}
|
||||
turnStart={timelineController.turnStart}
|
||||
pendingRevealWork={timelineController.pendingRevealWork}
|
||||
renderedMessages={timelineController.renderedMessages}
|
||||
hasMoreAboveTurns={timelineController.historySignals.hasMoreAboveTurns}
|
||||
isLoadingOlder={timelineController.isLoadingOlder}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
streamingMessageId={streamingMessageId}
|
||||
retryOverlay={retryOverlay}
|
||||
handleMessageContentChange={handleMessageContentChange}
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
handleLoadOlder={handleLoadOlder}
|
||||
scrollToBottom={scrollToBottom}
|
||||
sessionQuestions={sessionQuestions}
|
||||
sessionPermissions={sessionPermissions}
|
||||
isProgrammaticFollowActive={isProgrammaticFollowActive}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { useSessionMessageRecords } from '@/sync/sync-context';
|
||||
import { useUserMessageHistory } from '@/sync/sync-context';
|
||||
import { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||
import { AttachedFilesList } from './FileAttachment';
|
||||
@@ -41,7 +41,6 @@ import { StatusRow } from './StatusRow';
|
||||
import { MobileAgentButton } from './MobileAgentButton';
|
||||
import { MobileModelButton } from './MobileModelButton';
|
||||
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { toast } from '@/components/ui';
|
||||
// useMessageStore removed — messages now come from sync system
|
||||
@@ -655,7 +654,7 @@ const saveStoredDraft = (sessionId: string | null, draft: string): void => {
|
||||
}
|
||||
};
|
||||
|
||||
export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
||||
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom }) => {
|
||||
// Track if we restored a draft on mount (for text selection)
|
||||
const initialDraftRef = React.useRef<string | null>(null);
|
||||
// Track initial session ID (captured at mount time for draft restoration)
|
||||
@@ -749,7 +748,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
|
||||
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
||||
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
|
||||
const { working } = useAssistantStatus();
|
||||
const { git: runtimeGit } = useRuntimeAPIs();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const chatSearchDirectory = useChatSearchDirectory();
|
||||
@@ -973,24 +971,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||
const hasDrafts = draftCount > 0;
|
||||
|
||||
// User message history for up/down arrow navigation
|
||||
// Get raw messages from store (stable reference)
|
||||
const sessionMessages = useSessionMessageRecords(currentSessionId ?? "");
|
||||
// Derive user message history with useMemo to avoid infinite re-renders
|
||||
const userMessageHistory = React.useMemo(() => {
|
||||
if (!sessionMessages || !currentSessionId) return [];
|
||||
return sessionMessages
|
||||
.filter((m) => m.info.role === 'user')
|
||||
.map((m) => {
|
||||
const textPart = m.parts.find((p) => p.type === 'text');
|
||||
if (textPart && 'text' in textPart) {
|
||||
return String(textPart.text);
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter((text) => text.length > 0)
|
||||
.reverse(); // Most recent first
|
||||
}, [sessionMessages, currentSessionId]);
|
||||
// User message history for up/down arrow navigation.
|
||||
// Keep this on a narrow hook instead of full session message records.
|
||||
const userMessageHistory = useUserMessageHistory(currentSessionId ?? "");
|
||||
|
||||
// Keep messageRef in sync with message state
|
||||
React.useEffect(() => {
|
||||
@@ -1248,7 +1231,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const hasQueuedMessages = queuedMessages.length > 0;
|
||||
const canSend = hasContent || hasQueuedMessages;
|
||||
|
||||
const canAbort = working.isWorking;
|
||||
const canAbort = sessionPhase !== 'idle';
|
||||
|
||||
// Keep a ref to handleSubmit so callbacks don't depend on it.
|
||||
type SubmitOptions = {
|
||||
@@ -3135,10 +3118,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
});
|
||||
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
|
||||
|
||||
const workingStatusText = working.statusText;
|
||||
|
||||
React.useEffect(() => {
|
||||
const pendingAbortBanner = Boolean(working.wasAborted);
|
||||
const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
|
||||
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
|
||||
startAbortIndicator();
|
||||
if (currentSessionId) {
|
||||
@@ -3147,11 +3128,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
prevWasAbortedRef.current = pendingAbortBanner;
|
||||
}, [
|
||||
abortPromptSessionId,
|
||||
acknowledgeSessionAbort,
|
||||
currentSessionId,
|
||||
showAbortStatus,
|
||||
startAbortIndicator,
|
||||
working.wasAborted,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -3298,13 +3279,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
</div>
|
||||
)}
|
||||
<MemoStatusRow
|
||||
isWorking={working.isWorking}
|
||||
statusText={workingStatusText}
|
||||
isGenericStatus={working.isGenericStatus}
|
||||
isWaitingForPermission={working.isWaitingForPermission}
|
||||
wasAborted={working.wasAborted}
|
||||
abortActive={working.abortActive}
|
||||
retryInfo={working.retryInfo}
|
||||
showAbortStatus={showAbortStatus}
|
||||
showAssistantStatus={false}
|
||||
showTodos
|
||||
@@ -3732,3 +3706,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ChatInputComponent.displayName = 'ChatInput';
|
||||
|
||||
export const ChatInput = React.memo(ChatInputComponent);
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
import { measureElement as measureVirtualElement, type VirtualItem, useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import ChatMessage from './ChatMessage';
|
||||
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
|
||||
import { PermissionCard } from './PermissionCard';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import TurnItem from './components/TurnItem';
|
||||
import TurnList from './components/TurnList';
|
||||
import type { PermissionRequest } from '@/types/permission';
|
||||
import type { QuestionRequest } from '@/types/question';
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||
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 { useUIStore } from '@/stores/useUIStore';
|
||||
import { useStreamingStore } from '@/sync/streaming';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionStatus } from '@/sync/sync-context';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
|
||||
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
|
||||
import { StatusRowContainer } from './StatusRowContainer';
|
||||
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
|
||||
const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 40;
|
||||
const MESSAGE_LIST_OVERSCAN = 6;
|
||||
|
||||
const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => {
|
||||
if (!entry) {
|
||||
return 160;
|
||||
}
|
||||
|
||||
if (entry.kind === 'turn') {
|
||||
return 180 + Math.min(entry.turn.assistantMessages.length, 4) * 100;
|
||||
}
|
||||
|
||||
return 140;
|
||||
};
|
||||
|
||||
const useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
|
||||
const handlerRef = React.useRef(handler);
|
||||
React.useEffect(() => {
|
||||
@@ -297,8 +302,14 @@ interface MessageListProps {
|
||||
turnStart: number;
|
||||
disableStaging?: boolean;
|
||||
messages: ChatMessageEntry[];
|
||||
permissions: PermissionRequest[];
|
||||
questions: QuestionRequest[];
|
||||
sessionIsWorking?: boolean;
|
||||
activeStreamingMessageId?: string | null;
|
||||
retryOverlay?: {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
confirmedAt?: number;
|
||||
fallbackTimestamp?: number;
|
||||
} | null;
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
hasMoreAbove: boolean;
|
||||
@@ -884,21 +895,24 @@ function areMessageListEntryPropsEqual(prevProps: MessageListEntryProps, nextPro
|
||||
}
|
||||
|
||||
// Inner component that renders staged turn entries.
|
||||
const MessageListContent: React.FC<{
|
||||
const StaticHistoryList: React.FC<{
|
||||
entries: RenderEntry[];
|
||||
shouldVirtualize: boolean;
|
||||
virtualRows: VirtualItem[];
|
||||
totalSize: number;
|
||||
measureElement: (element: HTMLDivElement | null) => void;
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
stickyUserHeader: boolean;
|
||||
sessionIsWorking: boolean;
|
||||
defaultActivityExpanded: boolean;
|
||||
turnUiStates: Map<string, TurnUiState>;
|
||||
onToggleTurnGroup: (turnId: string) => void;
|
||||
chatRenderMode: 'sorted' | 'live';
|
||||
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
||||
onUserAnimationConsumed: (messageId: string) => void;
|
||||
activeStreamingMessageId?: string | null;
|
||||
}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingMessageId }) => {
|
||||
}> = React.memo(({ entries, shouldVirtualize, virtualRows, totalSize, measureElement, contentRef, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed }) => {
|
||||
const renderEntry = React.useCallback((entry: RenderEntry) => {
|
||||
return (
|
||||
<MessageListEntry
|
||||
@@ -908,22 +922,83 @@ const MessageListContent: React.FC<{
|
||||
getAnimationHandlers={getAnimationHandlers}
|
||||
scrollToBottom={scrollToBottom}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
sessionIsWorking={false}
|
||||
defaultActivityExpanded={defaultActivityExpanded}
|
||||
turnUiStates={turnUiStates}
|
||||
onToggleTurnGroup={onToggleTurnGroup}
|
||||
chatRenderMode={chatRenderMode}
|
||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
||||
activeStreamingMessageId={activeStreamingMessageId}
|
||||
activeStreamingMessageId={null}
|
||||
/>
|
||||
);
|
||||
}, [activeStreamingMessageId, chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, sessionIsWorking, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
|
||||
}, [chatRenderMode, defaultActivityExpanded, getAnimationHandlers, onMessageContentChange, onToggleTurnGroup, onUserAnimationConsumed, scrollToBottom, shouldAnimateUserMessage, stickyUserHeader, turnUiStates]);
|
||||
|
||||
const paddingTop = shouldVirtualize && virtualRows.length > 0
|
||||
? virtualRows[0]?.start ?? 0
|
||||
: 0;
|
||||
const paddingBottom = shouldVirtualize && virtualRows.length > 0
|
||||
? Math.max(0, totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||
: 0;
|
||||
|
||||
if (!shouldVirtualize) {
|
||||
return (
|
||||
<div ref={contentRef} className="relative w-full">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
data-turn-entry={entry.key}
|
||||
>
|
||||
{renderEntry(entry)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TurnList entries={entries} renderEntry={renderEntry} />
|
||||
<div ref={contentRef} className="relative w-full">
|
||||
{paddingTop > 0 ? <div aria-hidden="true" style={{ height: `${paddingTop}px` }} /> : null}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const entry = entries[virtualRow.index];
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
ref={measureElement}
|
||||
data-index={virtualRow.index}
|
||||
data-turn-entry={entry.key}
|
||||
>
|
||||
{renderEntry(entry)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 ? <div aria-hidden="true" style={{ height: `${paddingBottom}px` }} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}, (prevProps, nextProps) => {
|
||||
return prevProps.entries === nextProps.entries
|
||||
&& prevProps.shouldVirtualize === nextProps.shouldVirtualize
|
||||
&& prevProps.virtualRows === nextProps.virtualRows
|
||||
&& prevProps.totalSize === nextProps.totalSize
|
||||
&& prevProps.measureElement === nextProps.measureElement
|
||||
&& prevProps.contentRef === nextProps.contentRef
|
||||
&& prevProps.onMessageContentChange === nextProps.onMessageContentChange
|
||||
&& prevProps.getAnimationHandlers === nextProps.getAnimationHandlers
|
||||
&& prevProps.scrollToBottom === nextProps.scrollToBottom
|
||||
&& prevProps.stickyUserHeader === nextProps.stickyUserHeader
|
||||
&& prevProps.defaultActivityExpanded === nextProps.defaultActivityExpanded
|
||||
&& prevProps.turnUiStates === nextProps.turnUiStates
|
||||
&& prevProps.onToggleTurnGroup === nextProps.onToggleTurnGroup
|
||||
&& prevProps.chatRenderMode === nextProps.chatRenderMode
|
||||
&& prevProps.shouldAnimateUserMessage === nextProps.shouldAnimateUserMessage
|
||||
&& prevProps.onUserAnimationConsumed === nextProps.onUserAnimationConsumed;
|
||||
});
|
||||
|
||||
StaticHistoryList.displayName = 'StaticHistoryList';
|
||||
|
||||
const StreamingTailContent: React.FC<{
|
||||
entry: RenderEntry;
|
||||
@@ -994,8 +1069,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
turnStart,
|
||||
disableStaging: _disableStaging,
|
||||
messages,
|
||||
permissions,
|
||||
questions,
|
||||
sessionIsWorking = false,
|
||||
activeStreamingMessageId = null,
|
||||
retryOverlay = null,
|
||||
onMessageContentChange,
|
||||
getAnimationHandlers,
|
||||
hasMoreAbove,
|
||||
@@ -1006,9 +1082,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
}, ref) => {
|
||||
streamPerfCount('ui.message_list.render');
|
||||
void _disableStaging;
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
|
||||
const activeStreamingMessageId = useStreamingStore((state) => state.streamingMessageIds.get(sessionKey) ?? null);
|
||||
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
const activityRenderMode = useUIStore((state) => state.activityRenderMode);
|
||||
@@ -1032,20 +1105,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
ungroupedMessageIds: Set<string>;
|
||||
} | null>(null);
|
||||
|
||||
const stableOnMessageContentChange = useStableEvent(onMessageContentChange);
|
||||
const stableGetAnimationHandlers = useStableEvent(getAnimationHandlers);
|
||||
const stableOnLoadOlder = useStableEvent(onLoadOlder);
|
||||
const stableScrollToBottom = useStableEvent((options?: { instant?: boolean; force?: boolean }) => {
|
||||
scrollToBottom?.(options);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (permissions.length === 0 && questions.length === 0) {
|
||||
return;
|
||||
}
|
||||
stableOnMessageContentChange('permission');
|
||||
}, [permissions, questions, stableOnMessageContentChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTurnUiStates(new Map());
|
||||
}, [activityRenderMode]);
|
||||
@@ -1160,27 +1225,8 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return output;
|
||||
}), [messages]);
|
||||
|
||||
const currentSessionIdForRetry = useSessionUIStore((s) => s.currentSessionId);
|
||||
const retryStatusRaw = useSessionStatus(currentSessionIdForRetry ?? '');
|
||||
const activeRetryStatus = React.useMemo(() => {
|
||||
if (!currentSessionIdForRetry) return null;
|
||||
const status = retryStatusRaw;
|
||||
if (!status || status.type !== 'retry') return null;
|
||||
const rawMessage = typeof (status as { message?: string }).message === 'string' ? ((status as { message?: string }).message ?? '').trim() : '';
|
||||
return {
|
||||
sessionId: currentSessionIdForRetry,
|
||||
message: rawMessage || 'Quota limit reached. Retrying automatically.',
|
||||
confirmedAt: (status as { confirmedAt?: number }).confirmedAt,
|
||||
};
|
||||
}, [currentSessionIdForRetry, retryStatusRaw]);
|
||||
|
||||
const activeRetrySessionId = activeRetryStatus?.sessionId ?? null;
|
||||
const activeRetryMessage = activeRetryStatus?.message
|
||||
?? 'Quota limit reached. Retrying automatically.';
|
||||
const activeRetryConfirmedAt = activeRetryStatus?.confirmedAt;
|
||||
|
||||
const [fallbackRetryTimestamp, setFallbackRetryTimestamp] = React.useState<number>(0);
|
||||
const fallbackRetrySessionRef = React.useRef<string | null>(null);
|
||||
const historyContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const pendingVirtualMeasureFrameRef = React.useRef<number | null>(null);
|
||||
const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
|
||||
if (scrollRef?.current) {
|
||||
return scrollRef.current;
|
||||
@@ -1191,27 +1237,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return document.querySelector<HTMLDivElement>('[data-scrollbar="chat"]');
|
||||
}, [scrollRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeRetryStatus || typeof activeRetryStatus.confirmedAt === 'number') {
|
||||
fallbackRetrySessionRef.current = null;
|
||||
setFallbackRetryTimestamp(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fallbackRetrySessionRef.current !== activeRetryStatus.sessionId) {
|
||||
fallbackRetrySessionRef.current = activeRetryStatus.sessionId;
|
||||
setFallbackRetryTimestamp(Date.now());
|
||||
}
|
||||
}, [activeRetryStatus, activeRetryStatus?.sessionId, activeRetryStatus?.confirmedAt]);
|
||||
|
||||
const displayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.retry_overlay_ms', () => {
|
||||
return applyRetryOverlay(baseDisplayMessages, {
|
||||
sessionId: activeRetrySessionId,
|
||||
message: activeRetryMessage,
|
||||
confirmedAt: activeRetryConfirmedAt,
|
||||
fallbackTimestamp: fallbackRetryTimestamp,
|
||||
sessionId: retryOverlay?.sessionId ?? null,
|
||||
message: retryOverlay?.message ?? 'Quota limit reached. Retrying automatically.',
|
||||
confirmedAt: retryOverlay?.confirmedAt,
|
||||
fallbackTimestamp: retryOverlay?.fallbackTimestamp ?? 0,
|
||||
});
|
||||
}), [activeRetryMessage, activeRetryConfirmedAt, activeRetrySessionId, baseDisplayMessages, fallbackRetryTimestamp]);
|
||||
}), [baseDisplayMessages, retryOverlay]);
|
||||
|
||||
const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, {
|
||||
sessionKey,
|
||||
@@ -1341,10 +1374,102 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
}
|
||||
|
||||
const historyEntries = staticRenderEntries;
|
||||
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const [historyWidthPx, setHistoryWidthPx] = React.useState<number | null>(null);
|
||||
const historyMeasurementScopeKey = historyWidthPx === null ? 'width:unknown' : `width:${Math.round(historyWidthPx)}`;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const historyContent = historyContentRef.current;
|
||||
if (!historyContent || !shouldVirtualizeHistory) {
|
||||
setHistoryWidthPx((previous) => (previous === null ? previous : null));
|
||||
return;
|
||||
}
|
||||
|
||||
const updateWidth = (nextWidth: number) => {
|
||||
setHistoryWidthPx((previous) => {
|
||||
if (previous !== null && Math.abs(previous - nextWidth) < 0.5) {
|
||||
return previous;
|
||||
}
|
||||
return nextWidth;
|
||||
});
|
||||
};
|
||||
|
||||
updateWidth(historyContent.getBoundingClientRect().width);
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
updateWidth(historyContent.getBoundingClientRect().width);
|
||||
});
|
||||
observer.observe(historyContent);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [historyEntries.length, shouldVirtualizeHistory]);
|
||||
|
||||
const historyVirtualizer = useVirtualizer({
|
||||
count: historyEntries.length,
|
||||
getScrollElement: resolveScrollContainer,
|
||||
estimateSize: (index) => estimateHistoryEntryHeight(historyEntries[index]),
|
||||
getItemKey: (index) => `${historyMeasurementScopeKey}:${historyEntries[index]?.key ?? index}`,
|
||||
measureElement: measureVirtualElement,
|
||||
useAnimationFrameWithResizeObserver: true,
|
||||
overscan: MESSAGE_LIST_OVERSCAN,
|
||||
enabled: shouldVirtualizeHistory,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!shouldVirtualizeHistory || historyWidthPx === null) {
|
||||
return;
|
||||
}
|
||||
historyVirtualizer.measure();
|
||||
}, [historyVirtualizer, historyWidthPx, shouldVirtualizeHistory]);
|
||||
|
||||
const scheduleVirtualMeasure = React.useCallback(() => {
|
||||
if (!shouldVirtualizeHistory) {
|
||||
return;
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
historyVirtualizer.measure();
|
||||
return;
|
||||
}
|
||||
if (pendingVirtualMeasureFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
pendingVirtualMeasureFrameRef.current = window.requestAnimationFrame(() => {
|
||||
pendingVirtualMeasureFrameRef.current = null;
|
||||
historyVirtualizer.measure();
|
||||
});
|
||||
}, [historyVirtualizer, shouldVirtualizeHistory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pendingVirtualMeasureFrameRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(pendingVirtualMeasureFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const historyVirtualRows = React.useMemo(
|
||||
() => (shouldVirtualizeHistory ? historyVirtualizer.getVirtualItems() : []),
|
||||
[historyVirtualizer, shouldVirtualizeHistory],
|
||||
);
|
||||
|
||||
const allEntries = React.useMemo(() => {
|
||||
return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries;
|
||||
}, [historyEntries, trailingStreamingEntry]);
|
||||
|
||||
const stableHistoryContentChange = useStableEvent((reason?: ContentChangeReason) => {
|
||||
scheduleVirtualMeasure();
|
||||
onMessageContentChange(reason);
|
||||
});
|
||||
|
||||
const stableTailContentChange = useStableEvent((reason?: ContentChangeReason) => {
|
||||
onMessageContentChange(reason);
|
||||
});
|
||||
|
||||
const currentUserOrder = React.useMemo(() => {
|
||||
return messages
|
||||
.filter((message) => resolveMessageRole(message) === 'user')
|
||||
@@ -1427,6 +1552,23 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return container.querySelector(`[data-message-id="${messageId}"]`);
|
||||
}, [resolveScrollContainer]);
|
||||
|
||||
const scrollHistoryIndexIntoView = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => {
|
||||
if (!shouldVirtualizeHistory || index < 0 || index >= historyEntries.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const container = resolveScrollContainer();
|
||||
if (!container) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const virtualizerBehavior = behavior === 'smooth' ? 'smooth' : 'auto';
|
||||
historyVirtualizer.scrollToIndex(index, { align: 'start', behavior: virtualizerBehavior });
|
||||
const targetTop = Math.max(0, container.scrollTop - 50);
|
||||
container.scrollTo({ top: targetTop, behavior });
|
||||
return true;
|
||||
}, [historyEntries.length, historyVirtualizer, resolveScrollContainer, shouldVirtualizeHistory]);
|
||||
|
||||
const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
|
||||
const container = resolveScrollContainer();
|
||||
if (!container) {
|
||||
@@ -1469,7 +1611,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
}
|
||||
const turnElement = container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
|
||||
if (!turnElement) {
|
||||
return false;
|
||||
return scrollHistoryIndexIntoView(index, behavior);
|
||||
}
|
||||
turnElement.scrollIntoView({ behavior, block: 'start' });
|
||||
return true;
|
||||
@@ -1487,7 +1629,8 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return false;
|
||||
}
|
||||
|
||||
return scrollMessageElementIntoView(messageId, behavior);
|
||||
return scrollMessageElementIntoView(messageId, behavior)
|
||||
|| scrollHistoryIndexIntoView(index, behavior);
|
||||
},
|
||||
|
||||
captureViewportAnchor: () => {
|
||||
@@ -1551,6 +1694,13 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!applyAnchor()) {
|
||||
const index = messageIndexMap.get(anchor.messageId);
|
||||
if (typeof index === 'number' && index < historyEntries.length) {
|
||||
scrollHistoryIndexIntoView(index, 'auto');
|
||||
}
|
||||
}
|
||||
|
||||
return applyAnchor();
|
||||
},
|
||||
};
|
||||
@@ -1567,7 +1717,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return () => {
|
||||
objectRef.current = null;
|
||||
};
|
||||
}, [findMessageElement, historyEntries.length, messageIndexMap, scrollMessageElementIntoView, resolveScrollContainer, trailingStreamingEntry, turnIndexMap, ref]);
|
||||
}, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, trailingStreamingEntry, turnIndexMap, ref]);
|
||||
|
||||
const disableFadeIn = false;
|
||||
|
||||
@@ -1593,25 +1743,28 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
|
||||
<FadeInDisabledProvider disabled={disableFadeIn}>
|
||||
<div className="relative w-full">
|
||||
<MessageListContent
|
||||
<StaticHistoryList
|
||||
entries={historyEntries}
|
||||
onMessageContentChange={stableOnMessageContentChange}
|
||||
shouldVirtualize={shouldVirtualizeHistory}
|
||||
virtualRows={historyVirtualRows}
|
||||
totalSize={historyVirtualizer.getTotalSize()}
|
||||
measureElement={historyVirtualizer.measureElement}
|
||||
contentRef={historyContentRef}
|
||||
onMessageContentChange={stableHistoryContentChange}
|
||||
getAnimationHandlers={stableGetAnimationHandlers}
|
||||
scrollToBottom={stableScrollToBottom}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
defaultActivityExpanded={defaultActivityExpanded}
|
||||
turnUiStates={turnUiStates}
|
||||
onToggleTurnGroup={toggleTurnGroup}
|
||||
chatRenderMode={chatRenderMode}
|
||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
||||
activeStreamingMessageId={activeStreamingMessageId}
|
||||
/>
|
||||
{trailingStreamingEntry ? (
|
||||
<StreamingTailContent
|
||||
entry={trailingStreamingEntry}
|
||||
onMessageContentChange={stableOnMessageContentChange}
|
||||
onMessageContentChange={stableTailContentChange}
|
||||
getAnimationHandlers={stableGetAnimationHandlers}
|
||||
scrollToBottom={stableScrollToBottom}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
@@ -1628,23 +1781,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
</div>
|
||||
</FadeInDisabledProvider>
|
||||
|
||||
{(questions.length > 0 || permissions.length > 0) && (
|
||||
<div>
|
||||
{questions.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} />
|
||||
))}
|
||||
{permissions.map((permission) => (
|
||||
<PermissionCard key={permission.id} permission={permission} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<StatusRowContainer />
|
||||
</div>
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildTurnWindowModel,
|
||||
clampTurnStart,
|
||||
getInitialTurnStart,
|
||||
updateTurnWindowModelIncremental,
|
||||
windowMessagesByTurn,
|
||||
type TurnWindowModel,
|
||||
} from '../lib/turns/windowTurns';
|
||||
@@ -70,7 +71,19 @@ export const useChatTimelineController = ({
|
||||
isPinned,
|
||||
isOverflowing,
|
||||
}: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => {
|
||||
const turnWindowModel = React.useMemo(() => buildTurnWindowModel(messages), [messages]);
|
||||
const previousTurnWindowModelRef = React.useRef<TurnWindowModel | null>(null);
|
||||
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
|
||||
const turnWindowModel = React.useMemo(() => {
|
||||
const incrementalModel = updateTurnWindowModelIncremental(
|
||||
previousTurnWindowModelRef.current,
|
||||
previousMessagesRef.current,
|
||||
messages,
|
||||
);
|
||||
const nextModel = incrementalModel ?? buildTurnWindowModel(messages);
|
||||
previousTurnWindowModelRef.current = nextModel;
|
||||
previousMessagesRef.current = messages;
|
||||
return nextModel;
|
||||
}, [messages]);
|
||||
|
||||
const [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount));
|
||||
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
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 { streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
@@ -15,28 +16,124 @@ export interface TurnRecordsResult {
|
||||
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 = (
|
||||
messages: ChatMessageEntry[],
|
||||
options: UseTurnRecordsOptions,
|
||||
): TurnRecordsResult => {
|
||||
const previousProjectionRef = React.useRef<TurnProjectionResult | null>(null);
|
||||
const previousMessagesRef = React.useRef<ChatMessageEntry[] | null>(null);
|
||||
const staticTurnsRef = React.useRef<TurnRecord[]>([]);
|
||||
const streamingTurnRef = React.useRef<TurnRecord | undefined>(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 rawProjection = projectTurnRecords(messages, {
|
||||
const tailOnlyProjection = buildTailOnlyProjection(
|
||||
previousProjectionRef.current,
|
||||
previousMessagesRef.current,
|
||||
messages,
|
||||
options.showTextJustificationActivity,
|
||||
);
|
||||
const rawProjection = tailOnlyProjection ?? projectTurnRecords(messages, {
|
||||
previousProjection: previousProjectionRef.current,
|
||||
showTextJustificationActivity: options.showTextJustificationActivity,
|
||||
});
|
||||
const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current);
|
||||
previousProjectionRef.current = stabilizedProjection;
|
||||
previousMessagesRef.current = messages;
|
||||
return stabilizedProjection;
|
||||
});
|
||||
}, [messages, options.showTextJustificationActivity]);
|
||||
|
||||
@@ -23,6 +23,119 @@ export interface TurnWindowModel {
|
||||
turnCount: number;
|
||||
}
|
||||
|
||||
const getMessageSignature = (message: ChatMessageEntry | undefined): string | null => {
|
||||
if (!message) return null;
|
||||
const role = resolveMessageRole(message);
|
||||
const messageId = typeof message.info?.id === 'string' ? message.info.id : '';
|
||||
const parentId = resolveParentMessageId(message) ?? '';
|
||||
return `${messageId}::${role}::${parentId}`;
|
||||
};
|
||||
|
||||
const cloneTurnWindowModel = (model: TurnWindowModel): TurnWindowModel => ({
|
||||
turnIds: [...model.turnIds],
|
||||
turnMessageStartIndexes: [...model.turnMessageStartIndexes],
|
||||
turnIndexById: new Map(model.turnIndexById),
|
||||
messageToTurnId: new Map(model.messageToTurnId),
|
||||
messageToTurnIndex: new Map(model.messageToTurnIndex),
|
||||
turnCount: model.turnCount,
|
||||
});
|
||||
|
||||
export const updateTurnWindowModelIncremental = (
|
||||
previousModel: TurnWindowModel | null,
|
||||
previousMessages: ChatMessageEntry[] | null,
|
||||
nextMessages: ChatMessageEntry[],
|
||||
): TurnWindowModel | null => {
|
||||
if (!previousModel || !previousMessages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (previousMessages.length === nextMessages.length) {
|
||||
let changedIndex = -1;
|
||||
for (let index = 0; index < nextMessages.length; index += 1) {
|
||||
if (previousMessages[index] === nextMessages[index]) {
|
||||
continue;
|
||||
}
|
||||
if (changedIndex !== -1) {
|
||||
return null;
|
||||
}
|
||||
changedIndex = index;
|
||||
}
|
||||
|
||||
if (changedIndex === -1) {
|
||||
return previousModel;
|
||||
}
|
||||
|
||||
if (changedIndex !== nextMessages.length - 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getMessageSignature(previousMessages[changedIndex]) === getMessageSignature(nextMessages[changedIndex])
|
||||
? previousModel
|
||||
: null;
|
||||
}
|
||||
|
||||
if (nextMessages.length !== previousMessages.length + 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let index = 0; index < previousMessages.length; index += 1) {
|
||||
if (previousMessages[index] !== nextMessages[index]) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const nextMessage = nextMessages[nextMessages.length - 1];
|
||||
if (!nextMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role = resolveMessageRole(nextMessage);
|
||||
const messageId = nextMessage.info.id;
|
||||
const nextModel = cloneTurnWindowModel(previousModel);
|
||||
|
||||
if (role === 'user') {
|
||||
const nextTurnIndex = nextModel.turnIds.length;
|
||||
nextModel.turnIds.push(messageId);
|
||||
nextModel.turnMessageStartIndexes.push(nextMessages.length - 1);
|
||||
nextModel.turnIndexById.set(messageId, nextTurnIndex);
|
||||
nextModel.messageToTurnId.set(messageId, messageId);
|
||||
nextModel.messageToTurnIndex.set(messageId, nextTurnIndex);
|
||||
nextModel.turnCount = nextModel.turnIds.length;
|
||||
return nextModel;
|
||||
}
|
||||
|
||||
if (role !== 'assistant') {
|
||||
const currentTurnIndex = nextModel.turnIds.length - 1;
|
||||
if (currentTurnIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
const turnId = nextModel.turnIds[currentTurnIndex];
|
||||
if (!turnId) {
|
||||
return null;
|
||||
}
|
||||
nextModel.messageToTurnId.set(messageId, turnId);
|
||||
nextModel.messageToTurnIndex.set(messageId, currentTurnIndex);
|
||||
return nextModel;
|
||||
}
|
||||
|
||||
const parentId = resolveParentMessageId(nextMessage);
|
||||
const targetTurnIndex = parentId
|
||||
? nextModel.turnIndexById.get(parentId)
|
||||
: nextModel.turnIds.length - 1;
|
||||
if (typeof targetTurnIndex !== 'number' || targetTurnIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const turnId = nextModel.turnIds[targetTurnIndex];
|
||||
if (!turnId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
nextModel.messageToTurnId.set(messageId, turnId);
|
||||
nextModel.messageToTurnIndex.set(messageId, targetTurnIndex);
|
||||
return nextModel;
|
||||
};
|
||||
|
||||
export const buildTurnWindowModel = (messages: ChatMessageEntry[]): TurnWindowModel => {
|
||||
const turnIds: string[] = [];
|
||||
const turnMessageStartIndexes: number[] = [];
|
||||
|
||||
@@ -21,7 +21,7 @@ import { DiffIcon } from '@/components/icons/DiffIcon';
|
||||
import { useUIStore, type MainTab } from '@/stores/useUIStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSession, useSessionMessageRecords } from '@/sync/sync-context';
|
||||
import { useSession, useSessionMessagesResolved } from '@/sync/sync-context';
|
||||
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
@@ -665,8 +665,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
|
||||
const currentSessionMessages = currentSessionId ? (currentSessionMessageRecords.length > 0 ? currentSessionMessageRecords : undefined) : undefined;
|
||||
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
||||
const currentSyncedSession = useSession(currentSessionId ?? null);
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const activeProject = useProjectsStore((state) => {
|
||||
@@ -760,7 +759,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0);
|
||||
const contextUsage = getContextUsage(contextLimit, outputLimit);
|
||||
const [stableDesktopContextUsage, setStableDesktopContextUsage] = React.useState<SessionContextUsage | null>(null);
|
||||
const isContextUsageResolvedForSession = !currentSessionId || currentSessionMessages !== undefined;
|
||||
const isContextUsageResolvedForSession = !currentSessionId || currentSessionMessagesResolved;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSessionId) {
|
||||
|
||||
@@ -51,52 +51,6 @@ const normalizeDirectoryKey = (value: string): string => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const MemoSessionSidebar = React.memo(SessionSidebar);
|
||||
const MemoHeader = React.memo(Header);
|
||||
const MemoChatView = React.memo(ChatView);
|
||||
const MemoPlanView = React.memo(PlanView);
|
||||
const MemoGitView = React.memo(GitView);
|
||||
const MemoDiffView = React.memo(DiffView);
|
||||
const MemoTerminalView = React.memo(TerminalView);
|
||||
const MemoFilesView = React.memo(FilesView);
|
||||
const MemoRightSidebarTabs = React.memo(RightSidebarTabs);
|
||||
|
||||
const DesktopLeftSidebar = React.memo(function DesktopLeftSidebar({
|
||||
isSidebarOpen,
|
||||
isMobile,
|
||||
}: {
|
||||
isSidebarOpen: boolean;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile} className="border-0">
|
||||
<ErrorBoundary>
|
||||
<MemoSessionSidebar />
|
||||
</ErrorBoundary>
|
||||
</Sidebar>
|
||||
);
|
||||
});
|
||||
|
||||
const DesktopRightPanel = React.memo(function DesktopRightPanel({
|
||||
isRightSidebarOpen,
|
||||
setDesktopRightSidebarActionsHost,
|
||||
}: {
|
||||
isRightSidebarOpen: boolean;
|
||||
setDesktopRightSidebarActionsHost: React.Dispatch<React.SetStateAction<HTMLDivElement | null>>;
|
||||
}) {
|
||||
return (
|
||||
<RightSidebar
|
||||
isOpen={isRightSidebarOpen}
|
||||
className="border-0"
|
||||
onTopActionsHostChange={setDesktopRightSidebarActionsHost}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MemoRightSidebarTabs />
|
||||
</ErrorBoundary>
|
||||
</RightSidebar>
|
||||
);
|
||||
});
|
||||
|
||||
export const MainLayout: React.FC = () => {
|
||||
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
|
||||
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
|
||||
|
||||
@@ -25,7 +25,7 @@ const isSameThumbMetrics = (a: ThumbMetrics, b: ThumbMetrics): boolean => {
|
||||
return Math.abs(a.length - b.length) < METRIC_EPSILON && Math.abs(a.offset - b.offset) < METRIC_EPSILON;
|
||||
};
|
||||
|
||||
export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
||||
containerRef,
|
||||
minThumbSize = 32,
|
||||
hideDelayMs = 1000,
|
||||
@@ -50,6 +50,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
scrollLeft: number;
|
||||
}>({ pointerX: 0, pointerY: 0, scrollTop: 0, scrollLeft: 0 });
|
||||
const dragAxisRef = React.useRef<"vertical" | "horizontal" | null>(null);
|
||||
const observedElementsRef = React.useRef<Set<Element>>(new Set());
|
||||
|
||||
const updateMetrics = React.useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -91,6 +92,33 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
});
|
||||
}, [updateMetrics]);
|
||||
|
||||
const syncObservedElements = React.useCallback((container: HTMLElement, resizeObserver: ResizeObserver | null) => {
|
||||
if (!resizeObserver) {
|
||||
observedElementsRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextObserved = new Set<Element>();
|
||||
nextObserved.add(container);
|
||||
Array.from(container.children).forEach((child) => {
|
||||
nextObserved.add(child);
|
||||
});
|
||||
|
||||
observedElementsRef.current.forEach((element) => {
|
||||
if (!nextObserved.has(element)) {
|
||||
resizeObserver.unobserve(element);
|
||||
}
|
||||
});
|
||||
|
||||
nextObserved.forEach((element) => {
|
||||
if (!observedElementsRef.current.has(element)) {
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
});
|
||||
|
||||
observedElementsRef.current = nextObserved;
|
||||
}, []);
|
||||
|
||||
const scheduleHide = React.useCallback(() => {
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
@@ -160,16 +188,26 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
scheduleMetricsUpdate();
|
||||
})
|
||||
: null;
|
||||
resizeObserver?.observe(container);
|
||||
syncObservedElements(container, resizeObserver);
|
||||
|
||||
const mutationObserver =
|
||||
observeMutations && typeof MutationObserver !== "undefined"
|
||||
? new MutationObserver(() => scheduleMetricsUpdate())
|
||||
? new MutationObserver(() => {
|
||||
syncObservedElements(container, resizeObserver);
|
||||
scheduleMetricsUpdate();
|
||||
})
|
||||
: null;
|
||||
mutationObserver?.observe(container, { childList: true, subtree: true, characterData: true });
|
||||
mutationObserver?.observe(container, { childList: true });
|
||||
|
||||
const onInput = () => scheduleMetricsUpdate();
|
||||
const onLoad = () => scheduleMetricsUpdate();
|
||||
container.addEventListener("input", onInput, true);
|
||||
container.addEventListener("load", onLoad, true);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", onScroll);
|
||||
container.removeEventListener("input", onInput, true);
|
||||
container.removeEventListener("load", onLoad, true);
|
||||
if (userIntentOnly) {
|
||||
container.removeEventListener("wheel", markUserIntent);
|
||||
container.removeEventListener("touchstart", markUserIntent);
|
||||
@@ -178,11 +216,12 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
observedElementsRef.current.clear();
|
||||
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||
if (metricsFrameRef.current) cancelAnimationFrame(metricsFrameRef.current);
|
||||
};
|
||||
}, [containerRef, handleScroll, markUserIntent, observeMutations, scheduleMetricsUpdate, updateMetrics, userIntentOnly]);
|
||||
}, [containerRef, handleScroll, markUserIntent, observeMutations, scheduleMetricsUpdate, syncObservedElements, updateMetrics, userIntentOnly]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!suppressVisibility) {
|
||||
@@ -291,3 +330,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
OverlayScrollbarComponent.displayName = "OverlayScrollbar";
|
||||
|
||||
export const OverlayScrollbar = OverlayScrollbarComponent;
|
||||
|
||||
@@ -99,6 +99,8 @@ export const useChatScrollManager = ({
|
||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||
const [isPinned, setIsPinned] = React.useState(true);
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
const showScrollButtonRef = React.useRef(false);
|
||||
const isOverflowingRef = React.useRef(false);
|
||||
|
||||
const lastSessionIdRef = React.useRef<string | null>(null);
|
||||
const suppressUserScrollUntilRef = React.useRef<number>(0);
|
||||
@@ -130,6 +132,20 @@ export const useChatScrollManager = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setShowScrollButtonState = React.useCallback((next: boolean) => {
|
||||
showScrollButtonRef.current = next;
|
||||
setShowScrollButton((previous) => (previous === next ? previous : next));
|
||||
}, []);
|
||||
|
||||
const setIsOverflowingState = React.useCallback((next: boolean) => {
|
||||
isOverflowingRef.current = next;
|
||||
setIsOverflowing((previous) => (previous === next ? previous : next));
|
||||
}, []);
|
||||
|
||||
const shouldSkipLiveContentSync = React.useCallback(() => {
|
||||
return !isPinnedRef.current && showScrollButtonRef.current && isOverflowingRef.current;
|
||||
}, []);
|
||||
|
||||
const scrollToBottomInternal = React.useCallback((options?: { instant?: boolean; followBottom?: boolean }) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
@@ -151,22 +167,22 @@ export const useChatScrollManager = ({
|
||||
const updateScrollButtonVisibility = React.useCallback(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
setShowScrollButton(false);
|
||||
setIsOverflowing(false);
|
||||
setShowScrollButtonState(false);
|
||||
setIsOverflowingState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasScrollableContent = container.scrollHeight > container.clientHeight;
|
||||
setIsOverflowing(hasScrollableContent);
|
||||
setIsOverflowingState(hasScrollableContent);
|
||||
if (!hasScrollableContent) {
|
||||
setShowScrollButton(false);
|
||||
setShowScrollButtonState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show scroll button when scrolled above the 10vh threshold
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
setShowScrollButton(!isNearBottom(distanceFromBottom, getPinThreshold()));
|
||||
}, [getDistanceFromBottom, getPinThreshold]);
|
||||
setShowScrollButtonState(!isNearBottom(distanceFromBottom, getPinThreshold()));
|
||||
}, [getDistanceFromBottom, getPinThreshold, setIsOverflowingState, setShowScrollButtonState]);
|
||||
|
||||
const syncPinnedStateAndIndicators = React.useCallback(() => {
|
||||
pinnedSyncRafRef.current = null;
|
||||
@@ -267,8 +283,8 @@ export const useChatScrollManager = ({
|
||||
updatePinnedState(true);
|
||||
|
||||
scrollToBottomInternal(options);
|
||||
setShowScrollButton(false);
|
||||
}, [scrollToBottomInternal, updatePinnedState]);
|
||||
setShowScrollButtonState(false);
|
||||
}, [scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]);
|
||||
|
||||
const releasePinnedScroll = React.useCallback(() => {
|
||||
scrollEngine.cancelFollow();
|
||||
@@ -435,22 +451,25 @@ export const useChatScrollManager = ({
|
||||
// Always start pinned at bottom on session switch
|
||||
preferInstantPinRef.current = true;
|
||||
updatePinnedState(true);
|
||||
setShowScrollButton(false);
|
||||
setShowScrollButtonState(false);
|
||||
|
||||
const container = scrollRef.current;
|
||||
if (container) {
|
||||
markProgrammaticScroll();
|
||||
scrollToBottomInternal({ instant: true });
|
||||
}
|
||||
}, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, updatePinnedState]);
|
||||
}, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]);
|
||||
|
||||
// Maintain pin-to-bottom when content changes
|
||||
React.useEffect(() => {
|
||||
if (isSyncing) {
|
||||
return;
|
||||
}
|
||||
if (shouldSkipLiveContentSync()) {
|
||||
return;
|
||||
}
|
||||
schedulePinnedStateAndIndicators();
|
||||
}, [isSyncing, schedulePinnedStateAndIndicators, sessionMessages.length]);
|
||||
}, [isSyncing, schedulePinnedStateAndIndicators, sessionMessages.length, shouldSkipLiveContentSync]);
|
||||
|
||||
// Use ResizeObserver to detect content changes and maintain pin
|
||||
React.useEffect(() => {
|
||||
@@ -499,44 +518,49 @@ export const useChatScrollManager = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (scrollHeightChanged && shouldSkipLiveContentSync()) {
|
||||
return;
|
||||
}
|
||||
|
||||
schedulePinnedStateAndIndicators();
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
|
||||
// Also observe children for content changes
|
||||
const childObserver = new MutationObserver(() => {
|
||||
schedulePinnedStateAndIndicators();
|
||||
});
|
||||
|
||||
childObserver.observe(container, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
childObserver.disconnect();
|
||||
};
|
||||
}, [schedulePinnedStateAndIndicators, updateScrollButtonVisibility]);
|
||||
}, [markProgrammaticScroll, schedulePinnedStateAndIndicators, shouldSkipLiveContentSync, updateScrollButtonVisibility]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
if (shouldSkipLiveContentSync()) {
|
||||
return;
|
||||
}
|
||||
schedulePinnedStateAndIndicators();
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
if (shouldSkipLiveContentSync()) {
|
||||
return;
|
||||
}
|
||||
schedulePinnedStateAndIndicators();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessages.length]);
|
||||
}, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessages.length, shouldSkipLiveContentSync]);
|
||||
|
||||
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
||||
|
||||
const handleMessageContentChange = React.useCallback(() => {
|
||||
if (shouldSkipLiveContentSync()) {
|
||||
return;
|
||||
}
|
||||
schedulePinnedStateAndIndicators();
|
||||
}, [schedulePinnedStateAndIndicators]);
|
||||
}, [schedulePinnedStateAndIndicators, shouldSkipLiveContentSync]);
|
||||
|
||||
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
||||
const existing = animationHandlersRef.current.get(messageId);
|
||||
@@ -546,6 +570,9 @@ export const useChatScrollManager = ({
|
||||
|
||||
const handlers: AnimationHandlers = {
|
||||
onChunk: () => {
|
||||
if (shouldSkipLiveContentSync()) {
|
||||
return;
|
||||
}
|
||||
schedulePinnedStateAndIndicators();
|
||||
},
|
||||
onComplete: () => {
|
||||
@@ -554,6 +581,9 @@ export const useChatScrollManager = ({
|
||||
onStreamingCandidate: () => {},
|
||||
onAnimationStart: () => {},
|
||||
onAnimatedHeightChange: () => {
|
||||
if (shouldSkipLiveContentSync()) {
|
||||
return;
|
||||
}
|
||||
schedulePinnedStateAndIndicators();
|
||||
},
|
||||
onReservationCancelled: () => {},
|
||||
@@ -562,7 +592,7 @@ export const useChatScrollManager = ({
|
||||
|
||||
animationHandlersRef.current.set(messageId, handlers);
|
||||
return handlers;
|
||||
}, [schedulePinnedStateAndIndicators]);
|
||||
}, [schedulePinnedStateAndIndicators, shouldSkipLiveContentSync]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessageRecords, useSessionPermissions } from '@/sync/sync-context';
|
||||
import { useSessionPermissions, useSessionTextMessages } from '@/sync/sync-context';
|
||||
import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice';
|
||||
|
||||
/**
|
||||
@@ -9,7 +9,7 @@ import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice';
|
||||
*/
|
||||
export function useVoiceContext() {
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
const messages = useSessionMessageRecords(currentSessionId ?? '');
|
||||
const messages = useSessionTextMessages(currentSessionId ?? '');
|
||||
const permissions = useSessionPermissions(currentSessionId ?? '');
|
||||
|
||||
// Track last seen message count to only forward new messages
|
||||
@@ -26,10 +26,9 @@ export function useVoiceContext() {
|
||||
const newMessages = messages.slice(lastMessageCountRef.current);
|
||||
lastMessageCountRef.current = currentCount;
|
||||
|
||||
// Format for voice hooks (extract role and content)
|
||||
const formattedMessages = newMessages.map(m => ({
|
||||
role: m.info.role,
|
||||
content: m.parts.map((p: Record<string, unknown>) => ('text' in p ? p.text : '')).join('')
|
||||
role: m.role ?? '',
|
||||
content: m.text,
|
||||
}));
|
||||
|
||||
voiceHooks.onMessages(currentSessionId, formattedMessages);
|
||||
|
||||
@@ -667,17 +667,6 @@ const derivePrVisualState = (status: GitHubPullRequestStatus | null): string | n
|
||||
return 'open';
|
||||
};
|
||||
|
||||
const prVisualPriority = (state: string): number => {
|
||||
switch (state) {
|
||||
case 'open': return 5;
|
||||
case 'blocked': return 4;
|
||||
case 'draft': return 3;
|
||||
case 'merged': return 2;
|
||||
case 'closed': return 1;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const deriveSummary = (entry: PrStatusEntry): PrVisualSummary | null => {
|
||||
const vs = derivePrVisualState(entry.status ?? null);
|
||||
const pr = entry.status?.pr;
|
||||
|
||||
@@ -59,6 +59,7 @@ export {
|
||||
useDirectoryStore,
|
||||
useDirectorySync,
|
||||
useSessionMessages,
|
||||
useSessionMessagesResolved,
|
||||
useSessionParts,
|
||||
useSessionStatus,
|
||||
useSessionPermissions,
|
||||
@@ -68,6 +69,8 @@ export {
|
||||
useSyncDirectory,
|
||||
useChildStoreManager,
|
||||
useSessionMessageRecords,
|
||||
useSessionTextMessages,
|
||||
useUserMessageHistory,
|
||||
} from "./sync-context"
|
||||
|
||||
// Sync operations
|
||||
|
||||
@@ -642,6 +642,17 @@ export function useVisibleSessionMessages(sessionID: string, directory?: string)
|
||||
}, [messages, revertMessageID])
|
||||
}
|
||||
|
||||
/** Check whether the message list for a session has been loaded into sync state. */
|
||||
export function useSessionMessagesResolved(sessionID: string, directory?: string): boolean {
|
||||
return useDirectorySync(
|
||||
useCallback((state: State) => {
|
||||
if (!sessionID) return false
|
||||
return Object.prototype.hasOwnProperty.call(state.message, sessionID)
|
||||
}, [sessionID]),
|
||||
directory,
|
||||
)
|
||||
}
|
||||
|
||||
/** Get parts for a specific message */
|
||||
export function useSessionParts(messageID: string, directory?: string) {
|
||||
return useDirectorySync(
|
||||
@@ -836,24 +847,42 @@ export function useChildStoreManager() {
|
||||
return useSyncSystem().childStores
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages for a session in the old {info, parts}[] format.
|
||||
* Uses visible messages (filtered by revert state).
|
||||
*
|
||||
* Uses a ref-stable parts lookup that only triggers re-renders when
|
||||
* a part array for one of our displayed messages actually changes.
|
||||
*/
|
||||
export function useSessionMessageRecords(sessionID: string, directory?: string) {
|
||||
const messages = useVisibleSessionMessages(sessionID, directory)
|
||||
const store = useDirectoryStore(directory)
|
||||
const MESSAGE_PART_SNAPSHOT_THROTTLE_MS = 100
|
||||
|
||||
// Track parts with a ref to avoid subscribing to entire state.part map.
|
||||
// Re-derive only when messages list changes or on store subscription.
|
||||
export type SessionTextMessage = {
|
||||
id: string
|
||||
role: string | null
|
||||
text: string
|
||||
}
|
||||
|
||||
const getPartText = (part: Part): string => {
|
||||
if (part?.type !== "text") return ""
|
||||
const text = (part as { text?: unknown }).text
|
||||
return typeof text === "string" ? text : ""
|
||||
}
|
||||
|
||||
const getConcatenatedTextFromParts = (parts: Part[]): string => {
|
||||
let text = ""
|
||||
for (const part of parts) {
|
||||
text += getPartText(part)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
const getFirstTextFromParts = (parts: Part[]): string => {
|
||||
for (const part of parts) {
|
||||
const text = getPartText(part)
|
||||
if (text.length > 0) return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string) {
|
||||
const store = useDirectoryStore(directory)
|
||||
const prevPartsRef = useRef<Record<string, Part[]>>({})
|
||||
const [partsSnapshot, setPartsSnapshot] = React.useState<Record<string, Part[]>>({})
|
||||
|
||||
React.useEffect(() => {
|
||||
const messageIds = messages.map((m) => m.id)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let pending = false
|
||||
|
||||
@@ -866,7 +895,6 @@ export function useSessionMessageRecords(sessionID: string, directory?: string)
|
||||
const next: Record<string, Part[]> = {}
|
||||
for (const id of messageIds) {
|
||||
const parts = state.part[id] ?? EMPTY_PARTS
|
||||
// Preserve existing reference if parts haven't changed in the store
|
||||
next[id] = prev[id] === parts ? prev[id] : parts
|
||||
if (next[id] !== prev[id]) changed = true
|
||||
}
|
||||
@@ -876,10 +904,8 @@ export function useSessionMessageRecords(sessionID: string, directory?: string)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
flush()
|
||||
|
||||
// Throttled subscription — batch rapid delta events into ~100ms updates
|
||||
const unsub = store.subscribe(() => {
|
||||
if (timer) {
|
||||
pending = true
|
||||
@@ -889,26 +915,105 @@ export function useSessionMessageRecords(sessionID: string, directory?: string)
|
||||
flush()
|
||||
if (pending) {
|
||||
pending = false
|
||||
timer = setTimeout(flush, 100)
|
||||
timer = setTimeout(flush, MESSAGE_PART_SNAPSHOT_THROTTLE_MS)
|
||||
}
|
||||
}, 100)
|
||||
}, MESSAGE_PART_SNAPSHOT_THROTTLE_MS)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsub()
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [messages, store])
|
||||
}, [messageIds, store])
|
||||
|
||||
return partsSnapshot
|
||||
}
|
||||
|
||||
export function useSessionTextMessages(sessionID: string, directory?: string): SessionTextMessage[] {
|
||||
const messages = useVisibleSessionMessages(sessionID, directory)
|
||||
const messageIds = useMemo(() => messages.map((message) => message.id), [messages])
|
||||
const partsSnapshot = usePartsSnapshotForMessageIds(messageIds, directory)
|
||||
|
||||
return useMemo(
|
||||
() => messages.map((msg) => ({
|
||||
info: msg,
|
||||
parts: partsSnapshot[msg.id] ?? EMPTY_PARTS,
|
||||
() => messages.map((message) => ({
|
||||
id: message.id,
|
||||
role: typeof message.role === "string" ? message.role : null,
|
||||
text: getConcatenatedTextFromParts(partsSnapshot[message.id] ?? EMPTY_PARTS),
|
||||
})),
|
||||
[messages, partsSnapshot],
|
||||
)
|
||||
}
|
||||
|
||||
export function useUserMessageHistory(sessionID: string, directory?: string): string[] {
|
||||
const messages = useVisibleSessionMessages(sessionID, directory)
|
||||
const userMessages = useMemo(
|
||||
() => messages.filter((message) => message.role === "user"),
|
||||
[messages],
|
||||
)
|
||||
const userMessageIds = useMemo(() => userMessages.map((message) => message.id), [userMessages])
|
||||
const partsSnapshot = usePartsSnapshotForMessageIds(userMessageIds, directory)
|
||||
|
||||
return useMemo(() => {
|
||||
const history: string[] = []
|
||||
for (let index = userMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = userMessages[index]
|
||||
const text = getFirstTextFromParts(partsSnapshot[message.id] ?? EMPTY_PARTS)
|
||||
if (text.length > 0) {
|
||||
history.push(text)
|
||||
}
|
||||
}
|
||||
return history
|
||||
}, [partsSnapshot, userMessages])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages for a session in the old {info, parts}[] format.
|
||||
* Uses visible messages (filtered by revert state).
|
||||
*
|
||||
* Uses a ref-stable parts lookup that only triggers re-renders when
|
||||
* a part array for one of our displayed messages actually changes.
|
||||
*/
|
||||
export function useSessionMessageRecords(sessionID: string, directory?: string) {
|
||||
const messages = useVisibleSessionMessages(sessionID, directory)
|
||||
const messageIds = useMemo(() => messages.map((message) => message.id), [messages])
|
||||
const partsSnapshot = usePartsSnapshotForMessageIds(messageIds, directory)
|
||||
const previousRecordsRef = useRef<{
|
||||
list: Array<{ info: (typeof messages)[number]; parts: Part[] }>
|
||||
byId: Map<string, { info: (typeof messages)[number]; parts: Part[] }>
|
||||
}>({
|
||||
list: [],
|
||||
byId: new Map(),
|
||||
})
|
||||
|
||||
return useMemo(() => {
|
||||
const previous = previousRecordsRef.current
|
||||
const nextById = new Map<string, { info: (typeof messages)[number]; parts: Part[] }>()
|
||||
const nextList = messages.map((message) => {
|
||||
const parts = partsSnapshot[message.id] ?? EMPTY_PARTS
|
||||
const previousRecord = previous.byId.get(message.id)
|
||||
const record = previousRecord && previousRecord.info === message && previousRecord.parts === parts
|
||||
? previousRecord
|
||||
: { info: message, parts }
|
||||
nextById.set(message.id, record)
|
||||
return record
|
||||
})
|
||||
|
||||
const unchanged = previous.list.length === nextList.length
|
||||
&& previous.list.every((record, index) => record === nextList[index])
|
||||
|
||||
if (unchanged) {
|
||||
return previous.list
|
||||
}
|
||||
|
||||
previousRecordsRef.current = {
|
||||
list: nextList,
|
||||
byId: nextById,
|
||||
}
|
||||
|
||||
return nextList
|
||||
}, [messages, partsSnapshot])
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a session is actively working.
|
||||
* Checks session_status and only falls back to incomplete assistant messages
|
||||
|
||||
@@ -339,6 +339,7 @@ let isExternalOpenCode = false;
|
||||
let exitOnShutdown = true;
|
||||
let uiAuthController = null;
|
||||
let activeTunnelController = null;
|
||||
let globalWatcherStartPromise = null;
|
||||
const tunnelProviderRegistry = createTunnelProviderRegistry([
|
||||
createCloudflareTunnelProvider(),
|
||||
]);
|
||||
@@ -739,13 +740,24 @@ const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCo
|
||||
const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args);
|
||||
const refreshOpenCodeAfterConfigChange = (...args) => openCodeLifecycleRuntime.refreshOpenCodeAfterConfigChange(...args);
|
||||
const startHealthMonitoring = () => openCodeLifecycleRuntime.startHealthMonitoring(HEALTH_CHECK_INTERVAL);
|
||||
const ensureGlobalWatcherStarted = async () => {
|
||||
if (globalWatcherStartPromise) {
|
||||
return globalWatcherStartPromise;
|
||||
}
|
||||
|
||||
globalWatcherStartPromise = openCodeWatcherRuntime.start().catch((error) => {
|
||||
globalWatcherStartPromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return globalWatcherStartPromise;
|
||||
};
|
||||
const bootstrapOpenCodeAtStartup = async (...args) => {
|
||||
await openCodeLifecycleRuntime.bootstrapOpenCodeAtStartup(...args);
|
||||
scheduleOpenCodeApiDetection();
|
||||
startHealthMonitoring();
|
||||
void openCodeWatcherRuntime.start().catch((error) => {
|
||||
console.warn(`Global event watcher startup failed: ${error?.message || error}`);
|
||||
});
|
||||
if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) {
|
||||
startHealthMonitoring();
|
||||
}
|
||||
};
|
||||
const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args);
|
||||
|
||||
@@ -871,6 +883,7 @@ async function main(options = {}) {
|
||||
resolveZenModel,
|
||||
sayTTSCapability,
|
||||
ensurePushInitialized,
|
||||
ensureGlobalWatcherStarted,
|
||||
getOrCreateVapidKeys,
|
||||
getUiSessionTokenFromRequest,
|
||||
writeSettingsToDisk,
|
||||
|
||||
@@ -26,6 +26,7 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
const {
|
||||
uiAuthController,
|
||||
ensurePushInitialized,
|
||||
ensureGlobalWatcherStarted,
|
||||
getOrCreateVapidKeys,
|
||||
getUiSessionTokenFromRequest,
|
||||
readSettingsFromDiskMigrated,
|
||||
@@ -45,6 +46,17 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
setPushInitialized,
|
||||
} = dependencies;
|
||||
|
||||
const ensureSessionWatcher = async () => {
|
||||
if (typeof ensureGlobalWatcherStarted !== 'function') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ensureGlobalWatcherStarted();
|
||||
} catch (error) {
|
||||
console.warn('[OpenCodeWatcher] lazy start failed:', error?.message ?? error);
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/push/vapid-public-key', async (_req, res) => {
|
||||
try {
|
||||
await ensurePushInitialized();
|
||||
@@ -58,6 +70,7 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
|
||||
app.post('/api/push/subscribe', async (req, res) => {
|
||||
await ensurePushInitialized();
|
||||
await ensureSessionWatcher();
|
||||
|
||||
const uiToken = uiAuthController?.ensureSessionToken
|
||||
? await uiAuthController.ensureSessionToken(req, res)
|
||||
@@ -146,10 +159,12 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
});
|
||||
|
||||
app.get('/api/session-activity', (_req, res) => {
|
||||
void ensureSessionWatcher();
|
||||
res.json(getSessionActivitySnapshot());
|
||||
});
|
||||
|
||||
app.get('/api/sessions/snapshot', (_req, res) => {
|
||||
app.get('/api/sessions/snapshot', async (_req, res) => {
|
||||
await ensureSessionWatcher();
|
||||
res.json({
|
||||
statusSessions: getSessionStateSnapshot(),
|
||||
attentionSessions: getSessionAttentionSnapshot(),
|
||||
@@ -157,7 +172,8 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/sessions/status', (_req, res) => {
|
||||
app.get('/api/sessions/status', async (_req, res) => {
|
||||
await ensureSessionWatcher();
|
||||
const snapshot = getSessionStateSnapshot();
|
||||
res.json({
|
||||
sessions: snapshot,
|
||||
@@ -165,7 +181,8 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/status', (req, res) => {
|
||||
app.get('/api/sessions/:id/status', async (req, res) => {
|
||||
await ensureSessionWatcher();
|
||||
const sessionId = req.params.id;
|
||||
const state = getSessionState(sessionId);
|
||||
|
||||
@@ -182,7 +199,8 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/sessions/attention', (_req, res) => {
|
||||
app.get('/api/sessions/attention', async (_req, res) => {
|
||||
await ensureSessionWatcher();
|
||||
const snapshot = getSessionAttentionSnapshot();
|
||||
res.json({
|
||||
sessions: snapshot,
|
||||
@@ -190,7 +208,8 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/sessions/:id/attention', (req, res) => {
|
||||
app.get('/api/sessions/:id/attention', async (req, res) => {
|
||||
await ensureSessionWatcher();
|
||||
const sessionId = req.params.id;
|
||||
const state = getSessionAttentionState(sessionId);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
resolveZenModel,
|
||||
sayTTSCapability,
|
||||
ensurePushInitialized,
|
||||
ensureGlobalWatcherStarted,
|
||||
getOrCreateVapidKeys,
|
||||
getUiSessionTokenFromRequest,
|
||||
writeSettingsToDisk,
|
||||
@@ -74,6 +75,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
registerNotificationRoutes(app, {
|
||||
uiAuthController,
|
||||
ensurePushInitialized,
|
||||
ensureGlobalWatcherStarted,
|
||||
getOrCreateVapidKeys,
|
||||
getUiSessionTokenFromRequest,
|
||||
readSettingsFromDiskMigrated,
|
||||
|
||||
Reference in New Issue
Block a user