mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
01f5bedbc2
commit
538033e478
6 changed files with 543 additions and 30 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
128
src/components/chat-components/collapsibleStateUtils.ts
Normal file
128
src/components/chat-components/collapsibleStateUtils.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue