Skip to content

Commit 538033e

Browse files
authored
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.
1 parent 01f5bed commit 538033e

File tree

6 files changed

+543
-30
lines changed

6 files changed

+543
-30
lines changed

src/components/Chat.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { arrayBufferToBase64 } from "@/utils/base64";
4646
import { Notice, TFile } from "obsidian";
4747
import { ContextManageModal } from "@/components/modals/project/context-manage-modal";
4848
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
49+
import { v4 as uuidv4 } from "uuid";
4950
import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
5051
import { useActiveWebTabState } from "@/components/chat-components/hooks/useActiveWebTabState";
5152

@@ -81,14 +82,22 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
8182
const [inputMessage, setInputMessage] = useState("");
8283
const [latestTokenCount, setLatestTokenCount] = useState<number | null>(null);
8384
const abortControllerRef = useRef<AbortController | null>(null);
85+
// Stable ID for streaming message, shared with final persisted message
86+
// This allows collapsible UI state (think blocks) to persist across streaming -> history
87+
const streamingMessageIdRef = useRef<string | null>(null);
8488

85-
// Wrapper for addMessage that tracks token usage from AI responses
89+
// Wrapper for addMessage that attaches streaming ID and tracks token usage
8690
const addMessage = useCallback(
8791
(message: ChatMessage) => {
88-
rawAddMessage(message);
89-
// Track token usage from AI messages
90-
if (message.sender === AI_SENDER && message.responseMetadata?.tokenUsage?.totalTokens) {
91-
setLatestTokenCount(message.responseMetadata.tokenUsage.totalTokens);
92+
// Attach streaming ID to final AI message so it shares the same ID as streaming placeholder
93+
const streamingId = streamingMessageIdRef.current;
94+
const shouldAttachId =
95+
streamingId && message.sender === AI_SENDER && !message.isErrorMessage && !message.id;
96+
const messageToAdd = shouldAttachId ? { ...message, id: streamingId } : message;
97+
98+
rawAddMessage(messageToAdd);
99+
if (messageToAdd.sender === AI_SENDER && messageToAdd.responseMetadata?.tokenUsage?.totalTokens) {
100+
setLatestTokenCount(messageToAdd.responseMetadata.tokenUsage.totalTokens);
92101
}
93102
},
94103
[rawAddMessage]
@@ -258,6 +267,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
258267
// Clear input and images
259268
setInputMessage("");
260269
setSelectedImages([]);
270+
streamingMessageIdRef.current = `msg-${uuidv4()}`;
261271
safeSet.setLoading(true);
262272
safeSet.setLoadingMessage(LOADING_MESSAGES.DEFAULT);
263273

@@ -304,6 +314,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
304314
} finally {
305315
safeSet.setLoading(false);
306316
safeSet.setLoadingMessage(LOADING_MESSAGES.DEFAULT);
317+
streamingMessageIdRef.current = null;
307318
}
308319
};
309320

@@ -363,6 +374,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
363374

364375
// Clear current AI message and set loading state
365376
safeSet.setCurrentAiMessage("");
377+
streamingMessageIdRef.current = `msg-${uuidv4()}`;
366378
safeSet.setLoading(true);
367379
try {
368380
const success = await chatUIState.regenerateMessage(
@@ -386,6 +398,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
386398
new Notice("Failed to regenerate message. Please try again.");
387399
} finally {
388400
safeSet.setLoading(false);
401+
streamingMessageIdRef.current = null;
389402
}
390403
},
391404
[
@@ -429,6 +442,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
429442

430443
// If there were AI responses, generate new ones
431444
if (hadAIResponses) {
445+
streamingMessageIdRef.current = `msg-${uuidv4()}`;
432446
safeSet.setLoading(true);
433447
try {
434448
const llmMessage = chatUIState.getLLMMessage(messageToEdit.id!);
@@ -447,6 +461,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
447461
new Notice("Failed to regenerate AI response. Please try again.");
448462
} finally {
449463
safeSet.setLoading(false);
464+
streamingMessageIdRef.current = null;
450465
}
451466
}
452467
}
@@ -751,6 +766,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
751766
<ChatMessages
752767
chatHistory={chatHistory}
753768
currentAiMessage={currentAiMessage}
769+
streamingMessageId={streamingMessageIdRef.current}
754770
loading={loading}
755771
loadingMessage={loadingMessage}
756772
app={app}

