diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 81b6043f..e05c6989 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -7,9 +7,12 @@ import { useUIStore } from '@/stores/useUIStore'; import { Skeleton } from '@/components/ui/skeleton'; import ChatEmptyState from './ChatEmptyState'; import MessageList, { type MessageListHandle } from './MessageList'; +import { PermissionCard } from './PermissionCard'; +import { QuestionCard } from './QuestionCard'; +import { StatusRowContainer } from './StatusRowContainer'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; -import { useChatScrollManager } from '@/hooks/useChatScrollManager'; +import { useChatScrollManager, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatScrollManager'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; import { useTimelineStaging } from '@/hooks/useTimelineStaging'; @@ -42,6 +45,45 @@ const EMPTY_PERMISSIONS: PermissionRequest[] = []; const EMPTY_QUESTIONS: QuestionRequest[] = []; const IDLE_SESSION_STATUS = { type: 'idle' as const }; const SESSION_RESELECTED_EVENT = 'openchamber:session-reselected'; +const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.'; +const CHAT_SCROLL_STYLE = { overflowAnchor: 'none' } as const; +type SessionMessageRecord = { info: Message; parts: Part[] }; + +const getSessionMessageId = (message: SessionMessageRecord | undefined): string | null => { + const id = message?.info?.id; + return typeof id === 'string' && id.trim().length > 0 ? id : null; +}; + +const canFreezeDetachedViewport = ( + previous: SessionMessageRecord[], + next: SessionMessageRecord[], + streamingMessageId: string | null, +): boolean => { + if (!streamingMessageId || previous.length === 0 || next.length === 0) { + return false; + } + + if (next.length < previous.length) { + return false; + } + + if (next.length === previous.length) { + for (let index = 0; index < next.length - 1; index += 1) { + if (previous[index] !== next[index]) { + return false; + } + } + return getSessionMessageId(previous[previous.length - 1]) === getSessionMessageId(next[next.length - 1]); + } + + for (let index = 0; index < previous.length; index += 1) { + if (previous[index] !== next[index]) { + return false; + } + } + + return true; +}; type HydratingToolSkeletonRow = { id: string; @@ -49,6 +91,144 @@ type HydratingToolSkeletonRow = { detailWidth: string; }; +type ChatViewportProps = { + currentSessionId: string; + isDesktopExpandedInput: boolean; + isMobile: boolean; + stickyUserHeader: boolean; + scrollRef: React.RefObject; + messageListRef: React.RefObject; + 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 ( +
+
+ +
+ + {(sessionQuestions.length > 0 || sessionPermissions.length > 0) && ( +
+ {sessionQuestions.map((question) => ( + + ))} + {sessionPermissions.map((permission) => ( + + ))} +
+ )} + +
+ +
+ + + + +
+
+ ); +}, (prev, next) => { + return prev.currentSessionId === next.currentSessionId + && prev.isDesktopExpandedInput === next.isDesktopExpandedInput + && prev.isMobile === next.isMobile + && prev.stickyUserHeader === next.stickyUserHeader + && prev.scrollRef === next.scrollRef + && prev.messageListRef === next.messageListRef + && prev.turnStart === next.turnStart + && prev.pendingRevealWork === next.pendingRevealWork + && prev.renderedMessages === next.renderedMessages + && prev.hasMoreAboveTurns === next.hasMoreAboveTurns + && prev.isLoadingOlder === next.isLoadingOlder + && prev.sessionIsWorking === next.sessionIsWorking + && prev.streamingMessageId === next.streamingMessageId + && prev.retryOverlay === next.retryOverlay + && prev.handleMessageContentChange === next.handleMessageContentChange + && prev.getAnimationHandlers === next.getAnimationHandlers + && prev.handleLoadOlder === next.handleLoadOlder + && prev.scrollToBottom === next.scrollToBottom + && prev.sessionQuestions === next.sessionQuestions + && prev.sessionPermissions === next.sessionPermissions + && prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive; +}); + +ChatViewport.displayName = 'ChatViewport'; + const HYDRATING_SKELETON_ITEMS: Array<{ id: number; toolRows: HydratingToolSkeletonRow[]; @@ -163,6 +343,64 @@ export const ChatContainer: React.FC = () => { if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS; return flattenBlockingRequests(questionsMap, scopedSessionIds); }, [questionsMap, scopedSessionIds]); + const sessionIsWorking = React.useMemo(() => { + if (!currentSessionId || sessionPermissions.length > 0) { + return false; + } + + const statusType = sessionStatusForCurrent.type ?? 'idle'; + if (statusType === 'busy' || statusType === 'retry') { + return true; + } + + const lastMessage = sessionMessages[sessionMessages.length - 1]?.info as Message | undefined; + return Boolean( + lastMessage + && lastMessage.role === 'assistant' + && typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number', + ); + }, [currentSessionId, sessionMessages, sessionPermissions.length, sessionStatusForCurrent.type]); + const activeRetryStatus = React.useMemo(() => { + if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') { + return null; + } + + const rawMessage = typeof (sessionStatusForCurrent as { message?: string }).message === 'string' + ? (((sessionStatusForCurrent as { message?: string }).message) ?? '').trim() + : ''; + + return { + sessionId: currentSessionId, + message: rawMessage || DEFAULT_RETRY_MESSAGE, + confirmedAt: (sessionStatusForCurrent as { confirmedAt?: number }).confirmedAt, + }; + }, [currentSessionId, sessionStatusForCurrent]); + const [retryFallbackTimestamp, setRetryFallbackTimestamp] = React.useState(0); + const retryFallbackSessionRef = React.useRef(null); + + React.useEffect(() => { + if (!activeRetryStatus || typeof activeRetryStatus.confirmedAt === 'number') { + retryFallbackSessionRef.current = null; + setRetryFallbackTimestamp(0); + return; + } + + if (retryFallbackSessionRef.current !== activeRetryStatus.sessionId) { + retryFallbackSessionRef.current = activeRetryStatus.sessionId; + setRetryFallbackTimestamp(Date.now()); + } + }, [activeRetryStatus]); + + const retryOverlay = React.useMemo(() => { + if (!activeRetryStatus) { + return null; + } + + return { + ...activeRetryStatus, + fallbackTimestamp: retryFallbackTimestamp, + }; + }, [activeRetryStatus, retryFallbackTimestamp]); // History metadata — use sync's hasMore/isLoading const historyMeta = React.useMemo(() => { @@ -248,11 +486,36 @@ export const ChatContainer: React.FC = () => { onActiveTurnChange: handleActiveTurnChange, }); + const viewportMessagesRef = React.useRef(EMPTY_MESSAGES); + const viewportSessionIdRef = React.useRef(null); + const viewportMessages = React.useMemo(() => { + if (viewportSessionIdRef.current !== currentSessionId) { + viewportSessionIdRef.current = currentSessionId; + viewportMessagesRef.current = sessionMessages; + return sessionMessages; + } + + const shouldFreezeViewport = Boolean( + currentSessionId + && streamingMessageId + && !isPinned + && historyMeta?.loading !== true + && canFreezeDetachedViewport(viewportMessagesRef.current, sessionMessages, streamingMessageId), + ); + + if (shouldFreezeViewport) { + return viewportMessagesRef.current; + } + + viewportMessagesRef.current = sessionMessages; + return sessionMessages; + }, [currentSessionId, historyMeta?.loading, isPinned, sessionMessages, streamingMessageId]); + // Deferred timeline staging — renders 1 message on first paint, // adds 3 per rAF frame to avoid blocking. const { stagedMessages } = useTimelineStaging({ sessionKey: currentSessionId ?? '', - messages: sessionMessages, + messages: viewportMessages, }); const timelineController = useChatTimelineController({ @@ -266,12 +529,23 @@ export const ChatContainer: React.FC = () => { isPinned, isOverflowing, }); - const { resumeToBottomInstant } = timelineController; + const { loadEarlier, resumeToBottomInstant } = timelineController; React.useEffect(() => { activeTurnChangeRef.current = timelineController.handleActiveTurnChange; }, [timelineController.handleActiveTurnChange]); + React.useEffect(() => { + if (sessionPermissions.length === 0 && sessionQuestions.length === 0) { + return; + } + handleMessageContentChange('permission'); + }, [handleMessageContentChange, sessionPermissions, sessionQuestions]); + + const handleLoadOlder = React.useCallback(() => { + void loadEarlier(); + }, [loadEarlier]); + const navigation = useChatTurnNavigation({ sessionId: currentSessionId, turnIds: timelineController.turnIds, @@ -505,49 +779,29 @@ export const ChatContainer: React.FC = () => { style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined} > {returnToParentButton} -
-
- -
- { - void timelineController.loadEarlier(); - }} - scrollToBottom={scrollToBottom} - scrollRef={scrollRef} - /> -
-
- -
-
+
{ } }; -export const ChatInput: React.FC = ({ onOpenSettings, scrollToBottom }) => { +const ChatInputComponent: React.FC = ({ onOpenSettings, scrollToBottom }) => { // Track if we restored a draft on mount (for text selection) const initialDraftRef = React.useRef(null); // Track initial session ID (captured at mount time for draft restoration) @@ -749,7 +748,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled); const isExpandedInput = useUIStore((state) => state.isExpandedInput); const setExpandedInput = useUIStore((state) => state.setExpandedInput); - const { working } = useAssistantStatus(); const { git: runtimeGit } = useRuntimeAPIs(); const { currentTheme } = useThemeSystem(); const chatSearchDirectory = useChatSearchDirectory(); @@ -973,24 +971,9 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); const hasDrafts = draftCount > 0; - // User message history for up/down arrow navigation - // Get raw messages from store (stable reference) - const sessionMessages = useSessionMessageRecords(currentSessionId ?? ""); - // Derive user message history with useMemo to avoid infinite re-renders - const userMessageHistory = React.useMemo(() => { - if (!sessionMessages || !currentSessionId) return []; - return sessionMessages - .filter((m) => m.info.role === 'user') - .map((m) => { - const textPart = m.parts.find((p) => p.type === 'text'); - if (textPart && 'text' in textPart) { - return String(textPart.text); - } - return ''; - }) - .filter((text) => text.length > 0) - .reverse(); // Most recent first - }, [sessionMessages, currentSessionId]); + // User message history for up/down arrow navigation. + // Keep this on a narrow hook instead of full session message records. + const userMessageHistory = useUserMessageHistory(currentSessionId ?? ""); // Keep messageRef in sync with message state React.useEffect(() => { @@ -1248,7 +1231,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const hasQueuedMessages = queuedMessages.length > 0; const canSend = hasContent || hasQueuedMessages; - const canAbort = working.isWorking; + const canAbort = sessionPhase !== 'idle'; // Keep a ref to handleSubmit so callbacks don't depend on it. type SubmitOptions = { @@ -3135,10 +3118,8 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }); }, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]); - const workingStatusText = working.statusText; - React.useEffect(() => { - const pendingAbortBanner = Boolean(working.wasAborted); + const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId; if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) { startAbortIndicator(); if (currentSessionId) { @@ -3147,11 +3128,11 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } prevWasAbortedRef.current = pendingAbortBanner; }, [ + abortPromptSessionId, acknowledgeSessionAbort, currentSessionId, showAbortStatus, startAbortIndicator, - working.wasAborted, ]); React.useEffect(() => { @@ -3298,13 +3279,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo
)} = ({ onOpenSettings, scrollToBo ); }; + +ChatInputComponent.displayName = 'ChatInput'; + +export const ChatInput = React.memo(ChatInputComponent); diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index a79ab3fb..2394219e 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1,30 +1,35 @@ import React from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; +import { measureElement as measureVirtualElement, type VirtualItem, useVirtualizer } from '@tanstack/react-virtual'; import ChatMessage from './ChatMessage'; import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual } from './message/renderCompare'; -import { PermissionCard } from './PermissionCard'; -import { QuestionCard } from './QuestionCard'; import TurnItem from './components/TurnItem'; -import TurnList from './components/TurnList'; -import type { PermissionRequest } from '@/types/permission'; -import type { QuestionRequest } from '@/types/question'; import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager'; -import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { filterSyntheticParts } from '@/lib/messages/synthetic'; import type { ChatMessageEntry, TurnRecord, TurnGroupingContext } from './lib/turns/types'; import { useTurnRecords } from './hooks/useTurnRecords'; import { applyRetryOverlay } from './lib/turns/applyRetryOverlay'; import { useUIStore } from '@/stores/useUIStore'; -import { useStreamingStore } from '@/sync/streaming'; -import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSessionStatus } from '@/sync/sync-context'; -import { useDeviceInfo } from '@/lib/device'; import { FadeInDisabledProvider } from './message/FadeInOnReveal'; import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation'; -import { StatusRowContainer } from './StatusRowContainer'; import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug'; +const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 40; +const MESSAGE_LIST_OVERSCAN = 6; + +const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => { + if (!entry) { + return 160; + } + + if (entry.kind === 'turn') { + return 180 + Math.min(entry.turn.assistantMessages.length, 4) * 100; + } + + return 140; +}; + const useStableEvent = (handler: (...args: TArgs) => TResult) => { const handlerRef = React.useRef(handler); React.useEffect(() => { @@ -297,8 +302,14 @@ interface MessageListProps { turnStart: number; disableStaging?: boolean; messages: ChatMessageEntry[]; - permissions: PermissionRequest[]; - questions: QuestionRequest[]; + sessionIsWorking?: boolean; + activeStreamingMessageId?: string | null; + retryOverlay?: { + sessionId: string; + message: string; + confirmedAt?: number; + fallbackTimestamp?: number; + } | null; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; hasMoreAbove: boolean; @@ -884,21 +895,24 @@ function areMessageListEntryPropsEqual(prevProps: MessageListEntryProps, nextPro } // Inner component that renders staged turn entries. -const MessageListContent: React.FC<{ +const StaticHistoryList: React.FC<{ entries: RenderEntry[]; + shouldVirtualize: boolean; + virtualRows: VirtualItem[]; + totalSize: number; + measureElement: (element: HTMLDivElement | null) => void; + contentRef: React.RefObject; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; stickyUserHeader: boolean; - sessionIsWorking: boolean; defaultActivityExpanded: boolean; turnUiStates: Map; onToggleTurnGroup: (turnId: string) => void; chatRenderMode: 'sorted' | 'live'; shouldAnimateUserMessage: (message: ChatMessageEntry) => boolean; onUserAnimationConsumed: (messageId: string) => void; - activeStreamingMessageId?: string | null; -}> = ({ entries, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, sessionIsWorking, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed, activeStreamingMessageId }) => { +}> = React.memo(({ entries, shouldVirtualize, virtualRows, totalSize, measureElement, contentRef, onMessageContentChange, getAnimationHandlers, scrollToBottom, stickyUserHeader, defaultActivityExpanded, turnUiStates, onToggleTurnGroup, chatRenderMode, shouldAnimateUserMessage, onUserAnimationConsumed }) => { const renderEntry = React.useCallback((entry: RenderEntry) => { return ( ); - }, [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 ( +
+ {entries.map((entry) => ( +
+ {renderEntry(entry)} +
+ ))} +
+ ); + } return ( - +
+ {paddingTop > 0 ? ); }; + +OverlayScrollbarComponent.displayName = "OverlayScrollbar"; + +export const OverlayScrollbar = OverlayScrollbarComponent; diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index ebb1bad5..64b2131b 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -99,6 +99,8 @@ export const useChatScrollManager = ({ const [showScrollButton, setShowScrollButton] = React.useState(false); const [isPinned, setIsPinned] = React.useState(true); const [isOverflowing, setIsOverflowing] = React.useState(false); + const showScrollButtonRef = React.useRef(false); + const isOverflowingRef = React.useRef(false); const lastSessionIdRef = React.useRef(null); const suppressUserScrollUntilRef = React.useRef(0); @@ -130,6 +132,20 @@ export const useChatScrollManager = ({ } }, []); + const setShowScrollButtonState = React.useCallback((next: boolean) => { + showScrollButtonRef.current = next; + setShowScrollButton((previous) => (previous === next ? previous : next)); + }, []); + + const setIsOverflowingState = React.useCallback((next: boolean) => { + isOverflowingRef.current = next; + setIsOverflowing((previous) => (previous === next ? previous : next)); + }, []); + + const shouldSkipLiveContentSync = React.useCallback(() => { + return !isPinnedRef.current && showScrollButtonRef.current && isOverflowingRef.current; + }, []); + const scrollToBottomInternal = React.useCallback((options?: { instant?: boolean; followBottom?: boolean }) => { const container = scrollRef.current; if (!container) return; @@ -151,22 +167,22 @@ export const useChatScrollManager = ({ const updateScrollButtonVisibility = React.useCallback(() => { const container = scrollRef.current; if (!container) { - setShowScrollButton(false); - setIsOverflowing(false); + setShowScrollButtonState(false); + setIsOverflowingState(false); return; } const hasScrollableContent = container.scrollHeight > container.clientHeight; - setIsOverflowing(hasScrollableContent); + setIsOverflowingState(hasScrollableContent); if (!hasScrollableContent) { - setShowScrollButton(false); + setShowScrollButtonState(false); return; } // Show scroll button when scrolled above the 10vh threshold const distanceFromBottom = getDistanceFromBottom(); - setShowScrollButton(!isNearBottom(distanceFromBottom, getPinThreshold())); - }, [getDistanceFromBottom, getPinThreshold]); + setShowScrollButtonState(!isNearBottom(distanceFromBottom, getPinThreshold())); + }, [getDistanceFromBottom, getPinThreshold, setIsOverflowingState, setShowScrollButtonState]); const syncPinnedStateAndIndicators = React.useCallback(() => { pinnedSyncRafRef.current = null; @@ -267,8 +283,8 @@ export const useChatScrollManager = ({ updatePinnedState(true); scrollToBottomInternal(options); - setShowScrollButton(false); - }, [scrollToBottomInternal, updatePinnedState]); + setShowScrollButtonState(false); + }, [scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]); const releasePinnedScroll = React.useCallback(() => { scrollEngine.cancelFollow(); @@ -435,22 +451,25 @@ export const useChatScrollManager = ({ // Always start pinned at bottom on session switch preferInstantPinRef.current = true; updatePinnedState(true); - setShowScrollButton(false); + setShowScrollButtonState(false); const container = scrollRef.current; if (container) { markProgrammaticScroll(); scrollToBottomInternal({ instant: true }); } - }, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, updatePinnedState]); + }, [currentSessionId, flushViewportAnchor, markProgrammaticScroll, scrollToBottomInternal, setShowScrollButtonState, updatePinnedState]); // Maintain pin-to-bottom when content changes React.useEffect(() => { if (isSyncing) { return; } + if (shouldSkipLiveContentSync()) { + return; + } schedulePinnedStateAndIndicators(); - }, [isSyncing, schedulePinnedStateAndIndicators, sessionMessages.length]); + }, [isSyncing, schedulePinnedStateAndIndicators, sessionMessages.length, shouldSkipLiveContentSync]); // Use ResizeObserver to detect content changes and maintain pin React.useEffect(() => { @@ -499,44 +518,49 @@ export const useChatScrollManager = ({ return; } + if (scrollHeightChanged && shouldSkipLiveContentSync()) { + return; + } + schedulePinnedStateAndIndicators(); }); observer.observe(container); - // Also observe children for content changes - const childObserver = new MutationObserver(() => { - schedulePinnedStateAndIndicators(); - }); - - childObserver.observe(container, { childList: true, subtree: true }); - return () => { observer.disconnect(); - childObserver.disconnect(); }; - }, [schedulePinnedStateAndIndicators, updateScrollButtonVisibility]); + }, [markProgrammaticScroll, schedulePinnedStateAndIndicators, shouldSkipLiveContentSync, updateScrollButtonVisibility]); React.useEffect(() => { if (typeof window === 'undefined') { + if (shouldSkipLiveContentSync()) { + return; + } schedulePinnedStateAndIndicators(); return; } const rafId = window.requestAnimationFrame(() => { + if (shouldSkipLiveContentSync()) { + return; + } schedulePinnedStateAndIndicators(); }); return () => { window.cancelAnimationFrame(rafId); }; - }, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessages.length]); + }, [currentSessionId, schedulePinnedStateAndIndicators, sessionMessages.length, shouldSkipLiveContentSync]); const animationHandlersRef = React.useRef>(new Map()); const handleMessageContentChange = React.useCallback(() => { + if (shouldSkipLiveContentSync()) { + return; + } schedulePinnedStateAndIndicators(); - }, [schedulePinnedStateAndIndicators]); + }, [schedulePinnedStateAndIndicators, shouldSkipLiveContentSync]); const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => { const existing = animationHandlersRef.current.get(messageId); @@ -546,6 +570,9 @@ export const useChatScrollManager = ({ const handlers: AnimationHandlers = { onChunk: () => { + if (shouldSkipLiveContentSync()) { + return; + } schedulePinnedStateAndIndicators(); }, onComplete: () => { @@ -554,6 +581,9 @@ export const useChatScrollManager = ({ onStreamingCandidate: () => {}, onAnimationStart: () => {}, onAnimatedHeightChange: () => { + if (shouldSkipLiveContentSync()) { + return; + } schedulePinnedStateAndIndicators(); }, onReservationCancelled: () => {}, @@ -562,7 +592,7 @@ export const useChatScrollManager = ({ animationHandlersRef.current.set(messageId, handlers); return handlers; - }, [schedulePinnedStateAndIndicators]); + }, [schedulePinnedStateAndIndicators, shouldSkipLiveContentSync]); React.useEffect(() => { return () => { diff --git a/packages/ui/src/hooks/useVoiceContext.ts b/packages/ui/src/hooks/useVoiceContext.ts index 1e83273d..bf1ddb16 100644 --- a/packages/ui/src/hooks/useVoiceContext.ts +++ b/packages/ui/src/hooks/useVoiceContext.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSessionMessageRecords, useSessionPermissions } from '@/sync/sync-context'; +import { useSessionPermissions, useSessionTextMessages } from '@/sync/sync-context'; import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice'; /** @@ -9,7 +9,7 @@ import { voiceHooks, isVoiceSessionStarted } from '@/lib/voice'; */ export function useVoiceContext() { const currentSessionId = useSessionUIStore((s) => s.currentSessionId); - const messages = useSessionMessageRecords(currentSessionId ?? ''); + const messages = useSessionTextMessages(currentSessionId ?? ''); const permissions = useSessionPermissions(currentSessionId ?? ''); // Track last seen message count to only forward new messages @@ -26,10 +26,9 @@ export function useVoiceContext() { const newMessages = messages.slice(lastMessageCountRef.current); lastMessageCountRef.current = currentCount; - // Format for voice hooks (extract role and content) const formattedMessages = newMessages.map(m => ({ - role: m.info.role, - content: m.parts.map((p: Record) => ('text' in p ? p.text : '')).join('') + role: m.role ?? '', + content: m.text, })); voiceHooks.onMessages(currentSessionId, formattedMessages); diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index b8128c54..2113bbf7 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -667,17 +667,6 @@ const derivePrVisualState = (status: GitHubPullRequestStatus | null): string | n return 'open'; }; -const prVisualPriority = (state: string): number => { - switch (state) { - case 'open': return 5; - case 'blocked': return 4; - case 'draft': return 3; - case 'merged': return 2; - case 'closed': return 1; - default: return 0; - } -}; - const deriveSummary = (entry: PrStatusEntry): PrVisualSummary | null => { const vs = derivePrVisualState(entry.status ?? null); const pr = entry.status?.pr; diff --git a/packages/ui/src/sync/index.ts b/packages/ui/src/sync/index.ts index ae915772..74205c35 100644 --- a/packages/ui/src/sync/index.ts +++ b/packages/ui/src/sync/index.ts @@ -59,6 +59,7 @@ export { useDirectoryStore, useDirectorySync, useSessionMessages, + useSessionMessagesResolved, useSessionParts, useSessionStatus, useSessionPermissions, @@ -68,6 +69,8 @@ export { useSyncDirectory, useChildStoreManager, useSessionMessageRecords, + useSessionTextMessages, + useUserMessageHistory, } from "./sync-context" // Sync operations diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index f5d98eef..4ed9092b 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -642,6 +642,17 @@ export function useVisibleSessionMessages(sessionID: string, directory?: string) }, [messages, revertMessageID]) } +/** Check whether the message list for a session has been loaded into sync state. */ +export function useSessionMessagesResolved(sessionID: string, directory?: string): boolean { + return useDirectorySync( + useCallback((state: State) => { + if (!sessionID) return false + return Object.prototype.hasOwnProperty.call(state.message, sessionID) + }, [sessionID]), + directory, + ) +} + /** Get parts for a specific message */ export function useSessionParts(messageID: string, directory?: string) { return useDirectorySync( @@ -836,24 +847,42 @@ export function useChildStoreManager() { return useSyncSystem().childStores } -/** - * Get messages for a session in the old {info, parts}[] format. - * Uses visible messages (filtered by revert state). - * - * Uses a ref-stable parts lookup that only triggers re-renders when - * a part array for one of our displayed messages actually changes. - */ -export function useSessionMessageRecords(sessionID: string, directory?: string) { - const messages = useVisibleSessionMessages(sessionID, directory) - const store = useDirectoryStore(directory) +const MESSAGE_PART_SNAPSHOT_THROTTLE_MS = 100 - // Track parts with a ref to avoid subscribing to entire state.part map. - // Re-derive only when messages list changes or on store subscription. +export type SessionTextMessage = { + id: string + role: string | null + text: string +} + +const getPartText = (part: Part): string => { + if (part?.type !== "text") return "" + const text = (part as { text?: unknown }).text + return typeof text === "string" ? text : "" +} + +const getConcatenatedTextFromParts = (parts: Part[]): string => { + let text = "" + for (const part of parts) { + text += getPartText(part) + } + return text +} + +const getFirstTextFromParts = (parts: Part[]): string => { + for (const part of parts) { + const text = getPartText(part) + if (text.length > 0) return text + } + return "" +} + +function usePartsSnapshotForMessageIds(messageIds: string[], directory?: string) { + const store = useDirectoryStore(directory) const prevPartsRef = useRef>({}) const [partsSnapshot, setPartsSnapshot] = React.useState>({}) React.useEffect(() => { - const messageIds = messages.map((m) => m.id) let timer: ReturnType | null = null let pending = false @@ -866,7 +895,6 @@ export function useSessionMessageRecords(sessionID: string, directory?: string) const next: Record = {} for (const id of messageIds) { const parts = state.part[id] ?? EMPTY_PARTS - // Preserve existing reference if parts haven't changed in the store next[id] = prev[id] === parts ? prev[id] : parts if (next[id] !== prev[id]) changed = true } @@ -876,10 +904,8 @@ export function useSessionMessageRecords(sessionID: string, directory?: string) } } - // Initial sync flush() - // Throttled subscription — batch rapid delta events into ~100ms updates const unsub = store.subscribe(() => { if (timer) { pending = true @@ -889,26 +915,105 @@ export function useSessionMessageRecords(sessionID: string, directory?: string) flush() if (pending) { pending = false - timer = setTimeout(flush, 100) + timer = setTimeout(flush, MESSAGE_PART_SNAPSHOT_THROTTLE_MS) } - }, 100) + }, MESSAGE_PART_SNAPSHOT_THROTTLE_MS) }) return () => { unsub() if (timer) clearTimeout(timer) } - }, [messages, store]) + }, [messageIds, store]) + + return partsSnapshot +} + +export function useSessionTextMessages(sessionID: string, directory?: string): SessionTextMessage[] { + const messages = useVisibleSessionMessages(sessionID, directory) + const messageIds = useMemo(() => messages.map((message) => message.id), [messages]) + const partsSnapshot = usePartsSnapshotForMessageIds(messageIds, directory) return useMemo( - () => messages.map((msg) => ({ - info: msg, - parts: partsSnapshot[msg.id] ?? EMPTY_PARTS, + () => messages.map((message) => ({ + id: message.id, + role: typeof message.role === "string" ? message.role : null, + text: getConcatenatedTextFromParts(partsSnapshot[message.id] ?? EMPTY_PARTS), })), [messages, partsSnapshot], ) } +export function useUserMessageHistory(sessionID: string, directory?: string): string[] { + const messages = useVisibleSessionMessages(sessionID, directory) + const userMessages = useMemo( + () => messages.filter((message) => message.role === "user"), + [messages], + ) + const userMessageIds = useMemo(() => userMessages.map((message) => message.id), [userMessages]) + const partsSnapshot = usePartsSnapshotForMessageIds(userMessageIds, directory) + + return useMemo(() => { + const history: string[] = [] + for (let index = userMessages.length - 1; index >= 0; index -= 1) { + const message = userMessages[index] + const text = getFirstTextFromParts(partsSnapshot[message.id] ?? EMPTY_PARTS) + if (text.length > 0) { + history.push(text) + } + } + return history + }, [partsSnapshot, userMessages]) +} + +/** + * Get messages for a session in the old {info, parts}[] format. + * Uses visible messages (filtered by revert state). + * + * Uses a ref-stable parts lookup that only triggers re-renders when + * a part array for one of our displayed messages actually changes. + */ +export function useSessionMessageRecords(sessionID: string, directory?: string) { + const messages = useVisibleSessionMessages(sessionID, directory) + const messageIds = useMemo(() => messages.map((message) => message.id), [messages]) + const partsSnapshot = usePartsSnapshotForMessageIds(messageIds, directory) + const previousRecordsRef = useRef<{ + list: Array<{ info: (typeof messages)[number]; parts: Part[] }> + byId: Map + }>({ + list: [], + byId: new Map(), + }) + + return useMemo(() => { + const previous = previousRecordsRef.current + const nextById = new Map() + const nextList = messages.map((message) => { + const parts = partsSnapshot[message.id] ?? EMPTY_PARTS + const previousRecord = previous.byId.get(message.id) + const record = previousRecord && previousRecord.info === message && previousRecord.parts === parts + ? previousRecord + : { info: message, parts } + nextById.set(message.id, record) + return record + }) + + const unchanged = previous.list.length === nextList.length + && previous.list.every((record, index) => record === nextList[index]) + + if (unchanged) { + return previous.list + } + + previousRecordsRef.current = { + list: nextList, + byId: nextById, + } + + return nextList + }, [messages, partsSnapshot]) +} + /** * Determines if a session is actively working. * Checks session_status and only falls back to incomplete assistant messages diff --git a/packages/web/server/index.js b/packages/web/server/index.js index faf54815..c1aabfd1 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -339,6 +339,7 @@ let isExternalOpenCode = false; let exitOnShutdown = true; let uiAuthController = null; let activeTunnelController = null; +let globalWatcherStartPromise = null; const tunnelProviderRegistry = createTunnelProviderRegistry([ createCloudflareTunnelProvider(), ]); @@ -739,13 +740,24 @@ const waitForOpenCodeReady = (...args) => openCodeLifecycleRuntime.waitForOpenCo const waitForAgentPresence = (...args) => openCodeLifecycleRuntime.waitForAgentPresence(...args); const refreshOpenCodeAfterConfigChange = (...args) => openCodeLifecycleRuntime.refreshOpenCodeAfterConfigChange(...args); const startHealthMonitoring = () => openCodeLifecycleRuntime.startHealthMonitoring(HEALTH_CHECK_INTERVAL); +const ensureGlobalWatcherStarted = async () => { + if (globalWatcherStartPromise) { + return globalWatcherStartPromise; + } + + globalWatcherStartPromise = openCodeWatcherRuntime.start().catch((error) => { + globalWatcherStartPromise = null; + throw error; + }); + + return globalWatcherStartPromise; +}; const bootstrapOpenCodeAtStartup = async (...args) => { await openCodeLifecycleRuntime.bootstrapOpenCodeAtStartup(...args); scheduleOpenCodeApiDetection(); - startHealthMonitoring(); - void openCodeWatcherRuntime.start().catch((error) => { - console.warn(`Global event watcher startup failed: ${error?.message || error}`); - }); + if (openCodeLifecycleState.openCodeProcess && !openCodeLifecycleState.isExternalOpenCode) { + startHealthMonitoring(); + } }; const killProcessOnPort = (...args) => openCodeLifecycleRuntime.killProcessOnPort(...args); @@ -871,6 +883,7 @@ async function main(options = {}) { resolveZenModel, sayTTSCapability, ensurePushInitialized, + ensureGlobalWatcherStarted, getOrCreateVapidKeys, getUiSessionTokenFromRequest, writeSettingsToDisk, diff --git a/packages/web/server/lib/notifications/routes.js b/packages/web/server/lib/notifications/routes.js index 7be9883c..de550285 100644 --- a/packages/web/server/lib/notifications/routes.js +++ b/packages/web/server/lib/notifications/routes.js @@ -26,6 +26,7 @@ export const registerNotificationRoutes = (app, dependencies) => { const { uiAuthController, ensurePushInitialized, + ensureGlobalWatcherStarted, getOrCreateVapidKeys, getUiSessionTokenFromRequest, readSettingsFromDiskMigrated, @@ -45,6 +46,17 @@ export const registerNotificationRoutes = (app, dependencies) => { setPushInitialized, } = dependencies; + const ensureSessionWatcher = async () => { + if (typeof ensureGlobalWatcherStarted !== 'function') { + return; + } + try { + await ensureGlobalWatcherStarted(); + } catch (error) { + console.warn('[OpenCodeWatcher] lazy start failed:', error?.message ?? error); + } + }; + app.get('/api/push/vapid-public-key', async (_req, res) => { try { await ensurePushInitialized(); @@ -58,6 +70,7 @@ export const registerNotificationRoutes = (app, dependencies) => { app.post('/api/push/subscribe', async (req, res) => { await ensurePushInitialized(); + await ensureSessionWatcher(); const uiToken = uiAuthController?.ensureSessionToken ? await uiAuthController.ensureSessionToken(req, res) @@ -146,10 +159,12 @@ export const registerNotificationRoutes = (app, dependencies) => { }); app.get('/api/session-activity', (_req, res) => { + void ensureSessionWatcher(); res.json(getSessionActivitySnapshot()); }); - app.get('/api/sessions/snapshot', (_req, res) => { + app.get('/api/sessions/snapshot', async (_req, res) => { + await ensureSessionWatcher(); res.json({ statusSessions: getSessionStateSnapshot(), attentionSessions: getSessionAttentionSnapshot(), @@ -157,7 +172,8 @@ export const registerNotificationRoutes = (app, dependencies) => { }); }); - app.get('/api/sessions/status', (_req, res) => { + app.get('/api/sessions/status', async (_req, res) => { + await ensureSessionWatcher(); const snapshot = getSessionStateSnapshot(); res.json({ sessions: snapshot, @@ -165,7 +181,8 @@ export const registerNotificationRoutes = (app, dependencies) => { }); }); - app.get('/api/sessions/:id/status', (req, res) => { + app.get('/api/sessions/:id/status', async (req, res) => { + await ensureSessionWatcher(); const sessionId = req.params.id; const state = getSessionState(sessionId); @@ -182,7 +199,8 @@ export const registerNotificationRoutes = (app, dependencies) => { }); }); - app.get('/api/sessions/attention', (_req, res) => { + app.get('/api/sessions/attention', async (_req, res) => { + await ensureSessionWatcher(); const snapshot = getSessionAttentionSnapshot(); res.json({ sessions: snapshot, @@ -190,7 +208,8 @@ export const registerNotificationRoutes = (app, dependencies) => { }); }); - app.get('/api/sessions/:id/attention', (req, res) => { + app.get('/api/sessions/:id/attention', async (req, res) => { + await ensureSessionWatcher(); const sessionId = req.params.id; const state = getSessionAttentionState(sessionId); diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js index 30fc693e..f70111d2 100644 --- a/packages/web/server/lib/opencode/bootstrap-runtime.js +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -25,6 +25,7 @@ export const createBootstrapRuntime = (dependencies) => { resolveZenModel, sayTTSCapability, ensurePushInitialized, + ensureGlobalWatcherStarted, getOrCreateVapidKeys, getUiSessionTokenFromRequest, writeSettingsToDisk, @@ -74,6 +75,7 @@ export const createBootstrapRuntime = (dependencies) => { registerNotificationRoutes(app, { uiAuthController, ensurePushInitialized, + ensureGlobalWatcherStarted, getOrCreateVapidKeys, getUiSessionTokenFromRequest, readSettingsFromDiskMigrated,