diff --git a/src/commands/CustomCommandChatModal.tsx b/src/commands/CustomCommandChatModal.tsx index 554cd67c..60ad3e1f 100644 --- a/src/commands/CustomCommandChatModal.tsx +++ b/src/commands/CustomCommandChatModal.tsx @@ -7,7 +7,7 @@ import { } from "@/components/command-ui/constants"; import { SelectionHighlight } from "@/editor/selectionHighlight"; import { createHighlightReplaceGuard, type ReplaceGuard } from "@/editor/replaceGuard"; -import { logError, logWarn } from "@/logger"; +import { logError } from "@/logger"; import { cleanMessageForCopy, findCustomModel, insertIntoEditor } from "@/utils"; import { computeVerticalPlacement } from "@/utils/panelPlacement"; import { computeSelectionAnchors } from "@/utils/selectionAnchors"; @@ -183,12 +183,12 @@ function CustomCommandChatModalContent({ return command.modelKey || globalModelKey; }, [modelSelectionScope, settings.quickCommandModelKey, command.modelKey, globalModelKey]); - const [selectedModelKey, setSelectedModelKey] = useState(initialModelKey); + const [userSelectedModelKey, setUserSelectedModelKey] = useState(initialModelKey); // Handle model change with scope-aware persistence const handleModelChange = useCallback( (newModelKey: string) => { - setSelectedModelKey(newModelKey); + setUserSelectedModelKey(newModelKey); // Only persist for quick-command scope (shared with Quick Ask) if (modelSelectionScope === "quick-command") { updateSetting("quickCommandModelKey", newModelKey); @@ -209,16 +209,13 @@ function CustomCommandChatModalContent({ updateSetting("quickCommandIncludeNoteContext", checked); }, []); - // Track if we've already shown the fallback notice to avoid repeated notices - const didShowFallbackNoticeRef = useRef(false); - // Safely resolve the selected model with fallback to first enabled model const resolvedModel = useMemo((): CustomModel | null => { try { - const model = findCustomModel(selectedModelKey, settings.activeModels); + const model = findCustomModel(userSelectedModelKey, settings.activeModels); // Treat disabled models as invalid selections (ModelSelector won't present them) if (!model.enabled) { - throw new Error(`Selected model is disabled: ${selectedModelKey}`); + throw new Error(`Selected model is disabled: ${userSelectedModelKey}`); } return model; } catch { @@ -226,7 +223,7 @@ function CustomCommandChatModalContent({ // Avoid side effects during render; notify/log in the effect below. return settings.activeModels.find((m) => m.enabled) ?? null; } - }, [selectedModelKey, settings.activeModels]); + }, [userSelectedModelKey, settings.activeModels]); // Compute the key for the resolved model const resolvedModelKey = useMemo(() => { @@ -234,23 +231,8 @@ function CustomCommandChatModalContent({ return `${resolvedModel.name}|${resolvedModel.provider}`; }, [resolvedModel]); - // Update selectedModelKey if we had to fall back to a different model - useEffect(() => { - if (!resolvedModelKey) return; - if (resolvedModelKey === selectedModelKey) return; - - // Always keep UI selection consistent with the resolved model - setSelectedModelKey(resolvedModelKey); - - // Notify only once per modal lifecycle - if (didShowFallbackNoticeRef.current) return; - didShowFallbackNoticeRef.current = true; - logWarn("Selected model is no longer available. Falling back to a default model.", { - selectedModelKey, - resolvedModelKey, - }); - new Notice("Selected model is no longer available. Falling back to a default model."); - }, [resolvedModelKey, selectedModelKey]); + // Effective model key for the UI — falls back to user selection when resolution fails. + const effectiveModelKey = resolvedModelKey ?? userSelectedModelKey; // Use shared streaming hook const { @@ -277,12 +259,15 @@ function CustomCommandChatModalContent({ // Track the last input prompt for saving context on stop const lastInputPromptRef = useRef(""); - // Sync editedText with finalText when finalText changes - useEffect(() => { + // Sync editedText with finalText when finalText changes. Render-phase tracker + // preserves user edits made after the last finalText change until the next change. + const [prevFinalText, setPrevFinalText] = useState(finalText); + if (prevFinalText !== finalText) { + setPrevFinalText(finalText); if (finalText) { setEditedText(finalText); } - }, [finalText]); + } // Compute content state for MenuCommandModal const contentState: ContentState = useMemo(() => { @@ -453,7 +438,7 @@ function CustomCommandChatModalContent({ followUpValue={followUpValue} onFollowUpChange={setFollowUpValue} onFollowUpSubmit={handleFollowUpSubmit} - selectedModel={selectedModelKey} + selectedModel={effectiveModelKey} onSelectModel={handleModelChange} onStop={handleStop} onCopy={handleCopy} diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index 396014d1..2445a94c 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -88,7 +88,6 @@ 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 @@ -104,12 +103,6 @@ const ChatInternal: React.FC([]); - const [includeActiveNote, setIncludeActiveNote] = useState(false); - const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false); + const [includeActiveNote, setIncludeActiveNote] = useState( + settings.autoAddActiveContentToContext === true && currentChain !== ChainType.PROJECT_CHAIN + ); + const [includeActiveWebTab, setIncludeActiveWebTab] = useState( + settings.autoAddActiveContentToContext === true && currentChain !== ChainType.PROJECT_CHAIN + ); const [selectedImages, setSelectedImages] = useState([]); const [showChatUI, setShowChatUI] = useState(false); const [chatHistoryItems, setChatHistoryItems] = useState([]); @@ -182,10 +179,11 @@ const ChatInternal: React.FC { + const [prevProjectContextStatus, setPrevProjectContextStatus] = useState(projectContextStatus); + if (prevProjectContextStatus !== projectContextStatus) { + setPrevProjectContextStatus(projectContextStatus); setProgressCardVisible(null); - }, [projectContextStatus]); + } /** * Whether to show the indexing progress card. @@ -198,12 +196,22 @@ const ChatInternal: React.FC { + const [prevIndexingActivity, setPrevIndexingActivity] = useState({ + isActive: indexingState.isActive, + completionStatus: indexingState.completionStatus, + }); + if ( + prevIndexingActivity.isActive !== indexingState.isActive || + prevIndexingActivity.completionStatus !== indexingState.completionStatus + ) { + setPrevIndexingActivity({ + isActive: indexingState.isActive, + completionStatus: indexingState.completionStatus, + }); if (indexingState.isActive || indexingState.completionStatus !== "none") { setIndexingCardVisible(null); } - }, [indexingState.isActive, indexingState.completionStatus]); + } const handleIndexingCardClose = useCallback(() => { setIndexingCardVisible(false); @@ -228,11 +236,12 @@ const ChatInternal: React.FC { - if (chatHistory.length === 0) { - setLatestTokenCount(null); + const latestTokenCount = useMemo(() => { + for (let i = chatHistory.length - 1; i >= 0; i--) { + const m = chatHistory[i]; + if (m.sender === AI_SENDER) return m.responseMetadata?.tokenUsage?.totalTokens ?? null; } + return null; }, [chatHistory]); const [previousMode, setPreviousMode] = useState(null); @@ -687,7 +696,6 @@ const ChatInternal: React.FC ctx.sourceType === "web")?.url; clearSelectedTextContexts(); @@ -796,10 +804,19 @@ const ChatInternal: React.FC { + const [prevAutoAddTuple, setPrevAutoAddTuple] = useState({ + autoAdd: settings.autoAddActiveContentToContext, + chain: selectedChain, + }); + if ( + prevAutoAddTuple.autoAdd !== settings.autoAddActiveContentToContext || + prevAutoAddTuple.chain !== selectedChain + ) { + setPrevAutoAddTuple({ + autoAdd: settings.autoAddActiveContentToContext, + chain: selectedChain, + }); if (settings.autoAddActiveContentToContext !== undefined) { - // Only apply the setting if not in Project mode if (selectedChain === ChainType.PROJECT_CHAIN) { setIncludeActiveNote(false); setIncludeActiveWebTab(false); @@ -808,7 +825,7 @@ const ChatInternal: React.FC = ({ isStreaming, }) => { const [isExpanded, setIsExpanded] = useState(status === "reasoning"); + const [prevStatus, setPrevStatus] = useState(status); - // Auto-expand when reasoning, auto-collapse when done - useEffect(() => { + if (status !== prevStatus) { + setPrevStatus(status); if (status === "reasoning") { setIsExpanded(true); } else if (status === "collapsed" || status === "complete") { setIsExpanded(false); } - }, [status]); + } // Don't render anything if idle if (status === "idle") { diff --git a/src/components/chat-components/AtMentionTypeahead.tsx b/src/components/chat-components/AtMentionTypeahead.tsx index 3c81cb6e..d7081baa 100644 --- a/src/components/chat-components/AtMentionTypeahead.tsx +++ b/src/components/chat-components/AtMentionTypeahead.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useState } from "react"; import { TFile, TFolder } from "obsidian"; import { TypeaheadMenuPopover } from "./TypeaheadMenuPopover"; import { @@ -42,6 +42,8 @@ export function AtMentionTypeahead({ }>({ mode: "category", }); + const [prevIsOpen, setPrevIsOpen] = useState(isOpen); + const [prevResultsLength, setPrevResultsLength] = useState(0); const availableCategoryOptions = useAtMentionCategories(isCopilotPlus); @@ -161,8 +163,8 @@ export function AtMentionTypeahead({ [selectedIndex, searchResults, handleSelect, onClose, extendedState.mode, searchQuery] ); - // Reset state when menu closes - useEffect(() => { + if (isOpen !== prevIsOpen) { + setPrevIsOpen(isOpen); if (!isOpen) { setSearchQuery(""); setSelectedIndex(0); @@ -171,12 +173,12 @@ export function AtMentionTypeahead({ selectedCategory: undefined, }); } - }, [isOpen]); + } - // Reset selected index when options change - useEffect(() => { + if (searchResults.length !== prevResultsLength) { + setPrevResultsLength(searchResults.length); setSelectedIndex(0); - }, [searchResults.length]); + } if (!isOpen) { return null; diff --git a/src/components/chat-components/ChatInput.tsx b/src/components/chat-components/ChatInput.tsx index ea330d44..2fbb16a8 100644 --- a/src/components/chat-components/ChatInput.tsx +++ b/src/components/chat-components/ChatInput.tsx @@ -1,6 +1,5 @@ import { getCurrentProject, - ProjectConfig, subscribeToProjectChange, useChainType, useModelKey, @@ -24,7 +23,14 @@ import { isAllowedFileForNoteContext } from "@/utils"; import { getFileIdentityKey } from "@/utils/fileListUtils"; import { CornerDownLeft, Image, Loader2, StopCircle, X } from "lucide-react"; import { App, Notice, TFile, TFolder } from "obsidian"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { $getSelection, $isRangeSelection, LexicalEditor as LexicalEditorType } from "lexical"; import { ContextControl } from "./ContextControl"; import { $removePillsByPath } from "./pills/NotePillNode"; @@ -123,7 +129,6 @@ const ChatInput: React.FC = ({ const activeFile = app.workspace.getActiveFile(); return isAllowedFileForNoteContext(activeFile) ? activeFile : null; }); - const [selectedProject, setSelectedProject] = useState(null); const [notesFromPills, setNotesFromPills] = useState<{ path: string; basename: string }[]>([]); const [urlsFromPills, setUrlsFromPills] = useState([]); const [foldersFromPills, setFoldersFromPills] = useState([]); @@ -171,32 +176,26 @@ const ChatInput: React.FC = ({ "If you have many files in context, this can take a while...", ]; - // Sync autonomous agent toggle with settings and chain type - useEffect(() => { - if (currentChain === ChainType.PROJECT_CHAIN) { - // Force off in Projects mode - setAutonomousAgentToggle(false); - } else { - // In other modes, use the actual settings value - setAutonomousAgentToggle(settings.enableAutonomousAgent); - } - }, [settings.enableAutonomousAgent, currentChain]); + // Render-phase reset: re-derive autonomous agent toggle whenever chain or setting changes + const autonomousAgentSourceRef = useRef<{ + chain: ChainType; + enabled: boolean; + }>({ chain: currentChain, enabled: settings.enableAutonomousAgent }); + if ( + autonomousAgentSourceRef.current.chain !== currentChain || + autonomousAgentSourceRef.current.enabled !== settings.enableAutonomousAgent + ) { + autonomousAgentSourceRef.current = { + chain: currentChain, + enabled: settings.enableAutonomousAgent, + }; + setAutonomousAgentToggle( + currentChain === ChainType.PROJECT_CHAIN ? false : settings.enableAutonomousAgent + ); + } - useEffect(() => { - if (currentChain === ChainType.PROJECT_CHAIN) { - setSelectedProject(getCurrentProject()); - - const unsubscribe = subscribeToProjectChange((project) => { - setSelectedProject(project); - }); - - return () => { - unsubscribe(); - }; - } else { - setSelectedProject(null); - } - }, [currentChain]); + const subscribedProject = useSyncExternalStore(subscribeToProjectChange, getCurrentProject); + const selectedProject = currentChain === ChainType.PROJECT_CHAIN ? subscribedProject : null; useEffect(() => { if (!isProjectLoading) return; @@ -322,19 +321,25 @@ const ChatInput: React.FC = ({ }); }; - // Sync tool button states with tool pills - useEffect(() => { - if (!isCopilotPlus || autonomousAgentToggle) return; - - // Update button states based on current tool pills - const hasVault = toolsFromPills.includes("@vault"); - const hasWeb = toolsFromPills.includes("@websearch") || toolsFromPills.includes("@web"); - const hasComposer = toolsFromPills.includes("@composer"); - - setVaultToggle(hasVault); - setWebToggle(hasWeb); - setComposerToggle(hasComposer); - }, [toolsFromPills, isCopilotPlus, autonomousAgentToggle]); + // Render-phase reset: mirror tool pill presence into toggle state when inputs change. + // Users can still flip toggles independently via ChatToolControls. + const toolPillSourceRef = useRef<{ + tools: string[]; + isCopilotPlus: boolean; + autonomousAgentToggle: boolean; + }>({ tools: toolsFromPills, isCopilotPlus, autonomousAgentToggle }); + if ( + toolPillSourceRef.current.tools !== toolsFromPills || + toolPillSourceRef.current.isCopilotPlus !== isCopilotPlus || + toolPillSourceRef.current.autonomousAgentToggle !== autonomousAgentToggle + ) { + toolPillSourceRef.current = { tools: toolsFromPills, isCopilotPlus, autonomousAgentToggle }; + if (isCopilotPlus && !autonomousAgentToggle) { + setVaultToggle(toolsFromPills.includes("@vault")); + setWebToggle(toolsFromPills.includes("@websearch") || toolsFromPills.includes("@web")); + setComposerToggle(toolsFromPills.includes("@composer")); + } + } // Handle when context notes are removed from the context menu // This should remove all corresponding pills from the editor @@ -579,43 +584,34 @@ const ChatInput: React.FC = ({ }); }, [notesFromPills, app.vault, setContextNotes]); - // URL pill-to-context synchronization (when URL pills are added) - only for Plus chains + // Pill state is owned by the Lexical editor; absorb new entries into our context arrays + // without removing user-added ones (removal is handled in the dedicated handlers above). useEffect(() => { if (isPlusChain(currentChain)) { + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setContextUrls((prev) => { const contextUrlSet = new Set(prev); - - // Find URLs that need to be added - const newUrlsFromPills = urlsFromPills.filter((pillUrl) => { - // Only add if not already in context - return !contextUrlSet.has(pillUrl); - }); - - // Add completely new URLs from pills + const newUrlsFromPills = urlsFromPills.filter((pillUrl) => !contextUrlSet.has(pillUrl)); if (newUrlsFromPills.length > 0) { return Array.from(new Set([...prev, ...newUrlsFromPills])); } - return prev; }); } else { - // Clear URLs for non-Plus chains + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setContextUrls([]); } }, [urlsFromPills, currentChain]); - // Folder-to-context synchronization (when folders are added via pills) + // Pill state is owned by the Lexical editor; absorb new entries into context folders + // without removing user-added ones (removal is handled in handleFolderPillsRemoved). useEffect(() => { + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setContextFolders((prev) => { const contextFolderPaths = new Set(prev); - - // Find folders that need to be added - const newFoldersFromPills = foldersFromPills.filter((pillFolder) => { - // Only add if not already in context - return !contextFolderPaths.has(pillFolder); - }); - - // Add completely new folders from pills + const newFoldersFromPills = foldersFromPills.filter( + (pillFolder) => !contextFolderPaths.has(pillFolder) + ); return [...prev, ...newFoldersFromPills]; }); }, [foldersFromPills]); diff --git a/src/components/chat-components/ChatMessages.tsx b/src/components/chat-components/ChatMessages.tsx index d4a15a41..e7971712 100644 --- a/src/components/chat-components/ChatMessages.tsx +++ b/src/components/chat-components/ChatMessages.tsx @@ -53,6 +53,7 @@ const ChatMessages = memo( setLoadingDots((dots) => (dots.length < 6 ? dots + "." : "")); }, 200); } else { + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setLoadingDots(""); } return () => window.clearInterval(intervalId); diff --git a/src/components/chat-components/ChatSettingsPopover.tsx b/src/components/chat-components/ChatSettingsPopover.tsx index abb57da4..aefafaad 100644 --- a/src/components/chat-components/ChatSettingsPopover.tsx +++ b/src/components/chat-components/ChatSettingsPopover.tsx @@ -45,6 +45,11 @@ export function ChatSettingsPopover() { // Local editing state const [localModel, setLocalModel] = useState(originalModel); + const [prevModelKey, setPrevModelKey] = useState(modelKey); + if (prevModelKey !== modelKey) { + setPrevModelKey(modelKey); + setLocalModel(originalModel); + } // System prompt state (session-level, in-memory) const prompts = useSystemPrompts(); @@ -71,11 +76,6 @@ export function ChatSettingsPopover() { const [showConfirmation, setShowConfirmation] = useState(false); const confirmationRef = useRef(null); - // Update local state when original model changes (e.g., switching models) - useEffect(() => { - setLocalModel(originalModel); - }, [originalModel]); - // Auto-scroll to confirmation box when it appears useEffect(() => { if (showConfirmation && confirmationRef.current) { diff --git a/src/components/chat-components/ChatSingleMessage.tsx b/src/components/chat-components/ChatSingleMessage.tsx index a944bd47..53845906 100644 --- a/src/components/chat-components/ChatSingleMessage.tsx +++ b/src/components/chat-components/ChatSingleMessage.tsx @@ -37,7 +37,7 @@ import { ChatMessage } from "@/types/message"; import { cleanMessageForCopy, extractYoutubeVideoId, insertIntoEditor } from "@/utils"; import { preprocessAIResponse } from "@/utils/markdownPreprocess"; import { App, Component, MarkdownRenderer, MarkdownView, TFile } from "obsidian"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSettingsValue } from "@/settings/model"; import { buildCopilotCollapsibleDomId, @@ -314,12 +314,24 @@ const ChatSingleMessage: React.FC = ({ }) => { const [isCopied, setIsCopied] = useState(false); const [isEditing, setIsEditing] = useState(false); - // Agent Reasoning Block state - const [reasoningData, setReasoningData] = useState<{ + const parsedReasoningBlock = useMemo( + () => parseReasoningBlock(message.message), + [message.message] + ); + const reasoningData = useMemo<{ status: "reasoning" | "collapsed" | "complete"; elapsedSeconds: number; steps: string[]; - } | null>(null); + } | null>(() => { + if (!parsedReasoningBlock?.hasReasoning || parsedReasoningBlock.status === "idle") { + return null; + } + return { + status: parsedReasoningBlock.status, + elapsedSeconds: parsedReasoningBlock.elapsedSeconds, + steps: parsedReasoningBlock.steps, + }; + }, [parsedReasoningBlock]); const contentRef = useRef(null); const componentRef = useRef(null); const isUnmountingRef = useRef(false); @@ -661,20 +673,8 @@ const ChatSingleMessage: React.FC = ({ const originMessage = message.message; - // Parse and extract agent reasoning block if present - const reasoningBlockData = parseReasoningBlock(originMessage); - if (reasoningBlockData?.hasReasoning && reasoningBlockData.status !== "idle") { - setReasoningData({ - status: reasoningBlockData.status, - elapsedSeconds: reasoningBlockData.elapsedSeconds, - steps: reasoningBlockData.steps, - }); - } else { - setReasoningData(null); - } - // Use content after reasoning block (or full message if no reasoning block) - const messageContent = reasoningBlockData?.contentAfter ?? originMessage; + const messageContent = parsedReasoningBlock?.contentAfter ?? originMessage; const processedMessage = preprocess(messageContent); const parsedMessage = parseToolCallMarkers(processedMessage, messageId.current); @@ -848,7 +848,15 @@ const ChatSingleMessage: React.FC = ({ return () => { isUnmountingRef.current = true; }; - }, [message, app, componentRef, isStreaming, preprocess, collapsibleOpenStateMap]); + }, [ + message, + app, + componentRef, + isStreaming, + preprocess, + collapsibleOpenStateMap, + parsedReasoningBlock, + ]); // Cleanup effect that only runs on component unmount useEffect(() => { diff --git a/src/components/chat-components/ContextBadges.tsx b/src/components/chat-components/ContextBadges.tsx index 8d6be07a..86fe45a1 100644 --- a/src/components/chat-components/ContextBadges.tsx +++ b/src/components/chat-components/ContextBadges.tsx @@ -51,33 +51,35 @@ interface FaviconOrGlobeProps { className?: string; } +function FaviconImage({ faviconUrl, className }: { faviconUrl: string; className: string }) { + const [failed, setFailed] = React.useState(false); + if (failed) { + return ; + } + return ( + setFailed(true)} + /> + ); +} + export function FaviconOrGlobe({ faviconUrl, isLoaded = true, className = "tw-size-3", }: FaviconOrGlobeProps) { - const [showFavicon, setShowFavicon] = React.useState(Boolean(faviconUrl)); - - React.useEffect(() => { - setShowFavicon(Boolean(faviconUrl)); - }, [faviconUrl]); - if (!isLoaded) { return ; } - if (showFavicon && faviconUrl) { - return ( - setShowFavicon(false)} - /> - ); + if (faviconUrl) { + return ; } return ; diff --git a/src/components/chat-components/ProjectList.tsx b/src/components/chat-components/ProjectList.tsx index b24c5497..b1785348 100644 --- a/src/components/chat-components/ProjectList.tsx +++ b/src/components/chat-components/ProjectList.tsx @@ -34,7 +34,14 @@ import { } from "lucide-react"; import { getCachedProjectRecordById } from "@/projects/state"; import { App, Notice } from "obsidian"; -import React, { memo, useEffect, useMemo, useState } from "react"; +import React, { + memo, + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, +} from "react"; import { filterProjects } from "@/utils/projectUtils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -46,22 +53,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip function useRecentUsageManagerRevision( manager: RecentUsageManager | null | undefined ): number { - const [revision, setRevision] = useState(() => manager?.getRevision() ?? 0); - - useEffect(() => { - if (!manager) { - setRevision(0); - return; - } - - setRevision(manager.getRevision()); - - return manager.subscribe(() => { - setRevision(manager.getRevision()); - }); - }, [manager]); - - return revision; + const subscribe = useCallback( + (cb: () => void) => manager?.subscribe(cb) ?? (() => {}), + [manager] + ); + const getSnapshot = useCallback(() => manager?.getRevision() ?? 0, [manager]); + return useSyncExternalStore(subscribe, getSnapshot); } function ProjectItem({ @@ -219,15 +216,21 @@ export const ProjectList = memo( // Safety net: if the selected project disappears from the list (delete/move/duplicate-id), // clear local UI selection. Don't call setCurrentProject(null) here — ProjectManager's // records subscriber handles the atom update with save-first ordering via switchProject(null). + // Consolidated into a single effect so the local resets and the parent showChatUI(false) + // land in the same committed transition. Splitting them caused render-phase setState to + // flip the "missing" flag back to false before any effect observed the transition, + // leaving the chat UI open with no project selected. useEffect(() => { if (!selectedProject) return; const stillExists = projects.some((p) => p.id === selectedProject.id); - if (!stillExists) { - setSelectedProject(null); - setShowChatInput(false); - setIsOpen(true); - showChatUI(false); - } + if (stillExists) return; + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect + setSelectedProject(null); + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect + setShowChatInput(false); + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect + setIsOpen(true); + showChatUI(false); }, [projects, selectedProject, showChatUI]); // Get the project usage manager for subscription @@ -235,12 +238,14 @@ export const ProjectList = memo( plugin?.projectManager?.getProjectUsageTimestampsManager?.(); const projectUsageRevision = useRecentUsageManagerRevision(projectUsageTimestampsManager); - // Auto collapse when messages appear - useEffect(() => { - if (hasMessages) { - setIsOpen(false); - } - }, [hasMessages]); + // Auto collapse when messages first appear; user can re-open manually. + const [prevHasMessages, setPrevHasMessages] = useState(hasMessages); + if (!prevHasMessages && hasMessages) { + setPrevHasMessages(true); + setIsOpen(false); + } else if (prevHasMessages && !hasMessages) { + setPrevHasMessages(false); + } // Sort projects based on sort strategy // Note: projectUsageRevision triggers re-sort when in-memory timestamps change, diff --git a/src/components/chat-components/TypeaheadMenuContent.tsx b/src/components/chat-components/TypeaheadMenuContent.tsx index 9f9544b7..c710648d 100644 --- a/src/components/chat-components/TypeaheadMenuContent.tsx +++ b/src/components/chat-components/TypeaheadMenuContent.tsx @@ -52,11 +52,12 @@ export function TypeaheadMenuContent({ const selectedItemRef = useRef(null); const searchInputRef = useRef(null); const [hoveredIndex, setHoveredIndex] = React.useState(null); + const [prevSelectedIndex, setPrevSelectedIndex] = React.useState(selectedIndex); - // Clear hover state when keyboard navigation changes selection - useEffect(() => { + if (selectedIndex !== prevSelectedIndex) { + setPrevSelectedIndex(selectedIndex); setHoveredIndex(null); - }, [selectedIndex]); + } // Scroll the selected item into view when selection changes useEffect(() => { diff --git a/src/components/chat-components/TypeaheadMenuPortal.tsx b/src/components/chat-components/TypeaheadMenuPortal.tsx index fc21bdad..cdae9f26 100644 --- a/src/components/chat-components/TypeaheadMenuPortal.tsx +++ b/src/components/chat-components/TypeaheadMenuPortal.tsx @@ -92,6 +92,7 @@ export function TypeaheadMenuPortal({ const maxLeft = win.innerWidth - containerWidth - 8; const left = Math.min(Math.max(rect.left, minLeft), maxLeft); + // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect setPosition({ top, left, diff --git a/src/components/chat-components/plugins/AtMentionCommandPlugin.tsx b/src/components/chat-components/plugins/AtMentionCommandPlugin.tsx index 2d109cdc..df622986 100644 --- a/src/components/chat-components/plugins/AtMentionCommandPlugin.tsx +++ b/src/components/chat-components/plugins/AtMentionCommandPlugin.tsx @@ -39,7 +39,11 @@ export function AtMentionCommandPlugin({ const availableCategoryOptions = useAtMentionCategories(isCopilotPlus); // Load note content for preview using shared utilities - const loadNoteContentForPreview = useCallback(async (file: TFile) => { + const loadNoteContentForPreview = useCallback(async (file: TFile | null) => { + if (!file) { + setCurrentPreviewContent(""); + return; + } try { // Handle PDF and canvas files - treat as empty content (no preview) if (file.extension === "pdf" || file.extension === "canvas") { @@ -148,8 +152,8 @@ export function AtMentionCommandPlugin({ onStateChange: onStateChangeCallback, }); - // Load preview content when selection changes - useEffect(() => { + // Compute the currently selected file for preview, or null if not a note + const selectedFile = useMemo(() => { const selectedOption = searchResults[state.selectedIndex]; if ( selectedOption && @@ -157,11 +161,15 @@ export function AtMentionCommandPlugin({ selectedOption.category === "notes" && selectedOption.data instanceof TFile ) { - void loadNoteContentForPreview(selectedOption.data); - } else { - setCurrentPreviewContent(""); + return selectedOption.data; } - }, [state.selectedIndex, searchResults, isAtMentionOption, loadNoteContentForPreview]); + return null; + }, [state.selectedIndex, searchResults, isAtMentionOption]); + + // Load preview content when selected file changes + useEffect(() => { + void loadNoteContentForPreview(selectedFile); + }, [selectedFile, loadNoteContentForPreview]); // Create display options with preview content for the highlighted note const displayOptions = useMemo(() => { diff --git a/src/components/command-ui/content-area.tsx b/src/components/command-ui/content-area.tsx index 6ec237b9..6dfae06f 100644 --- a/src/components/command-ui/content-area.tsx +++ b/src/components/command-ui/content-area.tsx @@ -62,11 +62,13 @@ export function ContentArea({ // This covers both type transitions (idle→loading) and same-type transitions // (result→result with isStreaming flipping true for follow-up generation). const isGenerating = state.type === "loading" || (state.type === "result" && state.isStreaming); - React.useEffect(() => { - if (isGenerating) { - setIsEditMode(false); - } - }, [isGenerating]); + const [prevIsGenerating, setPrevIsGenerating] = React.useState(isGenerating); + if (isGenerating && !prevIsGenerating) { + setPrevIsGenerating(true); + setIsEditMode(false); + } else if (!isGenerating && prevIsGenerating) { + setPrevIsGenerating(false); + } // Auto-scroll to bottom when streaming React.useEffect(() => { diff --git a/src/components/command-ui/draggable-modal.tsx b/src/components/command-ui/draggable-modal.tsx index 3d40786b..34a600c0 100644 --- a/src/components/command-ui/draggable-modal.tsx +++ b/src/components/command-ui/draggable-modal.tsx @@ -111,15 +111,28 @@ export function DraggableModal({ [rawHandleMouseDown] ); + // Resize state (height and width) + const [heightPx, setHeightPx] = useState(null); + const [widthPx, setWidthPx] = useState(null); + // Reason: Reset transient state when the modal reopens so that stale // drag/resize/anchor state from a previous session does not leak. + // Render-phase prev-open tracker resets the size state on false→true transition; + // ref resets and the initialPosition setter live in an effect since refs aren't + // subject to the no-direct-set-state rule and setPosition belongs to a child hook. + const [prevOpen, setPrevOpen] = useState(open); + if (open && !prevOpen) { + setPrevOpen(true); + setHeightPx(null); + setWidthPx(null); + } else if (!open && prevOpen) { + setPrevOpen(false); + } useEffect(() => { if (!open) return; pendingDragCleanupRef.current?.(); pendingDragCleanupRef.current = null; isManualPositionRef.current = false; - setHeightPx(null); - setWidthPx(null); if (initialPosition) { setPosition(initialPosition); } @@ -133,10 +146,6 @@ export function DraggableModal({ }; }, []); - // Resize state (height and width) - const [heightPx, setHeightPx] = useState(null); - const [widthPx, setWidthPx] = useState(null); - // When resizable, lock an initial height so streaming/content won't "push" the modal taller. useLayoutEffect(() => { if (!open || !resizable) return; @@ -156,13 +165,10 @@ export function DraggableModal({ } }, [open, resizable, heightPx, widthPx, minHeight, dragRef]); - // Auto-expand height when minHeight increases (e.g., ContentArea becomes visible) - useEffect(() => { - if (!resizable || heightPx === null) return; - if (heightPx < minHeight) { - setHeightPx(minHeight); - } - }, [resizable, minHeight, heightPx]); + // Auto-expand height when minHeight increases (e.g., ContentArea becomes visible). + // Derived: effectiveHeight clamps the user's explicit resize to the current minHeight + // without writing back into heightPx state. + const effectiveHeight = heightPx === null ? null : Math.max(heightPx, minHeight); // Reason: For "above" placement, keep the panel's bottom edge anchored. // When height increases (e.g., ContentArea appears), shift position.y up so the @@ -170,14 +176,14 @@ export function DraggableModal({ // Uses useLayoutEffect to apply before paint, preventing visual flash. useLayoutEffect(() => { if (anchorBottom === undefined || isManualPositionRef.current) return; - if (heightPx === null) return; + if (effectiveHeight === null) return; - const newY = Math.max(12, anchorBottom - heightPx); + const newY = Math.max(12, anchorBottom - effectiveHeight); // Guard: only update if position actually changed (avoid infinite re-render) if (Math.abs(position.y - newY) < 1) return; setPosition({ x: position.x, y: newY }); - }, [anchorBottom, heightPx, position.x, position.y, setPosition]); + }, [anchorBottom, effectiveHeight, position.x, position.y, setPosition]); // Reason: Generic overflow correction for non-anchored panels. // If content/minHeight growth pushes the modal below the viewport edge, @@ -187,15 +193,15 @@ export function DraggableModal({ // height changes. useLayoutEffect(() => { if (anchorBottom !== undefined || isManualPositionRef.current) return; - if (heightPx === null) return; + if (effectiveHeight === null) return; const ownerWindow = dragRef.current?.win ?? window; - const maxY = ownerWindow.innerHeight - 12 - heightPx; + const maxY = ownerWindow.innerHeight - 12 - effectiveHeight; const newY = Math.max(12, Math.min(position.y, maxY)); if (Math.abs(position.y - newY) < 1) return; setPosition({ x: position.x, y: newY }); - }, [anchorBottom, heightPx, position.x, position.y, setPosition, dragRef]); + }, [anchorBottom, effectiveHeight, position.x, position.y, setPosition, dragRef]); const getResizeRect = useCallback(() => { return dragRef.current?.getBoundingClientRect() ?? null; @@ -305,7 +311,7 @@ export function DraggableModal({ )} style={{ width: resizable && widthPx !== null ? widthPx : width, - ...(resizable && heightPx !== null ? { height: heightPx } : {}), + ...(resizable && effectiveHeight !== null ? { height: effectiveHeight } : {}), }} > {/* Header: drag handle + close button (flex-none) */} diff --git a/src/components/ui/setting-slider.tsx b/src/components/ui/setting-slider.tsx index 73f8299b..c59689bf 100644 --- a/src/components/ui/setting-slider.tsx +++ b/src/components/ui/setting-slider.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { cn } from "@/lib/utils"; import { Slider } from "@/components/ui/slider"; @@ -23,20 +23,18 @@ export function SettingSlider({ className, suffix, }: SettingSliderProps) { - // Internal state for smooth updates - const [localValue, setLocalValue] = useState(initialValue); - - // Update local value when prop value changes - useEffect(() => { - setLocalValue(initialValue); - }, [initialValue]); + const [dragValue, setDragValue] = useState(null); + const displayedValue = dragValue ?? initialValue; return (
setLocalValue(value)} - onValueCommit={([value]) => onChange?.(value)} + value={[displayedValue]} + onValueChange={([value]) => setDragValue(value)} + onValueCommit={([value]) => { + setDragValue(null); + onChange?.(value); + }} min={min} max={max} step={step} @@ -44,9 +42,9 @@ export function SettingSlider({ className="tw-flex-1" />
- {localValue >= 1000 - ? `${localValue % 1000 === 0 ? localValue / 1000 : (localValue / 1000).toFixed(1)}k` - : localValue} + {displayedValue >= 1000 + ? `${displayedValue % 1000 === 0 ? displayedValue / 1000 : (displayedValue / 1000).toFixed(1)}k` + : displayedValue} {suffix}
diff --git a/src/settings/v2/components/ApiKeyDialog.tsx b/src/settings/v2/components/ApiKeyDialog.tsx index aa379e79..3f734a7d 100644 --- a/src/settings/v2/components/ApiKeyDialog.tsx +++ b/src/settings/v2/components/ApiKeyDialog.tsx @@ -10,7 +10,7 @@ import { getNeedSetKeyProvider, getProviderInfo, getProviderLabel } from "@/util import { ChevronDown, ChevronRight, ChevronUp, Info } from "lucide-react"; import { getApiKeyForProvider } from "@/utils/modelUtils"; import { App, Modal } from "obsidian"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { createRoot, Root } from "react-dom/client"; interface ApiKeyModalContentProps { @@ -28,10 +28,6 @@ function ApiKeyModalContent({ onClose, onGoToModelTab }: ApiKeyModalContentProps useSettingsValue(); const [expandedProvider, setExpandedProvider] = useState(null); - useEffect(() => { - setExpandedProvider(null); - }, []); - const providers: ProviderKeyItem[] = getNeedSetKeyProvider().map((provider) => { const providerKey = provider as SettingKeyProviders; const apiKey = getApiKeyForProvider(providerKey); @@ -115,6 +111,7 @@ function ApiKeyModalContent({ onClose, onGoToModelTab }: ApiKeyModalContentProps GitHubCopilotProvider.getInstance()); - const [authStep, setAuthStep] = useState("idle"); + const [authStep, setAuthStep] = useState(() => + copilotProvider.getAuthState().status === "authenticated" ? "done" : "idle" + ); const [deviceCode, setDeviceCode] = useState(null); const [pollCount, setPollCount] = useState(0); const [error, setError] = useState(null); @@ -30,6 +32,33 @@ export function GitHubCopilotAuth() { const authRequestIdRef = useRef(0); const isMountedRef = useRef(true); + // Render-phase reset: re-derive authStep when the underlying auth tokens change. + // GitHubCopilotProvider has no subscribe API, so we track the token tuple instead. + const [prevTokens, setPrevTokens] = useState({ + token: settings.githubCopilotToken, + accessToken: settings.githubCopilotAccessToken, + expiresAt: settings.githubCopilotTokenExpiresAt, + }); + if ( + prevTokens.token !== settings.githubCopilotToken || + prevTokens.accessToken !== settings.githubCopilotAccessToken || + prevTokens.expiresAt !== settings.githubCopilotTokenExpiresAt + ) { + setPrevTokens({ + token: settings.githubCopilotToken, + accessToken: settings.githubCopilotAccessToken, + expiresAt: settings.githubCopilotTokenExpiresAt, + }); + const state = copilotProvider.getAuthState(); + if (state.status === "authenticated") { + if (authStep !== "pending" && authStep !== "polling") { + setAuthStep("done"); + } + } else if (authStep === "done") { + setAuthStep("idle"); + } + } + // Cleanup on unmount: abort polling and prevent setState useEffect(() => { isMountedRef.current = true; @@ -41,34 +70,6 @@ export function GitHubCopilotAuth() { }; }, [copilotProvider]); - // Check initial auth state - useEffect(() => { - const state = copilotProvider.getAuthState(); - if (state.status === "authenticated") { - setAuthStep("done"); - } - }, [copilotProvider]); - - // Update auth step when settings change - reuse getAuthState() for consistency - useEffect(() => { - const state = copilotProvider.getAuthState(); - if (state.status === "authenticated") { - // Don't override in-flight auth UI during polling - if (authStep !== "pending" && authStep !== "polling") { - setAuthStep("done"); - } - } else if (authStep === "done") { - // Token expired or cleared, reset to idle - setAuthStep("idle"); - } - }, [ - settings.githubCopilotToken, - settings.githubCopilotAccessToken, - settings.githubCopilotTokenExpiresAt, - copilotProvider, - authStep, - ]); - /** * Runs the polling flow to complete OAuth authorization. * Shared by handleStartAuth (after getting device code) and handleRetryPolling. diff --git a/src/settings/v2/components/ModelEditDialog.tsx b/src/settings/v2/components/ModelEditDialog.tsx index 1a983fe5..e9eb9693 100644 --- a/src/settings/v2/components/ModelEditDialog.tsx +++ b/src/settings/v2/components/ModelEditDialog.tsx @@ -19,7 +19,7 @@ import { getSettings } from "@/settings/model"; import { debounce, getProviderInfo, getProviderLabel } from "@/utils"; import { getApiKeyForProvider } from "@/utils/modelUtils"; import { App, Modal, Platform } from "obsidian"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { createRoot, Root } from "react-dom/client"; import { ModelParametersEditor } from "@/components/ui/ModelParametersEditor"; @@ -40,21 +40,20 @@ export const ModelEditModalContent: React.FC = ({ isEmbeddingModel, onCancel, }) => { + // Reason: `model` is passed in once when ModelEditModal renders its React root + // (see class below) and never changes for the lifetime of this component — the + // modal unmounts on close and a new instance is constructed for each edit. + // No prop→state sync is needed; `useState(model)` at mount is sufficient. const [localModel, setLocalModel] = useState(model); - const [originalModel, setOriginalModel] = useState(model); - const [providerInfo, setProviderInfo] = useState({} as ProviderMetadata); + const originalModel = model; + const providerInfo = useMemo( + () => (model.provider ? getProviderInfo(model.provider) : ({} as ProviderMetadata)), + [model.provider] + ); const settings = getSettings(); const isBedrockProvider = (localModel.provider as ChatModelProviders) === ChatModelProviders.AMAZON_BEDROCK; - useEffect(() => { - setLocalModel(model); - setOriginalModel(model); - if (model.provider) { - setProviderInfo(getProviderInfo(model.provider)); - } - }, [model]); - // Debounce the onUpdate callback const debouncedOnUpdate = useMemo( () => diff --git a/src/settings/v2/components/ModelImporter.tsx b/src/settings/v2/components/ModelImporter.tsx index e44ef9a4..8c2fea8a 100644 --- a/src/settings/v2/components/ModelImporter.tsx +++ b/src/settings/v2/components/ModelImporter.tsx @@ -118,15 +118,6 @@ export function ModelImporter({ } }, [provider, isReady]); - // Reset cached models when provider or credentials change - // This ensures the model list is refreshed after re-authentication or API key rotation - useEffect(() => { - setModels(null); - setSelectedModel(null); - setError(null); - setVerificationMessage(null); - }, [provider, credentialVersion]); - // Auto-load models when expanded and ready useEffect(() => { if (expanded && isReady && models === null && !loading && !error) { diff --git a/src/settings/v2/components/PlusSettings.tsx b/src/settings/v2/components/PlusSettings.tsx index 32a9a7de..9e66d268 100644 --- a/src/settings/v2/components/PlusSettings.tsx +++ b/src/settings/v2/components/PlusSettings.tsx @@ -7,7 +7,7 @@ import { PLUS_UTM_MEDIUMS } from "@/constants"; import { checkIsPlusUser, navigateToPlusPage, useIsPlusUser } from "@/plusUtils"; import { updateSetting, useSettingsValue } from "@/settings/model"; import { ExternalLink, Loader2 } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; export function PlusSettings() { const app = useApp(); @@ -16,9 +16,6 @@ export function PlusSettings() { const [isChecking, setIsChecking] = useState(false); const isPlusUser = useIsPlusUser(); const [localLicenseKey, setLocalLicenseKey] = useState(settings.plusLicenseKey); - useEffect(() => { - setLocalLicenseKey(settings.plusLicenseKey); - }, [settings.plusLicenseKey]); return (