import { ProjectConfig } from "@/aiParams"; import { useApp } from "@/context"; import { ContextManageModal } from "@/components/modals/project/context-manage-modal"; import { openCachedItemPreview } from "@/utils/cacheFileOpener"; import type { ProcessingItem } from "@/components/project/processingAdapter"; import { ProcessingStatus } from "@/components/project/processing-status"; import { useProjectProcessingData } from "@/components/project/useProjectProcessingData"; import { Button } from "@/components/ui/button"; import { FormField } from "@/components/ui/form-field"; import { HelpTooltip } from "@/components/ui/help-tooltip"; import { Input } from "@/components/ui/input"; import { ObsidianNativeSelect } from "@/components/ui/obsidian-native-select"; import { ScrollArea } from "@/components/ui/scroll-area"; import { SettingSlider } from "@/components/ui/setting-slider"; import { Textarea } from "@/components/ui/textarea"; import { UrlTagInput } from "@/components/ui/url-tag-input"; import { SystemPromptSyntaxInstruction } from "@/components/SystemPromptSyntaxInstruction"; import { DEFAULT_MODEL_SETTING } from "@/constants"; import { ProjectContextBadgeList } from "@/components/project/ProjectContextBadgeList"; import { err2String, randomUUID } from "@/utils"; import { Settings } from "lucide-react"; import { type UrlItem, parseProjectUrls, serializeProjectUrls } from "@/utils/urlTagUtils"; import type CopilotPlugin from "@/main"; import { createPluginRoot } from "@/utils/react/createPluginRoot"; import { useChatBackendModelOptions } from "@/hooks/useChatBackendModelOptions"; import { App, Modal, Notice } from "obsidian"; import React, { useMemo, useState } from "react"; import { Root } from "react-dom/client"; interface AddProjectModalContentProps { initialProject?: ProjectConfig; onSave: (project: ProjectConfig) => Promise; onCancel: () => void; plugin?: CopilotPlugin; } function AddProjectModalContent({ initialProject, onSave, onCancel, plugin, }: AddProjectModalContentProps) { const app = useApp(); // Project model options come from the model-management "chat" backend // (same enabled set as Quick Chat), keyed by configuredModelId. const { options: chatModelOptions, resolveSelectionId } = useChatBackendModelOptions(); const [isSubmitting, setIsSubmitting] = useState(false); const [touched, setTouched] = useState({ name: false, systemPrompt: false, projectModelKey: false, inclusions: false, }); const [formData, setFormData] = useState(() => initialProject ? { ...initialProject, projectModelKey: resolveSelectionId(initialProject.projectModelKey) || initialProject.projectModelKey, } : { id: randomUUID(), name: "", description: "", systemPrompt: "", projectModelKey: "", modelConfigs: { temperature: DEFAULT_MODEL_SETTING.TEMPERATURE, maxTokens: DEFAULT_MODEL_SETTING.MAX_TOKENS, }, contextSource: { inclusions: "", exclusions: "", webUrls: "", youtubeUrls: "", }, created: Date.now(), UsageTimestamps: Date.now(), } ); // URL items derived from formData for UrlTagInput const urlItems = useMemo( () => parseProjectUrls( formData.contextSource?.webUrls || "", formData.contextSource?.youtubeUrls || "" ), [formData.contextSource?.webUrls, formData.contextSource?.youtubeUrls] ); // Reason: Shared hook handles cache loading, file enumeration, and processingData construction. // contextSource draft is passed so newly added (unsaved) URLs appear as "Pending". const { processingData, projectCache, isCurrentProject } = useProjectProcessingData({ cacheProject: initialProject ?? null, contextSource: formData.contextSource, }); const handleEditProjectContext = (projectDraft: ProjectConfig) => { const modal = new ContextManageModal( app, (updatedProject: ProjectConfig) => { // Reason: Only merge inclusions/exclusions (what ContextManageModal edits). // Don't replace the entire contextSource — that would overwrite any // webUrls/youtubeUrls changes the user made in AddProjectModal while // the child modal was open. setFormData((prev) => ({ ...prev, contextSource: { ...prev.contextSource, inclusions: updatedProject.contextSource?.inclusions, exclusions: updatedProject.contextSource?.exclusions, }, })); }, projectDraft ); modal.open(); }; const isFormValid = () => { return formData.name && formData.projectModelKey; }; const handleInputChange = ( field: string, value: string | number | string[] | Record ) => { setFormData((prev) => { if (typeof value === "string") { if (field === "projectModelKey") { value = value.trim(); } } if (Array.isArray(value) && value.every((item) => typeof item === "string")) { value = value.map((item) => item.trim()).filter(Boolean); } if (field.includes(".")) { const [parent, child] = field.split("."); const parentKey = parent as keyof typeof prev; const parentValue = prev[parentKey]; if (typeof parentValue === "object" && parentValue !== null) { return { ...prev, [parent]: { ...parentValue, [child]: value, }, }; } } return { ...prev, [field]: value, }; }); }; /** Handle URL adds from UrlTagInput, serialize back to formData strings */ const handleUrlAdd = (newUrls: UrlItem[]) => { const allUrls = [...urlItems, ...newUrls]; const { webUrls, youtubeUrls } = serializeProjectUrls(allUrls); handleInputChange("contextSource.webUrls", webUrls); handleInputChange("contextSource.youtubeUrls", youtubeUrls); }; /** Handle URL removal from UrlTagInput */ const handleUrlRemove = (id: string) => { const remaining = urlItems.filter((u) => u.id !== id); const { webUrls, youtubeUrls } = serializeProjectUrls(remaining); handleInputChange("contextSource.webUrls", webUrls); handleInputChange("contextSource.youtubeUrls", youtubeUrls); }; /** Handle inclusions pattern changes from badge list deletion */ const handleInclusionsChange = (value: string) => { handleInputChange("contextSource.inclusions", value); }; /** Handle exclusions pattern changes from badge list deletion */ const handleExclusionsChange = (value: string) => { handleInputChange("contextSource.exclusions", value); }; /** Handle opening cached parsed content for any item (file or URL) */ const handleOpenCachedItem = (item: ProcessingItem) => { void openCachedItemPreview(app, projectCache, item); }; /** Handle removing a failed URL from the project config via ProcessingStatus × button */ const handleRemoveUrl = (item: ProcessingItem) => { // Reason: ProcessingItem.id is the raw URL, while UrlItem.id is "type:url". // Match by url field and cacheKind to avoid ID format mismatch. const targetType = item.cacheKind === "youtube" ? "youtube" : "web"; const remaining = urlItems.filter((u) => !(u.type === targetType && u.url === item.id)); const { webUrls, youtubeUrls } = serializeProjectUrls(remaining); handleInputChange("contextSource.webUrls", webUrls); handleInputChange("contextSource.youtubeUrls", youtubeUrls); }; /** Handle retry for failed processing items */ const handleRetry = (itemId: string) => { if (!plugin?.projectManager || !processingData) return; const failedItem = processingData.failedItemMap.get(itemId); if (failedItem) { void plugin.projectManager.retryFailedItem(failedItem); } }; const handleSave = async () => { const trimmedName = formData.name?.trim() ?? ""; const saveData = { ...formData, name: trimmedName }; const requiredFields = ["name", "projectModelKey"]; const missingFields = requiredFields.filter((field) => !saveData[field as keyof ProjectConfig]); if (missingFields.length > 0) { setTouched((prev) => ({ ...prev, ...Object.fromEntries(missingFields.map((field) => [field, true])), })); new Notice("Please fill in all required fields"); return; } try { setIsSubmitting(true); await onSave(saveData); } catch (e) { new Notice(err2String(e)); setTouched((prev) => ({ ...prev, name: true, })); } finally { setIsSubmitting(false); } }; return (
{/* Header */}
{initialProject ? "Edit Project" : "New Project"}

Configure your project settings and context sources

{/* Scrollable Content */}
{/* Basic Info Card */}

Basic Info

handleInputChange("name", e.target.value)} onBlur={() => setTouched((prev) => ({ ...prev, name: true }))} className="tw-w-full" /> handleInputChange("description", e.target.value)} className="tw-w-full" />