mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
7b62cde598
commit
a92012dc4d
21 changed files with 321 additions and 303 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue