mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* feat: Add Web Viewer bridge for referencing open web tabs in chat. * refactor: Simplify markdown image extraction parser。 * fix: Prevent web selection from auto-reappearing after removal or new chat. * fix: Suppress active web tab when web selection exists. * feat: Add YouTube transcript extraction for Web Viewer tabs. * feat: Built-in slash prompts for web clippers. * feat: Improve web selection tracking and simplify context settings - Merge context settings: combine note/web active content and selection settings - Add auto-clear for web selection badge when deselected on page - Make note and web selections mutually exclusive - Show website favicon in web selection badge - Add migration logic and tests for settings
892 lines
31 KiB
TypeScript
892 lines
31 KiB
TypeScript
import {
|
|
getCurrentProject,
|
|
ProjectConfig,
|
|
subscribeToProjectChange,
|
|
useChainType,
|
|
useModelKey,
|
|
useProjectLoading,
|
|
} from "@/aiParams";
|
|
import { ChainType } from "@/chainFactory";
|
|
import { AddImageModal } from "@/components/modals/AddImageModal";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ModelSelector } from "@/components/ui/ModelSelector";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { ChatToolControls } from "./ChatToolControls";
|
|
import { isPlusChain } from "@/utils";
|
|
import {
|
|
mergeWebTabContexts,
|
|
normalizeUrlString,
|
|
normalizeWebTabContext,
|
|
} from "@/utils/urlNormalization";
|
|
|
|
import { useSettingsValue } from "@/settings/model";
|
|
import { SelectedTextContext, WebTabContext } from "@/types/message";
|
|
import { isAllowedFileForNoteContext } from "@/utils";
|
|
import { CornerDownLeft, Image, Loader2, StopCircle, X } from "lucide-react";
|
|
import { App, Notice, TFile } from "obsidian";
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { $getSelection, $isRangeSelection } from "lexical";
|
|
import { ContextControl } from "./ContextControl";
|
|
import { $removePillsByPath } from "./pills/NotePillNode";
|
|
import { $removeActiveNotePills } from "./pills/ActiveNotePillNode";
|
|
import { $removePillsByURL } from "./pills/URLPillNode";
|
|
import { $removePillsByFolder } from "./pills/FolderPillNode";
|
|
import { $removePillsByToolName, $createToolPillNode } from "./pills/ToolPillNode";
|
|
import { $removeActiveWebTabPills } from "./pills/ActiveWebTabPillNode";
|
|
import { $findWebTabPills, $removeWebTabPillsByUrl } from "./pills/WebTabPillNode";
|
|
import LexicalEditor from "./LexicalEditor";
|
|
|
|
interface ChatInputProps {
|
|
inputMessage: string;
|
|
setInputMessage: (message: string) => void;
|
|
handleSendMessage: (metadata?: {
|
|
toolCalls?: string[];
|
|
urls?: string[];
|
|
contextNotes?: TFile[];
|
|
contextFolders?: string[];
|
|
webTabs?: WebTabContext[];
|
|
}) => void;
|
|
isGenerating: boolean;
|
|
onStopGenerating: () => void;
|
|
app: App;
|
|
contextNotes: TFile[];
|
|
setContextNotes: React.Dispatch<React.SetStateAction<TFile[]>>;
|
|
includeActiveNote: boolean;
|
|
setIncludeActiveNote: (include: boolean) => void;
|
|
includeActiveWebTab: boolean;
|
|
setIncludeActiveWebTab: (include: boolean) => void;
|
|
activeWebTab: WebTabContext | null;
|
|
selectedImages: File[];
|
|
onAddImage: (files: File[]) => void;
|
|
setSelectedImages: React.Dispatch<React.SetStateAction<File[]>>;
|
|
disableModelSwitch?: boolean;
|
|
selectedTextContexts?: SelectedTextContext[];
|
|
onRemoveSelectedText?: (id: string) => void;
|
|
showProgressCard: () => void;
|
|
|
|
// Edit mode props
|
|
editMode?: boolean;
|
|
onEditSave?: (
|
|
text: string,
|
|
context: {
|
|
notes: TFile[];
|
|
urls: string[];
|
|
folders: string[];
|
|
}
|
|
) => void;
|
|
onEditCancel?: () => void;
|
|
initialContext?: {
|
|
notes?: TFile[];
|
|
urls?: string[];
|
|
folders?: string[];
|
|
};
|
|
}
|
|
|
|
const ChatInput: React.FC<ChatInputProps> = ({
|
|
inputMessage,
|
|
setInputMessage,
|
|
handleSendMessage,
|
|
isGenerating,
|
|
onStopGenerating,
|
|
app,
|
|
contextNotes,
|
|
setContextNotes,
|
|
includeActiveNote,
|
|
setIncludeActiveNote,
|
|
includeActiveWebTab,
|
|
setIncludeActiveWebTab,
|
|
activeWebTab,
|
|
selectedImages,
|
|
onAddImage,
|
|
setSelectedImages,
|
|
disableModelSwitch,
|
|
selectedTextContexts,
|
|
onRemoveSelectedText,
|
|
showProgressCard,
|
|
editMode = false,
|
|
onEditSave,
|
|
onEditCancel,
|
|
initialContext,
|
|
}) => {
|
|
const [contextUrls, setContextUrls] = useState<string[]>(initialContext?.urls || []);
|
|
const [contextFolders, setContextFolders] = useState<string[]>(initialContext?.folders || []);
|
|
const [contextWebTabs, setContextWebTabs] = useState<WebTabContext[]>([]);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const lexicalEditorRef = useRef<any>(null);
|
|
const [currentModelKey, setCurrentModelKey] = useModelKey();
|
|
const [currentChain] = useChainType();
|
|
const [isProjectLoading] = useProjectLoading();
|
|
const settings = useSettingsValue();
|
|
const [currentActiveNote, setCurrentActiveNote] = useState<TFile | null>(() => {
|
|
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[]>([]);
|
|
const [toolsFromPills, setToolsFromPills] = useState<string[]>([]);
|
|
const [webTabsFromPills, setWebTabsFromPills] = useState<WebTabContext[]>([]);
|
|
const isCopilotPlus = isPlusChain(currentChain);
|
|
|
|
// Merge badge-only contextWebTabs with pills-derived webTabsFromPills for display
|
|
// Uses shared normalization policy from urlNormalization.ts
|
|
const mergedContextWebTabs = useMemo(() => {
|
|
return mergeWebTabContexts([...contextWebTabs, ...webTabsFromPills]);
|
|
}, [contextWebTabs, webTabsFromPills]);
|
|
|
|
/**
|
|
* Extract WebTabPillNode data directly from the Lexical editor at send time.
|
|
* This avoids React state synchronization races (webTabsFromPills) when the user sends quickly.
|
|
*/
|
|
const getWebTabsFromEditorSnapshot = (): WebTabContext[] => {
|
|
const editor = lexicalEditorRef.current;
|
|
if (!editor) {
|
|
return webTabsFromPills;
|
|
}
|
|
|
|
return editor.read(() => {
|
|
const pills = $findWebTabPills();
|
|
return pills.map((pill) => ({
|
|
url: pill.getURL(),
|
|
title: pill.getTitle(),
|
|
faviconUrl: pill.getFaviconUrl(),
|
|
}));
|
|
});
|
|
};
|
|
|
|
// Toggle states for vault, web search, composer, and autonomous agent
|
|
const [vaultToggle, setVaultToggle] = useState(false);
|
|
const [webToggle, setWebToggle] = useState(false);
|
|
const [composerToggle, setComposerToggle] = useState(false);
|
|
const [autonomousAgentToggle, setAutonomousAgentToggle] = useState(
|
|
settings.enableAutonomousAgent
|
|
);
|
|
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
|
const loadingMessages = [
|
|
"Loading the project context...",
|
|
"Processing context files...",
|
|
"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]);
|
|
|
|
useEffect(() => {
|
|
if (currentChain === ChainType.PROJECT_CHAIN) {
|
|
setSelectedProject(getCurrentProject());
|
|
|
|
const unsubscribe = subscribeToProjectChange((project) => {
|
|
setSelectedProject(project);
|
|
});
|
|
|
|
return () => {
|
|
unsubscribe();
|
|
};
|
|
} else {
|
|
setSelectedProject(null);
|
|
}
|
|
}, [currentChain]);
|
|
|
|
useEffect(() => {
|
|
if (!isProjectLoading) return;
|
|
|
|
const interval = setInterval(() => {
|
|
setLoadingMessageIndex((prev) => (prev + 1) % loadingMessages.length);
|
|
}, 3000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [isProjectLoading, loadingMessages.length]);
|
|
|
|
const getDisplayModelKey = (): string => {
|
|
if (
|
|
selectedProject &&
|
|
currentChain === ChainType.PROJECT_CHAIN &&
|
|
selectedProject.projectModelKey
|
|
) {
|
|
return selectedProject.projectModelKey;
|
|
}
|
|
return currentModelKey;
|
|
};
|
|
|
|
const onSendMessage = () => {
|
|
// Handle edit mode
|
|
if (editMode && onEditSave) {
|
|
onEditSave(inputMessage, {
|
|
notes: contextNotes,
|
|
urls: contextUrls,
|
|
folders: contextFolders,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Combine badge-only web tabs with the send-time Lexical snapshot of WebTab pills.
|
|
// This avoids React state synchronization races when the user sends quickly.
|
|
// Active Web Tab is handled by ChatManager.
|
|
const webTabsFromEditor = getWebTabsFromEditorSnapshot();
|
|
const allWebTabs = mergeWebTabContexts([...contextWebTabs, ...webTabsFromEditor]);
|
|
|
|
if (!isCopilotPlus) {
|
|
// Non-Plus chains: only webTabs needs explicit passing
|
|
// - contextNotes: Chat.tsx has state, closure can access
|
|
// - contextFolders: {folderPath} in text gets expanded by processPrompt()
|
|
// - webTabs: passed here, Active Web Tab injected by ChatManager
|
|
handleSendMessage({
|
|
webTabs: allWebTabs,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Build tool calls based on toggle states
|
|
const toolCalls: string[] = [];
|
|
// Only add tool calls when autonomous agent is off
|
|
// When autonomous agent is on, it handles all tools internally
|
|
if (!autonomousAgentToggle) {
|
|
const messageLower = inputMessage.toLowerCase();
|
|
|
|
// Only add tools from buttons if they're not already in the message
|
|
if (vaultToggle && !messageLower.includes("@vault")) {
|
|
toolCalls.push("@vault");
|
|
}
|
|
if (webToggle && !messageLower.includes("@websearch") && !messageLower.includes("@web")) {
|
|
toolCalls.push("@websearch");
|
|
}
|
|
if (composerToggle && !messageLower.includes("@composer")) {
|
|
toolCalls.push("@composer");
|
|
}
|
|
}
|
|
|
|
handleSendMessage({
|
|
toolCalls,
|
|
contextNotes,
|
|
urls: contextUrls,
|
|
contextFolders,
|
|
webTabs: allWebTabs,
|
|
});
|
|
};
|
|
|
|
// Handle when pills are removed from the editor
|
|
const handleNotePillsRemoved = (removedNotes: { path: string; basename: string }[]) => {
|
|
const removedPaths = new Set(removedNotes.map((note) => note.path));
|
|
|
|
setContextNotes((prev) => {
|
|
return prev.filter((contextNote) => {
|
|
// Remove any note whose pill was removed
|
|
return !removedPaths.has(contextNote.path);
|
|
});
|
|
});
|
|
};
|
|
|
|
// Handle when URLs are removed from pills (when pills are deleted in editor)
|
|
const handleURLPillsRemoved = (removedUrls: string[]) => {
|
|
const removedUrlSet = new Set(removedUrls);
|
|
|
|
setContextUrls((prev) => {
|
|
return prev.filter((url) => {
|
|
if (removedUrlSet.has(url)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
});
|
|
};
|
|
|
|
// Handle when tools are removed from pills (when pills are deleted in editor)
|
|
const handleToolPillsRemoved = (removedTools: string[]) => {
|
|
if (!isCopilotPlus || autonomousAgentToggle) return;
|
|
|
|
// Update tool button states based on removed pills
|
|
removedTools.forEach((tool) => {
|
|
switch (tool) {
|
|
case "@vault":
|
|
setVaultToggle(false);
|
|
break;
|
|
case "@websearch":
|
|
case "@web":
|
|
setWebToggle(false);
|
|
break;
|
|
case "@composer":
|
|
setComposerToggle(false);
|
|
break;
|
|
}
|
|
});
|
|
};
|
|
|
|
// 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]);
|
|
|
|
// Handle when context notes are removed from the context menu
|
|
// This should remove all corresponding pills from the editor
|
|
const handleContextNoteRemoved = (notePath: string) => {
|
|
if (lexicalEditorRef.current) {
|
|
lexicalEditorRef.current.update(() => {
|
|
$removePillsByPath(notePath);
|
|
});
|
|
}
|
|
|
|
// Also immediately update notesFromPills to prevent stale data from re-adding the note
|
|
setNotesFromPills((prev) => prev.filter((note) => note.path !== notePath));
|
|
};
|
|
|
|
// Handle when context URLs are removed from the context menu
|
|
// This should remove all corresponding URL pills from the editor
|
|
const handleURLContextRemoved = (url: string) => {
|
|
if (lexicalEditorRef.current) {
|
|
lexicalEditorRef.current.update(() => {
|
|
$removePillsByURL(url);
|
|
});
|
|
}
|
|
|
|
// Also immediately update urlsFromPills to prevent stale data from re-adding the URL
|
|
setUrlsFromPills((prev) => prev.filter((pillUrl) => pillUrl !== url));
|
|
};
|
|
|
|
// Handle when context folders are removed from the context menu
|
|
// This should remove all corresponding folder pills from the editor
|
|
const handleFolderContextRemoved = (folderPath: string) => {
|
|
if (lexicalEditorRef.current) {
|
|
lexicalEditorRef.current.update(() => {
|
|
$removePillsByFolder(folderPath);
|
|
});
|
|
}
|
|
|
|
// Also immediately update foldersFromPills to prevent stale data from re-adding the folder
|
|
setFoldersFromPills((prev) => prev.filter((pillFolder) => pillFolder !== folderPath));
|
|
};
|
|
|
|
// Unified handler for adding to context (from popover @ mention)
|
|
const handleAddToContext = (category: string, data: any) => {
|
|
switch (category) {
|
|
case "activeNote":
|
|
// Set active note context flag (no pill needed - context badge shows it)
|
|
setIncludeActiveNote(true);
|
|
break;
|
|
case "notes":
|
|
if (data instanceof TFile) {
|
|
const activeNote = app.workspace.getActiveFile();
|
|
if (activeNote && data.path === activeNote.path) {
|
|
setIncludeActiveNote(true);
|
|
setContextNotes((prev) => prev.filter((n) => n.path !== data.path));
|
|
} else {
|
|
setContextNotes((prev) => {
|
|
const existingNote = prev.find((n) => n.path === data.path);
|
|
if (existingNote) {
|
|
return prev; // Note already exists, no change needed
|
|
} else {
|
|
return [...prev, data];
|
|
}
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
case "tools":
|
|
// Add tool pill to lexical editor when selected from @ mention typeahead
|
|
if (typeof data === "string" && lexicalEditorRef.current) {
|
|
lexicalEditorRef.current.update(() => {
|
|
// Insert tool pill at current cursor position
|
|
const selection = $getSelection();
|
|
if ($isRangeSelection(selection)) {
|
|
const toolPill = $createToolPillNode(data);
|
|
selection.insertNodes([toolPill]);
|
|
}
|
|
});
|
|
// Note: toolsFromPills will be updated automatically via ToolPillSyncPlugin
|
|
}
|
|
break;
|
|
case "folders":
|
|
// For folders from context menu, update contextFolders directly (no pills in editor)
|
|
if (data && data.path) {
|
|
const folderPath = data.path;
|
|
setContextFolders((prev) => {
|
|
const exists = prev.find((f) => f === folderPath);
|
|
if (!exists) {
|
|
return [...prev, folderPath];
|
|
}
|
|
return prev;
|
|
});
|
|
}
|
|
break;
|
|
case "webTabs":
|
|
// Badge-only behavior (like notes): add to contextWebTabs state, no pill insertion
|
|
if (data && typeof data.url === "string") {
|
|
const normalized = normalizeWebTabContext(data as WebTabContext);
|
|
if (!normalized) break;
|
|
|
|
// If selecting the active web tab, toggle the active badge instead
|
|
const activeUrl = normalizeUrlString(activeWebTab?.url);
|
|
if (activeUrl && normalized.url === activeUrl) {
|
|
setIncludeActiveWebTab(true);
|
|
setContextWebTabs((prev) =>
|
|
prev.filter((t) => normalizeUrlString(t.url) !== activeUrl)
|
|
);
|
|
break;
|
|
}
|
|
|
|
setContextWebTabs((prev) => mergeWebTabContexts([...prev, normalized]));
|
|
}
|
|
break;
|
|
case "activeWebTab":
|
|
// Badge-only behavior (like activeNote): toggle include flag, no pill insertion
|
|
setIncludeActiveWebTab(true);
|
|
// Remove from contextWebTabs if it was added as a regular tab
|
|
{
|
|
const activeUrl = normalizeUrlString(activeWebTab?.url);
|
|
if (activeUrl) {
|
|
setContextWebTabs((prev) =>
|
|
prev.filter((t) => normalizeUrlString(t.url) !== activeUrl)
|
|
);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
// Unified handler for removing from context (from context menu badges)
|
|
const handleRemoveFromContext = (category: string, data: any) => {
|
|
switch (category) {
|
|
case "activeNote":
|
|
// Remove active note pill from editor and turn off flag
|
|
setIncludeActiveNote(false);
|
|
if (lexicalEditorRef.current) {
|
|
lexicalEditorRef.current.update(() => {
|
|
$removeActiveNotePills();
|
|
});
|
|
}
|
|
break;
|
|
case "notes":
|
|
if (typeof data === "string") {
|
|
// data is the path
|
|
// Check if this is the active note
|
|
if (currentActiveNote?.path === data && includeActiveNote) {
|
|
setIncludeActiveNote(false);
|
|
} else {
|
|
// Remove from contextNotes
|
|
setContextNotes((prev) => prev.filter((note) => note.path !== data));
|
|
}
|
|
// Also remove corresponding pills from editor
|
|
handleContextNoteRemoved(data);
|
|
}
|
|
break;
|
|
case "urls":
|
|
if (typeof data === "string") {
|
|
setContextUrls((prev) => prev.filter((u) => u !== data));
|
|
handleURLContextRemoved(data);
|
|
}
|
|
break;
|
|
case "folders":
|
|
if (typeof data === "string") {
|
|
// data is the path
|
|
setContextFolders((prev) => prev.filter((f) => f !== data));
|
|
handleFolderContextRemoved(data);
|
|
}
|
|
break;
|
|
case "selectedText":
|
|
if (typeof data === "string") {
|
|
// data is the id
|
|
onRemoveSelectedText?.(data);
|
|
}
|
|
break;
|
|
case "activeWebTab":
|
|
// Remove active web tab pill from editor and turn off flag
|
|
setIncludeActiveWebTab(false);
|
|
if (lexicalEditorRef.current) {
|
|
lexicalEditorRef.current.update(() => {
|
|
$removeActiveWebTabPills();
|
|
});
|
|
}
|
|
break;
|
|
case "webTabs":
|
|
// Remove web tab from contextWebTabs state
|
|
if (typeof data === "string") {
|
|
const url = normalizeUrlString(data);
|
|
if (!url) break;
|
|
|
|
setContextWebTabs((prev) => prev.filter((t) => normalizeUrlString(t.url) !== url));
|
|
// Also immediately update pills-derived state to avoid UI re-adding during sync lag
|
|
setWebTabsFromPills((prev) => prev.filter((t) => normalizeUrlString(t.url) !== url));
|
|
// Also remove any corresponding pills from editor (if any exist)
|
|
if (lexicalEditorRef.current) {
|
|
lexicalEditorRef.current.update(() => {
|
|
$removeWebTabPillsByUrl(url);
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
// Handle when folders are removed from pills (when pills are deleted in editor)
|
|
const handleFolderPillsRemoved = (removedFolders: string[]) => {
|
|
const removedFolderPaths = new Set(removedFolders);
|
|
|
|
setContextFolders((prev) => {
|
|
return prev.filter((folder) => {
|
|
if (removedFolderPaths.has(folder)) {
|
|
return false; // Remove this folder
|
|
}
|
|
return true; // Keep this folder
|
|
});
|
|
});
|
|
};
|
|
|
|
// Pill-to-context synchronization (when pills are added)
|
|
useEffect(() => {
|
|
setContextNotes((prev) => {
|
|
const contextPaths = new Set(prev.map((note) => note.path));
|
|
|
|
// Find notes that need to be added
|
|
const newNotesFromPills = notesFromPills.filter((pillNote) => {
|
|
// Only add if not already in context
|
|
return !contextPaths.has(pillNote.path);
|
|
});
|
|
|
|
// Add completely new notes from pills
|
|
const newFiles: TFile[] = [];
|
|
newNotesFromPills.forEach((pillNote) => {
|
|
const file = app.vault.getAbstractFileByPath(pillNote.path);
|
|
if (file instanceof TFile) {
|
|
newFiles.push(file);
|
|
}
|
|
});
|
|
|
|
return [...prev, ...newFiles];
|
|
});
|
|
}, [notesFromPills, app.vault, setContextNotes]);
|
|
|
|
// URL pill-to-context synchronization (when URL pills are added) - only for Plus chains
|
|
useEffect(() => {
|
|
if (isPlusChain(currentChain)) {
|
|
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
|
|
if (newUrlsFromPills.length > 0) {
|
|
return Array.from(new Set([...prev, ...newUrlsFromPills]));
|
|
}
|
|
|
|
return prev;
|
|
});
|
|
} else {
|
|
// Clear URLs for non-Plus chains
|
|
setContextUrls([]);
|
|
}
|
|
}, [urlsFromPills, currentChain]);
|
|
|
|
// Folder-to-context synchronization (when folders are added via pills)
|
|
useEffect(() => {
|
|
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
|
|
return [...prev, ...newFoldersFromPills];
|
|
});
|
|
}, [foldersFromPills]);
|
|
|
|
// Update the current active note whenever it changes
|
|
useEffect(() => {
|
|
let timeoutId: ReturnType<typeof setTimeout>;
|
|
|
|
const handleActiveLeafChange = () => {
|
|
// Clear any existing timeout
|
|
clearTimeout(timeoutId);
|
|
|
|
// Set new timeout
|
|
timeoutId = setTimeout(() => {
|
|
const activeNote = app.workspace.getActiveFile();
|
|
setCurrentActiveNote(isAllowedFileForNoteContext(activeNote) ? activeNote : null);
|
|
}, 100); // Wait 100ms after the last event because it fires multiple times
|
|
};
|
|
|
|
const eventRef = app.workspace.on("active-leaf-change", handleActiveLeafChange);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId); // Clean up any pending timeout
|
|
// cspell:disable-next-line
|
|
app.workspace.offref(eventRef); // Remove event listener
|
|
};
|
|
}, [app.workspace]);
|
|
|
|
const onEditorReady = useCallback((editor: any) => {
|
|
lexicalEditorRef.current = editor;
|
|
}, []);
|
|
|
|
// Handle Escape key for edit mode
|
|
useEffect(() => {
|
|
if (!editMode || !onEditCancel) return;
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
onEditCancel();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [editMode, onEditCancel]);
|
|
|
|
// Handle tool button toggle-off events - remove corresponding pills
|
|
const handleVaultToggleOff = useCallback(() => {
|
|
if (lexicalEditorRef.current && isCopilotPlus) {
|
|
lexicalEditorRef.current.update(() => {
|
|
$removePillsByToolName("@vault");
|
|
});
|
|
}
|
|
}, [isCopilotPlus]);
|
|
|
|
const handleWebToggleOff = useCallback(() => {
|
|
if (lexicalEditorRef.current && isCopilotPlus) {
|
|
lexicalEditorRef.current.update(() => {
|
|
$removePillsByToolName("@websearch");
|
|
$removePillsByToolName("@web");
|
|
});
|
|
}
|
|
}, [isCopilotPlus]);
|
|
|
|
const handleComposerToggleOff = useCallback(() => {
|
|
if (lexicalEditorRef.current && isCopilotPlus) {
|
|
lexicalEditorRef.current.update(() => {
|
|
$removePillsByToolName("@composer");
|
|
});
|
|
}
|
|
}, [isCopilotPlus]);
|
|
|
|
// Active note pill sync callbacks
|
|
const handleActiveNoteAdded = useCallback(() => {
|
|
setIncludeActiveNote(true);
|
|
}, [setIncludeActiveNote]);
|
|
|
|
const handleActiveNoteRemoved = useCallback(() => {
|
|
setIncludeActiveNote(false);
|
|
}, [setIncludeActiveNote]);
|
|
|
|
// Active web tab pill sync callbacks (mirror activeNote behavior)
|
|
// Active Web Tab URL resolution is now handled by ChatManager at send time
|
|
const handleActiveWebTabAdded = useCallback(() => {
|
|
setIncludeActiveWebTab(true);
|
|
}, [setIncludeActiveWebTab]);
|
|
|
|
const handleActiveWebTabRemoved = useCallback(() => {
|
|
setIncludeActiveWebTab(false);
|
|
}, [setIncludeActiveWebTab]);
|
|
|
|
// Handle tag selection from typeahead - auto-enable vault search
|
|
const handleTagSelected = useCallback(() => {
|
|
if (isCopilotPlus && !autonomousAgentToggle && !vaultToggle) {
|
|
setVaultToggle(true);
|
|
new Notice("Vault search enabled for tag query");
|
|
}
|
|
}, [isCopilotPlus, autonomousAgentToggle, vaultToggle]);
|
|
|
|
return (
|
|
<div
|
|
className="tw-flex tw-w-full tw-flex-col tw-gap-0.5 tw-rounded-md tw-border tw-border-solid tw-border-border tw-px-1 tw-pb-1 tw-pt-2 tw-@container/chat-input"
|
|
ref={containerRef}
|
|
>
|
|
{/* Hide context controls in edit mode - editing only changes text, not context */}
|
|
{!editMode && (
|
|
<ContextControl
|
|
contextNotes={contextNotes}
|
|
includeActiveNote={includeActiveNote}
|
|
activeNote={currentActiveNote}
|
|
includeActiveWebTab={includeActiveWebTab}
|
|
activeWebTab={activeWebTab}
|
|
contextUrls={contextUrls}
|
|
contextFolders={contextFolders}
|
|
contextWebTabs={mergedContextWebTabs}
|
|
selectedTextContexts={selectedTextContexts}
|
|
showProgressCard={showProgressCard}
|
|
lexicalEditorRef={lexicalEditorRef}
|
|
onAddToContext={handleAddToContext}
|
|
onRemoveFromContext={handleRemoveFromContext}
|
|
/>
|
|
)}
|
|
|
|
{selectedImages.length > 0 && (
|
|
<div className="selected-images">
|
|
{selectedImages.map((file, index) => (
|
|
<div key={index} className="image-preview-container">
|
|
<img
|
|
src={URL.createObjectURL(file)}
|
|
alt={file.name}
|
|
className="selected-image-preview"
|
|
/>
|
|
<button
|
|
className="remove-image-button"
|
|
onClick={() => setSelectedImages((prev) => prev.filter((_, i) => i !== index))}
|
|
title="Remove image"
|
|
>
|
|
<X className="tw-size-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="tw-relative">
|
|
{isProjectLoading && (
|
|
<div className="tw-absolute tw-inset-0 tw-z-modal tw-flex tw-items-center tw-justify-center tw-bg-primary tw-opacity-80 tw-backdrop-blur-sm">
|
|
<div className="tw-flex tw-items-center tw-gap-2">
|
|
<Loader2 className="tw-size-4 tw-animate-spin" />
|
|
<span className="tw-text-sm">{loadingMessages[loadingMessageIndex]}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<LexicalEditor
|
|
value={inputMessage}
|
|
onChange={(value) => setInputMessage(value)}
|
|
onSubmit={onSendMessage}
|
|
onNotesChange={setNotesFromPills}
|
|
onNotesRemoved={handleNotePillsRemoved}
|
|
onActiveNoteAdded={handleActiveNoteAdded}
|
|
onActiveNoteRemoved={handleActiveNoteRemoved}
|
|
onURLsChange={isCopilotPlus ? setUrlsFromPills : undefined}
|
|
onURLsRemoved={isCopilotPlus ? handleURLPillsRemoved : undefined}
|
|
onToolsChange={isCopilotPlus ? setToolsFromPills : undefined}
|
|
onToolsRemoved={isCopilotPlus ? handleToolPillsRemoved : undefined}
|
|
onFoldersChange={setFoldersFromPills}
|
|
onFoldersRemoved={handleFolderPillsRemoved}
|
|
onWebTabsChange={setWebTabsFromPills}
|
|
onActiveWebTabAdded={handleActiveWebTabAdded}
|
|
onActiveWebTabRemoved={handleActiveWebTabRemoved}
|
|
onEditorReady={onEditorReady}
|
|
onImagePaste={onAddImage}
|
|
onTagSelected={handleTagSelected}
|
|
placeholder={"Your AI assistant for Obsidian • @ to add context • / for custom prompts"}
|
|
disabled={isProjectLoading}
|
|
isCopilotPlus={isCopilotPlus}
|
|
currentActiveFile={currentActiveNote}
|
|
currentChain={currentChain}
|
|
/>
|
|
</div>
|
|
|
|
<div className="tw-flex tw-h-6 tw-justify-between tw-gap-1 tw-px-1">
|
|
{isGenerating ? (
|
|
<div className="tw-flex tw-items-center tw-gap-1 tw-px-1 tw-text-sm tw-text-muted">
|
|
<Loader2 className="tw-size-3 tw-animate-spin" />
|
|
<span>Generating...</span>
|
|
</div>
|
|
) : (
|
|
<div className="tw-min-w-0 tw-flex-1">
|
|
<ModelSelector
|
|
variant="ghost2"
|
|
size="fit"
|
|
disabled={disableModelSwitch}
|
|
value={getDisplayModelKey()}
|
|
onChange={(modelKey) => {
|
|
// In project mode, we don't update the global model key
|
|
// as the project model takes precedence
|
|
if (currentChain !== ChainType.PROJECT_CHAIN) {
|
|
setCurrentModelKey(modelKey);
|
|
}
|
|
}}
|
|
className="tw-max-w-full tw-truncate"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="tw-flex tw-items-center tw-gap-1">
|
|
{isGenerating ? (
|
|
<Button
|
|
variant="ghost2"
|
|
size="fit"
|
|
className="tw-text-muted"
|
|
onClick={() => onStopGenerating()}
|
|
>
|
|
<StopCircle className="tw-size-4" />
|
|
Stop
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<ChatToolControls
|
|
vaultToggle={vaultToggle}
|
|
setVaultToggle={setVaultToggle}
|
|
webToggle={webToggle}
|
|
setWebToggle={setWebToggle}
|
|
composerToggle={composerToggle}
|
|
setComposerToggle={setComposerToggle}
|
|
autonomousAgentToggle={autonomousAgentToggle}
|
|
setAutonomousAgentToggle={setAutonomousAgentToggle}
|
|
currentChain={currentChain}
|
|
onVaultToggleOff={handleVaultToggleOff}
|
|
onWebToggleOff={handleWebToggleOff}
|
|
onComposerToggleOff={handleComposerToggleOff}
|
|
/>
|
|
<TooltipProvider delayDuration={0}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost2"
|
|
size="fit"
|
|
className="tw-text-muted hover:tw-text-accent"
|
|
onClick={() => {
|
|
new AddImageModal(app, onAddImage).open();
|
|
}}
|
|
>
|
|
<Image className="tw-size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="tw-px-1 tw-py-0.5">Add image(s)</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
{editMode && onEditCancel && (
|
|
<Button
|
|
variant="ghost2"
|
|
size="fit"
|
|
className="tw-text-muted"
|
|
onClick={onEditCancel}
|
|
>
|
|
<span>cancel</span>
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost2"
|
|
size="fit"
|
|
className="tw-text-muted"
|
|
onClick={() => onSendMessage()}
|
|
>
|
|
<CornerDownLeft className="!tw-size-3" />
|
|
<span>{editMode ? "save" : "chat"}</span>
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
ChatInput.displayName = "ChatInput";
|
|
|
|
export default ChatInput;
|