chore(lint): fix no-direct-set-state-in-use-effect violations (#2454)

* chore(lint): fix no-direct-set-state-in-use-effect violations

Replaced 56 setState-in-useEffect anti-patterns across 21 files with
idiomatic React patterns: derived state via useMemo, useSyncExternalStore
for external stores, key-prop resets, render-phase prev-value trackers,
and lazy useState initializers. Deleted dead defensive syncs in
PlusSettings, ModelEditDialog, and the fallback-notice effect in
CustomCommandChatModal. Narrowly suppressed three legitimate cases
(interval-driven animation, DOM measurement, Lexical pill→context absorb).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* address codex comment

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-05-14 16:23:55 -07:00 committed by GitHub
parent 7b62cde598
commit a92012dc4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 321 additions and 303 deletions

View file

@ -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<string>("");
// 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}

View file

@ -88,7 +88,6 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const [currentChain] = useChainType();
const [currentAiMessage, setCurrentAiMessage] = useState("");
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
@ -104,12 +103,6 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const messageToAdd = shouldAttachId ? { ...message, id: streamingId } : message;
rawAddMessage(messageToAdd);
if (
messageToAdd.sender === AI_SENDER &&
messageToAdd.responseMetadata?.tokenUsage?.totalTokens
) {
setLatestTokenCount(messageToAdd.responseMetadata.tokenUsage.totalTokens);
}
},
[rawAddMessage]
);
@ -122,8 +115,12 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
const [loading, setLoading] = useState(false);
const [loadingMessage, setLoadingMessage] = useState(LOADING_MESSAGES.DEFAULT);
const [contextNotes, setContextNotes] = useState<TFile[]>([]);
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<File[]>([]);
const [showChatUI, setShowChatUI] = useState(false);
const [chatHistoryItems, setChatHistoryItems] = useState<ChatHistoryItem[]>([]);
@ -182,10 +179,11 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
return projectContextStatus === "loading" || projectContextStatus === "error";
};
// Reset user preference when status changes to allow default behavior
useEffect(() => {
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<ChatProps & { chatInput: ReturnType<typeof useChatI
return indexingState.isActive || indexingState.completionStatus !== "none";
};
// Allow the card to show whenever new indexing activity or completion is detected
useEffect(() => {
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<ChatProps & { chatInput: ReturnType<typeof useChatI
await VectorStoreManager.getInstance().cancelIndexing();
}, []);
// Clear token count when chat is cleared or replaced (e.g., loading chat history)
useEffect(() => {
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<ChainType | null>(null);
@ -687,7 +696,6 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
// Additional UI state reset specific to this component
safeSet.setCurrentAiMessage("");
setContextNotes([]);
setLatestTokenCount(null); // Clear token count on new chat
// Capture web selection URL before clearing for suppression
const webSelectionUrl = selectedTextContexts.find((ctx) => ctx.sourceType === "web")?.url;
clearSelectedTextContexts();
@ -796,10 +804,19 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
};
}, [eventTarget, handleStopGenerating]);
// Use the autoAddActiveContentToContext setting
useEffect(() => {
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<ChatProps & { chatInput: ReturnType<typeof useChatI
setIncludeActiveWebTab(settings.autoAddActiveContentToContext);
}
}
}, [settings.autoAddActiveContentToContext, selectedChain]);
}
// Note: pendingMessages loading has been removed as ChatManager now handles
// message persistence and loading automatically based on project context

View file

