diff --git a/eslint.config.mjs b/eslint.config.mjs index b820dc6c..e9a1993f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,6 +31,21 @@ export default [ files: ["**/*.{jsx,tsx}"], ...eslintReact.configs.recommended, }, + { + files: ["**/*.{jsx,tsx}"], + rules: { + // Deferred to follow-up PRs — these flag legitimate anti-patterns but + // each fix requires per-component intent analysis, and they're surfaced + // as warnings (not errors) so they don't block CI. + // + // no-direct-set-state-in-use-effect: ~50 violations. Common pattern is + // "sync local state with prop", which has no one-size-fits-all fix — + // some cases want render-time derivation, others want a `key` prop reset + // or `useSyncExternalStore`. Refactoring blindly risks behavior regressions + // in the chat UI's stateful components. + "@eslint-react/hooks-extra/no-direct-set-state-in-use-effect": "warn", + }, + }, { files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"], plugins: { "react-hooks": reactHooks }, diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index ace3a170..396014d1 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -49,6 +49,7 @@ import { FileParserManager } from "@/tools/FileParserManager"; import { ChatMessage } from "@/types/message"; import { err2String, isPlusChain } from "@/utils"; import { arrayBufferToBase64 } from "@/utils/base64"; +import { appendUniqueFiles } from "@/utils/fileListUtils"; import { Notice, TFile } from "obsidian"; import { ContextManageModal } from "@/components/modals/project/context-manage-modal"; import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; @@ -241,13 +242,20 @@ const ChatInternal: React.FC { + setSelectedImages((prev) => appendUniqueFiles(prev, files)); + }, []); + // Drag-and-drop hook for file handling const { isDragActive } = useChatFileDrop({ app, contextNotes, setContextNotes, selectedImages, - onAddImage: (files) => setSelectedImages((prev) => [...prev, ...files]), + onAddImage: handleAddImage, containerRef: chatContainerRef, }); @@ -888,7 +896,7 @@ const ChatInternal: React.FC setSelectedImages((prev) => [...prev, ...files])} + onAddImage={handleAddImage} setSelectedImages={setSelectedImages} disableModelSwitch={selectedChain === ChainType.PROJECT_CHAIN} selectedTextContexts={selectedTextContexts} diff --git a/src/components/chat-components/AgentReasoningBlock.tsx b/src/components/chat-components/AgentReasoningBlock.tsx index f66dc7ae..aa4c1f0c 100644 --- a/src/components/chat-components/AgentReasoningBlock.tsx +++ b/src/components/chat-components/AgentReasoningBlock.tsx @@ -65,13 +65,13 @@ const CopilotSpinner: React.FC = () => { viewBox={`0 0 ${gridSize} ${gridSize}`} className="copilot-spinner" > - {sigmaDots.map((dot, index) => { + {sigmaDots.map((dot) => { const cx = dot.col * (dotSize + gap) + dotSize / 2; const cy = dot.row * (dotSize + gap) + dotSize / 2; return ( = ({ {steps.length > 0 && (
    {steps.map((step, i) => ( + // eslint-disable-next-line @eslint-react/no-array-index-key -- steps are append-only with no stable id; text may repeat
  • {step}
  • diff --git a/src/components/chat-components/ChatContextMenu.tsx b/src/components/chat-components/ChatContextMenu.tsx index f111512a..d8f2f05a 100644 --- a/src/components/chat-components/ChatContextMenu.tsx +++ b/src/components/chat-components/ChatContextMenu.tsx @@ -22,6 +22,8 @@ import { mergeWebTabContexts } from "@/utils/urlNormalization"; import { AtMentionTypeahead } from "./AtMentionTypeahead"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +const EMPTY_SELECTED_TEXT_CONTEXTS: SelectedTextContext[] = []; + interface ChatContextMenuProps { includeActiveNote: boolean; currentActiveFile: TFile | null; @@ -104,7 +106,7 @@ export const ChatContextMenu: React.FC = ({ contextUrls, contextFolders, contextWebTabs, - selectedTextContexts = [], + selectedTextContexts = EMPTY_SELECTED_TEXT_CONTEXTS, onRemoveContext, showProgressCard, showIndexingCard, diff --git a/src/components/chat-components/ChatInput.tsx b/src/components/chat-components/ChatInput.tsx index ede6349d..ea330d44 100644 --- a/src/components/chat-components/ChatInput.tsx +++ b/src/components/chat-components/ChatInput.tsx @@ -21,6 +21,7 @@ import { import { useSettingsValue } from "@/settings/model"; import { SelectedTextContext, WebTabContext } from "@/types/message"; 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"; @@ -745,13 +746,14 @@ const ChatInput: React.FC = ({ {selectedImages.length > 0 && (
    {selectedImages.map((file, index) => ( -
    +
    {file.name} - {dropdownActions.map((action, index) => ( + {dropdownActions.map((action) => ( { e.stopPropagation(); void action.onClick(item); diff --git a/src/components/ui/model-display.tsx b/src/components/ui/model-display.tsx index 759940f4..198d7125 100644 --- a/src/components/ui/model-display.tsx +++ b/src/components/ui/model-display.tsx @@ -14,20 +14,22 @@ interface ModelCapabilityIconsProps { iconSize?: number; } +const EMPTY_CAPABILITIES: ModelCapability[] = []; + export const ModelCapabilityIcons: React.FC = ({ - capabilities = [], + capabilities = EMPTY_CAPABILITIES, iconSize = 16, }) => { return ( <> {capabilities .sort((a, b) => a.localeCompare(b)) - .map((cap, index) => { + .map((cap) => { switch (cap) { case ModelCapability.REASONING: return ( @@ -35,7 +37,7 @@ export const ModelCapabilityIcons: React.FC = ({ case ModelCapability.VISION: return ( @@ -43,7 +45,7 @@ export const ModelCapabilityIcons: React.FC = ({ case ModelCapability.WEB_SEARCH: return ( diff --git a/src/context/ChatInputContext.tsx b/src/context/ChatInputContext.tsx index b4ba3cac..24abc10f 100644 --- a/src/context/ChatInputContext.tsx +++ b/src/context/ChatInputContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useCallback, useState } from "react"; +import React, { createContext, useContext, useCallback, useMemo, useState } from "react"; import { INSERT_TEXT_WITH_PILLS_COMMAND } from "@/components/chat-components/utils/lexicalTextUtils"; import { LexicalEditor } from "lexical"; @@ -59,12 +59,15 @@ export function ChatInputProvider({ children }: ChatInputProviderProps): JSX.Ele } }, [focusHandler]); - const contextValue: ChatInputContextType = { - insertTextWithPills, - focusInput, - registerEditor, - registerFocusHandler, - }; + const contextValue = useMemo( + () => ({ + insertTextWithPills, + focusInput, + registerEditor, + registerFocusHandler, + }), + [insertTextWithPills, focusInput, registerEditor, registerFocusHandler] + ); return {children}; } diff --git a/src/contexts/TabContext.tsx b/src/contexts/TabContext.tsx index 7527b5c2..3fc3a007 100644 --- a/src/contexts/TabContext.tsx +++ b/src/contexts/TabContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useEffect, useState, useRef } from "react"; +import React, { createContext, useContext, useMemo, useState } from "react"; interface TabContextType { selectedTab: string; @@ -10,22 +10,18 @@ const TabContext = createContext(undefined); export const TabProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [selectedTab, setSelectedTab] = useState("basic"); - const [modalContainer, setModalContainer] = useState(null); - const hasInitialized = useRef(false); - - useEffect(() => { - if (!hasInitialized.current) { - const modal = activeDocument.querySelector(".modal-container") as HTMLElement; - setModalContainer(modal); - hasInitialized.current = true; - } - }, []); - - return ( - - {children} - + // Compute the modal container lazily once; activeDocument is stable at provider + // mount inside an Obsidian modal, so avoid an effect that would re-render. + const [modalContainer] = useState(() => + activeDocument.querySelector(".modal-container") ); + + const value = useMemo( + () => ({ selectedTab, setSelectedTab, modalContainer }), + [selectedTab, modalContainer] + ); + + return {children}; }; export const useTab = () => { diff --git a/src/settings/v2/components/ApiKeyDialog.tsx b/src/settings/v2/components/ApiKeyDialog.tsx index dda8dc86..aa379e79 100644 --- a/src/settings/v2/components/ApiKeyDialog.tsx +++ b/src/settings/v2/components/ApiKeyDialog.tsx @@ -151,6 +151,7 @@ function ApiKeyModalContent({ onClose, onGoToModelTab }: ApiKeyModalContentProps configuration (Base URL, Deployment Name, etc.).