src/components/chat-components/ChatMessages.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import React, { memo, useEffect, useState } from "react";
1111
interface ChatMessagesProps {
1212
chatHistory: ChatMessage[];
1313
currentAiMessage: string;
14+
/** Stable ID for streaming message, shared with final persisted message */
15+
streamingMessageId?: string | null;
1416
loading?: boolean;
1517
loadingMessage?: string;
1618
app: App;
@@ -25,6 +27,7 @@ const ChatMessages = memo(
2527
({
2628
chatHistory,
2729
currentAiMessage,
30+
streamingMessageId,
2831
loading,
2932
loadingMessage,
3033
app,
@@ -118,8 +121,9 @@ const ChatMessages = memo(
118121
}}
119122
>
120123
<ChatSingleMessage
121-
key="ai_message_streaming"
124+
key={streamingMessageId ?? "ai_message_streaming"}
122125
message={{
126+
id: streamingMessageId ?? undefined,
123127
sender: "AI",
124128
message: currentAiMessage || getLoadingMessage(),
125129
isVisible: true,

src/components/chat-components/ChatSingleMessage.tsx

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ import { cleanMessageForCopy, extractYoutubeVideoId, insertIntoEditor } from "@/
3535
import { App, Component, MarkdownRenderer, MarkdownView, TFile } from "obsidian";
3636
import React, { useCallback, useEffect, useRef, useState } from "react";
3737
import { useSettingsValue } from "@/settings/model";
38+
import {
39+
buildCopilotCollapsibleDomId,
40+
captureCopilotCollapsibleOpenStates,
41+
getCopilotCollapsibleDetailsFromEvent,
42+
getMessageCollapsibleStates,
43+
isEventWithinDetailsSummary,
44+
} from "@/components/chat-components/collapsibleStateUtils";
3845

3946
const FOOTNOTE_SUFFIX_PATTERN = /^\d+-\d+$/;
4047

@@ -204,6 +211,13 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
204211
getMessageErrorBlockRoots(messageId.current)
205212
);
206213

214+
// Get the global collapsible state map for this message
215+
// This persists across component lifecycles (streaming -> final message)
216+
// Use ref to avoid triggering re-renders when map contents change
217+
const collapsibleOpenStateMapRef = useRef(getMessageCollapsibleStates(messageId.current));
218+
const collapsibleOpenStateMap = collapsibleOpenStateMapRef.current;
219+
220+
// Check if current model has reasoning capability
207221
const settings = useSettingsValue();
208222

209223
const copyToClipboard = () => {
@@ -262,13 +276,20 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
262276
const contentStyle = `margin-top: 0.75rem; padding: 0.75rem; border-radius: 4px; background-color: var(--background-primary)`;
263277

264278
const openTag = `<${tagName}>`;
279+
let sectionIndex = 0;
265280

266281
// During streaming, if we find any tag that's either unclosed or being processed
267282
if (isStreaming && content.includes(openTag)) {
268283
// Replace any complete sections first
269284
const completeRegex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, "g");
270285
content = content.replace(completeRegex, (_match, sectionContent) => {
271-
return `<details style="${detailsStyle}">
286+
const sectionKey = `${tagName}-${sectionIndex}`;
287+
sectionIndex += 1;
288+
const domId = buildCopilotCollapsibleDomId(messageId.current, sectionKey);
289+
// Check if user has explicitly set a state; if not, default to collapsed (original behavior)
290+
const openAttribute = collapsibleOpenStateMap.get(domId) ? " open" : "";
291+
292+
return `<details id="${domId}"${openAttribute} style="${detailsStyle}">
272293
<summary style="${summaryStyle}">${summaryText}</summary>
273294
<div class="tw-text-muted" style="${contentStyle}">${sectionContent.trim()}</div>
274295
</details>\n\n`;
@@ -289,7 +310,13 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
289310
// Not streaming, process all sections normally
290311
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, "g");
291312
return content.replace(regex, (_match, sectionContent) => {
292-
return `<details style="${detailsStyle}">
313+
const sectionKey = `${tagName}-${sectionIndex}`;
314+
sectionIndex += 1;
315+
const domId = buildCopilotCollapsibleDomId(messageId.current, sectionKey);
316+
// Restore open state from previous render
317+
const openAttribute = collapsibleOpenStateMap.get(domId) ? " open" : "";
318+
319+
return `<details id="${domId}"${openAttribute} style="${detailsStyle}">
293320
<summary style="${summaryStyle}">${summaryText}</summary>
294321
<div class="tw-text-muted" style="${contentStyle}">${sectionContent.trim()}</div>
295322
</details>\n\n`;
@@ -428,9 +455,73 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
428455

429456
return processYouTubeEmbed(noteLinksProcessed);
430457
},
431-
[app, isStreaming, settings.enableInlineCitations]
458+
[app, isStreaming, settings.enableInlineCitations, collapsibleOpenStateMap]
432459
);
433460

461+
// Persist collapsible open/closed state during streaming in real time.
462+
// Streaming updates can rebuild the markdown DOM between pointer down/up, preventing a click.
463+
useEffect(() => {
464+
const root = contentRef.current;
465+
if (!root || message.sender === USER_SENDER || !isStreaming) {
466+
return;
467+
}
468+
469+
/**
470+
* Handles user click on collapsible summary during streaming.
471+
* Directly sets details.open to avoid race conditions where DOM rebuilds
472+
* between pointerdown and click, causing double toggle that cancels user intent.
473+
*/
474+
const handleSummaryPointerDown = (event: Event): void => {
475+
// Only handle primary button (left click)
476+
if (event instanceof PointerEvent && (event.button !== 0 || !event.isPrimary)) {
477+
return;
478+
}
479+
480+
const details = getCopilotCollapsibleDetailsFromEvent(event, root);
481+
if (!details || !isEventWithinDetailsSummary(event, details)) {
482+
return;
483+
}
484+
485+
// Calculate and apply the next state immediately
486+
const nextOpen = !details.open;
487+
details.open = nextOpen;
488+
collapsibleOpenStateMap.set(details.id, nextOpen);
489+
};
490+
491+
/**
492+
* Prevents native click from triggering another toggle on <details>.
493+
* Since we already handled the state change in pointerdown, block the default behavior.
494+
*/
495+
const handleSummaryClick = (event: Event): void => {
496+
const details = getCopilotCollapsibleDetailsFromEvent(event, root);
497+
if (!details || !isEventWithinDetailsSummary(event, details)) {
498+
return;
499+
}
500+
event.preventDefault();
501+
};
502+
503+
/**
504+
* Captures actual open/closed state changes from native <details> interactions.
505+
*/
506+
const handleDetailsToggle = (event: Event): void => {
507+
const details = getCopilotCollapsibleDetailsFromEvent(event, root);
508+
if (!details) {
509+
return;
510+
}
511+
collapsibleOpenStateMap.set(details.id, details.open);
512+
};
513+
514+
// Use capture phase and listen on root (not document) to minimize scope
515+
root.addEventListener("pointerdown", handleSummaryPointerDown, true);
516+
root.addEventListener("click", handleSummaryClick, true);
517+
root.addEventListener("toggle", handleDetailsToggle, true);
518+
return () => {
519+
root.removeEventListener("pointerdown", handleSummaryPointerDown, true);
520+
root.removeEventListener("click", handleSummaryClick, true);
521+
root.removeEventListener("toggle", handleDetailsToggle, true);
522+
};
523+
}, [isStreaming, message.sender, collapsibleOpenStateMap]);
524+
434525
useEffect(() => {
435526
// Reset unmounting flag when effect runs
436527
isUnmountingRef.current = false;
@@ -441,6 +532,14 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
441532
componentRef.current = new Component();
442533
}
443534

535+
// Capture open states of collapsible sections before re-rendering
536+
// During streaming, don't overwrite user's explicit state changes from pointerdown
537+
captureCopilotCollapsibleOpenStates(
538+
contentRef.current,
539+
collapsibleOpenStateMap,
540+
{ overwriteExisting: !isStreaming }
541+
);
542+
444543
const originMessage = message.message;
445544
const processedMessage = preprocess(originMessage);
446545
const parsedMessage = parseToolCallMarkers(processedMessage, messageId.current);
@@ -599,7 +698,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
599698
return () => {
600699
isUnmountingRef.current = true;
601700
};
602-
}, [message, app, componentRef, isStreaming, preprocess]);
701+
}, [message, app, componentRef, isStreaming, preprocess, collapsibleOpenStateMap]);
603702

604703
// Cleanup effect that only runs on component unmount
605704
useEffect(() => {
@@ -629,8 +728,9 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
629728
currentComponentRef.current = null;
630729
}
631730

632-
// Only clean up roots if this is a temporary message (streaming message)
633-
// Permanent messages keep their roots to preserve tool call banners and error blocks
731+
// Only clean up roots if this is a temporary message (streaming message with temp- prefix).
732+
// For shared messageId (msg-xxx), container changes are handled by ensureToolCallRoot/ensureErrorBlockRoot
733+
// which detect container mismatch and recreate roots as needed.
634734
if (currentMessageId.startsWith("temp-")) {
635735
cleanupMessageToolCallRoots(currentMessageId, messageRootsSnapshot, "component cleanup");
636736
cleanupMessageErrorBlockRoots(currentMessageId, errorRootsSnapshot, "component cleanup");

0 commit comments

Comments
 (0)