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.
This commit is contained in:
Emt-lin 2026-01-13 14:52:35 +08:00 committed by GitHub
parent 01f5bedbc2
commit 538033e478
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 543 additions and 30 deletions

View file

@ -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<ChatProps & { chatInput: ReturnType<typeof useChatI
const [inputMessage, setInputMessage] = useState("");
const [latestTokenCount, setLatestTokenCount] = useState<number | null>(null);
const abortControllerRef = useRef<AbortController | null>(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<string | null>(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<ChatProps & { chatInput: ReturnType<typeof useChatI
// Clear input and images
setInputMessage("");
setSelectedImages([]);
streamingMessageIdRef.current = `msg-${uuidv4()}`;
safeSet.setLoading(true);
safeSet.setLoadingMessage(LOADING_MESSAGES.DEFAULT);
@ -304,6 +314,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
} finally {
safeSet.setLoading(false);
safeSet.setLoadingMessage(LOADING_MESSAGES.DEFAULT);
streamingMessageIdRef.current = null;
}
};
@ -363,6 +374,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
// Clear current AI message and set loading state
safeSet.setCurrentAiMessage("");
streamingMessageIdRef.current = `msg-${uuidv4()}`;
safeSet.setLoading(true);
try {
const success = await chatUIState.regenerateMessage(
@ -386,6 +398,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
new Notice("Failed to regenerate message. Please try again.");
} finally {
safeSet.setLoading(false);
streamingMessageIdRef.current = null;
}
},
[
@ -429,6 +442,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
// If there were AI responses, generate new ones
if (hadAIResponses) {
streamingMessageIdRef.current = `msg-${uuidv4()}`;
safeSet.setLoading(true);
try {
const llmMessage = chatUIState.getLLMMessage(messageToEdit.id!);
@ -447,6 +461,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
new Notice("Failed to regenerate AI response. Please try again.");
} finally {
safeSet.setLoading(false);
streamingMessageIdRef.current = null;
}
}
}
@ -751,6 +766,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
<ChatMessages
chatHistory={chatHistory}
currentAiMessage={currentAiMessage}
streamingMessageId={streamingMessageIdRef.current}
loading={loading}
loadingMessage={loadingMessage}
app={app}

View file

@ -11,6 +11,8 @@ import React, { memo, useEffect, useState } from "react";
interface ChatMessagesProps {
chatHistory: ChatMessage[];
currentAiMessage: string;
/** Stable ID for streaming message, shared with final persisted message */
streamingMessageId?: string | null;
loading?: boolean;
loadingMessage?: string;
app: App;
@ -25,6 +27,7 @@ const ChatMessages = memo(
({
chatHistory,
currentAiMessage,
streamingMessageId,
loading,
loadingMessage,
app,
@ -118,8 +121,9 @@ const ChatMessages = memo(
}}
>
<ChatSingleMessage
key="ai_message_streaming"
key={streamingMessageId ?? "ai_message_streaming"}
message={{
id: streamingMessageId ?? undefined,
sender: "AI",
message: currentAiMessage || getLoadingMessage(),
isVisible: true,

View file

@ -35,6 +35,13 @@ import { cleanMessageForCopy, extractYoutubeVideoId, insertIntoEditor } from "@/
import { App, Component, MarkdownRenderer, MarkdownView, TFile } from "obsidian";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useSettingsValue } from "@/settings/model";
import {
buildCopilotCollapsibleDomId,
captureCopilotCollapsibleOpenStates,
getCopilotCollapsibleDetailsFromEvent,
getMessageCollapsibleStates,
isEventWithinDetailsSummary,
} from "@/components/chat-components/collapsibleStateUtils";
const FOOTNOTE_SUFFIX_PATTERN = /^\d+-\d+$/;
@ -204,6 +211,13 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
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<ChatSingleMessageProps> = ({
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 `<details style="${detailsStyle}">
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 `<details id="${domId}"${openAttribute} style="${detailsStyle}">
<summary style="${summaryStyle}">${summaryText}</summary>
<div class="tw-text-muted" style="${contentStyle}">${sectionContent.trim()}</div>
</details>\n\n`;
@ -289,7 +310,13 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
// Not streaming, process all sections normally
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, "g");
return content.replace(regex, (_match, sectionContent) => {
return `<details style="${detailsStyle}">
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 `<details id="${domId}"${openAttribute} style="${detailsStyle}">
<summary style="${summaryStyle}">${summaryText}</summary>
<div class="tw-text-muted" style="${contentStyle}">${sectionContent.trim()}</div>
</details>\n\n`;
@ -428,9 +455,73 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
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 <details>.
* 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 <details> 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<ChatSingleMessageProps> = ({
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<ChatSingleMessageProps> = ({
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<ChatSingleMessageProps> = ({
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");

View file

@ -0,0 +1,128 @@
export const COPILOT_COLLAPSIBLE_DOM_ID_PREFIX = "copilot-collapsible";
declare global {
interface Window {
__copilotCollapsibleStates?: Map<string, Map<string, boolean>>;
}
}
/**
* 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<string, Map<string, boolean>> => {
if (!window.__copilotCollapsibleStates) {
window.__copilotCollapsibleStates = new Map<string, Map<string, boolean>>();
}
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<string, boolean> => {
const registry = getCollapsibleStateRegistry();
let states = registry.get(messageId);
if (!states) {
states = new Map<string, boolean>();
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<string, boolean>,
options: { overwriteExisting?: boolean } = {}
): void => {
const overwriteExisting = options.overwriteExisting ?? true;
const detailsList = root.querySelectorAll<HTMLDetailsElement>(
`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 <details> 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 <summary> of the given <details>.
*/
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);
};

View file

@ -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<string, ToolCallRootRecord>,
toolCallId: string,
oldRecord: ToolCallRootRecord,
logContext: string,
registry: Map<string, Map<string, ToolCallRootRecord>>
): 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<string, ToolCa
};
/**
* Clean up tool call roots for messages whose identifiers encode timestamps older than the configured threshold.
* Clean up tool call 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 cleanupStaleToolCallRoots = (now: number = Date.now()): void => {
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
);
});

View file

@ -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<SectionListProps> = ({
);
};
// ============================================================================
// Project Context Load Status Types and Utilities
// ============================================================================
type ProjectContextItemStatus = "success" | "failed" | "processing" | "notStarted";
interface ProjectContextItemStatusInfo {
status: ProjectContextItemStatus;
failedItem?: FailedItem;
}
interface ProjectContextLoadLookup {
success: ReadonlySet<string>;
failedByPath: ReadonlyMap<string, FailedItem>;
processingFiles: ReadonlySet<string>;
total: ReadonlySet<string>;
/** Files that have been cached (from ProjectContextCache) */
cachedFiles: ReadonlySet<string>;
/** 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<ProjectContextItemStatus, string> = {
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) {
</div>
<div className="tw-ml-auto tw-flex tw-min-w-[24px] tw-items-center tw-justify-end tw-gap-2">
{loadStatus && (
<Badge
variant="outline"
className={cn(
"tw-flex tw-items-center tw-gap-1 tw-whitespace-nowrap",
loadStatus.status === "success" && "tw-text-success",
loadStatus.status === "failed" && "tw-text-error",
loadStatus.status === "processing" && "tw-text-accent",
loadStatus.status === "notStarted" && "tw-text-muted"
)}
title={
loadStatus.status === "failed" && loadStatus.failedItem?.error
? `Failed: ${loadStatus.failedItem.error}`
: STATUS_LABELS[loadStatus.status]
}
>
{loadStatus.status === "processing" ? (
<Loader2 className="tw-size-3 tw-animate-spin" />
) : loadStatus.status === "success" ? (
<CheckCircle className="tw-size-3" />
) : loadStatus.status === "failed" ? (
<AlertCircle className="tw-size-3" />
) : (
<div className="tw-size-2 tw-rounded-full tw-border tw-border-solid tw-border-border" />
)}
<span className="tw-hidden md:tw-inline">{STATUS_LABELS[loadStatus.status]}</span>
</Badge>
)}
<IconComponent
className="tw-hidden tw-size-4 tw-shrink-0 tw-text-muted hover:tw-text-warning group-hover:tw-block group-hover:tw-flex-none"
onClick={(e) => 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<ContextCache | null>(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<string>();
}
// 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<ProjectContextLoadLookup>(() => {
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