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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
import ChatEmptyState from './ChatEmptyState';
|
import ChatEmptyState from './ChatEmptyState';
|
||||||
import MessageList, { type MessageListHandle } from './MessageList';
|
import MessageList, { type MessageListHandle } from './MessageList';
|
||||||
|
import { PermissionCard } from './PermissionCard';
|
||||||
|
import { QuestionCard } from './QuestionCard';
|
||||||
|
import { StatusRowContainer } from './StatusRowContainer';
|
||||||
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
||||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
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 { useChatTimelineController } from './hooks/useChatTimelineController';
|
||||||
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
||||||
import { useTimelineStaging } from '@/hooks/useTimelineStaging';
|
import { useTimelineStaging } from '@/hooks/useTimelineStaging';
|
||||||
@@ -42,6 +45,45 @@ const EMPTY_PERMISSIONS: PermissionRequest[] = [];
|
|||||||
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
||||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||||
const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected';
|
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 = {
|
type HydratingToolSkeletonRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,6 +91,144 @@ type HydratingToolSkeletonRow = {
|
|||||||
detailWidth: string;
|
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<{
|
const HYDRATING_SKELETON_ITEMS: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
toolRows: HydratingToolSkeletonRow[];
|
toolRows: HydratingToolSkeletonRow[];
|
||||||
@@ -163,6 +343,64 @@ export const ChatContainer: React.FC = () => {
|
|||||||
if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
|
if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
|
||||||
return flattenBlockingRequests(questionsMap, scopedSessionIds);
|
return flattenBlockingRequests(questionsMap, scopedSessionIds);
|
||||||
}, [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
|
// History metadata — use sync's hasMore/isLoading
|
||||||
const historyMeta = React.useMemo(() => {
|
const historyMeta = React.useMemo(() => {
|
||||||
@@ -248,11 +486,36 @@ export const ChatContainer: React.FC = () => {
|
|||||||
onActiveTurnChange: handleActiveTurnChange,
|
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,
|
// Deferred timeline staging — renders 1 message on first paint,
|
||||||
// adds 3 per rAF frame to avoid blocking.
|
// adds 3 per rAF frame to avoid blocking.
|
||||||
const { stagedMessages } = useTimelineStaging({
|
const { stagedMessages } = useTimelineStaging({
|
||||||
sessionKey: currentSessionId ?? '',
|
sessionKey: currentSessionId ?? '',
|
||||||
messages: sessionMessages,
|
messages: viewportMessages,
|
||||||
});
|
});
|
||||||
|
|
||||||
const timelineController = useChatTimelineController({
|
const timelineController = useChatTimelineController({
|
||||||
@@ -266,12 +529,23 @@ export const ChatContainer: React.FC = () => {
|
|||||||
isPinned,
|
isPinned,
|
||||||
isOverflowing,
|
isOverflowing,
|
||||||
});
|
});
|
||||||
const { resumeToBottomInstant } = timelineController;
|
const { loadEarlier, resumeToBottomInstant } = timelineController;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
||||||
}, [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({
|
const navigation = useChatTurnNavigation({
|
||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
turnIds: timelineController.turnIds,
|
turnIds: timelineController.turnIds,
|
||||||
@@ -505,49 +779,29 @@ export const ChatContainer: React.FC = () => {
|
|||||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||||
>
|
>
|
||||||
{returnToParentButton}
|
{returnToParentButton}
|
||||||
<div
|
<ChatViewport
|
||||||
className={cn(
|
currentSessionId={currentSessionId}
|
||||||
'relative min-h-0',
|
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||||
isDesktopExpandedInput
|
isMobile={isMobile}
|
||||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
stickyUserHeader={stickyUserHeader}
|
||||||
: 'flex-1'
|
scrollRef={scrollRef}
|
||||||
)}
|
messageListRef={messageListRef}
|
||||||
aria-hidden={isDesktopExpandedInput}
|
turnStart={timelineController.turnStart}
|
||||||
>
|
pendingRevealWork={timelineController.pendingRevealWork}
|
||||||
<div className="absolute inset-0">
|
renderedMessages={timelineController.renderedMessages}
|
||||||
<ScrollShadow
|
hasMoreAboveTurns={timelineController.historySignals.hasMoreAboveTurns}
|
||||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
isLoadingOlder={timelineController.isLoadingOlder}
|
||||||
ref={scrollRef}
|
sessionIsWorking={sessionIsWorking}
|
||||||
style={{ overflowAnchor: 'none' }}
|
streamingMessageId={streamingMessageId}
|
||||||
observeMutations={false}
|
retryOverlay={retryOverlay}
|
||||||
hideTopShadow={isMobile && stickyUserHeader}
|
handleMessageContentChange={handleMessageContentChange}
|
||||||
data-scroll-shadow="true"
|
getAnimationHandlers={getAnimationHandlers}
|
||||||
data-scrollbar="chat"
|
handleLoadOlder={handleLoadOlder}
|
||||||
>
|
scrollToBottom={scrollToBottom}
|
||||||
<div className="relative z-0 min-h-full">
|
sessionQuestions={sessionQuestions}
|
||||||
<MessageList
|
sessionPermissions={sessionPermissions}
|
||||||
ref={messageListRef}
|
isProgrammaticFollowActive={isProgrammaticFollowActive}
|
||||||
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>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { useSelectionStore } from '@/sync/selection-store';
|
|||||||
import { useInputStore } from '@/sync/input-store';
|
import { useInputStore } from '@/sync/input-store';
|
||||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||||
import * as sessionActions from '@/sync/session-actions';
|
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 { useInlineCommentDraftStore, type InlineCommentDraft } from '@/stores/useInlineCommentDraftStore';
|
||||||
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
import { appendInlineComments } from '@/lib/messages/inlineComments';
|
||||||
import { AttachedFilesList } from './FileAttachment';
|
import { AttachedFilesList } from './FileAttachment';
|
||||||
@@ -41,7 +41,6 @@ import { StatusRow } from './StatusRow';
|
|||||||
import { MobileAgentButton } from './MobileAgentButton';
|
import { MobileAgentButton } from './MobileAgentButton';
|
||||||
import { MobileModelButton } from './MobileModelButton';
|
import { MobileModelButton } from './MobileModelButton';
|
||||||
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
|
import { MobileSessionStatusBar } from './MobileSessionStatusBar';
|
||||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
|
||||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
// useMessageStore removed — messages now come from sync system
|
// 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)
|
// Track if we restored a draft on mount (for text selection)
|
||||||
const initialDraftRef = React.useRef<string | null>(null);
|
const initialDraftRef = React.useRef<string | null>(null);
|
||||||
// Track initial session ID (captured at mount time for draft restoration)
|
// 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 inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
|
||||||
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
||||||
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
|
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
|
||||||
const { working } = useAssistantStatus();
|
|
||||||
const { git: runtimeGit } = useRuntimeAPIs();
|
const { git: runtimeGit } = useRuntimeAPIs();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const chatSearchDirectory = useChatSearchDirectory();
|
const chatSearchDirectory = useChatSearchDirectory();
|
||||||
@@ -973,24 +971,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||||
const hasDrafts = draftCount > 0;
|
const hasDrafts = draftCount > 0;
|
||||||
|
|
||||||
// User message history for up/down arrow navigation
|
// User message history for up/down arrow navigation.
|
||||||
// Get raw messages from store (stable reference)
|
// Keep this on a narrow hook instead of full session message records.
|
||||||
const sessionMessages = useSessionMessageRecords(currentSessionId ?? "");
|
const userMessageHistory = useUserMessageHistory(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]);
|
|
||||||
|
|
||||||
// Keep messageRef in sync with message state
|
// Keep messageRef in sync with message state
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -1248,7 +1231,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const hasQueuedMessages = queuedMessages.length > 0;
|
const hasQueuedMessages = queuedMessages.length > 0;
|
||||||
const canSend = hasContent || hasQueuedMessages;
|
const canSend = hasContent || hasQueuedMessages;
|
||||||
|
|
||||||
const canAbort = working.isWorking;
|
const canAbort = sessionPhase !== 'idle';
|
||||||
|
|
||||||
// Keep a ref to handleSubmit so callbacks don't depend on it.
|
// Keep a ref to handleSubmit so callbacks don't depend on it.
|
||||||
type SubmitOptions = {
|
type SubmitOptions = {
|
||||||
@@ -3135,10 +3118,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
});
|
});
|
||||||
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
|
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
|
||||||
|
|
||||||
const workingStatusText = working.statusText;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const pendingAbortBanner = Boolean(working.wasAborted);
|
const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId;
|
||||||
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
|
if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) {
|
||||||
startAbortIndicator();
|
startAbortIndicator();
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
@@ -3147,11 +3128,11 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
prevWasAbortedRef.current = pendingAbortBanner;
|
prevWasAbortedRef.current = pendingAbortBanner;
|
||||||
}, [
|
}, [
|
||||||
|
abortPromptSessionId,
|
||||||
acknowledgeSessionAbort,
|
acknowledgeSessionAbort,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
showAbortStatus,
|
showAbortStatus,
|
||||||
startAbortIndicator,
|
startAbortIndicator,
|
||||||
working.wasAborted,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -3298,13 +3279,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MemoStatusRow
|
<MemoStatusRow
|
||||||
isWorking={working.isWorking}
|
|
||||||
statusText={workingStatusText}
|
|
||||||
isGenericStatus={working.isGenericStatus}
|
|
||||||
isWaitingForPermission={working.isWaitingForPermission}
|
|
||||||
wasAborted={working.wasAborted}
|
|
||||||
abortActive={working.abortActive}
|
|
||||||
retryInfo={working.retryInfo}
|
|
||||||
showAbortStatus={showAbortStatus}
|
showAbortStatus={showAbortStatus}
|
||||||
showAssistantStatus={false}
|
showAssistantStatus={false}
|
||||||
showTodos
|
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 React from 'react';
|
||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
|
import { measureElement as measureVirtualElement, type VirtualItem, useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
|
||||||
import ChatMessage from './ChatMessage';
|
import ChatMessage from './ChatMessage';
|
||||||
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
|
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual } from './message/renderCompare';
|
||||||
import { PermissionCard } from './PermissionCard';
|
|
||||||
import { QuestionCard } from './QuestionCard';
|
|
||||||
import TurnItem from './components/TurnItem';
|
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 type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
|
||||||
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
import { filterSyntheticParts } from '@/lib/messages/synthetic';
|
||||||
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
|
import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types';
|
||||||
import { useTurnRecords } from './hooks/useTurnRecords';
|
import { useTurnRecords } from './hooks/useTurnRecords';
|
||||||
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
|
import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
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 { FadeInDisabledProvider } from './message/FadeInOnReveal';
|
||||||
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
|
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
|
||||||
import { StatusRowContainer } from './StatusRowContainer';
|
|
||||||
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
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 useStableEvent = <TArgs extends unknown[], TResult>(handler: (...args: TArgs) => TResult) => {
|
||||||
const handlerRef = React.useRef(handler);
|
const handlerRef = React.useRef(handler);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -297,8 +302,14 @@ interface MessageListProps {
|
|||||||
turnStart: number;
|
turnStart: number;
|
||||||
disableStaging?: boolean;
|
disableStaging?: boolean;
|
||||||
messages: ChatMessageEntry[];
|
messages: ChatMessageEntry[];
|
||||||
permissions: PermissionRequest[];
|
sessionIsWorking?: boolean;
|
||||||
questions: QuestionRequest[];
|
activeStreamingMessageId?: string | null;
|
||||||
|
retryOverlay?: {
|
||||||
|
sessionId: string;
|
||||||
|
message: string;
|
||||||
|
confirmedAt?: number;
|
||||||
|
fallbackTimestamp?: number;
|
||||||
|
} | null;
|
||||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
hasMoreAbove: boolean;
|
hasMoreAbove: boolean;
|
||||||
@@ -884,21 +895,24 @@ function areMessageListEntryPropsEqual(prevProps: MessageListEntryProps, nextPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inner component that renders staged turn entries.
|
// Inner component that renders staged turn entries.
|
||||||
const MessageListContent: React.FC<{
|
const StaticHistoryList: React.FC<{
|
||||||
entries: RenderEntry[];
|
entries: RenderEntry[];
|
||||||
|
shouldVirtualize: boolean;
|
||||||
|
virtualRows: VirtualItem[];
|
||||||
|
totalSize: number;
|
||||||
|
measureElement: (element: HTMLDivElement | null) => void;
|
||||||
|
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||||
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
onMessageContentChange: (reason?: ContentChangeReason) => void;
|
||||||
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
||||||
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||||
stickyUserHeader: boolean;
|
stickyUserHeader: boolean;
|
||||||
sessionIsWorking: boolean;
|
|
||||||
defaultActivityExpanded: boolean;
|
defaultActivityExpanded: boolean;
|
||||||
turnUiStates: Map<string, TurnUiState>;
|
turnUiStates: Map<string, TurnUiState>;
|
||||||
onToggleTurnGroup: (turnId: string) => void;
|
onToggleTurnGroup: (turnId: string) => void;
|
||||||
chatRenderMode: 'sorted' | 'live';
|
chatRenderMode: 'sorted' | 'live';
|
||||||
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean;
|
||||||
onUserAnimationConsumed: (messageId: string) => void;
|
onUserAnimationConsumed: (messageId: string) => void;
|
||||||
activeStreamingMessageId?: string | null;
|
}> = React.memo(({ entries, shouldVirtualize, virtualRows, totalSize, measureElement, contentRef, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed }) => {
|
||||||
}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingMessageId }) => {
|
|
||||||
const renderEntry = React.useCallback((entry: RenderEntry) => {
|
const renderEntry = React.useCallback((entry: RenderEntry) => {
|
||||||
return (
|
return (
|
||||||
<MessageListEntry
|
<MessageListEntry
|
||||||
@@ -908,22 +922,83 @@ const MessageListContent: React.FC<{
|
|||||||
getAnimationHandlers={getAnimationHandlers}
|
getAnimationHandlers={getAnimationHandlers}
|
||||||
scrollToBottom={scrollToBottom}
|
scrollToBottom={scrollToBottom}
|
||||||
stickyUserHeader={stickyUserHeader}
|
stickyUserHeader={stickyUserHeader}
|
||||||
sessionIsWorking={sessionIsWorking}
|
sessionIsWorking={false}
|
||||||
defaultActivityExpanded={defaultActivityExpanded}
|
defaultActivityExpanded={defaultActivityExpanded}
|
||||||
turnUiStates={turnUiStates}
|
turnUiStates={turnUiStates}
|
||||||
onToggleTurnGroup={onToggleTurnGroup}
|
onToggleTurnGroup={onToggleTurnGroup}
|
||||||
chatRenderMode={chatRenderMode}
|
chatRenderMode={chatRenderMode}
|
||||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
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 (
|
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<{
|
const StreamingTailContent: React.FC<{
|
||||||
entry: RenderEntry;
|
entry: RenderEntry;
|
||||||
@@ -994,8 +1069,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
turnStart,
|
turnStart,
|
||||||
disableStaging: _disableStaging,
|
disableStaging: _disableStaging,
|
||||||
messages,
|
messages,
|
||||||
permissions,
|
sessionIsWorking = false,
|
||||||
questions,
|
activeStreamingMessageId = null,
|
||||||
|
retryOverlay = null,
|
||||||
onMessageContentChange,
|
onMessageContentChange,
|
||||||
getAnimationHandlers,
|
getAnimationHandlers,
|
||||||
hasMoreAbove,
|
hasMoreAbove,
|
||||||
@@ -1006,9 +1082,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
}, ref) => {
|
}, ref) => {
|
||||||
streamPerfCount('ui.message_list.render');
|
streamPerfCount('ui.message_list.render');
|
||||||
void _disableStaging;
|
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 stickyUserHeader = useUIStore(state => state.stickyUserHeader);
|
||||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||||
const activityRenderMode = useUIStore((state) => state.activityRenderMode);
|
const activityRenderMode = useUIStore((state) => state.activityRenderMode);
|
||||||
@@ -1032,20 +1105,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
ungroupedMessageIds: Set<string>;
|
ungroupedMessageIds: Set<string>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const stableOnMessageContentChange = useStableEvent(onMessageContentChange);
|
|
||||||
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 }) => {
|
||||||
scrollToBottom?.(options);
|
scrollToBottom?.(options);
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (permissions.length === 0 && questions.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stableOnMessageContentChange('permission');
|
|
||||||
}, [permissions, questions, stableOnMessageContentChange]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setTurnUiStates(new Map());
|
setTurnUiStates(new Map());
|
||||||
}, [activityRenderMode]);
|
}, [activityRenderMode]);
|
||||||
@@ -1160,27 +1225,8 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return output;
|
return output;
|
||||||
}), [messages]);
|
}), [messages]);
|
||||||
|
|
||||||
const currentSessionIdForRetry = useSessionUIStore((s) => s.currentSessionId);
|
const historyContentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const retryStatusRaw = useSessionStatus(currentSessionIdForRetry ?? '');
|
const pendingVirtualMeasureFrameRef = React.useRef<number | null>(null);
|
||||||
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 resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
|
const resolveScrollContainer = React.useCallback((): HTMLDivElement | null => {
|
||||||
if (scrollRef?.current) {
|
if (scrollRef?.current) {
|
||||||
return scrollRef.current;
|
return scrollRef.current;
|
||||||
@@ -1191,27 +1237,14 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return document.querySelector<HTMLDivElement>('[data-scrollbar="chat"]');
|
return document.querySelector<HTMLDivElement>('[data-scrollbar="chat"]');
|
||||||
}, [scrollRef]);
|
}, [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', () => {
|
const displayMessages = React.useMemo(() => streamPerfMeasure('ui.message_list.retry_overlay_ms', () => {
|
||||||
return applyRetryOverlay(baseDisplayMessages, {
|
return applyRetryOverlay(baseDisplayMessages, {
|
||||||
sessionId: activeRetrySessionId,
|
sessionId: retryOverlay?.sessionId ?? null,
|
||||||
message: activeRetryMessage,
|
message: retryOverlay?.message ?? 'Quota limit reached. Retrying automatically.',
|
||||||
confirmedAt: activeRetryConfirmedAt,
|
confirmedAt: retryOverlay?.confirmedAt,
|
||||||
fallbackTimestamp: fallbackRetryTimestamp,
|
fallbackTimestamp: retryOverlay?.fallbackTimestamp ?? 0,
|
||||||
});
|
});
|
||||||
}), [activeRetryMessage, activeRetryConfirmedAt, activeRetrySessionId, baseDisplayMessages, fallbackRetryTimestamp]);
|
}), [baseDisplayMessages, retryOverlay]);
|
||||||
|
|
||||||
const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, {
|
const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@@ -1341,10 +1374,102 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const historyEntries = staticRenderEntries;
|
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(() => {
|
const allEntries = React.useMemo(() => {
|
||||||
return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries;
|
return trailingStreamingEntry ? [...historyEntries, trailingStreamingEntry] : historyEntries;
|
||||||
}, [historyEntries, trailingStreamingEntry]);
|
}, [historyEntries, trailingStreamingEntry]);
|
||||||
|
|
||||||
|
const stableHistoryContentChange = useStableEvent((reason?: ContentChangeReason) => {
|
||||||
|
scheduleVirtualMeasure();
|
||||||
|
onMessageContentChange(reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
const stableTailContentChange = useStableEvent((reason?: ContentChangeReason) => {
|
||||||
|
onMessageContentChange(reason);
|
||||||
|
});
|
||||||
|
|
||||||
const currentUserOrder = React.useMemo(() => {
|
const currentUserOrder = React.useMemo(() => {
|
||||||
return messages
|
return messages
|
||||||
.filter((message) => resolveMessageRole(message) === 'user')
|
.filter((message) => resolveMessageRole(message) === 'user')
|
||||||
@@ -1427,6 +1552,23 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return container.querySelector(`[data-message-id="${messageId}"]`);
|
return container.querySelector(`[data-message-id="${messageId}"]`);
|
||||||
}, [resolveScrollContainer]);
|
}, [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 scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => {
|
||||||
const container = resolveScrollContainer();
|
const container = resolveScrollContainer();
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -1469,7 +1611,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
}
|
}
|
||||||
const turnElement = container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
|
const turnElement = container.querySelector<HTMLElement>(`[data-turn-id="${turnId}"]`);
|
||||||
if (!turnElement) {
|
if (!turnElement) {
|
||||||
return false;
|
return scrollHistoryIndexIntoView(index, behavior);
|
||||||
}
|
}
|
||||||
turnElement.scrollIntoView({ behavior, block: 'start' });
|
turnElement.scrollIntoView({ behavior, block: 'start' });
|
||||||
return true;
|
return true;
|
||||||
@@ -1487,7 +1629,8 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return scrollMessageElementIntoView(messageId, behavior);
|
return scrollMessageElementIntoView(messageId, behavior)
|
||||||
|
|| scrollHistoryIndexIntoView(index, behavior);
|
||||||
},
|
},
|
||||||
|
|
||||||
captureViewportAnchor: () => {
|
captureViewportAnchor: () => {
|
||||||
@@ -1551,6 +1694,13 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!applyAnchor()) {
|
||||||
|
const index = messageIndexMap.get(anchor.messageId);
|
||||||
|
if (typeof index === 'number' && index < historyEntries.length) {
|
||||||
|
scrollHistoryIndexIntoView(index, 'auto');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return applyAnchor();
|
return applyAnchor();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1567,7 +1717,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
return () => {
|
return () => {
|
||||||
objectRef.current = null;
|
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;
|
const disableFadeIn = false;
|
||||||
|
|
||||||
@@ -1593,25 +1743,28 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
|
|
||||||
<FadeInDisabledProvider disabled={disableFadeIn}>
|
<FadeInDisabledProvider disabled={disableFadeIn}>
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<MessageListContent
|
<StaticHistoryList
|
||||||
entries={historyEntries}
|
entries={historyEntries}
|
||||||
onMessageContentChange={stableOnMessageContentChange}
|
shouldVirtualize={shouldVirtualizeHistory}
|
||||||
|
virtualRows={historyVirtualRows}
|
||||||
|
totalSize={historyVirtualizer.getTotalSize()}
|
||||||
|
measureElement={historyVirtualizer.measureElement}
|
||||||
|
contentRef={historyContentRef}
|
||||||
|
onMessageContentChange={stableHistoryContentChange}
|
||||||
getAnimationHandlers={stableGetAnimationHandlers}
|
getAnimationHandlers={stableGetAnimationHandlers}
|
||||||
scrollToBottom={stableScrollToBottom}
|
scrollToBottom={stableScrollToBottom}
|
||||||
stickyUserHeader={stickyUserHeader}
|
stickyUserHeader={stickyUserHeader}
|
||||||
sessionIsWorking={sessionIsWorking}
|
|
||||||
defaultActivityExpanded={defaultActivityExpanded}
|
defaultActivityExpanded={defaultActivityExpanded}
|
||||||
turnUiStates={turnUiStates}
|
turnUiStates={turnUiStates}
|
||||||
onToggleTurnGroup={toggleTurnGroup}
|
onToggleTurnGroup={toggleTurnGroup}
|
||||||
chatRenderMode={chatRenderMode}
|
chatRenderMode={chatRenderMode}
|
||||||
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
shouldAnimateUserMessage={shouldAnimateUserMessage}
|
||||||
onUserAnimationConsumed={onUserAnimationConsumed}
|
onUserAnimationConsumed={onUserAnimationConsumed}
|
||||||
activeStreamingMessageId={activeStreamingMessageId}
|
|
||||||
/>
|
/>
|
||||||
{trailingStreamingEntry ? (
|
{trailingStreamingEntry ? (
|
||||||
<StreamingTailContent
|
<StreamingTailContent
|
||||||
entry={trailingStreamingEntry}
|
entry={trailingStreamingEntry}
|
||||||
onMessageContentChange={stableOnMessageContentChange}
|
onMessageContentChange={stableTailContentChange}
|
||||||
getAnimationHandlers={stableGetAnimationHandlers}
|
getAnimationHandlers={stableGetAnimationHandlers}
|
||||||
scrollToBottom={stableScrollToBottom}
|
scrollToBottom={stableScrollToBottom}
|
||||||
stickyUserHeader={stickyUserHeader}
|
stickyUserHeader={stickyUserHeader}
|
||||||
@@ -1628,23 +1781,6 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
|||||||
</div>
|
</div>
|
||||||
</FadeInDisabledProvider>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
buildTurnWindowModel,
|
buildTurnWindowModel,
|
||||||
clampTurnStart,
|
clampTurnStart,
|
||||||
getInitialTurnStart,
|
getInitialTurnStart,
|
||||||
|
updateTurnWindowModelIncremental,
|
||||||
windowMessagesByTurn,
|
windowMessagesByTurn,
|
||||||
type TurnWindowModel,
|
type TurnWindowModel,
|
||||||
} from '../lib/turns/windowTurns';
|
} from '../lib/turns/windowTurns';
|
||||||
@@ -70,7 +71,19 @@ export const useChatTimelineController = ({
|
|||||||
isPinned,
|
isPinned,
|
||||||
isOverflowing,
|
isOverflowing,
|
||||||
}: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => {
|
}: 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 [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount));
|
||||||
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 { 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';
|
||||||
@@ -15,28 +16,124 @@ 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);
|
||||||
|
|
||||||
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 rawProjection = projectTurnRecords(messages, {
|
const tailOnlyProjection = buildTailOnlyProjection(
|
||||||
|
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);
|
const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current);
|
||||||
previousProjectionRef.current = stabilizedProjection;
|
previousProjectionRef.current = stabilizedProjection;
|
||||||
|
previousMessagesRef.current = messages;
|
||||||
return stabilizedProjection;
|
return stabilizedProjection;
|
||||||
});
|
});
|
||||||
}, [messages, options.showTextJustificationActivity]);
|
}, [messages, options.showTextJustificationActivity]);
|
||||||
|
|||||||
@@ -23,6 +23,119 @@ export interface TurnWindowModel {
|
|||||||
turnCount: number;
|
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 => {
|
export const buildTurnWindowModel = (messages: ChatMessageEntry[]): TurnWindowModel => {
|
||||||
const turnIds: string[] = [];
|
const turnIds: string[] = [];
|
||||||
const turnMessageStartIndexes: number[] = [];
|
const turnMessageStartIndexes: number[] = [];
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { DiffIcon } from '@/components/icons/DiffIcon';
|
|||||||
import { useUIStore, type MainTab } from '@/stores/useUIStore';
|
import { useUIStore, type MainTab } from '@/stores/useUIStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
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 { getAllSyncSessions } from '@/sync/sync-refs';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||||
@@ -665,8 +665,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const currentSessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '');
|
const currentSessionMessagesResolved = useSessionMessagesResolved(currentSessionId ?? '');
|
||||||
const currentSessionMessages = currentSessionId ? (currentSessionMessageRecords.length > 0 ? currentSessionMessageRecords : undefined) : undefined;
|
|
||||||
const currentSyncedSession = useSession(currentSessionId ?? null);
|
const currentSyncedSession = useSession(currentSessionId ?? null);
|
||||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||||
const activeProject = useProjectsStore((state) => {
|
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 outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0);
|
||||||
const contextUsage = getContextUsage(contextLimit, outputLimit);
|
const contextUsage = getContextUsage(contextLimit, outputLimit);
|
||||||
const [stableDesktopContextUsage, setStableDesktopContextUsage] = React.useState<SessionContextUsage | null>(null);
|
const [stableDesktopContextUsage, setStableDesktopContextUsage] = React.useState<SessionContextUsage | null>(null);
|
||||||
const isContextUsageResolvedForSession = !currentSessionId || currentSessionMessages !== undefined;
|
const isContextUsageResolvedForSession = !currentSessionId || currentSessionMessagesResolved;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
|
|||||||
@@ -51,52 +51,6 @@ const normalizeDirectoryKey = (value: string): string => {
|
|||||||
return normalized;
|
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 = () => {
|
export const MainLayout: React.FC = () => {
|
||||||
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
|
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
|
||||||
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
|
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;
|
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,
|
containerRef,
|
||||||
minThumbSize = 32,
|
minThumbSize = 32,
|
||||||
hideDelayMs = 1000,
|
hideDelayMs = 1000,
|
||||||
@@ -50,6 +50,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
|||||||
scrollLeft: number;
|
scrollLeft: number;
|
||||||
}>({ pointerX: 0, pointerY: 0, scrollTop: 0, scrollLeft: 0 });
|
}>({ pointerX: 0, pointerY: 0, scrollTop: 0, scrollLeft: 0 });
|
||||||
const dragAxisRef = React.useRef<"vertical" | "horizontal" | null>(null);
|
const dragAxisRef = React.useRef<"vertical" | "horizontal" | null>(null);
|
||||||
|
const observedElementsRef = React.useRef<Set<Element>>(new Set());
|
||||||
|
|
||||||
const updateMetrics = React.useCallback(() => {
|
const updateMetrics = React.useCallback(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
@@ -91,6 +92,33 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
|||||||
});
|
});
|
||||||
}, [updateMetrics]);
|
}, [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(() => {
|
const scheduleHide = React.useCallback(() => {
|
||||||
if (hideTimeoutRef.current) {
|
if (hideTimeoutRef.current) {
|
||||||
clearTimeout(hideTimeoutRef.current);
|
clearTimeout(hideTimeoutRef.current);
|
||||||
@@ -160,16 +188,26 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
|||||||
scheduleMetricsUpdate();
|
scheduleMetricsUpdate();
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
resizeObserver?.observe(container);
|
syncObservedElements(container, resizeObserver);
|
||||||
|
|
||||||
const mutationObserver =
|
const mutationObserver =
|
||||||
observeMutations && typeof MutationObserver !== "undefined"
|
observeMutations && typeof MutationObserver !== "undefined"
|
||||||
? new MutationObserver(() => scheduleMetricsUpdate())
|
? new MutationObserver(() => {
|
||||||
|
syncObservedElements(container, resizeObserver);
|
||||||
|
scheduleMetricsUpdate();
|
||||||
|
})
|
||||||
: null;
|
: 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 () => {
|
return () => {
|
||||||
container.removeEventListener("scroll", onScroll);
|
container.removeEventListener("scroll", onScroll);
|
||||||
|
container.removeEventListener("input", onInput, true);
|
||||||
|
container.removeEventListener("load", onLoad, true);
|
||||||
if (userIntentOnly) {
|
if (userIntentOnly) {
|
||||||
container.removeEventListener("wheel", markUserIntent);
|
container.removeEventListener("wheel", markUserIntent);
|
||||||
container.removeEventListener("touchstart", markUserIntent);
|
container.removeEventListener("touchstart", markUserIntent);
|
||||||
@@ -178,11 +216,12 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
|||||||
}
|
}
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
mutationObserver?.disconnect();
|
mutationObserver?.disconnect();
|
||||||
|
observedElementsRef.current.clear();
|
||||||
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
||||||
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
||||||
if (metricsFrameRef.current) cancelAnimationFrame(metricsFrameRef.current);
|
if (metricsFrameRef.current) cancelAnimationFrame(metricsFrameRef.current);
|
||||||
};
|
};
|
||||||
}, [containerRef, handleScroll, markUserIntent, observeMutations, scheduleMetricsUpdate, updateMetrics, userIntentOnly]);
|
}, [containerRef, handleScroll, markUserIntent, observeMutations, scheduleMetricsUpdate, syncObservedElements, updateMetrics, userIntentOnly]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!suppressVisibility) {
|
if (!suppressVisibility) {
|
||||||
@@ -291,3 +330,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
OverlayScrollbarComponent.displayName = "OverlayScrollbar";
|
||||||
|
|
||||||
|
export const OverlayScrollbar = OverlayScrollbarComponent;
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export const useChatScrollManager = ({
|
|||||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||||
const [isPinned, setIsPinned] = React.useState(true);
|
const [isPinned, setIsPinned] = React.useState(true);
|
||||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||||
|
const showScrollButtonRef = React.useRef(false);
|
||||||
|
const isOverflowingRef = React.useRef(false);
|
||||||
|
|
||||||
const lastSessionIdRef = React.useRef<string | null>(null);
|
const lastSessionIdRef = React.useRef<string | null>(null);
|
||||||
const suppressUserScrollUntilRef = React.useRef<number>(0);
|
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 scrollToBottomInternal = React.useCallback((options?: { instant?: boolean; followBottom?: boolean }) => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -151,22 +167,22 @@ export const useChatScrollManager = ({
|
|||||||
const updateScrollButtonVisibility = React.useCallback(() => {
|
const updateScrollButtonVisibility = React.useCallback(() => {
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
setShowScrollButton(false);
|
setShowScrollButtonState(false);
|
||||||
setIsOverflowing(false);
|
setIsOverflowingState(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasScrollableContent = container.scrollHeight > container.clientHeight;
|
const hasScrollableContent = container.scrollHeight > container.clientHeight;
|
||||||
setIsOverflowing(hasScrollableContent);
|
setIsOverflowingState(hasScrollableContent);
|
||||||
if (!hasScrollableContent) {
|
if (!hasScrollableContent) {
|
||||||
setShowScrollButton(false);
|
setShowScrollButtonState(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show scroll button when scrolled above the 10vh threshold
|
// Show scroll button when scrolled above the 10vh threshold
|
||||||
const distanceFromBottom = getDistanceFromBottom();
|
const distanceFromBottom = getDistanceFromBottom();
|
||||||
setShowScrollButton(!isNearBottom(distanceFromBottom, getPinThreshold()));
|
setShowScrollButtonState(!isNearBottom(distanceFromBottom, getPinThreshold()));
|
||||||
}, [getDistanceFromBottom, getPinThreshold]);
|
}, [getDistanceFromBottom, getPinThreshold, setIsOverflowingState, setShowScrollButtonState]);
|
||||||
|
|
||||||
const syncPinnedStateAndIndicators = React.useCallback(() => {
|
const syncPinnedStateAndIndicators = React.useCallback(() => {
|
||||||
pinnedSyncRafRef.current = null;
|
pinnedSyncRafRef.current = null;
|
||||||
@@ -267,8 +283,8 @@ export const useChatScrollManager = ({
|
|||||||
updatePinnedState(true);
|
updatePinnedState(true);
|
||||||
|
|
||||||
scrollToBottomInternal(options);
|
scrollToBottomInternal(options);
|
||||||
setShowScrollButton(false);
|
setShowScrollButtonState(false);
|
||||||
}, [scrollToBottomInternal, updatePinnedState]);
|
}, [scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]);
|
||||||
|
|
||||||
const releasePinnedScroll = React.useCallback(() => {
|
const releasePinnedScroll = React.useCallback(() => {
|
||||||
scrollEngine.cancelFollow();
|
scrollEngine.cancelFollow();
|
||||||
@@ -435,22 +451,25 @@ export const useChatScrollManager = ({
|
|||||||
// Always start pinned at bottom on session switch
|
// Always start pinned at bottom on session switch
|
||||||
preferInstantPinRef.current = true;
|
preferInstantPinRef.current = true;
|
||||||
updatePinnedState(true);
|
updatePinnedState(true);
|
||||||
setShowScrollButton(false);
|
setShowScrollButtonState(false);
|
||||||
|
|
||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (container) {
|
if (container) {
|
||||||
markProgrammaticScroll();
|
markProgrammaticScroll();
|
||||||
scrollToBottomInternal({ instant: true });
|
scrollToBottomInternal({ instant: true });
|
||||||
}
|
}
|
||||||
}, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, updatePinnedState]);
|
}, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]);
|
||||||
|
|
||||||
// Maintain pin-to-bottom when content changes
|
// Maintain pin-to-bottom when content changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isSyncing) {
|
if (isSyncing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (shouldSkipLiveContentSync()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
}, [isSyncing, schedulePinnedStateAndIndicators, sessionMessages.length]);
|
}, [isSyncing, schedulePinnedStateAndIndicators, sessionMessages.length, shouldSkipLiveContentSync]);
|
||||||
|
|
||||||
// Use ResizeObserver to detect content changes and maintain pin
|
// Use ResizeObserver to detect content changes and maintain pin
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -499,44 +518,49 @@ export const useChatScrollManager = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scrollHeightChanged && shouldSkipLiveContentSync()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(container);
|
observer.observe(container);
|
||||||
|
|
||||||
// Also observe children for content changes
|
|
||||||
const childObserver = new MutationObserver(() => {
|
|
||||||
schedulePinnedStateAndIndicators();
|
|
||||||
});
|
|
||||||
|
|
||||||
childObserver.observe(container, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
childObserver.disconnect();
|
|
||||||
};
|
};
|
||||||
}, [schedulePinnedStateAndIndicators, updateScrollButtonVisibility]);
|
}, [markProgrammaticScroll, schedulePinnedStateAndIndicators, shouldSkipLiveContentSync, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
|
if (shouldSkipLiveContentSync()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rafId = window.requestAnimationFrame(() => {
|
const rafId = window.requestAnimationFrame(() => {
|
||||||
|
if (shouldSkipLiveContentSync()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.cancelAnimationFrame(rafId);
|
window.cancelAnimationFrame(rafId);
|
||||||
};
|
};
|
||||||
}, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessages.length]);
|
}, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessages.length, shouldSkipLiveContentSync]);
|
||||||
|
|
||||||
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
|
||||||
|
|
||||||
const handleMessageContentChange = React.useCallback(() => {
|
const handleMessageContentChange = React.useCallback(() => {
|
||||||
|
if (shouldSkipLiveContentSync()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
}, [schedulePinnedStateAndIndicators]);
|
}, [schedulePinnedStateAndIndicators, shouldSkipLiveContentSync]);
|
||||||
|
|
||||||
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
||||||
const existing = animationHandlersRef.current.get(messageId);
|
const existing = animationHandlersRef.current.get(messageId);
|
||||||
@@ -546,6 +570,9 @@ export const useChatScrollManager = ({
|
|||||||
|
|
||||||
const handlers: AnimationHandlers = {
|
const handlers: AnimationHandlers = {
|
||||||
onChunk: () => {
|
onChunk: () => {
|
||||||
|
if (shouldSkipLiveContentSync()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
},
|
},
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
@@ -554,6 +581,9 @@ export const useChatScrollManager = ({
|
|||||||
onStreamingCandidate: () => {},
|
onStreamingCandidate: () => {},
|
||||||
onAnimationStart: () => {},
|
onAnimationStart: () => {},
|
||||||
onAnimatedHeightChange: () => {
|
onAnimatedHeightChange: () => {
|
||||||
|
if (shouldSkipLiveContentSync()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
},
|
},
|
||||||
onReservationCancelled: () => {},
|
onReservationCancelled: () => {},
|
||||||
@@ -562,7 +592,7 @@ export const useChatScrollManager = ({
|
|||||||
|
|
||||||
animationHandlersRef.current.set(messageId, handlers);
|
animationHandlersRef.current.set(messageId, handlers);
|
||||||
return handlers;
|
return handlers;
|
||||||
}, [schedulePinnedStateAndIndicators]);
|
}, [schedulePinnedStateAndIndicators, shouldSkipLiveContentSync]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
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';
|
import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,7 +9,7 @@ import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice';
|
|||||||
*/
|
*/
|
||||||
export function useVoiceContext() {
|
export function useVoiceContext() {
|
||||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
const messages = useSessionMessageRecords(currentSessionId ?? '');
|
const messages = useSessionTextMessages(currentSessionId ?? '');
|
||||||
const permissions = useSessionPermissions(currentSessionId ?? '');
|
const permissions = useSessionPermissions(currentSessionId ?? '');
|
||||||
|
|
||||||
// Track last seen message count to only forward new messages
|
// Track last seen message count to only forward new messages
|
||||||
@@ -26,10 +26,9 @@ export function useVoiceContext() {
|
|||||||
const newMessages = messages.slice(lastMessageCountRef.current);
|
const newMessages = messages.slice(lastMessageCountRef.current);
|
||||||
lastMessageCountRef.current = currentCount;
|
lastMessageCountRef.current = currentCount;
|
||||||
|
|
||||||
// Format for voice hooks (extract role and content)
|
|
||||||
const formattedMessages = newMessages.map(m => ({
|
const formattedMessages = newMessages.map(m => ({
|
||||||
role: m.info.role,
|
role: m.role ?? '',
|
||||||
content: m.parts.map((p: Record<string, unknown>) => ('text' in p ? p.text : '')).join('')
|
content: m.text,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
voiceHooks.onMessages(currentSessionId, formattedMessages);
|
voiceHooks.onMessages(currentSessionId, formattedMessages);
|
||||||
|
|||||||
@@ -667,17 +667,6 @@ const derivePrVisualState = (status: GitHubPullRequestStatus | null): string | n
|
|||||||
return 'open';
|
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 deriveSummary = (entry: PrStatusEntry): PrVisualSummary | null => {
|
||||||
const vs = derivePrVisualState(entry.status ?? null);
|
const vs = derivePrVisualState(entry.status ?? null);
|
||||||
const pr = entry.status?.pr;
|
const pr = entry.status?.pr;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export {
|
|||||||
useDirectoryStore,
|
useDirectoryStore,
|
||||||
useDirectorySync,
|
useDirectorySync,
|
||||||
useSessionMessages,
|
useSessionMessages,
|
||||||
|
useSessionMessagesResolved,
|
||||||
useSessionParts,
|
useSessionParts,
|
||||||
useSessionStatus,
|
useSessionStatus,
|
||||||
useSessionPermissions,
|
useSessionPermissions,
|
||||||
@@ -68,6 +69,8 @@ export {
|
|||||||
useSyncDirectory,
|
useSyncDirectory,
|
||||||
useChildStoreManager,
|
useChildStoreManager,
|
||||||
useSessionMessageRecords,
|
useSessionMessageRecords,
|
||||||
|
useSessionTextMessages,
|
||||||
|
useUserMessageHistory,
|
||||||
} from "./sync-context"
|
} from "./sync-context"
|
||||||
|
|
||||||
// Sync operations
|
// Sync operations
|
||||||
|
|||||||
@@ -642,6 +642,17 @@ export function useVisibleSessionMessages(sessionID: string, directory?: string)
|
|||||||
}, [messages, revertMessageID])
|
}, [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 */
|
/** Get parts for a specific message */
|
||||||
export function useSessionParts(messageID: string, directory?: string) {
|
export function useSessionParts(messageID: string, directory?: string) {
|
||||||
return useDirectorySync(
|
return useDirectorySync(
|
||||||
@@ -836,24 +847,42 @@ export function useChildStoreManager() {
|
|||||||
return useSyncSystem().childStores
|
return useSyncSystem().childStores
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const MESSAGE_PART_SNAPSHOT_THROTTLE_MS = 100
|
||||||
* 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)
|
|
||||||
|
|
||||||
// Track parts with a ref to avoid subscribing to entire state.part map.
|
export type SessionTextMessage = {
|
||||||
// Re-derive only when messages list changes or on store subscription.
|
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 prevPartsRef = useRef<Record<string, Part[]>>({})
|
||||||
const [partsSnapshot, setPartsSnapshot] = React.useState<Record<string, Part[]>>({})
|
const [partsSnapshot, setPartsSnapshot] = React.useState<Record<string, Part[]>>({})
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const messageIds = messages.map((m) => m.id)
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
let pending = false
|
let pending = false
|
||||||
|
|
||||||
@@ -866,7 +895,6 @@ export function useSessionMessageRecords(sessionID: string, directory?: string)
|
|||||||
const next: Record<string, Part[]> = {}
|
const next: Record<string, Part[]> = {}
|
||||||
for (const id of messageIds) {
|
for (const id of messageIds) {
|
||||||
const parts = state.part[id] ?? EMPTY_PARTS
|
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
|
next[id] = prev[id] === parts ? prev[id] : parts
|
||||||
if (next[id] !== prev[id]) changed = true
|
if (next[id] !== prev[id]) changed = true
|
||||||
}
|
}
|
||||||
@@ -876,10 +904,8 @@ export function useSessionMessageRecords(sessionID: string, directory?: string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial sync
|
|
||||||
flush()
|
flush()
|
||||||
|
|
||||||
// Throttled subscription — batch rapid delta events into ~100ms updates
|
|
||||||
const unsub = store.subscribe(() => {
|
const unsub = store.subscribe(() => {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
pending = true
|
pending = true
|
||||||
@@ -889,26 +915,105 @@ export function useSessionMessageRecords(sessionID: string, directory?: string)
|
|||||||
flush()
|
flush()
|
||||||
if (pending) {
|
if (pending) {
|
||||||
pending = false
|
pending = false
|
||||||
timer = setTimeout(flush, 100)
|
timer = setTimeout(flush, MESSAGE_PART_SNAPSHOT_THROTTLE_MS)
|
||||||
}
|
}
|
||||||
}, 100)
|
}, MESSAGE_PART_SNAPSHOT_THROTTLE_MS)
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsub()
|
unsub()
|
||||||
if (timer) clearTimeout(timer)
|
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(
|
return useMemo(
|
||||||
() => messages.map((msg) => ({
|
() => messages.map((message) => ({
|
||||||
info: msg,
|
id: message.id,
|
||||||
parts: partsSnapshot[msg.id] ?? EMPTY_PARTS,
|
role: typeof message.role === "string" ? message.role : null,
|
||||||
|
text: getConcatenatedTextFromParts(partsSnapshot[message.id] ?? EMPTY_PARTS),
|
||||||
})),
|
})),
|
||||||
[messages, partsSnapshot],
|
[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.
|
* Determines if a session is actively working.
|
||||||
* Checks session_status and only falls back to incomplete assistant messages
|
* Checks session_status and only falls back to incomplete assistant messages
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ let isExternalOpenCode = false;
|
|||||||
let exitOnShutdown = true;
|
let exitOnShutdown = true;
|
||||||
let uiAuthController = null;
|
let uiAuthController = null;
|
||||||
let activeTunnelController = null;
|
let activeTunnelController = null;
|
||||||
|
let globalWatcherStartPromise = null;
|
||||||
const tunnelProviderRegistry = createTunnelProviderRegistry([
|
const tunnelProviderRegistry = createTunnelProviderRegistry([
|
||||||
createCloudflareTunnelProvider(),
|
createCloudflareTunnelProvider(),
|
||||||
]);
|
]);
|
||||||
@@ -739,13 +740,24 @@ const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCo
|
|||||||
const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args);
|
const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args);
|
||||||
const refreshOpenCodeAfterConfigChange = (...args) => openCodeLifecycleRuntime.refreshOpenCodeAfterConfigChange(...args);
|
const refreshOpenCodeAfterConfigChange = (...args) => openCodeLifecycleRuntime.refreshOpenCodeAfterConfigChange(...args);
|
||||||
const startHealthMonitoring = () => openCodeLifecycleRuntime.startHealthMonitoring(HEALTH_CHECK_INTERVAL);
|
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) => {
|
const bootstrapOpenCodeAtStartup = async (...args) => {
|
||||||
await openCodeLifecycleRuntime.bootstrapOpenCodeAtStartup(...args);
|
await openCodeLifecycleRuntime.bootstrapOpenCodeAtStartup(...args);
|
||||||
scheduleOpenCodeApiDetection();
|
scheduleOpenCodeApiDetection();
|
||||||
startHealthMonitoring();
|
if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) {
|
||||||
void openCodeWatcherRuntime.start().catch((error) => {
|
startHealthMonitoring();
|
||||||
console.warn(`Global event watcher startup failed: ${error?.message || error}`);
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args);
|
const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args);
|
||||||
|
|
||||||
@@ -871,6 +883,7 @@ async function main(options = {}) {
|
|||||||
resolveZenModel,
|
resolveZenModel,
|
||||||
sayTTSCapability,
|
sayTTSCapability,
|
||||||
ensurePushInitialized,
|
ensurePushInitialized,
|
||||||
|
ensureGlobalWatcherStarted,
|
||||||
getOrCreateVapidKeys,
|
getOrCreateVapidKeys,
|
||||||
getUiSessionTokenFromRequest,
|
getUiSessionTokenFromRequest,
|
||||||
writeSettingsToDisk,
|
writeSettingsToDisk,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
|||||||
const {
|
const {
|
||||||
uiAuthController,
|
uiAuthController,
|
||||||
ensurePushInitialized,
|
ensurePushInitialized,
|
||||||
|
ensureGlobalWatcherStarted,
|
||||||
getOrCreateVapidKeys,
|
getOrCreateVapidKeys,
|
||||||
getUiSessionTokenFromRequest,
|
getUiSessionTokenFromRequest,
|
||||||
readSettingsFromDiskMigrated,
|
readSettingsFromDiskMigrated,
|
||||||
@@ -45,6 +46,17 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
|||||||
setPushInitialized,
|
setPushInitialized,
|
||||||
} = dependencies;
|
} = 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) => {
|
app.get('/api/push/vapid-public-key', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
await ensurePushInitialized();
|
await ensurePushInitialized();
|
||||||
@@ -58,6 +70,7 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
|||||||
|
|
||||||
app.post('/api/push/subscribe', async (req, res) => {
|
app.post('/api/push/subscribe', async (req, res) => {
|
||||||
await ensurePushInitialized();
|
await ensurePushInitialized();
|
||||||
|
await ensureSessionWatcher();
|
||||||
|
|
||||||
const uiToken = uiAuthController?.ensureSessionToken
|
const uiToken = uiAuthController?.ensureSessionToken
|
||||||
? await uiAuthController.ensureSessionToken(req, res)
|
? await uiAuthController.ensureSessionToken(req, res)
|
||||||
@@ -146,10 +159,12 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/session-activity', (_req, res) => {
|
app.get('/api/session-activity', (_req, res) => {
|
||||||
|
void ensureSessionWatcher();
|
||||||
res.json(getSessionActivitySnapshot());
|
res.json(getSessionActivitySnapshot());
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/sessions/snapshot', (_req, res) => {
|
app.get('/api/sessions/snapshot', async (_req, res) => {
|
||||||
|
await ensureSessionWatcher();
|
||||||
res.json({
|
res.json({
|
||||||
statusSessions: getSessionStateSnapshot(),
|
statusSessions: getSessionStateSnapshot(),
|
||||||
attentionSessions: getSessionAttentionSnapshot(),
|
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();
|
const snapshot = getSessionStateSnapshot();
|
||||||
res.json({
|
res.json({
|
||||||
sessions: snapshot,
|
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 sessionId = req.params.id;
|
||||||
const state = getSessionState(sessionId);
|
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();
|
const snapshot = getSessionAttentionSnapshot();
|
||||||
res.json({
|
res.json({
|
||||||
sessions: snapshot,
|
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 sessionId = req.params.id;
|
||||||
const state = getSessionAttentionState(sessionId);
|
const state = getSessionAttentionState(sessionId);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
|||||||
resolveZenModel,
|
resolveZenModel,
|
||||||
sayTTSCapability,
|
sayTTSCapability,
|
||||||
ensurePushInitialized,
|
ensurePushInitialized,
|
||||||
|
ensureGlobalWatcherStarted,
|
||||||
getOrCreateVapidKeys,
|
getOrCreateVapidKeys,
|
||||||
getUiSessionTokenFromRequest,
|
getUiSessionTokenFromRequest,
|
||||||
writeSettingsToDisk,
|
writeSettingsToDisk,
|
||||||
@@ -74,6 +75,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
|||||||
registerNotificationRoutes(app, {
|
registerNotificationRoutes(app, {
|
||||||
uiAuthController,
|
uiAuthController,
|
||||||
ensurePushInitialized,
|
ensurePushInitialized,
|
||||||
|
ensureGlobalWatcherStarted,
|
||||||
getOrCreateVapidKeys,
|
getOrCreateVapidKeys,
|
||||||
getUiSessionTokenFromRequest,
|
getUiSessionTokenFromRequest,
|
||||||
readSettingsFromDiskMigrated,
|
readSettingsFromDiskMigrated,
|
||||||
|
|||||||
Reference in New Issue
Block a user