@ -2,7 +2,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { cn } from "@/lib/utils";
import { ReasoningStatus } from "@/LLMProviders/chainRunner/utils/AgentReasoningState";
import { ChevronRight } from "lucide-react";
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
/**
* Props for the AgentReasoningBlock component
@ -106,15 +106,16 @@ export const AgentReasoningBlock: React.FC<AgentReasoningBlockProps> = ({
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") {

View file

@ -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;

View file

@ -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<ChatInputProps> = ({
const activeFile = app.workspace.getActiveFile();
return isAllowedFileForNoteContext(activeFile) ? activeFile : null;
});
const [selectedProject, setSelectedProject] = useState<ProjectConfig | null>(null);
const [notesFromPills, setNotesFromPills] = useState<{ path: string; basename: string }[]>([]);
const [urlsFromPills, setUrlsFromPills] = useState<string[]>([]);
const [foldersFromPills, setFoldersFromPills] = useState<string[]>([]);
@ -171,32 +176,26 @@ const ChatInput: React.FC<ChatInputProps> = ({
"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<ChatInputProps> = ({
});
};
// 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<ChatInputProps> = ({
});
}, [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]);

View file

@ -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);

View file

@ -45,6 +45,11 @@ export function ChatSettingsPopover() {
// Local editing state
const [localModel, setLocalModel] = useState<CustomModel | undefined>(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<HTMLDivElement>(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) {

View file

@ -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<ChatSingleMessageProps> = ({
}) => {
const [isCopied, setIsCopied] = useState<boolean>(false);
const [isEditing, setIsEditing] = useState<boolean>(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<HTMLDivElement>(null);
const componentRef = useRef<Component | null>(null);
const isUnmountingRef = useRef<boolean>(false);
@ -661,20 +673,8 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
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<ChatSingleMessageProps> = ({
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(() => {

View file

@ -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 <Globe className={className} />;
}
return (
<img
src={faviconUrl}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
decoding="async"
className={cn(className, "tw-rounded-sm")}
onError={() => setFailed(true)}
/>
);
}
export function FaviconOrGlobe({
faviconUrl,
isLoaded = true,
className = "tw-size-3",
}: FaviconOrGlobeProps) {
const [showFavicon, setShowFavicon] = React.useState<boolean>(Boolean(faviconUrl));
React.useEffect(() => {
setShowFavicon(Boolean(faviconUrl));
}, [faviconUrl]);
if (!isLoaded) {
return <CircleDashed className={cn(className, "tw-text-muted")} />;
}
if (showFavicon && faviconUrl) {
return (
<img
src={faviconUrl}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
decoding="async"
className={cn(className, "tw-rounded-sm")}
onError={() => setShowFavicon(false)}
/>
);
if (faviconUrl) {
return <FaviconImage key={faviconUrl} faviconUrl={faviconUrl} className={className} />;
}
return <Globe className={className} />;

View file

@ -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<Key extends string>(
manager: RecentUsageManager<Key> | 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,

View file

@ -52,11 +52,12 @@ export function TypeaheadMenuContent({
const selectedItemRef = useRef<HTMLDivElement | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const [hoveredIndex, setHoveredIndex] = React.useState<number | null>(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(() => {

View file

@ -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,

View file

@ -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<TFile | null>(() => {
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(() => {

View file

@ -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(() => {

View file

@ -111,15 +111,28 @@ export function DraggableModal({
[rawHandleMouseDown]
);
// Resize state (height and width)
const [heightPx, setHeightPx] = useState<number | null>(null);
const [widthPx, setWidthPx] = useState<number | null>(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<number | null>(null);
const [widthPx, setWidthPx] = useState<number | null>(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) */}

View file

@ -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<number | null>(null);
const displayedValue = dragValue ?? initialValue;
return (
<div className={cn("tw-flex tw-items-center tw-gap-4", className)}>
<Slider
value={[localValue]}
onValueChange={([value]) => 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"
/>
<div className="tw-min-w-[60px] tw-text-right tw-text-sm tw-tabular-nums">
{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}
</div>
</div>

View file

@ -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<SettingKeyProviders | null>(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
<Collapsible open={isExpanded} className="tw-mt-2">
<CollapsibleContent className="tw-rounded-md tw-p-3">
<ModelImporter
key={`${item.provider}:${item.apiKey}`}
provider={item.provider}
isReady={Boolean(item.apiKey)}
expanded={isExpanded}

View file

@ -22,7 +22,9 @@ type AuthStep = "idle" | "pending" | "polling" | "done" | "error";
export function GitHubCopilotAuth() {
const settings = useSettingsValue();
const [copilotProvider] = useState(() => GitHubCopilotProvider.getInstance());
const [authStep, setAuthStep] = useState<AuthStep>("idle");
const [authStep, setAuthStep] = useState<AuthStep>(() =>
copilotProvider.getAuthState().status === "authenticated" ? "done" : "idle"
);
const [deviceCode, setDeviceCode] = useState<DeviceCodeResponse | null>(null);
const [pollCount, setPollCount] = useState(0);
const [error, setError] = useState<string | null>(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.

View file

@ -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<ModelEditModalContentProps> = ({
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<CustomModel>(model);
const [originalModel, setOriginalModel] = useState<CustomModel>(model);
const [providerInfo, setProviderInfo] = useState<ProviderMetadata>({} as ProviderMetadata);
const originalModel = model;
const providerInfo = useMemo<ProviderMetadata>(
() => (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(
() =>

View file

@ -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) {

View file

@ -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 (
<section className="tw-flex tw-flex-col tw-gap-4 tw-rounded-lg tw-bg-secondary tw-p-4">