diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 5a4651a2..b598a568 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -35,9 +35,11 @@ import { useSessionMessageRecords, useSessions, useDirectorySync, + useSyncDirectory, useSessionStatus, } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; +import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache'; import { getSessionMaterializationStatus } from '@/sync/materialization'; import { usePlanDetection } from '@/hooks/usePlanDetection'; import { getAllSyncSessions } from '@/sync/sync-refs'; @@ -342,6 +344,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr // Sync actions const sync = useSync(); + const syncDirectory = useSyncDirectory(); const ensureSessionRenderable = React.useCallback( (sessionId: string) => sync.ensureSessionRenderable(sessionId), [sync], @@ -384,12 +387,25 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr // Messages from sync system const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; + const sessionPrefetchInfo = React.useSyncExternalStore( + React.useCallback( + (notify) => currentSessionId + ? subscribeSessionPrefetch(syncDirectory, currentSessionId, notify) + : () => undefined, + [currentSessionId, syncDirectory], + ), + React.useCallback( + () => currentSessionId ? getSessionPrefetch(syncDirectory, currentSessionId) : undefined, + [currentSessionId, syncDirectory], + ), + React.useCallback(() => undefined, []), + ); // Sessions from sync system const sessions = useSessions(); // Plan detection - watches messages for plan creation and signals store - usePlanDetection(currentSessionId ?? ''); + usePlanDetection(currentSessionId ?? '', sessionMessages); // Session status from sync system const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '') ?? IDLE_SESSION_STATUS; @@ -494,12 +510,13 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr // History metadata — use sync's hasMore/isLoading const historyMeta = React.useMemo(() => { if (!currentSessionId) return null; + const prefetchHasMore = Boolean(sessionPrefetchInfo?.cursor) && sessionPrefetchInfo?.complete !== true; return { limit: sessionMessages.length, - complete: !sync.hasMore(currentSessionId), + complete: !(sync.hasMore(currentSessionId) || prefetchHasMore), loading: sync.isLoading(currentSessionId), }; - }, [currentSessionId, sessionMessages.length, sync]); + }, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]); const { isMobile } = useDeviceInfo(); const draftOpen = Boolean(newSessionDraft?.open); @@ -868,6 +885,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr
{returnToParentButton} { const themeSystem = useOptionalThemeSystem(); @@ -714,6 +715,8 @@ const normalizeCodeBlockText = (code: string, language: string): string => { }; const CODE_HIGHLIGHT_SETTLE_MS = 300; +const CODE_HIGHLIGHT_LINE_LIMIT = 1200; +const VSCODE_CODE_HIGHLIGHT_LINE_LIMIT = 200; const CODE_SHARED_STYLE: React.CSSProperties = { margin: 0, background: 'transparent', @@ -722,6 +725,23 @@ const CODE_SHARED_STYLE: React.CSSProperties = { lineHeight: 'var(--markdown-code-block-line-height)', }; +const exceedsLineLimit = (value: string, limit: number): boolean => { + let lineCount = 1; + for (let index = 0; index < value.length; index += 1) { + if (value.charCodeAt(index) === 10) { + lineCount += 1; + if (lineCount > limit) { + return true; + } + } + } + return false; +}; + +const getCodeHighlightLineLimit = (): number => ( + isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT +); + const downloadTextFile = (content: string, filename: string, mimeType: string) => { if (typeof window === 'undefined') { return; @@ -753,6 +773,7 @@ const MarkdownCodeBlock: React.FC<{ const prevCodeRef = React.useRef(code); const timerRef = React.useRef | null>(null); const { isMobile, isTablet } = useDeviceInfo(); + const skipHighlight = exceedsLineLimit(code, getCodeHighlightLineLimit()); const canPreview = language === 'html' || language === 'htm'; @@ -852,7 +873,7 @@ const MarkdownCodeBlock: React.FC<{
) : (
- {highlight ? ( + {highlight && !skipHighlight ? ( >(); let activeFileReferenceStatCount = 0; const pendingFileReferenceStats: Array<() => void> = []; +const getFileReferenceStatCacheMax = (): number => ( + isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX +); + +const getFileReferenceLinkLimit = (): number => ( + isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT +); + type ParsedFileReference = { path: string; line?: number; @@ -1300,6 +1333,8 @@ const fileReferenceExists = (resolvedPath: string): Promise => { const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath); if (cached) { + FILE_REFERENCE_STAT_CACHE.delete(normalizedPath); + FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached); return cached; } @@ -1326,6 +1361,14 @@ const fileReferenceExists = (resolvedPath: string): Promise => { pendingFileReferenceStats.push(run); }); + const maxCacheEntries = getFileReferenceStatCacheMax(); + while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) { + const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value; + if (typeof oldest !== 'string') { + break; + } + FILE_REFERENCE_STAT_CACHE.delete(oldest); + } FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request); return request; }; @@ -1362,6 +1405,7 @@ const useFileReferenceInteractions = ({ return; } let cancelled = false; + const fileReferenceLinkLimit = getFileReferenceLinkLimit(); const clearFileLinkAttributes = (candidate: HTMLElement) => { candidate.removeAttribute('data-openchamber-file-link'); @@ -1376,18 +1420,37 @@ const useFileReferenceInteractions = ({ } }; + const clearAnnotatedFileLinks = () => { + const annotated = container.querySelectorAll(FILE_LINK_SELECTOR); + for (const candidate of Array.from(annotated)) { + clearFileLinkAttributes(candidate); + } + }; + + if (!enabled) { + clearAnnotatedFileLinks(); + return; + } + const annotateFileLinks = () => { const candidates = container.querySelectorAll('[data-markdown="inline-code"], a'); + let linkedCount = 0; for (const candidate of Array.from(candidates)) { const rawCandidate = extractPathCandidateFromElement(candidate); const resolved = getResolvedReference(rawCandidate, effectiveDirectory); clearFileLinkAttributes(candidate); - if (!enabled || !resolved) { + if (!resolved) { continue; } + if (linkedCount >= fileReferenceLinkLimit) { + continue; + } + + linkedCount += 1; + void fileReferenceExists(resolved.resolvedPath).then((exists) => { if (cancelled || !exists || !container.contains(candidate)) { return; diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 5a12b3a0..a1400d95 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -984,7 +984,7 @@ const StaticHistoryList: React.FC<{ ? Math.max(0, totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0)) : 0; - if (!shouldVirtualize || (virtualRows.length === 0 && entries.length > 0)) { + if (!shouldVirtualize) { return (
{entries.map((entry) => ( @@ -999,6 +999,27 @@ const StaticHistoryList: React.FC<{ ); } + if (virtualRows.length === 0 && entries.length > 0) { + const fallbackStart = Math.max(0, entries.length - MESSAGE_LIST_OVERSCAN * 2); + const fallbackEntries = entries.slice(fallbackStart); + const fallbackHeight = fallbackEntries.reduce((total, entry) => total + estimateHistoryEntryHeight(entry), 0); + const fallbackPaddingTop = Math.max(0, totalSize - fallbackHeight); + + return ( +
+ {fallbackPaddingTop > 0 ? + ); + } + return (
{paddingTop > 0 ?