From 538033e47850d082c38bdf2ca68ccc460fad9983 Mon Sep 17 00:00:00 2001 From: Emt-lin <41323133+Emt-lin@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:52:35 +0800 Subject: [PATCH] File status and think block state (#2087) * feat: add file processing status display in context manage modal. * fix: preserve think block collapse state across streaming -> history transition. # Conflicts: # src/components/chat-components/ChatSingleMessage.tsx * fix: improve tool call root and think block state handling during streaming transitions - Add container reference to ToolCallRootRecord for detecting container changes - Handle container mismatch when streaming component unmounts and history mounts - Use container.isConnected for stale cleanup instead of timestamp-only approach - Prevent double toggle on think blocks by handling state in pointerdown * fix: prevent current project file status from falling through to cached check For the current project, if a file is not in processing/failed/success real-time state, it should show "Not started" instead of incorrectly showing "Processed" based on cached fileContexts entries. --- src/components/Chat.tsx | 26 ++- .../chat-components/ChatMessages.tsx | 6 +- .../chat-components/ChatSingleMessage.tsx | 112 +++++++++++- .../chat-components/collapsibleStateUtils.ts | 128 ++++++++++++++ .../chat-components/toolCallRootManager.tsx | 134 ++++++++++++-- .../modals/project/context-manage-modal.tsx | 167 +++++++++++++++++- 6 files changed, 543 insertions(+), 30 deletions(-) create mode 100644 src/components/chat-components/collapsibleStateUtils.ts diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index a6b033a7..6c51ab81 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -46,6 +46,7 @@ import { arrayBufferToBase64 } from "@/utils/base64"; import { Notice, TFile } from "obsidian"; import { ContextManageModal } from "@/components/modals/project/context-manage-modal"; import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover"; import { useActiveWebTabState } from "@/components/chat-components/hooks/useActiveWebTabState"; @@ -81,14 +82,22 @@ const ChatInternal: React.FC(null); const abortControllerRef = useRef(null); + // Stable ID for streaming message, shared with final persisted message + // This allows collapsible UI state (think blocks) to persist across streaming -> history + const streamingMessageIdRef = useRef(null); - // Wrapper for addMessage that tracks token usage from AI responses + // Wrapper for addMessage that attaches streaming ID and tracks token usage const addMessage = useCallback( (message: ChatMessage) => { - rawAddMessage(message); - // Track token usage from AI messages - if (message.sender === AI_SENDER && message.responseMetadata?.tokenUsage?.totalTokens) { - setLatestTokenCount(message.responseMetadata.tokenUsage.totalTokens); + // Attach streaming ID to final AI message so it shares the same ID as streaming placeholder + const streamingId = streamingMessageIdRef.current; + const shouldAttachId = + streamingId && message.sender === AI_SENDER && !message.isErrorMessage && !message.id; + const messageToAdd = shouldAttachId ? { ...message, id: streamingId } : message; + + rawAddMessage(messageToAdd); + if (messageToAdd.sender === AI_SENDER && messageToAdd.responseMetadata?.tokenUsage?.totalTokens) { + setLatestTokenCount(messageToAdd.responseMetadata.tokenUsage.totalTokens); } }, [rawAddMessage] @@ -258,6 +267,7 @@ const ChatInternal: React.FC = ({ getMessageErrorBlockRoots(messageId.current) ); + // Get the global collapsible state map for this message + // This persists across component lifecycles (streaming -> final message) + // Use ref to avoid triggering re-renders when map contents change + const collapsibleOpenStateMapRef = useRef(getMessageCollapsibleStates(messageId.current)); + const collapsibleOpenStateMap = collapsibleOpenStateMapRef.current; + + // Check if current model has reasoning capability const settings = useSettingsValue(); const copyToClipboard = () => { @@ -262,13 +276,20 @@ const ChatSingleMessage: React.FC = ({ const contentStyle = `margin-top: 0.75rem; padding: 0.75rem; border-radius: 4px; background-color: var(--background-primary)`; const openTag = `<${tagName}>`; + let sectionIndex = 0; // During streaming, if we find any tag that's either unclosed or being processed if (isStreaming && content.includes(openTag)) { // Replace any complete sections first const completeRegex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, "g"); content = content.replace(completeRegex, (_match, sectionContent) => { - return `
+ const sectionKey = `${tagName}-${sectionIndex}`; + sectionIndex += 1; + const domId = buildCopilotCollapsibleDomId(messageId.current, sectionKey); + // Check if user has explicitly set a state; if not, default to collapsed (original behavior) + const openAttribute = collapsibleOpenStateMap.get(domId) ? " open" : ""; + + return `
${summaryText}
${sectionContent.trim()}
\n\n`; @@ -289,7 +310,13 @@ const ChatSingleMessage: React.FC = ({ // Not streaming, process all sections normally const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, "g"); return content.replace(regex, (_match, sectionContent) => { - return `
+ const sectionKey = `${tagName}-${sectionIndex}`; + sectionIndex += 1; + const domId = buildCopilotCollapsibleDomId(messageId.current, sectionKey); + // Restore open state from previous render + const openAttribute = collapsibleOpenStateMap.get(domId) ? " open" : ""; + + return `
${summaryText}
${sectionContent.trim()}
\n\n`; @@ -428,9 +455,73 @@ const ChatSingleMessage: React.FC = ({ return processYouTubeEmbed(noteLinksProcessed); }, - [app, isStreaming, settings.enableInlineCitations] + [app, isStreaming, settings.enableInlineCitations, collapsibleOpenStateMap] ); + // Persist collapsible open/closed state during streaming in real time. + // Streaming updates can rebuild the markdown DOM between pointer down/up, preventing a click. + useEffect(() => { + const root = contentRef.current; + if (!root || message.sender === USER_SENDER || !isStreaming) { + return; + } + + /** + * Handles user click on collapsible summary during streaming. + * Directly sets details.open to avoid race conditions where DOM rebuilds + * between pointerdown and click, causing double toggle that cancels user intent. + */ + const handleSummaryPointerDown = (event: Event): void => { + // Only handle primary button (left click) + if (event instanceof PointerEvent && (event.button !== 0 || !event.isPrimary)) { + return; + } + + const details = getCopilotCollapsibleDetailsFromEvent(event, root); + if (!details || !isEventWithinDetailsSummary(event, details)) { + return; + } + + // Calculate and apply the next state immediately + const nextOpen = !details.open; + details.open = nextOpen; + collapsibleOpenStateMap.set(details.id, nextOpen); + }; + + /** + * Prevents native click from triggering another toggle on
. + * Since we already handled the state change in pointerdown, block the default behavior. + */ + const handleSummaryClick = (event: Event): void => { + const details = getCopilotCollapsibleDetailsFromEvent(event, root); + if (!details || !isEventWithinDetailsSummary(event, details)) { + return; + } + event.preventDefault(); + }; + + /** + * Captures actual open/closed state changes from native
interactions. + */ + const handleDetailsToggle = (event: Event): void => { + const details = getCopilotCollapsibleDetailsFromEvent(event, root); + if (!details) { + return; + } + collapsibleOpenStateMap.set(details.id, details.open); + }; + + // Use capture phase and listen on root (not document) to minimize scope + root.addEventListener("pointerdown", handleSummaryPointerDown, true); + root.addEventListener("click", handleSummaryClick, true); + root.addEventListener("toggle", handleDetailsToggle, true); + return () => { + root.removeEventListener("pointerdown", handleSummaryPointerDown, true); + root.removeEventListener("click", handleSummaryClick, true); + root.removeEventListener("toggle", handleDetailsToggle, true); + }; + }, [isStreaming, message.sender, collapsibleOpenStateMap]); + useEffect(() => { // Reset unmounting flag when effect runs isUnmountingRef.current = false; @@ -441,6 +532,14 @@ const ChatSingleMessage: React.FC = ({ componentRef.current = new Component(); } + // Capture open states of collapsible sections before re-rendering + // During streaming, don't overwrite user's explicit state changes from pointerdown + captureCopilotCollapsibleOpenStates( + contentRef.current, + collapsibleOpenStateMap, + { overwriteExisting: !isStreaming } + ); + const originMessage = message.message; const processedMessage = preprocess(originMessage); const parsedMessage = parseToolCallMarkers(processedMessage, messageId.current); @@ -599,7 +698,7 @@ const ChatSingleMessage: React.FC = ({ return () => { isUnmountingRef.current = true; }; - }, [message, app, componentRef, isStreaming, preprocess]); + }, [message, app, componentRef, isStreaming, preprocess, collapsibleOpenStateMap]); // Cleanup effect that only runs on component unmount useEffect(() => { @@ -629,8 +728,9 @@ const ChatSingleMessage: React.FC = ({ currentComponentRef.current = null; } - // Only clean up roots if this is a temporary message (streaming message) - // Permanent messages keep their roots to preserve tool call banners and error blocks + // Only clean up roots if this is a temporary message (streaming message with temp- prefix). + // For shared messageId (msg-xxx), container changes are handled by ensureToolCallRoot/ensureErrorBlockRoot + // which detect container mismatch and recreate roots as needed. if (currentMessageId.startsWith("temp-")) { cleanupMessageToolCallRoots(currentMessageId, messageRootsSnapshot, "component cleanup"); cleanupMessageErrorBlockRoots(currentMessageId, errorRootsSnapshot, "component cleanup"); diff --git a/src/components/chat-components/collapsibleStateUtils.ts b/src/components/chat-components/collapsibleStateUtils.ts new file mode 100644 index 00000000..04dd3bbc --- /dev/null +++ b/src/components/chat-components/collapsibleStateUtils.ts @@ -0,0 +1,128 @@ +export const COPILOT_COLLAPSIBLE_DOM_ID_PREFIX = "copilot-collapsible"; + +declare global { + interface Window { + __copilotCollapsibleStates?: Map>; + } +} + +/** + * Retrieve the global registry that keeps track of collapsible section states. + * The registry is stored on `window` to preserve state across component lifecycles, + * ensuring that user's collapse/expand preferences persist when streaming messages + * transition to final messages. + */ +const getCollapsibleStateRegistry = (): Map> => { + if (!window.__copilotCollapsibleStates) { + window.__copilotCollapsibleStates = new Map>(); + } + return window.__copilotCollapsibleStates; +}; + +/** + * Get the collapsible state map for a specific message. + * Creates a new map if it doesn't exist. + */ +export const getMessageCollapsibleStates = (messageId: string): Map => { + const registry = getCollapsibleStateRegistry(); + let states = registry.get(messageId); + if (!states) { + states = new Map(); + registry.set(messageId, states); + } + return states; +}; + +/** + * Builds a stable DOM id for a collapsible section within a message. + * Includes messageId to ensure uniqueness across messages. + */ +export const buildCopilotCollapsibleDomId = ( + messageInstanceId: string, + sectionKey: string +): string => { + // Normalize messageId to be safe for DOM id attribute + const safeMessageId = messageInstanceId.replace(/[^a-zA-Z0-9_-]/g, "_"); + return `${COPILOT_COLLAPSIBLE_DOM_ID_PREFIX}-${safeMessageId}-${sectionKey}`; +}; + +/** + * Captures the open/closed state for Copilot-rendered collapsible sections. + * Used to persist user toggles across markdown re-renders during streaming. + */ +export const captureCopilotCollapsibleOpenStates = ( + root: HTMLElement, + stateById: Map, + options: { overwriteExisting?: boolean } = {} +): void => { + const overwriteExisting = options.overwriteExisting ?? true; + const detailsList = root.querySelectorAll( + `details[id^="${COPILOT_COLLAPSIBLE_DOM_ID_PREFIX}-"]` + ); + detailsList.forEach((details) => { + const id = details.id; + if (!id) { + return; + } + // During streaming, don't overwrite user's explicit state changes + if (!overwriteExisting && stateById.has(id)) { + return; + } + stateById.set(id, details.open); + }); +}; + +/** + * Returns the Copilot collapsible
element associated with an event. + * Uses composedPath() when available to remain robust against retargeting. + */ +export const getCopilotCollapsibleDetailsFromEvent = ( + event: Event, + root: HTMLElement +): HTMLDetailsElement | null => { + const path = typeof event.composedPath === "function" ? event.composedPath() : []; + for (const entry of path) { + if (entry instanceof HTMLElement && entry.tagName === "DETAILS") { + const details = entry as HTMLDetailsElement; + if ( + details.id.startsWith(`${COPILOT_COLLAPSIBLE_DOM_ID_PREFIX}-`) && + root.contains(details) + ) { + return details; + } + } + } + + const target = event.target; + if (!(target instanceof Element)) { + return null; + } + + const details = target.closest(`details[id^="${COPILOT_COLLAPSIBLE_DOM_ID_PREFIX}-"]`); + if (details instanceof HTMLElement && details.tagName === "DETAILS" && root.contains(details)) { + return details as HTMLDetailsElement; + } + + return null; +}; + +/** + * Returns true when the event originated from the of the given
. + */ +export const isEventWithinDetailsSummary = ( + event: Event, + details: HTMLDetailsElement +): boolean => { + const summary = details.querySelector("summary"); + if (!summary) { + return false; + } + + const target = event.target; + if (target instanceof Node) { + return summary.contains(target); + } + + const path = typeof event.composedPath === "function" ? event.composedPath() : []; + return path.includes(summary); +}; diff --git a/src/components/chat-components/toolCallRootManager.tsx b/src/components/chat-components/toolCallRootManager.tsx index 0981cd4f..077b4667 100644 --- a/src/components/chat-components/toolCallRootManager.tsx +++ b/src/components/chat-components/toolCallRootManager.tsx @@ -16,6 +16,8 @@ declare global { export interface ToolCallRootRecord { root: Root; isUnmounting: boolean; + /** Reference to the DOM container to detect container changes across component lifecycles */ + container: HTMLElement; } const STALE_ROOT_MAX_AGE_MS = 60 * 60 * 1000; @@ -87,6 +89,39 @@ const disposeToolCallRoot = ( pruneEmptyMessageEntry(messageId, messageRoots, registry); }; +/** + * Handle container change by immediately removing the old record from the map + * and scheduling a deferred unmount. This is used when the same messageId + toolCallId + * is reused with a different DOM container (e.g., streaming -> history transition). + */ +const handleContainerChange = ( + messageId: string, + messageRoots: Map, + toolCallId: string, + oldRecord: ToolCallRootRecord, + logContext: string, + registry: Map> +): void => { + // Immediately remove from map so new record can be created + messageRoots.delete(toolCallId); + + // Mark as unmounting to prevent duplicate disposal attempts + oldRecord.isUnmounting = true; + + // Defer unmount to avoid "synchronously unmount while React was already rendering" warning + setTimeout(() => { + try { + oldRecord.root.unmount(); + } catch (error) { + logWarn(`Error unmounting tool call root during ${logContext}`, toolCallId, error); + } + oldRecord.isUnmounting = false; + + // Prune empty message entry from registry + pruneEmptyMessageEntry(messageId, messageRoots, registry); + }, 0); +}; + /** * Schedule a deferred unmount for a tool call root while preventing duplicate requests. */ @@ -141,10 +176,27 @@ export const ensureToolCallRoot = ( record = undefined; } + // Detect container change: if the record exists but points to a different container, + // dispose the old root and create a new one. This happens when streaming component + // unmounts (destroying its DOM) and history component mounts with a new container. + // Only check if record.container exists (backwards compatibility with old registries). + if (record && record.container && record.container !== container) { + handleContainerChange( + messageId, + messageRoots, + toolCallId, + record, + `${logContext} (container changed)`, + getRegistry() + ); + record = undefined; + } + if (!record) { record = { root: createRoot(container), isUnmounting: false, + container, }; messageRoots.set(toolCallId, record); @@ -178,10 +230,26 @@ export const ensureErrorBlockRoot = ( record = undefined; } + // Detect container change: if the record exists but points to a different container, + // dispose the old root and create a new one. + // Only check if record.container exists (backwards compatibility with old registries). + if (record && record.container && record.container !== container) { + handleContainerChange( + messageId, + messageRoots, + errorId, + record, + `${logContext} (container changed)`, + getErrorBlockRegistry() + ); + record = undefined; + } + if (!record) { record = { root: createRoot(container), isUnmounting: false, + container, }; messageRoots.set(errorId, record); @@ -296,25 +364,42 @@ export const getMessageErrorBlockRoots = (messageId: string): Map { const registry = getRegistry(); registry.forEach((messageRoots, messageId) => { - const timestamp = Number.parseInt(messageId, 10); - - if (Number.isNaN(timestamp) || now - timestamp < STALE_ROOT_MAX_AGE_MS) { - return; - } - messageRoots.forEach((record, toolCallId) => { + // Primary cleanup: check if container is detached from DOM + if (record.container) { + if (record.container.isConnected) { + return; // Container still in DOM, skip cleanup + } + scheduleToolCallRootDisposal( + messageId, + messageRoots, + toolCallId, + record, + "stale cleanup (detached container)", + registry + ); + return; + } + + // Fallback for legacy records without container: use timestamp-based cleanup + const timestamp = Number.parseInt(messageId, 10); + if (Number.isNaN(timestamp) || now - timestamp < STALE_ROOT_MAX_AGE_MS) { + return; + } scheduleToolCallRootDisposal( messageId, messageRoots, toolCallId, record, - "stale message cleanup", + "stale cleanup (legacy record)", registry ); }); @@ -322,25 +407,42 @@ export const cleanupStaleToolCallRoots = (now: number = Date.now()): void => { }; /** - * Clean up error block roots for messages whose identifiers encode timestamps older than the configured threshold. + * Clean up error block roots that are no longer attached to the DOM. + * Uses container.isConnected for records with container reference, + * falls back to timestamp-based cleanup for legacy records. */ export const cleanupStaleErrorBlockRoots = (now: number = Date.now()): void => { const registry = getErrorBlockRegistry(); registry.forEach((messageRoots, messageId) => { - const timestamp = Number.parseInt(messageId, 10); - - if (Number.isNaN(timestamp) || now - timestamp < STALE_ROOT_MAX_AGE_MS) { - return; - } - messageRoots.forEach((record, errorId) => { + // Primary cleanup: check if container is detached from DOM + if (record.container) { + if (record.container.isConnected) { + return; // Container still in DOM, skip cleanup + } + scheduleToolCallRootDisposal( + messageId, + messageRoots, + errorId, + record, + "stale error cleanup (detached container)", + registry + ); + return; + } + + // Fallback for legacy records without container: use timestamp-based cleanup + const timestamp = Number.parseInt(messageId, 10); + if (Number.isNaN(timestamp) || now - timestamp < STALE_ROOT_MAX_AGE_MS) { + return; + } scheduleToolCallRootDisposal( messageId, messageRoots, errorId, record, - "stale error block cleanup", + "stale error cleanup (legacy record)", registry ); }); diff --git a/src/components/modals/project/context-manage-modal.tsx b/src/components/modals/project/context-manage-modal.tsx index 130c0f9d..f7c8fc5e 100644 --- a/src/components/modals/project/context-manage-modal.tsx +++ b/src/components/modals/project/context-manage-modal.tsx @@ -1,8 +1,10 @@ -import { ProjectConfig } from "@/aiParams"; +import { FailedItem, ProjectConfig, useProjectContextLoad, getCurrentProject } from "@/aiParams"; +import { ContextCache, ProjectContextCache } from "@/cache/projectContextCache"; import { FolderSearchModal } from "@/components/modals/FolderSearchModal"; import { ProjectFileSelectModal } from "@/components/modals/ProjectFileSelectModal"; import { TagSearchModal } from "@/components/modals/TagSearchModal"; import { TruncatedText } from "@/components/TruncatedText"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -19,11 +21,14 @@ import { } from "@/search/searchUtils"; import { getTagsFromNote } from "@/utils"; import { + AlertCircle, + CheckCircle, FileAudio, FileImage, FileText, FileVideo, FolderIcon, + Loader2, Plus, PlusCircle, TagIcon, @@ -177,13 +182,82 @@ const SectionList: React.FC = ({ ); }; +// ============================================================================ +// Project Context Load Status Types and Utilities +// ============================================================================ + +type ProjectContextItemStatus = "success" | "failed" | "processing" | "notStarted"; + +interface ProjectContextItemStatusInfo { + status: ProjectContextItemStatus; + failedItem?: FailedItem; +} + +interface ProjectContextLoadLookup { + success: ReadonlySet; + failedByPath: ReadonlyMap; + processingFiles: ReadonlySet; + total: ReadonlySet; + /** Files that have been cached (from ProjectContextCache) */ + cachedFiles: ReadonlySet; + /** Whether we're viewing the currently loaded project */ + isCurrentProject: boolean; +} + +/** + * Derives a display status for the given project context item key. + * - For current project: uses real-time load state (processing > failed > success > notStarted) + * - For other projects: uses cache state (success if cached, notStarted otherwise) + */ +function getProjectContextItemStatus( + key: string, + lookup: ProjectContextLoadLookup +): ProjectContextItemStatusInfo { + // For the currently loaded project, use real-time status + if (lookup.isCurrentProject) { + if (lookup.processingFiles.has(key)) { + return { status: "processing" }; + } + + const failedItem = lookup.failedByPath.get(key); + if (failedItem) { + return { status: "failed", failedItem }; + } + + if (lookup.success.has(key)) { + return { status: "success" }; + } + // Current project: if not in any real-time status, it hasn't been processed yet + return { status: "notStarted" }; + } + + // For non-current projects, check if they're cached + if (lookup.cachedFiles.has(key)) { + return { status: "success" }; + } + + return { status: "notStarted" }; +} + +const STATUS_LABELS: Record = { + success: "Processed", + failed: "Failed", + processing: "Processing", + notStarted: "Not started", +}; + +// ============================================================================ +// ItemCard Component +// ============================================================================ + interface ItemCardProps { item: GroupItem; viewMode: "list"; + loadStatus?: ProjectContextItemStatusInfo; onDelete: (e: React.MouseEvent, item: GroupItem) => void; } -function ItemCard({ item, viewMode, onDelete }: ItemCardProps) { +function ItemCard({ item, viewMode, loadStatus, onDelete }: ItemCardProps) { const extension = item.id.split(".").pop() || ""; // add or remove @@ -205,6 +279,34 @@ function ItemCard({ item, viewMode, onDelete }: ItemCardProps) {
+ {loadStatus && ( + + {loadStatus.status === "processing" ? ( + + ) : loadStatus.status === "success" ? ( + + ) : loadStatus.status === "failed" ? ( + + ) : ( +
+ )} + {STATUS_LABELS[loadStatus.status]} + + )} onDelete(e, item)} @@ -304,6 +406,62 @@ function isCategoryItem(item: DisplayItem): item is CategoryItem { function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageProps) { const isMobile = Platform.isMobile; + const [contextLoadState] = useProjectContextLoad(); + const [projectCache, setProjectCache] = useState(null); + + // Load project cache on mount + useEffect(() => { + let isMounted = true; + const loadCache = async () => { + const cache = await ProjectContextCache.getInstance().get(initialProject); + if (isMounted) { + setProjectCache(cache); + } + }; + loadCache(); + return () => { + isMounted = false; + }; + }, [initialProject]); + + // Check if viewing the currently loaded project + const isCurrentProject = useMemo(() => { + const currentProject = getCurrentProject(); + return currentProject?.id === initialProject.id; + }, [initialProject.id]); + + // Build set of cached files from project cache + const cachedFiles = useMemo(() => { + if (!projectCache?.fileContexts) { + return new Set(); + } + // Files with valid cacheKey are considered cached/processed + return new Set( + Object.entries(projectCache.fileContexts) + .filter(([, entry]) => entry?.cacheKey) + .map(([filePath]) => filePath) + ); + }, [projectCache]); + + // Memoize lookup structures for O(1) status queries + const contextLoadLookup = useMemo(() => { + return { + success: new Set(contextLoadState.success), + failedByPath: new Map(contextLoadState.failed.map((item) => [item.path, item])), + processingFiles: new Set(contextLoadState.processingFiles), + total: new Set(contextLoadState.total), + cachedFiles, + isCurrentProject, + }; + }, [ + contextLoadState.success, + contextLoadState.failed, + contextLoadState.processingFiles, + contextLoadState.total, + cachedFiles, + isCurrentProject, + ]); + const { inclusions: inclusionPatterns, exclusions: exclusionPatterns } = useMemo(() => { return getMatchingPatterns({ inclusions: initialProject?.contextSource.inclusions, @@ -1140,6 +1298,11 @@ function ContextManage({ initialProject, onSave, onCancel, app }: ContextManageP key={item.id} item={item} viewMode="list" + loadStatus={ + activeSection === "ignoreFiles" || item.isIgnored + ? undefined + : getProjectContextItemStatus(item.id, contextLoadLookup) + } onDelete={ activeSection === "ignoreFiles" || item.isIgnored ? handleDeleteIgnoreItem