Adds duplicate-submission guard to the Quick Entry box, locking the submit action before the duplicate check resolves and preventing concurrent or double submissions from creating duplicate tasks. Includes tests covering the lock/unlock flow and duplicate-skip behavior. Fusion-Task-Id: FN-5136
2147 lines
80 KiB
TypeScript
2147 lines
80 KiB
TypeScript
import "./QuickEntryBox.css";
|
||
import { useState, useCallback, useRef, useEffect } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import type { ToastType } from "../hooks/useToast";
|
||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core";
|
||
import type { Task, Settings, TaskPriority } from "@fusion/core";
|
||
import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch } from "../api";
|
||
import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment } from "../api";
|
||
import { DuplicateWarningModal } from "./DuplicateWarningModal";
|
||
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react";
|
||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||
import { useNodes } from "../hooks/useNodes";
|
||
import type { NodeInfo } from "../api";
|
||
import { NodeHealthDot } from "./NodeHealthDot";
|
||
import { ProviderIcon } from "./ProviderIcon";
|
||
|
||
const STORAGE_KEY = "kb-quick-entry-text";
|
||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||
|
||
interface PendingImage {
|
||
file: File;
|
||
previewUrl: string;
|
||
}
|
||
|
||
interface QuickEntryBoxProps {
|
||
onCreate?: (input: CreateTaskInput) => Promise<Task | void>;
|
||
addToast: (message: string, type?: ToastType) => void;
|
||
tasks?: Task[];
|
||
availableModels?: ModelInfo[];
|
||
/**
|
||
* Called when the user clicks the "Plan" button to open planning mode.
|
||
*/
|
||
onPlanningMode?: (initialPlan: string) => void;
|
||
/**
|
||
* Called when the user clicks the "Subtask" button to trigger subtask breakdown.
|
||
*/
|
||
onSubtaskBreakdown?: (description: string) => void;
|
||
/** Optional project context for API calls */
|
||
projectId?: string;
|
||
/**
|
||
* When true, the component automatically expands when focused.
|
||
* Set to false to keep the view collapsed until manually toggled.
|
||
* Defaults to true for backward compatibility.
|
||
*/
|
||
autoExpand?: boolean;
|
||
/**
|
||
* Favorited provider IDs from shared app-level state.
|
||
* When provided (alongside availableModels), the component uses these
|
||
* instead of its own internal favorite state.
|
||
*/
|
||
favoriteProviders?: string[];
|
||
/**
|
||
* Favorited model IDs from shared app-level state.
|
||
* When provided (alongside availableModels), the component uses these
|
||
* instead of its own internal favorite state.
|
||
*/
|
||
favoriteModels?: string[];
|
||
/**
|
||
* Toggle favorite provider callback from shared app-level state.
|
||
*/
|
||
onToggleFavorite?: (provider: string) => void;
|
||
/**
|
||
* Toggle favorite model callback from shared app-level state.
|
||
*/
|
||
onToggleModelFavorite?: (modelId: string) => void;
|
||
onOpenTask?: (id: string) => void;
|
||
}
|
||
|
||
function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||
if (status === "online") return "Online";
|
||
if (status === "connecting") return "Connecting";
|
||
if (status === "error") return "Error";
|
||
return "Offline";
|
||
}
|
||
|
||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||
return provider && modelId ? `${provider}/${modelId}` : "";
|
||
}
|
||
|
||
function parseModelSelection(value: string): { provider?: string; modelId?: string } {
|
||
if (!value) {
|
||
return { provider: undefined, modelId: undefined };
|
||
}
|
||
|
||
const slashIndex = value.indexOf("/");
|
||
if (slashIndex === -1) {
|
||
return { provider: undefined, modelId: undefined };
|
||
}
|
||
|
||
return {
|
||
provider: value.slice(0, slashIndex),
|
||
modelId: value.slice(slashIndex + 1),
|
||
};
|
||
}
|
||
|
||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, projectId, autoExpand = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
|
||
const [description, setDescription] = useState(() => {
|
||
if (typeof window !== "undefined") {
|
||
return getScopedItem(STORAGE_KEY, projectId) || "";
|
||
}
|
||
return "";
|
||
});
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
// isExpanded controls textarea height styling (auto-resize)
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
// isDisclosureExpanded controls visibility of the controls panel (Deps, Models, etc.)
|
||
// Always starts collapsed — user must explicitly toggle each session
|
||
const [isDisclosureExpanded, setIsDisclosureExpanded] = useState(false);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const justResetRef = useRef(false);
|
||
const previousProjectIdRef = useRef(projectId);
|
||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||
const pendingImagesRef = useRef<PendingImage[]>([]);
|
||
|
||
// Rich creation state (mirrors InlineCreateCard)
|
||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||
const [showDeps, setShowDeps] = useState(false);
|
||
const [depSearch, setDepSearch] = useState("");
|
||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||
const [agents, setAgents] = useState<Agent[]>([]);
|
||
const [agentsProjectId, setAgentsProjectId] = useState<string | undefined>(undefined);
|
||
const [showAgentPicker, setShowAgentPicker] = useState(false);
|
||
const [showNodePicker, setShowNodePicker] = useState(false);
|
||
const [showPriorityPicker, setShowPriorityPicker] = useState(false);
|
||
const [agentsLoading, setAgentsLoading] = useState(false);
|
||
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
||
const [activeModelSubmenu, setActiveModelSubmenu] = useState<"plan" | "executor" | "validator" | null>(null);
|
||
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
|
||
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
|
||
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
|
||
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined);
|
||
const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined);
|
||
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
|
||
const modelTriggerRef = useRef<HTMLButtonElement>(null);
|
||
const modelMenuPortalRef = useRef<HTMLDivElement>(null);
|
||
const agentPickerRef = useRef<HTMLDivElement>(null);
|
||
const agentPickerPortalRef = useRef<HTMLDivElement>(null);
|
||
const nodePickerRef = useRef<HTMLDivElement>(null);
|
||
const nodePickerPortalRef = useRef<HTMLDivElement>(null);
|
||
const priorityPickerRef = useRef<HTMLDivElement>(null);
|
||
const priorityPickerPortalRef = useRef<HTMLDivElement>(null);
|
||
const [agentPickerPosition, setAgentPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
|
||
const [nodePickerPosition, setNodePickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
|
||
const [priorityPickerPosition, setPriorityPickerPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
|
||
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
|
||
// Dependency dropdown portal refs and state
|
||
const depTriggerRef = useRef<HTMLButtonElement>(null);
|
||
const depDropdownPortalRef = useRef<HTMLDivElement>(null);
|
||
const [depDropdownPosition, setDepDropdownPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
|
||
const [portalRoot] = useState<HTMLElement | null>(() =>
|
||
typeof document !== "undefined" ? document.body : null,
|
||
);
|
||
const [modelsLoading, setModelsLoading] = useState(false);
|
||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||
const [settings, setSettings] = useState<Settings | null>(null);
|
||
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
|
||
const [isFastMode, setIsFastMode] = useState(false);
|
||
const [githubTrackingOverride, setGithubTrackingOverride] = useState<boolean | null>(null);
|
||
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
|
||
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
|
||
const submitInFlightRef = useRef(false);
|
||
const { nodes } = useNodes();
|
||
// AI Refinement state
|
||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||
const [isRefining, setIsRefining] = useState(false);
|
||
const refineMenuRef = useRef<HTMLDivElement>(null);
|
||
const refineMenuPortalRef = useRef<HTMLDivElement>(null);
|
||
const [refineMenuPosition, setRefineMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||
|
||
// Use parent-provided favorites when available, otherwise internal state
|
||
const effectiveFavoriteProviders = parentFavoriteProviders ?? favoriteProviders;
|
||
const effectiveFavoriteModels = parentFavoriteModels ?? favoriteModels;
|
||
|
||
// If onCreate is not provided, the component is disabled
|
||
const isDisabled = !onCreate;
|
||
|
||
// Fetch models if not provided by parent
|
||
useEffect(() => {
|
||
if (availableModels) {
|
||
setLoadedModels(availableModels);
|
||
setModelsLoading(false);
|
||
setModelsError(null);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
setModelsLoading(true);
|
||
setModelsError(null);
|
||
fetchModels()
|
||
.then((response) => {
|
||
if (!cancelled) {
|
||
setLoadedModels(response.models);
|
||
// Only set internal favorites when parent doesn't manage them
|
||
if (!parentFavoriteProviders) {
|
||
setFavoriteProviders(response.favoriteProviders);
|
||
}
|
||
if (!parentFavoriteModels) {
|
||
setFavoriteModels(response.favoriteModels);
|
||
}
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
if (!cancelled) {
|
||
setModelsError(getErrorMessage(err) || "Failed to load models");
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) {
|
||
setModelsLoading(false);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [availableModels, parentFavoriteProviders, parentFavoriteModels]);
|
||
|
||
// Settings always drive toggle/preset behavior, regardless of model source.
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
fetchSettings(projectId)
|
||
.then((nextSettings) => {
|
||
if (!cancelled) {
|
||
setSettings(nextSettings);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// Silently ignore settings fetch failure
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId]);
|
||
|
||
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
|
||
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
|
||
const planningSelectionValue = getModelSelectionValue(planningProvider, planningModelId);
|
||
|
||
const hasExecutorOverride = Boolean(executorProvider && executorModelId);
|
||
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
|
||
const hasPlanningOverride = Boolean(planningProvider && planningModelId);
|
||
const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride) + Number(hasPlanningOverride);
|
||
const modelMenuLabel = selectedPresetId
|
||
? settings?.modelPresets?.find((p) => p.id === selectedPresetId)?.name ?? "Models"
|
||
: selectedModelCount > 0
|
||
? `${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||
: "Models";
|
||
|
||
const getModelBadgeLabel = useCallback(
|
||
(provider?: string, modelId?: string) => {
|
||
if (!provider || !modelId) return "Using default";
|
||
const matched = loadedModels.find((model) => model.provider === provider && model.id === modelId);
|
||
return matched ? `${matched.provider}/${matched.id}` : `${provider}/${modelId}`;
|
||
},
|
||
[loadedModels],
|
||
);
|
||
|
||
useEffect(() => {
|
||
setDescription(getScopedItem(STORAGE_KEY, projectId) || "");
|
||
}, [projectId]);
|
||
|
||
// Persist description to localStorage whenever it changes
|
||
useEffect(() => {
|
||
if (typeof window !== "undefined") {
|
||
setScopedItem(STORAGE_KEY, description, projectId);
|
||
}
|
||
}, [description, projectId]);
|
||
|
||
// Clear agents cache when projectId changes to prevent stale agents from leaking across projects
|
||
useEffect(() => {
|
||
if (previousProjectIdRef.current === projectId) {
|
||
return;
|
||
}
|
||
previousProjectIdRef.current = projectId;
|
||
setAgents([]);
|
||
setAgentsProjectId(undefined);
|
||
setSelectedAgentId(null);
|
||
setShowAgentPicker(false);
|
||
setAgentPickerPosition(null);
|
||
}, [projectId]);
|
||
|
||
// Clean up legacy disclosure persistence key from previous versions
|
||
useEffect(() => {
|
||
if (typeof window !== "undefined") {
|
||
localStorage.removeItem("kb-quick-entry-expanded");
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
pendingImagesRef.current = pendingImages;
|
||
}, [pendingImages]);
|
||
|
||
// Cleanup image preview URLs on unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
pendingImagesRef.current.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||
};
|
||
}, []);
|
||
|
||
// Auto-resize textarea based on content
|
||
const autoResize = useCallback(() => {
|
||
const textarea = textareaRef.current;
|
||
if (!textarea) return;
|
||
|
||
// Reset height to auto to get accurate scrollHeight
|
||
textarea.style.height = "auto";
|
||
// Set to scrollHeight (capped at max-height via CSS)
|
||
const newHeight = Math.min(textarea.scrollHeight, 200);
|
||
textarea.style.height = `${newHeight}px`;
|
||
}, []);
|
||
|
||
// Resize when description changes (not in fullscreen mode since CSS handles it)
|
||
useEffect(() => {
|
||
if (isExpanded) {
|
||
autoResize();
|
||
}
|
||
}, [description, isExpanded, autoResize]);
|
||
|
||
// Restore focus after submission completes (when textarea is re-enabled)
|
||
useEffect(() => {
|
||
if (!isSubmitting && description === "" && textareaRef.current) {
|
||
// Use setTimeout to ensure focus happens after React re-enables the textarea
|
||
const focusTimeout = setTimeout(() => {
|
||
textareaRef.current?.focus();
|
||
}, 0);
|
||
return () => clearTimeout(focusTimeout);
|
||
}
|
||
}, [isSubmitting, description]);
|
||
|
||
// Clear dep search when dropdown closes
|
||
useEffect(() => {
|
||
if (!showDeps) setDepSearch("");
|
||
}, [showDeps]);
|
||
|
||
// Close refine menu when clicking outside
|
||
useEffect(() => {
|
||
if (!isRefineMenuOpen) return;
|
||
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
const target = e.target as Node;
|
||
const clickedInsideTrigger = refineMenuRef.current?.contains(target);
|
||
const clickedInsidePortal = refineMenuPortalRef.current?.contains(target);
|
||
|
||
if (!clickedInsideTrigger && !clickedInsidePortal) {
|
||
setIsRefineMenuOpen(false);
|
||
}
|
||
};
|
||
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [isRefineMenuOpen]);
|
||
|
||
// Close model menu when clicking outside
|
||
useEffect(() => {
|
||
if (!isModelMenuOpen) return;
|
||
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
const target = e.target as Node;
|
||
const clickedInsideTrigger = modelTriggerRef.current?.contains(target);
|
||
const clickedInsidePortal = modelMenuPortalRef.current?.contains(target);
|
||
// Also check for clicks inside CustomModelDropdown's portaled dropdown
|
||
const clickedInsideCombobox = (target instanceof Element) && (target.closest?.(".model-combobox-dropdown--portal") != null);
|
||
|
||
if (!clickedInsideTrigger && !clickedInsidePortal && !clickedInsideCombobox) {
|
||
setIsModelMenuOpen(false);
|
||
setActiveModelSubmenu(null);
|
||
}
|
||
};
|
||
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [isModelMenuOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!showNodePicker) return;
|
||
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
const target = e.target as Node;
|
||
if (nodePickerRef.current?.contains(target)) return;
|
||
if (nodePickerPortalRef.current?.contains(target)) return;
|
||
setShowNodePicker(false);
|
||
setNodePickerPosition(null);
|
||
};
|
||
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [showNodePicker]);
|
||
|
||
useEffect(() => {
|
||
if (!showAgentPicker) return;
|
||
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
const target = e.target as Node;
|
||
// Check both the trigger button and the portaled dropdown
|
||
if (agentPickerRef.current?.contains(target)) return;
|
||
if (agentPickerPortalRef.current?.contains(target)) return;
|
||
setShowAgentPicker(false);
|
||
setAgentPickerPosition(null);
|
||
};
|
||
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [showAgentPicker]);
|
||
|
||
useEffect(() => {
|
||
if (!showPriorityPicker) return;
|
||
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
const target = e.target as Node;
|
||
if (priorityPickerRef.current?.contains(target)) return;
|
||
if (priorityPickerPortalRef.current?.contains(target)) return;
|
||
setShowPriorityPicker(false);
|
||
setPriorityPickerPosition(null);
|
||
};
|
||
|
||
document.addEventListener("mousedown", handleClickOutside);
|
||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||
}, [showPriorityPicker]);
|
||
|
||
const resetForm = useCallback(() => {
|
||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||
setPendingImages([]);
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = "";
|
||
}
|
||
|
||
setDescription("");
|
||
setDependencies([]);
|
||
setSelectedAgentId(null);
|
||
setShowAgentPicker(false);
|
||
setAgentPickerPosition(null);
|
||
setShowNodePicker(false);
|
||
setNodePickerPosition(null);
|
||
setShowPriorityPicker(false);
|
||
setPriorityPickerPosition(null);
|
||
setExecutorProvider(undefined);
|
||
setExecutorModelId(undefined);
|
||
setValidatorProvider(undefined);
|
||
setValidatorModelId(undefined);
|
||
setPlanningProvider(undefined);
|
||
setPlanningModelId(undefined);
|
||
setSelectedPresetId(undefined);
|
||
setIsFastMode(false);
|
||
setGithubTrackingOverride(null);
|
||
setPriority(DEFAULT_TASK_PRIORITY);
|
||
setNodeId(undefined);
|
||
setShowDeps(false);
|
||
setIsModelMenuOpen(false);
|
||
setModelMenuPosition(null);
|
||
setActiveModelSubmenu(null);
|
||
setIsRefineMenuOpen(false);
|
||
setIsRefining(false);
|
||
setIsExpanded(false); // Collapse textarea height on reset
|
||
setIsDisclosureExpanded(false); // Always reset controls to collapsed after creation
|
||
justResetRef.current = true;
|
||
if (textareaRef.current) {
|
||
textareaRef.current.style.height = "auto";
|
||
}
|
||
// Clear localStorage when form is reset (after successful creation)
|
||
if (typeof window !== "undefined") {
|
||
removeScopedItem(STORAGE_KEY, projectId);
|
||
}
|
||
}, [pendingImages, projectId]);
|
||
|
||
const handleImageFiles = useCallback((files: FileList | null | undefined) => {
|
||
if (!files || files.length === 0) return;
|
||
|
||
const newImages: PendingImage[] = [];
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i];
|
||
if (ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||
newImages.push({ file, previewUrl: URL.createObjectURL(file) });
|
||
}
|
||
}
|
||
|
||
if (newImages.length > 0) {
|
||
setPendingImages((prev) => [...prev, ...newImages]);
|
||
}
|
||
}, []);
|
||
|
||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||
if (isSubmitting) return;
|
||
handleImageFiles(e.clipboardData?.files);
|
||
}, [handleImageFiles, isSubmitting]);
|
||
|
||
const removeImage = useCallback((index: number) => {
|
||
setPendingImages((prev) => {
|
||
const removed = prev[index];
|
||
if (removed) {
|
||
URL.revokeObjectURL(removed.previewUrl);
|
||
}
|
||
return prev.filter((_, i) => i !== index);
|
||
});
|
||
}, []);
|
||
|
||
const submitCreateTask = useCallback(async (trimmed: string, overrides?: { acknowledgedDuplicates?: string[] }) => {
|
||
if (!onCreate) {
|
||
return;
|
||
}
|
||
|
||
const originalDescription = description;
|
||
setDescription("");
|
||
try {
|
||
const createdTask = await onCreate({
|
||
description: trimmed,
|
||
column: "triage",
|
||
dependencies: dependencies.length ? dependencies : undefined,
|
||
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
|
||
modelPresetId: selectedPresetId,
|
||
modelProvider: hasExecutorOverride ? executorProvider : undefined,
|
||
modelId: hasExecutorOverride ? executorModelId : undefined,
|
||
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
|
||
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
|
||
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
|
||
planningModelId: hasPlanningOverride ? planningModelId : undefined,
|
||
...(isFastMode ? { executionMode: "fast" } : {}),
|
||
githubTracking: settings?.githubTrackingEnabledByDefault === true
|
||
? (githubTrackingOverride !== null ? { enabled: githubTrackingOverride } : undefined)
|
||
: undefined,
|
||
priority,
|
||
nodeId,
|
||
acknowledgedDuplicates: overrides?.acknowledgedDuplicates,
|
||
});
|
||
if (createdTask && pendingImages.length > 0) {
|
||
const failures: string[] = [];
|
||
for (const pendingImage of pendingImages) {
|
||
try {
|
||
await uploadAttachment(createdTask.id, pendingImage.file, projectId);
|
||
} catch {
|
||
failures.push(pendingImage.file.name);
|
||
}
|
||
}
|
||
|
||
if (failures.length > 0) {
|
||
addToast(`Failed to upload: ${failures.join(", ")}`, "error");
|
||
}
|
||
}
|
||
resetForm();
|
||
} catch (err) {
|
||
setDescription(originalDescription);
|
||
addToast(getErrorMessage(err) || "Failed to create task", "error");
|
||
} finally {
|
||
submitInFlightRef.current = false;
|
||
setIsSubmitting(false);
|
||
}
|
||
}, [
|
||
onCreate,
|
||
description,
|
||
dependencies,
|
||
selectedAgentId,
|
||
selectedPresetId,
|
||
hasExecutorOverride,
|
||
executorProvider,
|
||
executorModelId,
|
||
hasValidatorOverride,
|
||
validatorProvider,
|
||
validatorModelId,
|
||
hasPlanningOverride,
|
||
planningProvider,
|
||
planningModelId,
|
||
isFastMode,
|
||
settings,
|
||
githubTrackingOverride,
|
||
priority,
|
||
nodeId,
|
||
pendingImages,
|
||
projectId,
|
||
addToast,
|
||
resetForm,
|
||
]);
|
||
|
||
const handleSubmit = useCallback(async () => {
|
||
if (submitInFlightRef.current) {
|
||
return;
|
||
}
|
||
|
||
submitInFlightRef.current = true;
|
||
setIsSubmitting(true);
|
||
|
||
const trimmed = description.trim();
|
||
if (!trimmed || !onCreate) {
|
||
submitInFlightRef.current = false;
|
||
setIsSubmitting(false);
|
||
return;
|
||
}
|
||
|
||
let releaseLockOnExit = true;
|
||
try {
|
||
const matches = await checkDuplicateTasks({ description: trimmed }, projectId);
|
||
if (matches.length > 0) {
|
||
setDuplicateMatches(matches);
|
||
releaseLockOnExit = false;
|
||
return;
|
||
}
|
||
} catch (_error) {
|
||
addToast("Duplicate check failed; creating task anyway.", "error");
|
||
}
|
||
|
||
releaseLockOnExit = false;
|
||
await submitCreateTask(trimmed);
|
||
|
||
if (releaseLockOnExit) {
|
||
submitInFlightRef.current = false;
|
||
setIsSubmitting(false);
|
||
}
|
||
}, [description, onCreate, projectId, submitCreateTask, addToast]);
|
||
|
||
const handleDuplicateOpen = useCallback((taskId: string) => {
|
||
if (onOpenTask) {
|
||
onOpenTask(taskId);
|
||
} else if (typeof window !== "undefined") {
|
||
window.location.hash = `#/tasks/${taskId}`;
|
||
}
|
||
setDuplicateMatches(null);
|
||
}, [onOpenTask]);
|
||
|
||
const handleDuplicateProceed = useCallback(async () => {
|
||
const trimmed = description.trim();
|
||
const matches = duplicateMatches;
|
||
if (!trimmed || !matches || matches.length === 0) {
|
||
setDuplicateMatches(null);
|
||
submitInFlightRef.current = false;
|
||
setIsSubmitting(false);
|
||
return;
|
||
}
|
||
|
||
setDuplicateMatches(null);
|
||
// Keep optimistic submit lock active while duplicate-confirmed creation is in flight.
|
||
submitInFlightRef.current = true;
|
||
setIsSubmitting(true);
|
||
await submitCreateTask(trimmed, { acknowledgedDuplicates: matches.map((match) => match.id) });
|
||
}, [description, duplicateMatches, submitCreateTask]);
|
||
|
||
const handleDuplicateCancel = useCallback(() => {
|
||
setDuplicateMatches(null);
|
||
submitInFlightRef.current = false;
|
||
setIsSubmitting(false);
|
||
}, []);
|
||
|
||
const handleKeyDown = useCallback(
|
||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (e.key === "Enter") {
|
||
if (e.shiftKey && isExpanded) {
|
||
// Allow Shift+Enter to insert newline when expanded or in fullscreen mode
|
||
// Don't prevent default - let the newline be inserted
|
||
return;
|
||
}
|
||
// Enter without Shift submits
|
||
e.preventDefault();
|
||
if (duplicateMatches || submitInFlightRef.current) {
|
||
return;
|
||
}
|
||
handleSubmit();
|
||
} else if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
// Close model submenu first if open
|
||
if (activeModelSubmenu) {
|
||
setActiveModelSubmenu(null);
|
||
return;
|
||
}
|
||
// Close model menu if open
|
||
if (isModelMenuOpen) {
|
||
setIsModelMenuOpen(false);
|
||
setModelMenuPosition(null);
|
||
return;
|
||
}
|
||
if (showDeps) {
|
||
setShowDeps(false);
|
||
return;
|
||
}
|
||
if (isRefineMenuOpen) {
|
||
setIsRefineMenuOpen(false);
|
||
return;
|
||
}
|
||
if (showNodePicker) {
|
||
setShowNodePicker(false);
|
||
setNodePickerPosition(null);
|
||
return;
|
||
}
|
||
if (showPriorityPicker) {
|
||
setShowPriorityPicker(false);
|
||
setPriorityPickerPosition(null);
|
||
return;
|
||
}
|
||
if (showAgentPicker) {
|
||
setShowAgentPicker(false);
|
||
setAgentPickerPosition(null);
|
||
return;
|
||
}
|
||
// Clear non-empty input on Escape and clear localStorage
|
||
if (description.trim()) {
|
||
setDescription("");
|
||
// Reset height
|
||
if (textareaRef.current) {
|
||
textareaRef.current.style.height = "auto";
|
||
}
|
||
// Clear localStorage when user explicitly clears input
|
||
if (typeof window !== "undefined") {
|
||
removeScopedItem(STORAGE_KEY, projectId);
|
||
}
|
||
}
|
||
// Collapse textarea and disclosure on escape
|
||
setIsExpanded(false);
|
||
setIsDisclosureExpanded(false);
|
||
textareaRef.current?.blur();
|
||
}
|
||
},
|
||
[
|
||
handleSubmit,
|
||
description,
|
||
isExpanded,
|
||
showDeps,
|
||
showAgentPicker,
|
||
showNodePicker,
|
||
isModelMenuOpen,
|
||
activeModelSubmenu,
|
||
isRefineMenuOpen,
|
||
showPriorityPicker,
|
||
projectId,
|
||
setIsDisclosureExpanded,
|
||
duplicateMatches,
|
||
],
|
||
);
|
||
|
||
const handleBlur = useCallback(() => {
|
||
// No auto-collapse on blur — state persists until manually toggled or task is submitted/cancelled
|
||
// Only clear the justResetRef flag if needed
|
||
if (justResetRef.current) {
|
||
justResetRef.current = false;
|
||
}
|
||
}, []);
|
||
|
||
const handleFocus = useCallback(() => {
|
||
// Auto-expand on focus when autoExpand prop is true (default)
|
||
if (autoExpand) {
|
||
setIsExpanded(true);
|
||
}
|
||
}, [autoExpand]);
|
||
|
||
const toggleDep = useCallback((id: string) => {
|
||
setDependencies((prev) =>
|
||
prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id],
|
||
);
|
||
}, []);
|
||
|
||
const getEffectiveViewport = useCallback(() => {
|
||
const vv = window.visualViewport;
|
||
if (vv && vv.width > 0 && vv.height > 0) {
|
||
return {
|
||
width: vv.width,
|
||
height: vv.height,
|
||
offsetTop: vv.offsetTop,
|
||
offsetLeft: vv.offsetLeft,
|
||
};
|
||
}
|
||
|
||
return {
|
||
width: window.innerWidth,
|
||
height: window.innerHeight,
|
||
offsetTop: 0,
|
||
offsetLeft: 0,
|
||
};
|
||
}, []);
|
||
|
||
const updateModelMenuPosition = useCallback(() => {
|
||
const trigger = modelTriggerRef.current;
|
||
if (!trigger) return;
|
||
|
||
const rect = trigger.getBoundingClientRect();
|
||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||
const horizontalPadding = 16;
|
||
const verticalPadding = 16;
|
||
const gap = 4;
|
||
const isMobile = viewportWidth <= 768;
|
||
|
||
const preferredHeight = isMobile
|
||
? Math.min(viewportHeight * 0.6, 360)
|
||
: Math.min(viewportHeight * 0.5, 360);
|
||
|
||
const preferredDesktopWidth = Math.max(rect.width * 1.35, 320);
|
||
const preferredWidth = isMobile
|
||
? Math.min(viewportWidth - horizontalPadding * 2, 360)
|
||
: preferredDesktopWidth;
|
||
|
||
const width = Math.min(
|
||
preferredWidth,
|
||
Math.max(viewportWidth - horizontalPadding * 2, 240),
|
||
);
|
||
|
||
const triggerTop = rect.top - offsetTop;
|
||
const triggerBottom = rect.bottom - offsetTop;
|
||
const triggerLeft = rect.left - offsetLeft;
|
||
|
||
const spaceBelow = viewportHeight - triggerBottom;
|
||
const spaceAbove = triggerTop;
|
||
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160);
|
||
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160);
|
||
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||
|
||
const maxHeight = Math.max(
|
||
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
|
||
160,
|
||
);
|
||
|
||
const left = Math.min(
|
||
Math.max(triggerLeft, horizontalPadding),
|
||
viewportWidth - horizontalPadding - width,
|
||
) + offsetLeft;
|
||
|
||
const top = openUpward
|
||
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||
: Math.min(
|
||
triggerBottom + gap + offsetTop,
|
||
viewportHeight + offsetTop - verticalPadding - maxHeight,
|
||
);
|
||
|
||
setModelMenuPosition({
|
||
top,
|
||
left,
|
||
width,
|
||
maxHeight,
|
||
});
|
||
}, [getEffectiveViewport]);
|
||
|
||
const updateRefineMenuPosition = useCallback(() => {
|
||
const trigger = refineMenuRef.current?.querySelector(".refine-button") as HTMLElement | null;
|
||
if (!trigger) return;
|
||
|
||
const rect = trigger.getBoundingClientRect();
|
||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||
const horizontalPadding = 8;
|
||
const verticalPadding = 12;
|
||
const gap = 4;
|
||
const expectedMenuHeight = Math.min(200, Math.max(viewportHeight - verticalPadding * 2, 160));
|
||
const menuWidth = Math.min(200, viewportWidth - horizontalPadding * 2);
|
||
|
||
const triggerTop = rect.top - offsetTop;
|
||
const triggerBottom = rect.bottom - offsetTop;
|
||
const triggerLeft = rect.left - offsetLeft;
|
||
|
||
const spaceBelow = viewportHeight - triggerBottom;
|
||
const spaceAbove = triggerTop;
|
||
const openUpward = spaceBelow < expectedMenuHeight && spaceAbove > spaceBelow;
|
||
|
||
const left = Math.min(
|
||
Math.max(triggerLeft, horizontalPadding),
|
||
viewportWidth - horizontalPadding - menuWidth,
|
||
) + offsetLeft;
|
||
|
||
const top = openUpward
|
||
? Math.max(verticalPadding + offsetTop, triggerTop - expectedMenuHeight - gap + offsetTop)
|
||
: Math.min(
|
||
triggerBottom + gap + offsetTop,
|
||
viewportHeight + offsetTop - verticalPadding - expectedMenuHeight,
|
||
);
|
||
|
||
setRefineMenuPosition({
|
||
top,
|
||
left,
|
||
});
|
||
}, [getEffectiveViewport]);
|
||
|
||
const updateDepDropdownPosition = useCallback(() => {
|
||
const trigger = depTriggerRef.current;
|
||
if (!trigger) return;
|
||
|
||
const rect = trigger.getBoundingClientRect();
|
||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||
const horizontalPadding = 16;
|
||
const verticalPadding = 16;
|
||
const gap = 4;
|
||
const isMobile = viewportWidth <= 768;
|
||
|
||
const preferredHeight = isMobile
|
||
? Math.min(viewportHeight * 0.6, 320)
|
||
: Math.min(viewportHeight * 0.5, 320);
|
||
|
||
// Wider dropdown for dependency selection - easier to read task names
|
||
const preferredWidth = isMobile
|
||
? Math.min(viewportWidth - horizontalPadding * 2, 360)
|
||
: Math.max(rect.width, 280);
|
||
|
||
const width = Math.min(
|
||
preferredWidth,
|
||
Math.max(viewportWidth - horizontalPadding * 2, 240),
|
||
);
|
||
|
||
const triggerTop = rect.top - offsetTop;
|
||
const triggerBottom = rect.bottom - offsetTop;
|
||
const triggerLeft = rect.left - offsetLeft;
|
||
|
||
const spaceBelow = viewportHeight - triggerBottom;
|
||
const spaceAbove = triggerTop;
|
||
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 200);
|
||
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 200);
|
||
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||
|
||
const maxHeight = Math.max(
|
||
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
|
||
200,
|
||
);
|
||
|
||
const left = Math.min(
|
||
Math.max(triggerLeft, horizontalPadding),
|
||
viewportWidth - horizontalPadding - width,
|
||
) + offsetLeft;
|
||
|
||
const top = openUpward
|
||
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||
: Math.min(
|
||
triggerBottom + gap + offsetTop,
|
||
viewportHeight + offsetTop - verticalPadding - maxHeight,
|
||
);
|
||
|
||
setDepDropdownPosition({
|
||
top,
|
||
left,
|
||
width,
|
||
maxHeight,
|
||
});
|
||
}, [getEffectiveViewport]);
|
||
|
||
const updateAgentPickerPosition = useCallback(() => {
|
||
const trigger = agentPickerRef.current?.querySelector("button") as HTMLButtonElement | null;
|
||
if (!trigger) return;
|
||
|
||
const rect = trigger.getBoundingClientRect();
|
||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||
const horizontalPadding = 16;
|
||
const verticalPadding = 16;
|
||
const gap = 4;
|
||
const isMobile = viewportWidth <= 768;
|
||
|
||
const preferredHeight = isMobile
|
||
? Math.min(viewportHeight * 0.6, 320)
|
||
: Math.min(viewportHeight * 0.5, 320);
|
||
|
||
const preferredWidth = isMobile
|
||
? Math.min(viewportWidth - horizontalPadding * 2, 280)
|
||
: Math.max(rect.width, 240);
|
||
|
||
const width = Math.min(
|
||
preferredWidth,
|
||
Math.max(viewportWidth - horizontalPadding * 2, 200),
|
||
);
|
||
|
||
const triggerTop = rect.top - offsetTop;
|
||
const triggerBottom = rect.bottom - offsetTop;
|
||
const triggerLeft = rect.left - offsetLeft;
|
||
|
||
const spaceBelow = viewportHeight - triggerBottom;
|
||
const spaceAbove = triggerTop;
|
||
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160);
|
||
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160);
|
||
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||
|
||
const maxHeight = Math.max(
|
||
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
|
||
160,
|
||
);
|
||
|
||
const left = Math.min(
|
||
Math.max(triggerLeft, horizontalPadding),
|
||
viewportWidth - horizontalPadding - width,
|
||
) + offsetLeft;
|
||
|
||
const top = openUpward
|
||
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||
: Math.min(
|
||
triggerBottom + gap + offsetTop,
|
||
viewportHeight + offsetTop - verticalPadding - maxHeight,
|
||
);
|
||
|
||
setAgentPickerPosition({
|
||
top,
|
||
left,
|
||
width,
|
||
maxHeight,
|
||
});
|
||
}, [getEffectiveViewport]);
|
||
|
||
const updateNodePickerPosition = useCallback(() => {
|
||
const trigger = nodePickerRef.current?.querySelector("button") as HTMLButtonElement | null;
|
||
if (!trigger) return;
|
||
|
||
const rect = trigger.getBoundingClientRect();
|
||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||
const horizontalPadding = 16;
|
||
const verticalPadding = 16;
|
||
const gap = 4;
|
||
const isMobile = viewportWidth <= 768;
|
||
|
||
const preferredHeight = isMobile
|
||
? Math.min(viewportHeight * 0.6, 320)
|
||
: Math.min(viewportHeight * 0.5, 320);
|
||
|
||
const preferredWidth = isMobile
|
||
? Math.min(viewportWidth - horizontalPadding * 2, 280)
|
||
: Math.max(rect.width, 240);
|
||
|
||
const width = Math.min(
|
||
preferredWidth,
|
||
Math.max(viewportWidth - horizontalPadding * 2, 200),
|
||
);
|
||
|
||
const triggerTop = rect.top - offsetTop;
|
||
const triggerBottom = rect.bottom - offsetTop;
|
||
const triggerLeft = rect.left - offsetLeft;
|
||
|
||
const spaceBelow = viewportHeight - triggerBottom;
|
||
const spaceAbove = triggerTop;
|
||
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160);
|
||
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160);
|
||
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||
|
||
const maxHeight = Math.max(
|
||
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
|
||
160,
|
||
);
|
||
|
||
const left = Math.min(
|
||
Math.max(triggerLeft, horizontalPadding),
|
||
viewportWidth - horizontalPadding - width,
|
||
) + offsetLeft;
|
||
|
||
const top = openUpward
|
||
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||
: Math.min(
|
||
triggerBottom + gap + offsetTop,
|
||
viewportHeight + offsetTop - verticalPadding - maxHeight,
|
||
);
|
||
|
||
setNodePickerPosition({
|
||
top,
|
||
left,
|
||
width,
|
||
maxHeight,
|
||
});
|
||
}, [getEffectiveViewport]);
|
||
|
||
const updatePriorityPickerPosition = useCallback(() => {
|
||
const trigger = priorityPickerRef.current?.querySelector("button") as HTMLButtonElement | null;
|
||
if (!trigger) return;
|
||
|
||
const rect = trigger.getBoundingClientRect();
|
||
const { width: viewportWidth, height: viewportHeight, offsetTop, offsetLeft } = getEffectiveViewport();
|
||
const horizontalPadding = 16;
|
||
const verticalPadding = 16;
|
||
const gap = 4;
|
||
const preferredHeight = 220;
|
||
const width = Math.min(
|
||
Math.max(rect.width, 200),
|
||
Math.max(viewportWidth - horizontalPadding * 2, 200),
|
||
);
|
||
|
||
const triggerTop = rect.top - offsetTop;
|
||
const triggerBottom = rect.bottom - offsetTop;
|
||
const triggerLeft = rect.left - offsetLeft;
|
||
|
||
const spaceBelow = viewportHeight - triggerBottom;
|
||
const spaceAbove = triggerTop;
|
||
const availableBelow = Math.max(spaceBelow - verticalPadding - gap, 160);
|
||
const availableAbove = Math.max(spaceAbove - verticalPadding - gap, 160);
|
||
const openUpward = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||
const maxHeight = Math.max(
|
||
Math.min(openUpward ? availableAbove : availableBelow, preferredHeight),
|
||
160,
|
||
);
|
||
|
||
const left = Math.min(
|
||
Math.max(triggerLeft, horizontalPadding),
|
||
viewportWidth - horizontalPadding - width,
|
||
) + offsetLeft;
|
||
|
||
const top = openUpward
|
||
? Math.max(verticalPadding + offsetTop, triggerTop - maxHeight - gap + offsetTop)
|
||
: Math.min(
|
||
triggerBottom + gap + offsetTop,
|
||
viewportHeight + offsetTop - verticalPadding - maxHeight,
|
||
);
|
||
|
||
setPriorityPickerPosition({
|
||
top,
|
||
left,
|
||
width,
|
||
maxHeight,
|
||
});
|
||
}, [getEffectiveViewport]);
|
||
|
||
// Keep model menu portal anchored during scroll/resize
|
||
useEffect(() => {
|
||
if (!isModelMenuOpen) return;
|
||
|
||
const handleReposition = () => updateModelMenuPosition();
|
||
|
||
window.addEventListener("resize", handleReposition);
|
||
window.addEventListener("scroll", handleReposition, true);
|
||
|
||
const vv = window.visualViewport;
|
||
if (vv) {
|
||
vv.addEventListener("resize", handleReposition);
|
||
vv.addEventListener("scroll", handleReposition);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", handleReposition);
|
||
window.removeEventListener("scroll", handleReposition, true);
|
||
if (vv) {
|
||
vv.removeEventListener("resize", handleReposition);
|
||
vv.removeEventListener("scroll", handleReposition);
|
||
}
|
||
};
|
||
}, [isModelMenuOpen, updateModelMenuPosition]);
|
||
|
||
// Keep refine menu portal anchored during scroll/resize
|
||
useEffect(() => {
|
||
if (!isRefineMenuOpen) return;
|
||
|
||
const handleReposition = () => updateRefineMenuPosition();
|
||
|
||
window.addEventListener("resize", handleReposition);
|
||
window.addEventListener("scroll", handleReposition, true);
|
||
|
||
const vv = window.visualViewport;
|
||
if (vv) {
|
||
vv.addEventListener("resize", handleReposition);
|
||
vv.addEventListener("scroll", handleReposition);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", handleReposition);
|
||
window.removeEventListener("scroll", handleReposition, true);
|
||
if (vv) {
|
||
vv.removeEventListener("resize", handleReposition);
|
||
vv.removeEventListener("scroll", handleReposition);
|
||
}
|
||
};
|
||
}, [isRefineMenuOpen, updateRefineMenuPosition]);
|
||
|
||
// Keep dependency dropdown portal anchored during scroll/resize
|
||
useEffect(() => {
|
||
if (!showDeps) return;
|
||
|
||
const handleReposition = () => updateDepDropdownPosition();
|
||
|
||
window.addEventListener("resize", handleReposition);
|
||
window.addEventListener("scroll", handleReposition, true);
|
||
|
||
const vv = window.visualViewport;
|
||
if (vv) {
|
||
vv.addEventListener("resize", handleReposition);
|
||
vv.addEventListener("scroll", handleReposition);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", handleReposition);
|
||
window.removeEventListener("scroll", handleReposition, true);
|
||
if (vv) {
|
||
vv.removeEventListener("resize", handleReposition);
|
||
vv.removeEventListener("scroll", handleReposition);
|
||
}
|
||
};
|
||
}, [showDeps, updateDepDropdownPosition]);
|
||
|
||
// Keep agent picker portal anchored during scroll/resize
|
||
useEffect(() => {
|
||
if (!showAgentPicker) return;
|
||
|
||
const handleReposition = () => updateAgentPickerPosition();
|
||
|
||
window.addEventListener("resize", handleReposition);
|
||
window.addEventListener("scroll", handleReposition, true);
|
||
|
||
const vv = window.visualViewport;
|
||
if (vv) {
|
||
vv.addEventListener("resize", handleReposition);
|
||
vv.addEventListener("scroll", handleReposition);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", handleReposition);
|
||
window.removeEventListener("scroll", handleReposition, true);
|
||
if (vv) {
|
||
vv.removeEventListener("resize", handleReposition);
|
||
vv.removeEventListener("scroll", handleReposition);
|
||
}
|
||
};
|
||
}, [showAgentPicker, updateAgentPickerPosition]);
|
||
|
||
// Keep node picker portal anchored during scroll/resize
|
||
useEffect(() => {
|
||
if (!showNodePicker) return;
|
||
|
||
const handleReposition = () => updateNodePickerPosition();
|
||
|
||
window.addEventListener("resize", handleReposition);
|
||
window.addEventListener("scroll", handleReposition, true);
|
||
|
||
const vv = window.visualViewport;
|
||
if (vv) {
|
||
vv.addEventListener("resize", handleReposition);
|
||
vv.addEventListener("scroll", handleReposition);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", handleReposition);
|
||
window.removeEventListener("scroll", handleReposition, true);
|
||
if (vv) {
|
||
vv.removeEventListener("resize", handleReposition);
|
||
vv.removeEventListener("scroll", handleReposition);
|
||
}
|
||
};
|
||
}, [showNodePicker, updateNodePickerPosition]);
|
||
|
||
// Keep priority picker portal anchored during scroll/resize
|
||
useEffect(() => {
|
||
if (!showPriorityPicker) return;
|
||
|
||
const handleReposition = () => updatePriorityPickerPosition();
|
||
|
||
window.addEventListener("resize", handleReposition);
|
||
window.addEventListener("scroll", handleReposition, true);
|
||
|
||
const vv = window.visualViewport;
|
||
if (vv) {
|
||
vv.addEventListener("resize", handleReposition);
|
||
vv.addEventListener("scroll", handleReposition);
|
||
}
|
||
|
||
return () => {
|
||
window.removeEventListener("resize", handleReposition);
|
||
window.removeEventListener("scroll", handleReposition, true);
|
||
if (vv) {
|
||
vv.removeEventListener("resize", handleReposition);
|
||
vv.removeEventListener("scroll", handleReposition);
|
||
}
|
||
};
|
||
}, [showPriorityPicker, updatePriorityPickerPosition]);
|
||
|
||
const handlePlanningModelChange = useCallback((value: string) => {
|
||
const next = parseModelSelection(value);
|
||
setPlanningProvider(next.provider);
|
||
setPlanningModelId(next.modelId);
|
||
}, []);
|
||
|
||
const handleExecutorChange = useCallback((value: string) => {
|
||
const next = parseModelSelection(value);
|
||
setExecutorProvider(next.provider);
|
||
setExecutorModelId(next.modelId);
|
||
}, []);
|
||
|
||
const handleValidatorChange = useCallback((value: string) => {
|
||
const next = parseModelSelection(value);
|
||
setValidatorProvider(next.provider);
|
||
setValidatorModelId(next.modelId);
|
||
}, []);
|
||
|
||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||
// Delegate to parent callback when available
|
||
if (parentToggleFavorite) {
|
||
parentToggleFavorite(provider);
|
||
return;
|
||
}
|
||
|
||
const currentFavorites = favoriteProviders;
|
||
const isFavorite = currentFavorites.includes(provider);
|
||
const newFavorites = isFavorite
|
||
? currentFavorites.filter((p) => p !== provider)
|
||
: [provider, ...currentFavorites];
|
||
|
||
setFavoriteProviders(newFavorites);
|
||
|
||
try {
|
||
await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels });
|
||
} catch {
|
||
// Revert on error
|
||
setFavoriteProviders(currentFavorites);
|
||
}
|
||
}, [favoriteProviders, favoriteModels, parentToggleFavorite]);
|
||
|
||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||
// Delegate to parent callback when available
|
||
if (parentToggleModelFavorite) {
|
||
parentToggleModelFavorite(modelId);
|
||
return;
|
||
}
|
||
|
||
const currentFavorites = favoriteModels;
|
||
const isFavorite = currentFavorites.includes(modelId);
|
||
const newFavorites = isFavorite
|
||
? currentFavorites.filter((m) => m !== modelId)
|
||
: [modelId, ...currentFavorites];
|
||
|
||
setFavoriteModels(newFavorites);
|
||
|
||
try {
|
||
await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites });
|
||
} catch {
|
||
// Revert on error
|
||
setFavoriteModels(currentFavorites);
|
||
}
|
||
}, [favoriteModels, favoriteProviders, parentToggleModelFavorite]);
|
||
|
||
const handlePlanClick = useCallback(() => {
|
||
const trimmed = description.trim();
|
||
if (!trimmed) {
|
||
addToast("Enter a description first", "error");
|
||
return;
|
||
}
|
||
onPlanningMode?.(trimmed);
|
||
// Clear the form after triggering planning mode
|
||
resetForm();
|
||
}, [description, onPlanningMode, addToast, resetForm]);
|
||
|
||
const handleSubtaskClick = useCallback(() => {
|
||
const trimmed = description.trim();
|
||
if (!trimmed) {
|
||
addToast("Enter a description first", "error");
|
||
return;
|
||
}
|
||
onSubtaskBreakdown?.(trimmed);
|
||
// Clear the form after triggering subtask breakdown
|
||
resetForm();
|
||
}, [description, onSubtaskBreakdown, addToast, resetForm]);
|
||
|
||
const handleSaveClick = useCallback(() => {
|
||
// Save button now creates the task (same as Enter key)
|
||
handleSubmit();
|
||
}, [handleSubmit]);
|
||
|
||
const handleRefine = useCallback(async (type: RefinementType) => {
|
||
const trimmed = description.trim();
|
||
if (!trimmed || isRefining) return;
|
||
|
||
setIsRefineMenuOpen(false);
|
||
setIsRefining(true);
|
||
try {
|
||
const refined = await refineText(trimmed, type, projectId);
|
||
setDescription(refined);
|
||
addToast("Description refined with AI", "success");
|
||
// Auto-resize textarea after content update
|
||
if (textareaRef.current) {
|
||
textareaRef.current.style.height = "auto";
|
||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
|
||
}
|
||
} catch (err) {
|
||
const errorMessage = getRefineErrorMessage(err);
|
||
addToast(errorMessage, "error");
|
||
} finally {
|
||
setIsRefining(false);
|
||
}
|
||
}, [description, isRefining, addToast, projectId]);
|
||
|
||
const truncate = (s: string, len: number) =>
|
||
s.length > len ? s.slice(0, len) + "…" : s;
|
||
|
||
const loadModels = useCallback(async () => {
|
||
if (availableModels) {
|
||
setLoadedModels(availableModels);
|
||
setModelsError(null);
|
||
setModelsLoading(false);
|
||
return;
|
||
}
|
||
|
||
setModelsLoading(true);
|
||
setModelsError(null);
|
||
try {
|
||
const response = await fetchModels();
|
||
setLoadedModels(response.models);
|
||
// Only set internal favorites when parent doesn't manage them
|
||
if (!parentFavoriteProviders) {
|
||
setFavoriteProviders(response.favoriteProviders);
|
||
}
|
||
if (!parentFavoriteModels) {
|
||
setFavoriteModels(response.favoriteModels);
|
||
}
|
||
} catch (err) {
|
||
setModelsError(getErrorMessage(err) || "Failed to load models");
|
||
} finally {
|
||
setModelsLoading(false);
|
||
}
|
||
}, [availableModels, parentFavoriteProviders, parentFavoriteModels]);
|
||
|
||
const loadAgents = useCallback(async () => {
|
||
if (agents.length > 0 && agentsProjectId === projectId) {
|
||
setShowAgentPicker(true);
|
||
updateAgentPickerPosition();
|
||
return;
|
||
}
|
||
|
||
setAgentsLoading(true);
|
||
try {
|
||
const result = await fetchAgents(undefined, projectId);
|
||
setAgents(result);
|
||
setAgentsProjectId(projectId);
|
||
setShowAgentPicker(true);
|
||
updateAgentPickerPosition();
|
||
} catch (err) {
|
||
const msg = getErrorMessage(err);
|
||
addToast(msg ? `Failed to load agents: ${msg}` : "Failed to load agents", "error");
|
||
setShowAgentPicker(false);
|
||
} finally {
|
||
setAgentsLoading(false);
|
||
}
|
||
}, [agents.length, agentsProjectId, projectId, addToast, updateAgentPickerPosition]);
|
||
|
||
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
|
||
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
|
||
const selectedNode = nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
|
||
const projectGithubTrackingDefault = settings?.githubTrackingEnabledByDefault === true;
|
||
const githubTrackingProjectEnabled = projectGithubTrackingDefault;
|
||
const effectiveGithubTracking = githubTrackingProjectEnabled
|
||
? (githubTrackingOverride ?? true)
|
||
: false;
|
||
const githubToggleDisabledLabel = "GitHub tracking is disabled for this project — enable it in Settings to use per-task tracking";
|
||
const githubToggleLabel = githubTrackingProjectEnabled
|
||
? (effectiveGithubTracking
|
||
? `GitHub tracking ON for next task (project default: ${projectGithubTrackingDefault ? "on" : "off"})`
|
||
: "GitHub tracking OFF for next task")
|
||
: githubToggleDisabledLabel;
|
||
|
||
// Show expanded controls based on disclosure state (user preference), not textarea focus
|
||
const showExpandedControls = isDisclosureExpanded;
|
||
|
||
const toggleExpanded = useCallback(() => {
|
||
setIsDisclosureExpanded((prev) => {
|
||
const next = !prev;
|
||
setIsExpanded(next);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
return (
|
||
<>
|
||
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
|
||
<div className="description-with-refine">
|
||
<div className="quick-entry-main-row">
|
||
<div className="quick-entry-textarea-wrap">
|
||
<textarea
|
||
ref={textareaRef}
|
||
className={`quick-entry-input ${isExpanded ? "quick-entry-input--expanded" : ""}`}
|
||
placeholder={isSubmitting ? "Creating..." : "Add a task..."}
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onPaste={handlePaste}
|
||
onFocus={handleFocus}
|
||
onBlur={handleBlur}
|
||
disabled={isSubmitting || isDisabled}
|
||
data-testid="quick-entry-input"
|
||
rows={2}
|
||
aria-controls="quick-entry-controls"
|
||
aria-expanded={isDisclosureExpanded}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm quick-entry-toggle"
|
||
onClick={toggleExpanded}
|
||
aria-expanded={isDisclosureExpanded}
|
||
aria-controls="quick-entry-controls"
|
||
data-testid="quick-entry-toggle"
|
||
title={isDisclosureExpanded ? "Collapse" : "Expand"}
|
||
>
|
||
{isDisclosureExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
id="quick-entry-controls"
|
||
className="quick-entry-controls"
|
||
hidden={!showExpandedControls}
|
||
aria-hidden={!showExpandedControls}
|
||
>
|
||
{/* All quick-create actions behind single disclosure toggle */}
|
||
{showExpandedControls && !isSubmitting && (
|
||
<div className="quick-entry-actions" data-testid="quick-entry-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm"
|
||
onClick={handlePlanClick}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
disabled={!description.trim()}
|
||
data-testid="plan-button"
|
||
title="Open planning mode with current description"
|
||
>
|
||
<Lightbulb size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
|
||
Plan
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm"
|
||
onClick={handleSubtaskClick}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
disabled={!description.trim()}
|
||
data-testid="subtask-button"
|
||
title="Break down into AI-generated subtasks"
|
||
>
|
||
<ListTree size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
|
||
Subtask
|
||
</button>
|
||
<div className="refine-trigger-wrap" ref={refineMenuRef}>
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`}
|
||
onClick={() => {
|
||
setIsRefineMenuOpen((prev) => {
|
||
const next = !prev;
|
||
if (next) {
|
||
// Compute position synchronously so the portal renders on first paint
|
||
updateRefineMenuPosition();
|
||
} else {
|
||
setRefineMenuPosition(null);
|
||
}
|
||
return next;
|
||
});
|
||
}}
|
||
disabled={!description.trim() || isRefining}
|
||
data-testid="refine-button"
|
||
title="Refine description with AI"
|
||
>
|
||
<Sparkles size={12} style={{ verticalAlign: "middle" }} />
|
||
{isRefining ? "Refining..." : "Refine"}
|
||
</button>
|
||
{isRefineMenuOpen && portalRoot && refineMenuPosition && createPortal(
|
||
<div
|
||
ref={refineMenuPortalRef}
|
||
className="refine-menu refine-menu--portal"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
style={{
|
||
position: "fixed",
|
||
top: `${refineMenuPosition.top}px`,
|
||
left: `${refineMenuPosition.left}px`,
|
||
}}
|
||
>
|
||
<div
|
||
className="refine-menu-item"
|
||
onClick={() => handleRefine("clarify")}
|
||
data-testid="refine-clarify"
|
||
>
|
||
<div className="refine-menu-item-title">Clarify</div>
|
||
<div className="refine-menu-item-desc">Make the description clearer and more specific</div>
|
||
</div>
|
||
<div
|
||
className="refine-menu-item"
|
||
onClick={() => handleRefine("add-details")}
|
||
data-testid="refine-add-details"
|
||
>
|
||
<div className="refine-menu-item-title">Add details</div>
|
||
<div className="refine-menu-item-desc">Add implementation details and context</div>
|
||
</div>
|
||
<div
|
||
className="refine-menu-item"
|
||
onClick={() => handleRefine("expand")}
|
||
data-testid="refine-expand"
|
||
>
|
||
<div className="refine-menu-item-title">Expand</div>
|
||
<div className="refine-menu-item-desc">Expand into a more comprehensive description</div>
|
||
</div>
|
||
<div
|
||
className="refine-menu-item"
|
||
onClick={() => handleRefine("simplify")}
|
||
data-testid="refine-simplify"
|
||
>
|
||
<div className="refine-menu-item-title">Simplify</div>
|
||
<div className="refine-menu-item-desc">Simplify and make more concise</div>
|
||
</div>
|
||
</div>,
|
||
portalRoot,
|
||
)}
|
||
</div>
|
||
|
||
<div className="dep-trigger-wrap">
|
||
<button
|
||
ref={depTriggerRef}
|
||
type="button"
|
||
className="btn btn-sm dep-trigger"
|
||
data-testid="quick-entry-deps"
|
||
onClick={() => {
|
||
setShowDeps((prev) => {
|
||
const next = !prev;
|
||
if (next) {
|
||
setIsModelMenuOpen(false);
|
||
setModelMenuPosition(null);
|
||
setActiveModelSubmenu(null);
|
||
setShowAgentPicker(false);
|
||
setAgentPickerPosition(null);
|
||
setShowNodePicker(false);
|
||
setNodePickerPosition(null);
|
||
setShowPriorityPicker(false);
|
||
setPriorityPickerPosition(null);
|
||
// Position the dropdown before rendering
|
||
updateDepDropdownPosition();
|
||
} else {
|
||
setDepDropdownPosition(null);
|
||
}
|
||
return next;
|
||
});
|
||
}}
|
||
>
|
||
<Link size={12} style={{ verticalAlign: "middle" }} />
|
||
{dependencies.length > 0 ? `${dependencies.length} deps` : "Deps"}
|
||
</button>
|
||
</div>
|
||
{/* Dependency dropdown rendered via portal for proper viewport positioning */}
|
||
{showDeps && portalRoot && depDropdownPosition && (() => {
|
||
const term = depSearch.toLowerCase();
|
||
const filtered = (term
|
||
? tasks.filter((t) =>
|
||
t.id.toLowerCase().includes(term) ||
|
||
(t.title && t.title.toLowerCase().includes(term)) ||
|
||
(t.description && t.description.toLowerCase().includes(term))
|
||
)
|
||
: [...tasks]
|
||
).sort((a, b) => {
|
||
const cmp = b.createdAt.localeCompare(a.createdAt);
|
||
if (cmp !== 0) return cmp;
|
||
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
|
||
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
|
||
return bNum - aNum;
|
||
});
|
||
return createPortal(
|
||
<div
|
||
ref={depDropdownPortalRef}
|
||
className="dep-dropdown dep-dropdown--portal"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
style={{
|
||
position: "fixed",
|
||
top: `${depDropdownPosition.top}px`,
|
||
left: `${depDropdownPosition.left}px`,
|
||
width: `${depDropdownPosition.width}px`,
|
||
maxHeight: depDropdownPosition.maxHeight ? `${depDropdownPosition.maxHeight}px` : undefined,
|
||
overflowY: depDropdownPosition.maxHeight ? "auto" : undefined,
|
||
}}
|
||
>
|
||
<input
|
||
className="dep-dropdown-search"
|
||
placeholder="Search tasks…"
|
||
autoFocus
|
||
value={depSearch}
|
||
onChange={(e) => setDepSearch(e.target.value)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
{filtered.length === 0 ? (
|
||
<div className="dep-dropdown-empty">No existing tasks</div>
|
||
) : (
|
||
filtered.map((t) => (
|
||
<div
|
||
key={t.id}
|
||
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => toggleDep(t.id)}
|
||
>
|
||
<span className="dep-dropdown-id">{t.id}</span>
|
||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 60)}</span>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>,
|
||
portalRoot,
|
||
);
|
||
})()}
|
||
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm"
|
||
data-testid="quick-entry-attach"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
<Paperclip size={12} style={{ verticalAlign: "middle" }} />
|
||
{pendingImages.length > 0 ? `Attach (${pendingImages.length})` : "Attach"}
|
||
</button>
|
||
|
||
<button
|
||
ref={modelTriggerRef}
|
||
type="button"
|
||
className="btn btn-sm"
|
||
data-testid="quick-entry-models"
|
||
onClick={() => {
|
||
setShowDeps(false);
|
||
setShowAgentPicker(false);
|
||
setAgentPickerPosition(null);
|
||
setShowNodePicker(false);
|
||
setNodePickerPosition(null);
|
||
setShowPriorityPicker(false);
|
||
setPriorityPickerPosition(null);
|
||
setActiveModelSubmenu(null);
|
||
setIsModelMenuOpen(true);
|
||
updateModelMenuPosition();
|
||
}}
|
||
>
|
||
<Brain size={12} style={{ verticalAlign: "middle" }} />
|
||
{modelMenuLabel}
|
||
</button>
|
||
|
||
<div className="node-trigger-wrap" ref={nodePickerRef}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm dep-trigger"
|
||
data-testid="quick-entry-node-button"
|
||
onClick={() => {
|
||
setShowDeps(false);
|
||
setShowAgentPicker(false);
|
||
setAgentPickerPosition(null);
|
||
setIsModelMenuOpen(false);
|
||
setModelMenuPosition(null);
|
||
setActiveModelSubmenu(null);
|
||
setShowPriorityPicker(false);
|
||
setPriorityPickerPosition(null);
|
||
setShowNodePicker((prev) => {
|
||
const next = !prev;
|
||
if (next) {
|
||
updateNodePickerPosition();
|
||
} else {
|
||
setNodePickerPosition(null);
|
||
}
|
||
return next;
|
||
});
|
||
}}
|
||
>
|
||
<Server size={12} style={{ verticalAlign: "middle" }} />
|
||
{` ${selectedNode?.name ?? "Node"}`}
|
||
{selectedNode && (
|
||
<span className="quick-entry-node-status">
|
||
<NodeHealthDot status={selectedNode.status} showLabel />
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{showNodePicker && portalRoot && nodePickerPosition && createPortal(
|
||
<div
|
||
ref={nodePickerPortalRef}
|
||
className="dep-dropdown node-picker-dropdown node-picker-dropdown--portal"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
style={{
|
||
position: "fixed",
|
||
top: `${nodePickerPosition.top}px`,
|
||
left: `${nodePickerPosition.left}px`,
|
||
width: `${nodePickerPosition.width}px`,
|
||
maxHeight: nodePickerPosition.maxHeight ? `${nodePickerPosition.maxHeight}px` : undefined,
|
||
overflowY: nodePickerPosition.maxHeight ? "auto" : undefined,
|
||
}}
|
||
>
|
||
<div className="dep-dropdown-search-header">Select execution node</div>
|
||
<div
|
||
className={`dep-dropdown-item node-picker-item${nodeId == null ? " selected" : ""}`}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => {
|
||
setNodeId(undefined);
|
||
setShowNodePicker(false);
|
||
setNodePickerPosition(null);
|
||
}}
|
||
>
|
||
<span className="node-picker-item-name">Project default / local</span>
|
||
</div>
|
||
{nodes.map((node) => (
|
||
<div
|
||
key={node.id}
|
||
className={`dep-dropdown-item node-picker-item${nodeId === node.id ? " selected" : ""}`}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => {
|
||
setNodeId(node.id);
|
||
setShowNodePicker(false);
|
||
setNodePickerPosition(null);
|
||
}}
|
||
>
|
||
<span className="quick-entry-node-status">
|
||
<NodeHealthDot status={node.status} />
|
||
</span>
|
||
<span className="node-picker-item-name">{node.name}</span>
|
||
<span className="node-picker-item-status">{getNodeStatusLabel(node.status)}</span>
|
||
</div>
|
||
))}
|
||
</div>,
|
||
portalRoot,
|
||
)}
|
||
|
||
<div className="agent-trigger-wrap" ref={agentPickerRef}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm dep-trigger"
|
||
onClick={() => {
|
||
if (showAgentPicker) {
|
||
setShowAgentPicker(false);
|
||
setAgentPickerPosition(null);
|
||
} else {
|
||
setShowNodePicker(false);
|
||
setNodePickerPosition(null);
|
||
setShowPriorityPicker(false);
|
||
setPriorityPickerPosition(null);
|
||
void loadAgents();
|
||
}
|
||
}}
|
||
data-testid="quick-entry-agent-button"
|
||
>
|
||
<Bot size={12} style={{ verticalAlign: "middle" }} />
|
||
{selectedAgentLabel ? ` ${selectedAgentLabel}` : " Agent"}
|
||
</button>
|
||
</div>
|
||
{showAgentPicker && portalRoot && agentPickerPosition && createPortal(
|
||
<div
|
||
ref={agentPickerPortalRef}
|
||
className="dep-dropdown agent-picker-dropdown agent-picker-dropdown--portal"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
style={{
|
||
position: "fixed",
|
||
top: `${agentPickerPosition.top}px`,
|
||
left: `${agentPickerPosition.left}px`,
|
||
width: `${agentPickerPosition.width}px`,
|
||
maxHeight: agentPickerPosition.maxHeight ? `${agentPickerPosition.maxHeight}px` : undefined,
|
||
overflowY: agentPickerPosition.maxHeight ? "auto" : undefined,
|
||
}}
|
||
>
|
||
<div className="dep-dropdown-search-header">Select agent</div>
|
||
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
|
||
{!agentsLoading && agents.map((a) => (
|
||
<div
|
||
key={a.id}
|
||
className={`dep-dropdown-item${selectedAgentId === a.id ? " selected" : ""}`}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => {
|
||
setSelectedAgentId(a.id === selectedAgentId ? null : a.id);
|
||
setShowAgentPicker(false);
|
||
setAgentPickerPosition(null);
|
||
}}
|
||
>
|
||
<Bot size={12} style={{ marginRight: 6 }} />
|
||
<span className="dep-dropdown-id">{a.role}</span>
|
||
<span className="dep-dropdown-title">{a.name}</span>
|
||
</div>
|
||
))}
|
||
{!agentsLoading && agents.length === 0 && (
|
||
<div className="dep-dropdown-empty">No agents available</div>
|
||
)}
|
||
{selectedAgentId && (
|
||
<div
|
||
className="dep-dropdown-item"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => {
|
||
setSelectedAgentId(null);
|
||
setShowAgentPicker(false);
|
||
setAgentPickerPosition(null);
|
||
}}
|
||
>
|
||
<span className="dep-dropdown-title">Clear selection</span>
|
||
</div>
|
||
)}
|
||
</div>,
|
||
portalRoot,
|
||
)}
|
||
|
||
<div className="priority-trigger-wrap" ref={priorityPickerRef}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm dep-trigger"
|
||
data-testid="quick-entry-priority-button"
|
||
onClick={() => {
|
||
setShowDeps(false);
|
||
setShowAgentPicker(false);
|
||
setAgentPickerPosition(null);
|
||
setShowNodePicker(false);
|
||
setNodePickerPosition(null);
|
||
setIsModelMenuOpen(false);
|
||
setModelMenuPosition(null);
|
||
setActiveModelSubmenu(null);
|
||
setShowPriorityPicker((prev) => {
|
||
const next = !prev;
|
||
if (next) {
|
||
updatePriorityPickerPosition();
|
||
} else {
|
||
setPriorityPickerPosition(null);
|
||
}
|
||
return next;
|
||
});
|
||
}}
|
||
>
|
||
<Flag size={12} style={{ verticalAlign: "middle" }} />
|
||
{` ${priority[0].toUpperCase()}${priority.slice(1)}`}
|
||
</button>
|
||
</div>
|
||
|
||
{showPriorityPicker && portalRoot && priorityPickerPosition && createPortal(
|
||
<div
|
||
ref={priorityPickerPortalRef}
|
||
className="dep-dropdown priority-picker-dropdown priority-picker-dropdown--portal"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
style={{
|
||
position: "fixed",
|
||
top: `${priorityPickerPosition.top}px`,
|
||
left: `${priorityPickerPosition.left}px`,
|
||
width: `${priorityPickerPosition.width}px`,
|
||
maxHeight: priorityPickerPosition.maxHeight ? `${priorityPickerPosition.maxHeight}px` : undefined,
|
||
overflowY: priorityPickerPosition.maxHeight ? "auto" : undefined,
|
||
}}
|
||
>
|
||
<div className="dep-dropdown-search-header">Select priority</div>
|
||
{TASK_PRIORITIES.map((taskPriority) => {
|
||
const label = `${taskPriority[0].toUpperCase()}${taskPriority.slice(1)}`;
|
||
return (
|
||
<div
|
||
key={taskPriority}
|
||
className={`dep-dropdown-item${priority === taskPriority ? " selected" : ""}`}
|
||
data-testid={`quick-entry-priority-option-${taskPriority}`}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => {
|
||
setPriority(taskPriority);
|
||
setShowPriorityPicker(false);
|
||
setPriorityPickerPosition(null);
|
||
}}
|
||
>
|
||
<span className="dep-dropdown-title">{label}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>,
|
||
portalRoot,
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm ${isFastMode ? "btn-primary" : ""}`}
|
||
onClick={() => setIsFastMode((prev) => !prev)}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
aria-pressed={isFastMode}
|
||
data-testid="quick-entry-fast-toggle"
|
||
title="Toggle fast execution mode"
|
||
>
|
||
Fast
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
className={`btn btn-sm ${githubTrackingProjectEnabled && effectiveGithubTracking ? "btn-primary" : ""}`}
|
||
onClick={() => {
|
||
if (!githubTrackingProjectEnabled) {
|
||
return;
|
||
}
|
||
setGithubTrackingOverride((prev) => (prev ?? true) ? false : true);
|
||
}}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
disabled={!githubTrackingProjectEnabled}
|
||
aria-pressed={effectiveGithubTracking}
|
||
aria-disabled={!githubTrackingProjectEnabled || undefined}
|
||
data-testid="quick-entry-github-toggle"
|
||
title={githubToggleLabel}
|
||
aria-label={githubToggleLabel}
|
||
>
|
||
<ProviderIcon provider="github" size="sm" />
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
className="btn btn-task-create btn-sm"
|
||
onClick={handleSaveClick}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
disabled={!description.trim() || isSubmitting}
|
||
data-testid="quick-entry-save"
|
||
title="Create task"
|
||
>
|
||
<Save size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
|
||
Save
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{pendingImages.length > 0 && (
|
||
<div className="inline-create-previews">
|
||
{pendingImages.map((img, index) => (
|
||
<div key={img.previewUrl} className="inline-create-preview">
|
||
<img src={img.previewUrl} alt={img.file.name} />
|
||
<button
|
||
type="button"
|
||
className="inline-create-preview-remove"
|
||
onClick={() => removeImage(index)}
|
||
disabled={isSubmitting}
|
||
title="Remove image"
|
||
data-testid={`quick-entry-preview-remove-${index}`}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{isModelMenuOpen && portalRoot && modelMenuPosition && createPortal(
|
||
<div
|
||
ref={modelMenuPortalRef}
|
||
className="model-nested-menu model-nested-menu--portal"
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
data-testid="model-nested-menu"
|
||
style={{
|
||
position: "fixed",
|
||
top: `${modelMenuPosition.top}px`,
|
||
left: `${modelMenuPosition.left}px`,
|
||
width: `${modelMenuPosition.width}px`,
|
||
maxHeight: modelMenuPosition.maxHeight ? `${modelMenuPosition.maxHeight}px` : undefined,
|
||
overflowY: modelMenuPosition.maxHeight ? "auto" : undefined,
|
||
}}
|
||
>
|
||
{activeModelSubmenu === null ? (
|
||
// Top-level menu with Plan/Executor/Reviewer choices
|
||
<div className="model-menu-items">
|
||
<button
|
||
type="button"
|
||
className={`model-menu-item ${hasPlanningOverride ? "model-menu-item--active" : ""}`}
|
||
onClick={() => setActiveModelSubmenu("plan")}
|
||
data-testid="model-menu-plan"
|
||
>
|
||
<span className="model-menu-item-label">
|
||
<Lightbulb size={12} style={{ verticalAlign: "middle", marginRight: 6 }} />
|
||
Plan
|
||
</span>
|
||
<span className="model-menu-item-value">
|
||
{hasPlanningOverride
|
||
? getModelBadgeLabel(planningProvider, planningModelId)
|
||
: "Using default"}
|
||
</span>
|
||
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`model-menu-item ${hasExecutorOverride ? "model-menu-item--active" : ""}`}
|
||
onClick={() => setActiveModelSubmenu("executor")}
|
||
data-testid="model-menu-executor"
|
||
>
|
||
<span className="model-menu-item-label">
|
||
<Sparkles size={12} style={{ verticalAlign: "middle", marginRight: 6 }} />
|
||
Executor
|
||
</span>
|
||
<span className="model-menu-item-value">
|
||
{hasExecutorOverride
|
||
? getModelBadgeLabel(executorProvider, executorModelId)
|
||
: "Using default"}
|
||
</span>
|
||
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`model-menu-item ${hasValidatorOverride ? "model-menu-item--active" : ""}`}
|
||
onClick={() => setActiveModelSubmenu("validator")}
|
||
data-testid="model-menu-validator"
|
||
>
|
||
<span className="model-menu-item-label">
|
||
<Brain size={12} style={{ verticalAlign: "middle", marginRight: 6 }} />
|
||
Reviewer
|
||
</span>
|
||
<span className="model-menu-item-value">
|
||
{hasValidatorOverride
|
||
? getModelBadgeLabel(validatorProvider, validatorModelId)
|
||
: "Using default"}
|
||
</span>
|
||
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
// Submenu with CustomModelDropdown for the selected target
|
||
<div className="model-submenu">
|
||
<button
|
||
type="button"
|
||
className="model-submenu-back"
|
||
onClick={() => setActiveModelSubmenu(null)}
|
||
data-testid="model-submenu-back"
|
||
>
|
||
<ChevronDown size={12} style={{ transform: "rotate(90deg)", marginRight: 4 }} />
|
||
Back
|
||
</button>
|
||
<div className="model-submenu-header">
|
||
{activeModelSubmenu === "plan" && "Plan Model"}
|
||
{activeModelSubmenu === "executor" && "Executor Model"}
|
||
{activeModelSubmenu === "validator" && "Reviewer Model"}
|
||
</div>
|
||
<CustomModelDropdown
|
||
models={loadedModels}
|
||
value={
|
||
activeModelSubmenu === "plan"
|
||
? planningSelectionValue
|
||
: activeModelSubmenu === "executor"
|
||
? executorSelectionValue
|
||
: validatorSelectionValue
|
||
}
|
||
onChange={
|
||
activeModelSubmenu === "plan"
|
||
? handlePlanningModelChange
|
||
: activeModelSubmenu === "executor"
|
||
? handleExecutorChange
|
||
: handleValidatorChange
|
||
}
|
||
placeholder="Using default"
|
||
disabled={modelsLoading}
|
||
id={`model-${activeModelSubmenu}-select`}
|
||
label={`${activeModelSubmenu} model`}
|
||
favoriteProviders={effectiveFavoriteProviders}
|
||
onToggleFavorite={handleToggleFavorite}
|
||
favoriteModels={effectiveFavoriteModels}
|
||
onToggleModelFavorite={handleToggleModelFavorite}
|
||
/>
|
||
{modelsError && (
|
||
<div className="model-submenu-error">
|
||
<span>{modelsError}</span>
|
||
<button type="button" className="btn btn-sm" onClick={loadModels}>
|
||
Retry
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>,
|
||
portalRoot,
|
||
)}
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
style={{ display: "none" }}
|
||
onChange={(e) => {
|
||
handleImageFiles(e.target.files);
|
||
e.currentTarget.value = "";
|
||
}}
|
||
data-testid="quick-entry-file-input"
|
||
/>
|
||
<div className="quick-entry-hint">
|
||
Enter to create · Esc to cancel
|
||
</div>
|
||
</div>
|
||
{duplicateMatches && (
|
||
<DuplicateWarningModal
|
||
matches={duplicateMatches}
|
||
onOpen={handleDuplicateOpen}
|
||
onProceed={handleDuplicateProceed}
|
||
onCancel={handleDuplicateCancel}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|