chore(lint): fix no-array-index-key and other React lint warnings (#2453)

* chore(lint): fix no-array-index-key and other React lint warnings

Re-enable @eslint-react/no-array-index-key, replace index keys with stable
ids where possible (capability enum, action label, file paths, computed
coords), and add per-line eslint-disable with rationale where index is
intentional (diff parts, append-only steps, parsed markdown, duplicate-
tolerant context badges).

Also addresses adjacent React lint warnings: useMemo for context provider
values, stable module-scope defaults for empty array props, lazy useState
initializers, and explicit type="button" on non-submit buttons. Surface
no-direct-set-state-in-use-effect as a warning for follow-up triage.

* fix: dedupe selected image files
This commit is contained in:
Zero Liu 2026-05-14 02:19:38 -07:00 committed by GitHub
parent 6ca2dc01ea
commit 13beaf133b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 219 additions and 75 deletions

View file

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

View file

@ -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<ChatProps & { chatInput: ReturnType<typeof useChatI
const appContext = useContext(AppContext);
const app = plugin.app || appContext;
/**
* Add selected image files while preserving the original selection order.
*/
const handleAddImage = useCallback((files: File[]) => {
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<ChatProps & { chatInput: ReturnType<typeof useChatI
setIncludeActiveWebTab={setIncludeActiveWebTab}
activeWebTab={currentActiveWebTab}
selectedImages={selectedImages}
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
onAddImage={handleAddImage}
setSelectedImages={setSelectedImages}
disableModelSwitch={selectedChain === ChainType.PROJECT_CHAIN}
selectedTextContexts={selectedTextContexts}

View file

@ -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 (
<circle
key={index}
key={`${dot.row}-${dot.col}`}
cx={cx}
cy={cy}
r={dotSize / 2}
@ -164,6 +164,7 @@ export const AgentReasoningBlock: React.FC<AgentReasoningBlockProps> = ({
{steps.length > 0 && (
<ul className="agent-reasoning-steps">
{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
<li key={i} className="agent-reasoning-step">
{step}
</li>

View file

@ -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<ChatContextMenuProps> = ({
contextUrls,
contextFolders,
contextWebTabs,
selectedTextContexts = [],
selectedTextContexts = EMPTY_SELECTED_TEXT_CONTEXTS,
onRemoveContext,
showProgressCard,
showIndexingCard,

View file

@ -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<ChatInputProps> = ({
{selectedImages.length > 0 && (
<div className="selected-images">
{selectedImages.map((file, index) => (
<div key={index} className="image-preview-container">
<div key={getFileIdentityKey(file)} className="image-preview-container">
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="selected-image-preview"
/>
<button
type="button"
className="remove-image-button"
onClick={() => setSelectedImages((prev) => prev.filter((_, i) => i !== index))}
title="Remove image"

View file

@ -67,7 +67,7 @@ export function ChatSettingsPopover() {
: "";
// Read state from session atom
const [disableBuiltin, setDisableBuiltin] = useState(getDisableBuiltinSystemPrompt());
const [disableBuiltin, setDisableBuiltin] = useState(() => getDisableBuiltinSystemPrompt());
const [showConfirmation, setShowConfirmation] = useState(false);
const confirmationRef = useRef<HTMLDivElement>(null);

View file

@ -215,6 +215,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
return (
<div className="tw-flex tw-flex-wrap tw-gap-2">
{context.notes.map((note, index) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicate notes (see MessageContext.test.tsx duplicate-handling cases)
<Tooltip key={`note-${index}-${note.path}`}>
<TooltipTrigger asChild>
<div>
@ -225,6 +226,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
</Tooltip>
))}
{context.urls.map((url, index) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicate urls (see MessageContext.test.tsx duplicate-handling cases)
<Tooltip key={`url-${index}-${url}`}>
<TooltipTrigger asChild>
<div>
@ -235,6 +237,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
</Tooltip>
))}
{context.webTabs?.map((webTab, index) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicates; index disambiguates same-url entries
<Tooltip key={`webTab-${index}-${webTab.url}`}>
<TooltipTrigger asChild>
<div>
@ -254,6 +257,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
</Tooltip>
))}
{context.tags?.map((tag, index) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicates; index disambiguates same-value entries
<Tooltip key={`tag-${index}-${tag}`}>
<TooltipTrigger asChild>
<div>
@ -264,6 +268,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
</Tooltip>
))}
{context.folders?.map((folder, index) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicates; index disambiguates same-value entries
<Tooltip key={`folder-${index}-${folder}`}>
<TooltipTrigger asChild>
<div>
@ -274,6 +279,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
</Tooltip>
))}
{context.selectedTextContexts?.map((selectedText, index) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicates; index disambiguates same-id entries
<Tooltip key={`selectedText-${index}-${selectedText.id}`}>
<TooltipTrigger asChild>
<div>
@ -864,7 +870,8 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
// Set unmounting flag immediately
isUnmountingRef.current = true;
// Defer cleanup to avoid React rendering conflicts
// Defer cleanup to avoid React rendering conflicts.
// eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout -- fire-and-forget defer; no cleanup target available inside an effect-cleanup
window.setTimeout(() => {
// Clean up component
if (currentComponentRef.current) {
@ -924,6 +931,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
(item, index) => {
if (item.type === "text") {
return (
// eslint-disable-next-line @eslint-react/no-array-index-key -- content array is fixed once message is rendered; items not reordered
<div key={index}>
{message.sender === USER_SENDER ? (
<div className="tw-whitespace-pre-wrap tw-break-words tw-text-[calc(var(--font-text-size)_-_2px)] tw-font-normal">
@ -939,6 +947,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
);
} else if (item.type === "image_url") {
return (
// eslint-disable-next-line @eslint-react/no-array-index-key -- content array is fixed once message is rendered; items not reordered
<div key={index} className="message-image-content">
<img
src={item.image_url!.url}

View file

@ -3,6 +3,7 @@ import { TFile, App } from "obsidian";
import ChatInput from "./ChatInput";
import { ChatMessage } from "@/types/message";
import { useActiveWebTabState } from "./hooks/useActiveWebTabState";
import { appendUniqueFiles } from "@/utils/fileListUtils";
interface InlineMessageEditorProps {
/** The initial message text to edit */
@ -78,8 +79,11 @@ export const InlineMessageEditor: React.FC<InlineMessageEditorProps> = ({
// Not used in edit mode
}, []);
/**
* Add selected image files while preserving the original selection order.
*/
const handleAddImage = useCallback((files: File[]) => {
setSelectedImages((prev) => [...prev, ...files]);
setSelectedImages((prev) => appendUniqueFiles(prev, files));
}, []);
const handleRemoveSelectedText = useCallback((id: string) => {

View file

@ -31,11 +31,13 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
return (
<div className="tw-flex tw-flex-wrap tw-gap-2">
{context.notes.map((note, index) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- mirrors the production component; context arrays may contain duplicates
<div key={`${index}-${note.path}`} data-testid="note-badge">
<span>{note.basename}</span>
</div>
))}
{context.urls.map((url, index) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- mirrors the production component; context arrays may contain duplicates
<div key={`${index}-${url}`} data-testid="url-badge">
<span>{url}</span>
</div>

View file

@ -252,12 +252,14 @@ function RelevantNotePopover({
<span className="tw-text-xs tw-text-muted">{note.note.path}</span>
<div className="tw-flex tw-gap-2">
<button
type="button"
onClick={onAddToChat}
className="tw-inline-flex tw-items-center tw-gap-2 tw-border tw-border-solid tw-border-border !tw-bg-transparent !tw-shadow-none hover:!tw-bg-interactive-hover"
>
Add to Chat <PlusCircle className="tw-size-4" />
</button>
<button
type="button"
onClick={(e) => {
const openInNewLeaf = e.metaKey || e.ctrlKey;
onNavigateToNote(openInNewLeaf);

View file

@ -104,9 +104,9 @@ export const SuggestedPrompts: React.FC<SuggestedPromptsProps> = ({ onClick }) =
</CardHeader>
<CardContent className="tw-p-2 tw-pt-0">
<div className="tw-flex tw-flex-col tw-gap-2">
{prompts.map((prompt, i) => (
{prompts.map((prompt) => (
<div
key={i}
key={prompt.text}
className="tw-flex tw-justify-between tw-gap-2 tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-2 tw-text-sm"
>
<div className="tw-flex tw-flex-col tw-gap-1">

View file

@ -133,6 +133,7 @@ export const ToolCallBanner: React.FC<ToolCallBannerProps> = ({
{!actuallyExecuting && onAccept && onReject && (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAccept();
@ -143,6 +144,7 @@ export const ToolCallBanner: React.FC<ToolCallBannerProps> = ({
<Check className="tw-size-4 tw-text-success" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onReject();

View file

@ -1,4 +1,4 @@
import React, { createContext, useContext } from "react";
import React, { createContext, useContext, useMemo } from "react";
import { TFile } from "obsidian";
/**
@ -36,9 +36,6 @@ interface ActiveFileProviderProps {
* to all descendant components
*/
export function ActiveFileProvider({ currentActiveFile, children }: ActiveFileProviderProps) {
return (
<ActiveFileContext.Provider value={{ currentActiveFile }}>
{children}
</ActiveFileContext.Provider>
);
const value = useMemo(() => ({ currentActiveFile }), [currentActiveFile]);
return <ActiveFileContext.Provider value={value}>{children}</ActiveFileContext.Provider>;
}

View file

@ -27,7 +27,7 @@ export function NoteCommandPlugin({
// Use shared preview cache
const [previewCache] = useState(() => new NotePreviewCache());
const [previewContent, setPreviewContent] = useState<Map<string, string>>(new Map());
const [previewContent, setPreviewContent] = useState<Map<string, string>>(() => new Map());
// Function to load note content for preview using shared cache
const loadNoteContent = useCallback(

View file

@ -131,6 +131,7 @@ const WordDiffSpan: React.FC<WordDiffSpanProps> = memo(({ original, modified, si
if (side === "original") {
if (part.removed) {
return (
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff parts are computed once per render and not reordered
<span key={idx} className="tw-bg-error tw-text-error">
{part.value}
</span>
@ -140,6 +141,7 @@ const WordDiffSpan: React.FC<WordDiffSpanProps> = memo(({ original, modified, si
} else {
if (part.added) {
return (
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff parts are computed once per render and not reordered
<span key={idx} className="tw-bg-success tw-text-success">
{part.value}
</span>
@ -147,6 +149,7 @@ const WordDiffSpan: React.FC<WordDiffSpanProps> = memo(({ original, modified, si
}
if (part.removed) return null;
}
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff parts are computed once per render and not reordered
return <span key={idx}>{part.value}</span>;
})}
</span>
@ -285,6 +288,7 @@ const SideBySideBlock = memo(({ block }: SideBySideBlockProps) => {
{/* Original (left) column */}
<div className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-primary tw-p-2">
{rows.map((row, idx) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff rows are computed once per block and not reordered
<div key={idx} className="tw-whitespace-pre-wrap tw-font-mono tw-text-sm">
<DiffCell row={row} side="original" />
</div>
@ -294,6 +298,7 @@ const SideBySideBlock = memo(({ block }: SideBySideBlockProps) => {
{/* Modified (right) column */}
<div className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-primary tw-p-2">
{rows.map((row, idx) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff rows are computed once per block and not reordered
<div key={idx} className="tw-whitespace-pre-wrap tw-font-mono tw-text-sm">
<DiffCell row={row} side="modified" />
</div>
@ -319,6 +324,7 @@ const SplitBlock = memo(({ block }: SplitBlockProps) => {
return (
<div className="tw-whitespace-pre-wrap tw-px-2 tw-py-1 tw-font-mono tw-text-sm tw-text-normal">
{block.map((change, idx) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- block changes are computed once per render and not reordered
<span key={idx}>{change.value}</span>
))}
</div>
@ -333,6 +339,7 @@ const SplitBlock = memo(({ block }: SplitBlockProps) => {
<div className="tw-whitespace-pre-wrap tw-font-mono tw-text-sm">
{rows.map((row, idx) =>
row.original !== null ? (
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff rows are computed once per block and not reordered
<div key={idx}>
<DiffCell row={row} side="original" />
</div>
@ -347,6 +354,7 @@ const SplitBlock = memo(({ block }: SplitBlockProps) => {
<div className="tw-whitespace-pre-wrap tw-font-mono tw-text-sm">
{rows.map((row, idx) =>
row.modified !== null ? (
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff rows are computed once per block and not reordered
<div key={idx}>
<DiffCell row={row} side="modified" />
</div>
@ -616,6 +624,7 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
return (
<div
// eslint-disable-next-line @eslint-react/no-array-index-key -- changeBlocks is computed once from the diff and not reordered; blockIndex also keys blockRefs
key={blockIndex}
ref={(el) => (blockRefs.current[blockIndex] = el)}
className={cn("tw-mb-4 tw-overflow-hidden tw-rounded-md")}
@ -626,6 +635,7 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
{block
.filter((change) => !change.removed)
.map((change, idx) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- block changes are computed once per render and not reordered
<div key={idx}>{change.value}</div>
))}
</div>
@ -635,6 +645,7 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
{block
.filter((change) => !change.added)
.map((change, idx) => (
// eslint-disable-next-line @eslint-react/no-array-index-key -- block changes are computed once per render and not reordered
<div key={idx}>{change.value}</div>
))}
</div>

View file

@ -51,25 +51,26 @@ function AddProjectModalContent({
});
const [formData, setFormData] = useState<ProjectConfig>(
initialProject || {
id: randomUUID(),
name: "",
description: "",
systemPrompt: "",
projectModelKey: "",
modelConfigs: {
temperature: DEFAULT_MODEL_SETTING.TEMPERATURE,
maxTokens: DEFAULT_MODEL_SETTING.MAX_TOKENS,
},
contextSource: {
inclusions: "",
exclusions: "",
webUrls: "",
youtubeUrls: "",
},
created: Date.now(),
UsageTimestamps: Date.now(),
}
() =>
initialProject || {
id: randomUUID(),
name: "",
description: "",
systemPrompt: "",
projectModelKey: "",
modelConfigs: {
temperature: DEFAULT_MODEL_SETTING.TEMPERATURE,
maxTokens: DEFAULT_MODEL_SETTING.MAX_TOKENS,
},
contextSource: {
inclusions: "",
exclusions: "",
webUrls: "",
youtubeUrls: "",
},
created: Date.now(),
UsageTimestamps: Date.now(),
}
);
// URL items derived from formData for UrlTagInput

View file

@ -218,9 +218,9 @@ export default function ProgressCard({ plugin, setHiddenCard, onEditContext }: P
</Badge>
)}
</div>
{mdProcessingFiles.map((path, i) => (
{mdProcessingFiles.map((path) => (
<div
key={`md-proc-${i}`}
key={`md-proc-${path}`}
className="tw-flex tw-items-center tw-gap-2 tw-rounded-md tw-border tw-border-border tw-bg-primary tw-p-2"
>
<Loader2 className="tw-size-3.5 tw-animate-spin tw-text-loading" />
@ -232,9 +232,9 @@ export default function ProgressCard({ plugin, setHiddenCard, onEditContext }: P
</TruncatedText>
</div>
))}
{mdFailedFiles.map((item, i) => (
{mdFailedFiles.map((item) => (
<div
key={`md-fail-${i}`}
key={`md-fail-${item.path}`}
className="tw-rounded-md tw-border tw-border-border tw-bg-primary tw-p-2"
>
<div className="tw-flex tw-items-center tw-gap-2">

View file

@ -19,6 +19,8 @@ export interface MobileCardDropdownAction<T = object> {
variant?: "default" | "destructive";
}
const EMPTY_DROPDOWN_ACTIONS: [] = [];
export interface MobileCardProps<T extends object> {
id: string;
item: T;
@ -53,7 +55,7 @@ export function MobileCard<T extends object>({
defaultExpanded = false,
expandedContent,
primaryAction,
dropdownActions = [],
dropdownActions = EMPTY_DROPDOWN_ACTIONS,
containerRef,
className,
onExpandToggle,
@ -173,9 +175,9 @@ export function MobileCard<T extends object>({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" container={containerRef?.current}>
{dropdownActions.map((action, index) => (
{dropdownActions.map((action) => (
<DropdownMenuItem
key={index}
key={action.label}
onClick={(e) => {
e.stopPropagation();
void action.onClick(item);

View file

@ -14,20 +14,22 @@ interface ModelCapabilityIconsProps {
iconSize?: number;
}
const EMPTY_CAPABILITIES: ModelCapability[] = [];
export const ModelCapabilityIcons: React.FC<ModelCapabilityIconsProps> = ({
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 (
<Lightbulb
key={index}
key={cap}
className="tw-text-model-capabilities-blue"
style={{ width: iconSize, height: iconSize }}
/>
@ -35,7 +37,7 @@ export const ModelCapabilityIcons: React.FC<ModelCapabilityIconsProps> = ({
case ModelCapability.VISION:
return (
<Eye
key={index}
key={cap}
className="tw-text-model-capabilities-green"
style={{ width: iconSize, height: iconSize }}
/>
@ -43,7 +45,7 @@ export const ModelCapabilityIcons: React.FC<ModelCapabilityIconsProps> = ({
case ModelCapability.WEB_SEARCH:
return (
<Globe
key={index}
key={cap}
className="tw-text-model-capabilities-blue"
style={{ width: iconSize, height: iconSize }}
/>

View file

@ -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<ChatInputContextType>(
() => ({
insertTextWithPills,
focusInput,
registerEditor,
registerFocusHandler,
}),
[insertTextWithPills, focusInput, registerEditor, registerFocusHandler]
);
return <ChatInputContext.Provider value={contextValue}>{children}</ChatInputContext.Provider>;
}

View file

@ -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<TabContextType | undefined>(undefined);
export const TabProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [selectedTab, setSelectedTab] = useState("basic");
const [modalContainer, setModalContainer] = useState<HTMLElement | null>(null);
const hasInitialized = useRef(false);
useEffect(() => {
if (!hasInitialized.current) {
const modal = activeDocument.querySelector(".modal-container") as HTMLElement;
setModalContainer(modal);
hasInitialized.current = true;
}
}, []);
return (
<TabContext.Provider value={{ selectedTab, setSelectedTab, modalContainer }}>
{children}
</TabContext.Provider>
// 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<HTMLElement | null>(() =>
activeDocument.querySelector(".modal-container")
);
const value = useMemo(
() => ({ selectedTab, setSelectedTab, modalContainer }),
[selectedTab, modalContainer]
);
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
};
export const useTab = () => {

View file

@ -151,6 +151,7 @@ function ApiKeyModalContent({ onClose, onGoToModelTab }: ApiKeyModalContentProps
configuration (Base URL, Deployment Name, etc.).
</p>
<button
type="button"
onClick={() => {
onGoToModelTab();
onClose();

View file

@ -77,7 +77,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
};
const [dialogElement, setDialogElement] = useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = useState(hasRequiredExtraSettings(defaultProvider));
const [isOpen, setIsOpen] = useState(() => hasRequiredExtraSettings(defaultProvider));
const [isVerifying, setIsVerifying] = useState(false);
const [verifyStatus, setVerifyStatus] = useState<"idle" | "success" | "failed">("idle");
const [errors, setErrors] = useState<FormErrors>({
@ -179,7 +179,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
return baseModel;
};
const [model, setModel] = useState<CustomModel>(getInitialModel());
const [model, setModel] = useState<CustomModel>(() => getInitialModel());
/**
* Updates model state and resets verify status when connection-related fields change.
@ -207,7 +207,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
};
};
const [providerInfo, setProviderInfo] = useState<ProviderMetadata>(
const [providerInfo, setProviderInfo] = useState<ProviderMetadata>(() =>
getProviderInfo(defaultProvider)
);

View file

@ -46,6 +46,7 @@ function renderVerificationMessage(message: string): React.ReactNode {
if (match) {
return (
<a
// eslint-disable-next-line @eslint-react/no-array-index-key -- static one-shot render of a parsed markdown paragraph; nodes are not reordered
key={j}
href={match[2]}
target="_blank"
@ -59,6 +60,7 @@ function renderVerificationMessage(message: string): React.ReactNode {
return part;
});
return (
// eslint-disable-next-line @eslint-react/no-array-index-key -- static one-shot render of parsed markdown paragraphs; paragraphs are not reordered
<p key={i} className={i > 0 ? "tw-mt-1" : ""}>
{nodes}
</p>

View file

@ -0,0 +1,51 @@
import { appendUniqueFiles, getFileIdentityKey } from "@/utils/fileListUtils";
describe("fileListUtils", () => {
it("deduplicates appended files by stable file identity", () => {
const existingFile = new File(["image"], "image.png", {
type: "image/png",
lastModified: 123,
});
const duplicateFile = new File(["image"], "image.png", {
type: "image/png",
lastModified: 123,
});
const newFile = new File(["other"], "other.png", {
type: "image/png",
lastModified: 456,
});
const result = appendUniqueFiles([existingFile], [duplicateFile, newFile, duplicateFile]);
expect(result).toEqual([existingFile, newFile]);
});
it("returns the original array when every incoming file is already present", () => {
const existingFile = new File(["image"], "image.png", {
type: "image/png",
lastModified: 123,
});
const duplicateFile = new File(["image"], "image.png", {
type: "image/png",
lastModified: 123,
});
const existingFiles = [existingFile];
const result = appendUniqueFiles(existingFiles, [duplicateFile]);
expect(result).toBe(existingFiles);
});
it("includes the file type in the identity key", () => {
const pngFile = new File(["image"], "image", {
type: "image/png",
lastModified: 123,
});
const jpegFile = new File(["image"], "image", {
type: "image/jpeg",
lastModified: 123,
});
expect(getFileIdentityKey(pngFile)).not.toBe(getFileIdentityKey(jpegFile));
});
});

View file

@ -0,0 +1,31 @@
/**
* Returns a stable identity key for a browser File object.
*/
export function getFileIdentityKey(file: File): string {
return `${file.name}-${file.size}-${file.lastModified}-${file.type}`;
}
/**
* Appends incoming files while preserving order and skipping files already present.
*/
export function appendUniqueFiles(existingFiles: File[], incomingFiles: File[]): File[] {
if (incomingFiles.length === 0) {
return existingFiles;
}
const seenKeys = new Set(existingFiles.map(getFileIdentityKey));
const uniqueIncomingFiles = incomingFiles.filter((file) => {
const key = getFileIdentityKey(file);
if (seenKeys.has(key)) {
return false;
}
seenKeys.add(key);
return true;
});
if (uniqueIncomingFiles.length === 0) {
return existingFiles;
}
return [...existingFiles, ...uniqueIncomingFiles];
}