@@ -35,6 +35,13 @@ import { cleanMessageForCopy, extractYoutubeVideoId, insertIntoEditor } from "@/
3535import { App , Component , MarkdownRenderer , MarkdownView , TFile } from "obsidian" ;
3636import React , { useCallback , useEffect , useRef , useState } from "react" ;
3737import { useSettingsValue } from "@/settings/model" ;
38+ import {
39+ buildCopilotCollapsibleDomId ,
40+ captureCopilotCollapsibleOpenStates ,
41+ getCopilotCollapsibleDetailsFromEvent ,
42+ getMessageCollapsibleStates ,
43+ isEventWithinDetailsSummary ,
44+ } from "@/components/chat-components/collapsibleStateUtils" ;
3845
3946const 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