mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
744 lines
27 KiB
TypeScript
744 lines
27 KiB
TypeScript
import {
|
|
getCurrentProject,
|
|
ProjectConfig,
|
|
subscribeToProjectChange,
|
|
useChainType,
|
|
useModelKey,
|
|
useProjectLoading,
|
|
} from "@/aiParams";
|
|
import { ChainType } from "@/chainFactory";
|
|
import { CustomCommandManager } from "@/commands/customCommandManager";
|
|
import { sortSlashCommands } from "@/commands/customCommandUtils";
|
|
import { getCachedCustomCommands } from "@/commands/state";
|
|
import { AddContextNoteModal } from "@/components/modals/AddContextNoteModal";
|
|
import { AddImageModal } from "@/components/modals/AddImageModal";
|
|
import { ListPromptModal } from "@/components/modals/ListPromptModal";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ModelSelector } from "@/components/ui/ModelSelector";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { ContextProcessor } from "@/contextProcessor";
|
|
import { cn } from "@/lib/utils";
|
|
import { COPILOT_TOOL_NAMES } from "@/LLMProviders/intentAnalyzer";
|
|
import { Mention } from "@/mentions/Mention";
|
|
import { isPlusChain } from "@/utils";
|
|
|
|
import { updateSetting, useSettingsValue } from "@/settings/model";
|
|
import { SelectedTextContext } from "@/types/message";
|
|
import { getToolDescription } from "@/tools/toolManager";
|
|
import { extractNoteFiles, isAllowedFileForContext, isNoteTitleUnique } from "@/utils";
|
|
import {
|
|
CornerDownLeft,
|
|
Database,
|
|
Globe,
|
|
Image,
|
|
Loader2,
|
|
StopCircle,
|
|
X,
|
|
Pen,
|
|
Sparkles,
|
|
Brain,
|
|
} from "lucide-react";
|
|
import { App, Platform, TFile } from "obsidian";
|
|
import React, {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useDropzone } from "react-dropzone";
|
|
import ContextControl from "./ContextControl";
|
|
|
|
interface ChatInputProps {
|
|
inputMessage: string;
|
|
setInputMessage: (message: string) => void;
|
|
handleSendMessage: (metadata?: {
|
|
toolCalls?: string[];
|
|
urls?: string[];
|
|
contextNotes?: TFile[];
|
|
}) => void;
|
|
isGenerating: boolean;
|
|
onStopGenerating: () => void;
|
|
app: App;
|
|
contextNotes: TFile[];
|
|
setContextNotes: React.Dispatch<React.SetStateAction<TFile[]>>;
|
|
includeActiveNote: boolean;
|
|
setIncludeActiveNote: (include: boolean) => void;
|
|
mention: Mention;
|
|
selectedImages: File[];
|
|
onAddImage: (files: File[]) => void;
|
|
setSelectedImages: React.Dispatch<React.SetStateAction<File[]>>;
|
|
disableModelSwitch?: boolean;
|
|
selectedTextContexts?: SelectedTextContext[];
|
|
onRemoveSelectedText?: (id: string) => void;
|
|
showProgressCard: () => void;
|
|
}
|
|
|
|
const ChatInput = forwardRef<{ focus: () => void }, ChatInputProps>(
|
|
(
|
|
{
|
|
inputMessage,
|
|
setInputMessage,
|
|
handleSendMessage,
|
|
isGenerating,
|
|
onStopGenerating,
|
|
app,
|
|
contextNotes,
|
|
setContextNotes,
|
|
includeActiveNote,
|
|
setIncludeActiveNote,
|
|
mention,
|
|
selectedImages,
|
|
onAddImage,
|
|
setSelectedImages,
|
|
disableModelSwitch,
|
|
selectedTextContexts,
|
|
onRemoveSelectedText,
|
|
showProgressCard,
|
|
},
|
|
ref
|
|
) => {
|
|
const [contextUrls, setContextUrls] = useState<string[]>([]);
|
|
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(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 isAllowedFileForContext(activeFile) ? activeFile : null;
|
|
});
|
|
const [selectedProject, setSelectedProject] = useState<ProjectConfig | null>(null);
|
|
const isCopilotPlus = isPlusChain(currentChain);
|
|
|
|
// 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...",
|
|
];
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
focus: () => {
|
|
textAreaRef.current?.focus();
|
|
},
|
|
}));
|
|
|
|
// 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 = () => {
|
|
if (!isCopilotPlus) {
|
|
handleSendMessage();
|
|
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) {
|
|
if (vaultToggle) toolCalls.push("@vault");
|
|
if (webToggle) toolCalls.push("@websearch");
|
|
if (composerToggle) toolCalls.push("@composer");
|
|
}
|
|
|
|
handleSendMessage({
|
|
toolCalls,
|
|
contextNotes,
|
|
urls: contextUrls,
|
|
});
|
|
};
|
|
|
|
const handleInputChange = async (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const inputValue = event.target.value;
|
|
const cursorPos = event.target.selectionStart;
|
|
|
|
// Check for slash BEFORE updating state
|
|
// Only show slash modal if:
|
|
// 1. We just typed a "/" AND
|
|
// 2. Either the input is empty OR there's a space before the "/"
|
|
const shouldShowSlashModal =
|
|
cursorPos > 0 &&
|
|
inputValue[cursorPos - 1] === "/" &&
|
|
(cursorPos === 1 || inputValue[cursorPos - 2] === " ");
|
|
|
|
setInputMessage(inputValue);
|
|
adjustTextareaHeight();
|
|
|
|
// Extract URLs and update mentions
|
|
const urls = mention.extractAllUrls(inputValue);
|
|
|
|
// Update URLs in context, ensuring uniqueness
|
|
const newUrls = urls.filter((url) => !contextUrls.includes(url));
|
|
if (newUrls.length > 0 && isPlusChain(currentChain)) {
|
|
// Only add URLs to context for Plus chains
|
|
// Use Set to ensure uniqueness
|
|
setContextUrls((prev) => Array.from(new Set([...prev, ...newUrls])));
|
|
}
|
|
|
|
// Handle other input triggers
|
|
if (cursorPos >= 2 && inputValue.slice(cursorPos - 2, cursorPos) === "[[") {
|
|
showNoteTitleModal(cursorPos);
|
|
} else if (shouldShowSlashModal) {
|
|
// Pass the inputValue directly to ensure we use the current value
|
|
showCustomPromptModal(cursorPos, inputValue);
|
|
} else if (inputValue.slice(-1) === "@" && isCopilotPlus) {
|
|
showCopilotPlusOptionsModal();
|
|
}
|
|
};
|
|
|
|
const adjustTextareaHeight = () => {
|
|
if (textAreaRef.current) {
|
|
textAreaRef.current.style.height = "auto"; // Reset height
|
|
textAreaRef.current.style.height = `${textAreaRef.current.scrollHeight}px`; // Adjust height
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
adjustTextareaHeight();
|
|
}, [inputMessage]);
|
|
|
|
const showNoteTitleModal = (cursorPos: number) => {
|
|
const fetchNoteTitles = async () => {
|
|
const contextProcessor = ContextProcessor.getInstance();
|
|
|
|
new AddContextNoteModal({
|
|
app,
|
|
onNoteSelect: async (note: TFile) => {
|
|
const before = inputMessage.slice(0, cursorPos - 2);
|
|
const after = inputMessage.slice(cursorPos - 1);
|
|
|
|
// Check if this note title has duplicates
|
|
const isUnique = isNoteTitleUnique(note.basename, app.vault);
|
|
// If the title is unique, just show the title, otherwise show the full path
|
|
const noteRef = isUnique ? note.basename : note.path;
|
|
const newInputMessage = `${before}[[${noteRef}]]${after}`;
|
|
setInputMessage(newInputMessage);
|
|
|
|
const activeNote = app.workspace.getActiveFile();
|
|
if (note) {
|
|
await contextProcessor.addNoteToContext(
|
|
note,
|
|
app.vault,
|
|
contextNotes,
|
|
activeNote,
|
|
setContextNotes,
|
|
setIncludeActiveNote
|
|
);
|
|
}
|
|
|
|
// Add a delay to ensure the cursor is set after inputMessage is updated
|
|
setTimeout(() => {
|
|
if (textAreaRef.current) {
|
|
const newCursorPos = cursorPos + noteRef.length + 2;
|
|
textAreaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
|
}
|
|
}, 0);
|
|
},
|
|
excludeNotePaths,
|
|
}).open();
|
|
};
|
|
fetchNoteTitles();
|
|
};
|
|
|
|
const showCustomPromptModal = (cursorPos: number, currentInputValue: string) => {
|
|
const commandManager = CustomCommandManager.getInstance();
|
|
const commands = getCachedCustomCommands();
|
|
const slashCommands = sortSlashCommands(
|
|
commands.filter((command) => command.showInSlashMenu)
|
|
);
|
|
const commandTitles = slashCommands.map((command) => command.title);
|
|
|
|
// Use the passed input value and cursor position
|
|
const slashPosition = cursorPos - 1;
|
|
|
|
const modal = new ListPromptModal(app, commandTitles, (commandTitle: string) => {
|
|
const selectedCommand = slashCommands.find((command) => command.title === commandTitle);
|
|
if (selectedCommand) {
|
|
commandManager.recordUsage(selectedCommand);
|
|
|
|
// Replace the "/" with the command content
|
|
let before = "";
|
|
let after = "";
|
|
|
|
if (slashPosition >= 0 && currentInputValue[slashPosition] === "/") {
|
|
before = currentInputValue.slice(0, slashPosition);
|
|
after = currentInputValue.slice(slashPosition + 1);
|
|
}
|
|
|
|
const newInputMessage = before + selectedCommand.content + after;
|
|
setInputMessage(newInputMessage);
|
|
|
|
// Set cursor position after the inserted command content
|
|
setTimeout(() => {
|
|
if (textAreaRef.current) {
|
|
const newCursorPos = before.length + selectedCommand.content.length;
|
|
textAreaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
|
textAreaRef.current.focus();
|
|
}
|
|
}, 0);
|
|
}
|
|
});
|
|
modal.open();
|
|
};
|
|
|
|
const showCopilotPlusOptionsModal = () => {
|
|
// Create a map of options with their descriptions
|
|
const optionsWithDescriptions = COPILOT_TOOL_NAMES.map((option) => ({
|
|
title: option,
|
|
description: getToolDescription(option),
|
|
}));
|
|
|
|
new ListPromptModal(
|
|
app,
|
|
optionsWithDescriptions.map((o) => o.title),
|
|
(selectedOption: string) => {
|
|
setInputMessage(inputMessage + selectedOption + " ");
|
|
},
|
|
// Add descriptions as a separate array
|
|
optionsWithDescriptions.map((o) => o.description)
|
|
).open();
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.nativeEvent.isComposing) return;
|
|
|
|
if (e.key === "Enter") {
|
|
/**
|
|
* send msg:
|
|
* 1. non-mobile platforms: only input Enter
|
|
* 2. mobile platforms: Shift+Enter
|
|
*/
|
|
const shouldSendMessage =
|
|
(!e.shiftKey && !Platform.isMobile) || (e.shiftKey && Platform.isMobile);
|
|
|
|
if (!shouldSendMessage) {
|
|
// do nothing here, allowing the default newline behavior
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
onSendMessage();
|
|
}
|
|
};
|
|
|
|
const handlePaste = useCallback(
|
|
async (e: React.ClipboardEvent) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
|
|
const imageItems = Array.from(items).filter((item) => item.type.indexOf("image") !== -1);
|
|
|
|
if (imageItems.length > 0) {
|
|
e.preventDefault();
|
|
|
|
const files = await Promise.all(
|
|
imageItems.map((item) => {
|
|
const file = item.getAsFile();
|
|
if (!file) return null;
|
|
return file;
|
|
})
|
|
);
|
|
|
|
const validFiles = files.filter((file) => file !== null);
|
|
if (validFiles.length > 0) {
|
|
onAddImage(validFiles);
|
|
}
|
|
}
|
|
},
|
|
[onAddImage]
|
|
);
|
|
|
|
useEffect(() => {
|
|
// Get all note titles that are referenced using [[note]] syntax in the input
|
|
const currentFiles = new Set(extractNoteFiles(inputMessage, app.vault));
|
|
// Get all URLs mentioned in the input
|
|
const currentUrls = mention.extractAllUrls(inputMessage);
|
|
|
|
setContextNotes((prev) =>
|
|
prev.filter((note) => {
|
|
// Check if this note was added manually via the "+" button
|
|
const wasAddedManually = (note as any).wasAddedManually === true;
|
|
// If it was added manually, always keep it
|
|
if (wasAddedManually) return true;
|
|
|
|
// Check if this note was added by typing [[note]] in the input
|
|
// as opposed to being added via the "Add Note to Context" button
|
|
const wasAddedViaReference = (note as any).wasAddedViaReference === true;
|
|
|
|
// Special handling for the active note
|
|
if (note.path === currentActiveNote?.path) {
|
|
if (wasAddedViaReference) {
|
|
// Case 1: Active note was added by typing [[note]]
|
|
// Keep it only if its file is still in the input
|
|
return currentFiles.has(note);
|
|
} else {
|
|
// Case 2: Active note was NOT added by [[note]], but by the includeActiveNote toggle
|
|
// Keep it only if includeActiveNote is true
|
|
return includeActiveNote;
|
|
}
|
|
} else {
|
|
// Handling for all other notes (not the active note)
|
|
if (wasAddedViaReference) {
|
|
// Case 3: Other note was added by typing [[note]]
|
|
// Keep it only if its file is still in the input
|
|
return currentFiles.has(note);
|
|
} else {
|
|
// Case 4: Other note was added via "Add Note to Context" button
|
|
// Always keep these notes as they were manually added
|
|
return true;
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
// Remove any URLs that are no longer present in the input
|
|
// Only keep URLs if URL processing is supported for the current chain
|
|
if (isPlusChain(currentChain)) {
|
|
setContextUrls((prev) => prev.filter((url) => currentUrls.includes(url)));
|
|
} else {
|
|
// Clear all URLs for non-Plus chains
|
|
setContextUrls([]);
|
|
}
|
|
}, [
|
|
inputMessage,
|
|
includeActiveNote,
|
|
currentActiveNote,
|
|
mention,
|
|
setContextNotes,
|
|
app.vault,
|
|
currentChain,
|
|
]);
|
|
|
|
// 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(isAllowedFileForContext(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
|
|
app.workspace.offref(eventRef); // unregister
|
|
};
|
|
}, [app.workspace]);
|
|
|
|
// Add dropzone configuration
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
accept: {
|
|
"image/*": [".png", ".gif", ".jpeg", ".jpg", ".webp"],
|
|
},
|
|
onDrop: (acceptedFiles) => {
|
|
onAddImage(acceptedFiles);
|
|
},
|
|
noClick: true, // Prevents clicking on textarea from opening file dialog
|
|
noDragEventsBubbling: true,
|
|
});
|
|
|
|
const excludeNotePaths = useMemo(
|
|
() =>
|
|
[
|
|
...contextNotes.map((note) => note.path),
|
|
...(includeActiveNote && currentActiveNote ? [currentActiveNote.path] : []),
|
|
].filter((note) => note != null),
|
|
[contextNotes, includeActiveNote, currentActiveNote]
|
|
);
|
|
|
|
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}
|
|
>
|
|
<ContextControl
|
|
app={app}
|
|
excludeNotePaths={excludeNotePaths}
|
|
contextNotes={contextNotes}
|
|
setContextNotes={setContextNotes}
|
|
includeActiveNote={includeActiveNote}
|
|
setIncludeActiveNote={setIncludeActiveNote}
|
|
activeNote={currentActiveNote}
|
|
contextUrls={contextUrls}
|
|
onRemoveUrl={(url: string) => setContextUrls((prev) => prev.filter((u) => u !== url))}
|
|
selectedTextContexts={selectedTextContexts}
|
|
onRemoveSelectedText={onRemoveSelectedText}
|
|
showProgressCard={showProgressCard}
|
|
/>
|
|
|
|
{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" {...getRootProps()}>
|
|
{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>
|
|
)}
|
|
<textarea
|
|
ref={textAreaRef}
|
|
className="tw-max-h-40 tw-min-h-[60px] tw-w-full tw-resize-none tw-overflow-y-auto tw-rounded-md tw-border-none tw-bg-transparent tw-px-2 tw-text-sm tw-text-normal placeholder:tw-text-sm placeholder:tw-text-muted/60 focus-visible:tw-ring-0"
|
|
placeholder={
|
|
"Ask anything. [[ for notes. / for custom prompts. " +
|
|
(isCopilotPlus ? "@ for tools." : "")
|
|
}
|
|
value={inputMessage}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
disabled={isProjectLoading}
|
|
/>
|
|
<input {...getInputProps()} />
|
|
{/* Overlay that appears when dragging */}
|
|
{isDragActive && (
|
|
<div className="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center tw-rounded-md tw-border tw-border-dashed tw-bg-primary">
|
|
<span>Drop images here...</span>
|
|
</div>
|
|
)}
|
|
</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>
|
|
) : (
|
|
<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);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<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>
|
|
) : (
|
|
<TooltipProvider delayDuration={0}>
|
|
{/* Autonomous Agent button - only show in Copilot Plus mode and NOT in Projects mode */}
|
|
{isCopilotPlus && currentChain !== ChainType.PROJECT_CHAIN && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost2"
|
|
size="fit"
|
|
onClick={() => {
|
|
const newValue = !autonomousAgentToggle;
|
|
setAutonomousAgentToggle(newValue);
|
|
updateSetting("enableAutonomousAgent", newValue);
|
|
}}
|
|
className={cn(
|
|
"tw-mr-2 tw-text-muted hover:tw-text-accent",
|
|
autonomousAgentToggle && "tw-text-accent tw-bg-accent/10"
|
|
)}
|
|
>
|
|
<Brain className="tw-size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="tw-px-1 tw-py-0.5">
|
|
Toggle autonomous agent mode
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{/* Toggle buttons for vault, web search, and composer - show when Autonomous Agent is off */}
|
|
{!autonomousAgentToggle && isCopilotPlus && (
|
|
<>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost2"
|
|
size="fit"
|
|
onClick={() => setVaultToggle(!vaultToggle)}
|
|
className={cn(
|
|
"tw-mr-2 tw-text-muted hover:tw-text-accent",
|
|
vaultToggle && "tw-text-accent tw-bg-accent/10"
|
|
)}
|
|
>
|
|
<Database className="tw-size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="tw-px-1 tw-py-0.5">
|
|
Toggle vault search
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost2"
|
|
size="fit"
|
|
onClick={() => setWebToggle(!webToggle)}
|
|
className={cn(
|
|
"tw-mr-2 tw-text-muted hover:tw-text-accent",
|
|
webToggle && "tw-text-accent tw-bg-accent/10"
|
|
)}
|
|
>
|
|
<Globe className="tw-size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="tw-px-1 tw-py-0.5">
|
|
Toggle web search
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost2"
|
|
size="fit"
|
|
onClick={() => setComposerToggle(!composerToggle)}
|
|
className={cn(
|
|
"tw-mr-2 tw-text-muted hover:tw-text-accent",
|
|
composerToggle && "tw-text-accent tw-bg-accent/10"
|
|
)}
|
|
>
|
|
<span className="tw-flex tw-items-center tw-gap-0.5">
|
|
<Sparkles className="tw-size-2" />
|
|
<Pen className="tw-size-3" />
|
|
</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="tw-px-1 tw-py-0.5">
|
|
Toggle composer (note editing)
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
|
|
<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>
|
|
<Button
|
|
variant="ghost2"
|
|
size="fit"
|
|
className="tw-text-muted"
|
|
onClick={() => onSendMessage()}
|
|
>
|
|
<CornerDownLeft className="!tw-size-3" />
|
|
<span>chat</span>
|
|
</Button>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
ChatInput.displayName = "ChatInput";
|
|
|
|
export default ChatInput;
|