Files
fusion/packages/dashboard/app/components/InlineCreateCard.tsx
gsxdsm 0359166b6c feat(FN-1276): scope dashboard persistence by project
- Add a project storage utility with scoped key helpers and key inventories for global vs project state
- Scope dashboard/task view, list preferences, quick-entry drafts, agent/tree state, terminal tabs, usage view, and modal draft persistence to projectId
- Reload persisted UI state when project context changes so per-project settings and drafts do not leak across projects
- Update and expand dashboard tests, including new projectStorage coverage and scoped persistence regression fixes
2026-04-08 10:39:38 -07:00

909 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp, Bot } from "lucide-react";
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents } from "../api";
import type { ModelInfo, Agent } from "../api";
import { ModelSelectionModal } from "./ModelSelectionModal";
import { applyPresetToSelection } from "../utils/modelPresets";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const STORAGE_KEY = "kb-inline-create-text";
interface PendingImage {
file: File;
previewUrl: string;
}
interface InlineCreateCardProps {
tasks: Task[];
onSubmit: (input: TaskCreateInput) => Promise<Task>;
onCancel: () => void;
addToast: (msg: string, type?: ToastType) => void;
projectId?: string;
/**
* Optional model list from a parent surface. When omitted, InlineCreateCard
* fetches models itself so it can stay reusable in both list and board flows
* without forcing model data to be threaded through every caller.
*/
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;
}
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 InlineCreateCard({
tasks,
onSubmit,
onCancel,
addToast,
projectId,
availableModels,
onPlanningMode,
onSubtaskBreakdown,
}: InlineCreateCardProps) {
const [description, setDescription] = useState(() => {
if (typeof window !== "undefined") {
return getScopedItem(STORAGE_KEY, projectId) || "";
}
return "";
});
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 [showAgentPicker, setShowAgentPicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const [isModelModalOpen, setIsModelModalOpen] = useState(false);
const [showPresets, setShowPresets] = useState(false);
const [settings, setSettings] = useState<Settings | null>(null);
const [selectedPresetId, setSelectedPresetId] = useState<string | undefined>(undefined);
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 [browserVerification, setBrowserVerification] = useState(false);
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 [submitting, setSubmitting] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const justResetRef = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const agentPickerRef = useRef<HTMLDivElement>(null);
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]);
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);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
} catch (err: any) {
setModelsError(err?.message || "Failed to load models");
} finally {
setModelsLoading(false);
}
}, [availableModels]);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
if (!showDeps) setDepSearch("");
}, [showDeps]);
useEffect(() => {
if (!showAgentPicker) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (agentPickerRef.current?.contains(target)) return;
setShowAgentPicker(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAgentPicker]);
useEffect(() => {
let cancelled = false;
if (availableModels) {
setLoadedModels(availableModels);
setModelsLoading(false);
setModelsError(null);
return;
}
setModelsLoading(true);
setModelsError(null);
fetchModels()
.then((response) => {
if (!cancelled) {
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
}
})
.catch((err: any) => {
if (!cancelled) {
setModelsError(err?.message || "Failed to load models");
}
})
.finally(() => {
if (!cancelled) {
setModelsLoading(false);
}
});
fetchSettings(projectId)
.then((nextSettings) => {
if (!cancelled) {
setSettings(nextSettings);
}
})
.catch(() => {
if (!cancelled) {
setSettings(null);
}
});
return () => {
cancelled = true;
};
}, [availableModels, projectId]);
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
const availablePresets = settings?.modelPresets || [];
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
const hasExecutorOverride = Boolean(executorProvider && executorModelId);
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride);
// Track focus-out for conditional cancel behavior and justResetRef cleanup.
useEffect(() => {
const card = cardRef.current;
if (!card) return;
const handleFocusOut = (e: FocusEvent) => {
// relatedTarget is the element receiving focus — if it's inside the card, ignore
if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) return;
if (justResetRef.current) {
justResetRef.current = false;
return;
}
const hasOpenOverlay = showDeps || showAgentPicker || isModelModalOpen || showPresets;
const hasDescription = description.trim().length > 0;
const shouldCancelWhenCollapsed = !isExpanded && !hasDescription && !hasOpenOverlay;
const shouldCancelWhenExpanded = isExpanded && !hasDescription && !hasOpenOverlay;
if (shouldCancelWhenCollapsed || shouldCancelWhenExpanded) {
onCancel();
}
};
card.addEventListener("focusout", handleFocusOut);
return () => card.removeEventListener("focusout", handleFocusOut);
}, [description, isExpanded, onCancel, showDeps, showAgentPicker, isModelModalOpen, showPresets]);
// Clean up object URLs on unmount to prevent memory leaks
useEffect(() => {
return () => {
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
// Clear localStorage on unmount if there's no description (user abandoned)
if (typeof window !== "undefined") {
const current = getScopedItem(STORAGE_KEY, projectId);
if (current && current.trim() === "") {
removeScopedItem(STORAGE_KEY, projectId);
}
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount
}, [projectId]);
/**
* Handles paste events on the textarea. Extracts image files from the
* clipboard data, creates object URL previews, and appends them to
* the pendingImages state. Non-image files are silently ignored.
*/
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
if (submitting) return;
const files = e.clipboardData?.files;
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]);
}
},
[submitting],
);
const removeImage = useCallback((index: number) => {
setPendingImages((prev) => {
const removed = prev[index];
if (removed) URL.revokeObjectURL(removed.previewUrl);
return prev.filter((_, i) => i !== index);
});
}, []);
const handleSubmit = useCallback(async () => {
if (!description.trim() || submitting) return;
setSubmitting(true);
try {
const task = await onSubmit({
description: description.trim(),
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,
enabledWorkflowSteps: browserVerification ? ["browser-verification"] : undefined,
});
// Upload pending images as attachments
if (pendingImages.length > 0) {
const failures: string[] = [];
for (const img of pendingImages) {
try {
await uploadAttachment(task.id, img.file, projectId);
} catch {
failures.push(img.file.name);
}
}
if (failures.length > 0) {
addToast(`Failed to upload: ${failures.join(", ")}`, "error");
}
}
// Clean up preview URLs
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setPendingImages([]);
setDescription("");
if (inputRef.current) {
inputRef.current.style.height = "auto";
}
setSelectedPresetId(undefined);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setBrowserVerification(false);
setDependencies([]);
setSelectedAgentId(null);
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
addToast(`Created ${task.id}`, "success");
// Collapse and clear localStorage after successful task creation
setIsExpanded(false);
justResetRef.current = true;
if (typeof window !== "undefined") {
removeScopedItem(STORAGE_KEY, projectId);
}
} catch (err: any) {
addToast(err.message, "error");
} finally {
setSubmitting(false);
}
}, [
description,
dependencies,
selectedAgentId,
hasExecutorOverride,
executorProvider,
executorModelId,
hasValidatorOverride,
validatorProvider,
validatorModelId,
browserVerification,
submitting,
pendingImages,
onSubmit,
addToast,
projectId,
selectedPresetId,
]);
const handleKeyDown = useCallback(
async (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
// Close dropdowns first if open
if (showDeps || showAgentPicker || isModelModalOpen || showPresets) {
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
return;
}
// Clear non-empty input on Escape and clear localStorage
if (description.trim()) {
setDescription("");
// Reset height
if (inputRef.current) {
inputRef.current.style.height = "auto";
}
// Clear localStorage when user explicitly clears input
if (typeof window !== "undefined") {
removeScopedItem(STORAGE_KEY, projectId);
}
}
// Collapse and cancel on escape
setIsExpanded(false);
onCancel();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[
handleSubmit,
onCancel,
description,
showDeps,
showAgentPicker,
isModelModalOpen,
showPresets,
projectId,
],
);
const toggleDep = useCallback((id: string) => {
setDependencies((prev) =>
prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id],
);
}, []);
const toggleDepsDropdown = useCallback(() => {
setShowDeps((prev) => {
const next = !prev;
if (next) {
setIsModelModalOpen(false);
setShowAgentPicker(false);
}
return next;
});
}, []);
const toggleModelsDropdown = useCallback(() => {
setIsModelModalOpen(true);
setShowDeps(false);
setShowAgentPicker(false);
}, []);
const loadAgents = useCallback(async () => {
if (agents.length > 0) {
setShowAgentPicker(true);
return;
}
setAgentsLoading(true);
try {
const result = await fetchAgents(undefined, projectId);
setAgents(result);
setShowAgentPicker(true);
} catch (err: any) {
addToast(err?.message ? `Failed to load agents: ${err.message}` : "Failed to load agents", "error");
setShowAgentPicker(false);
} finally {
setAgentsLoading(false);
}
}, [agents.length, projectId, addToast]);
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
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) => {
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]);
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
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]);
const handleModelDropdownMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target;
if (
target instanceof HTMLElement &&
(target.closest("button") || target.closest("input"))
) {
return;
}
e.preventDefault();
}, []);
const handlePlanClick = useCallback(() => {
const trimmed = description.trim();
if (!trimmed) {
addToast("Enter a description first", "error");
return;
}
onPlanningMode?.(trimmed);
// Clear the input after triggering planning mode
setDescription("");
setDependencies([]);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setBrowserVerification(false);
setSelectedPresetId(undefined);
setSelectedAgentId(null);
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
setIsExpanded(false);
}, [description, onPlanningMode, addToast]);
const handleSubtaskClick = useCallback(() => {
const trimmed = description.trim();
if (!trimmed) {
addToast("Enter a description first", "error");
return;
}
onSubtaskBreakdown?.(trimmed);
// Clear the input after triggering subtask breakdown
setDescription("");
setDependencies([]);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setBrowserVerification(false);
setSelectedPresetId(undefined);
setSelectedAgentId(null);
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
setIsExpanded(false);
}, [description, onSubtaskBreakdown, addToast]);
const truncate = (s: string, len: number) =>
s.length > len ? s.slice(0, len) + "…" : s;
const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
return (
<div className={`inline-create-card ${isExpanded ? "inline-create-card--expanded" : "inline-create-card--collapsed"}`} ref={cardRef}>
<div className="inline-create-main-row">
<textarea
ref={inputRef}
rows={1}
className="inline-create-input"
placeholder="What needs to be done?"
value={description}
onChange={(e) => {
setDescription(e.target.value);
const el = e.target;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
disabled={submitting}
aria-controls={isExpanded ? "inline-create-controls" : undefined}
/>
<button
type="button"
className="btn btn-sm inline-create-toggle"
onClick={toggleExpanded}
aria-expanded={isExpanded}
aria-controls={isExpanded ? "inline-create-controls" : undefined}
aria-label={isExpanded ? "Collapse advanced task options" : "Expand advanced task options"}
data-testid="inline-create-toggle"
title={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
{/* AI-assisted refinement actions — always visible when expanded */}
{isExpanded && !submitting && (
<div className="inline-create-description-actions" data-testid="inline-create-description-actions">
<button
type="button"
className="btn btn-sm"
onClick={handlePlanClick}
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}
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>
)}
{pendingImages.length > 0 && (
<div className="inline-create-previews">
{pendingImages.map((img, i) => (
<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(i)}
disabled={submitting}
title="Remove image"
>
×
</button>
</div>
))}
</div>
)}
{isExpanded && (
<div id="inline-create-controls" className="inline-create-footer">
<div className="inline-create-controls">
<div className="dep-trigger-wrap">
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={toggleDepsDropdown}
>
<Link size={12} style={{ verticalAlign: "middle" }} />
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
</button>
{showDeps && (() => {
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 (
<div className="dep-dropdown" onMouseDown={(e) => e.preventDefault()}>
<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, 30)}</span>
</div>
))
)}
</div>
);
})()}
</div>
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={() => {
if (showAgentPicker) {
setShowAgentPicker(false);
} else {
void loadAgents();
}
}}
data-testid="inline-create-agent-button"
>
<Bot size={12} style={{ verticalAlign: "middle" }} />
{selectedAgentLabel ? ` ${selectedAgentLabel}` : " Agent"}
</button>
{showAgentPicker && (
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">Select agent</div>
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
{!agentsLoading && agents.filter((a) => a.state !== "terminated").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);
}}
>
<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.filter((a) => a.state !== "terminated").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);
}}
>
<span className="dep-dropdown-title">Clear selection</span>
</div>
)}
</div>
)}
</div>
<button
type="button"
className="btn btn-sm"
data-testid="inline-create-browser-verification-toggle"
aria-pressed={browserVerification}
onClick={() => setBrowserVerification((prev) => !prev)}
title="Enable browser verification workflow step"
>
{browserVerification ? "Browser Verify ✓" : "Browser Verify"}
</button>
<div className="inline-create-model-wrap">
<button
type="button"
className="btn btn-sm inline-create-model-trigger"
onClick={() => {
setShowPresets((prev) => {
const next = !prev;
if (next) {
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
}
return next;
});
}}
aria-expanded={showPresets}
aria-haspopup="listbox"
>
<Zap size={12} style={{ verticalAlign: "middle" }} />
{selectedPreset ? ` ${selectedPreset.name}` : " Preset"}
</button>
{showPresets && (
<div className="inline-create-model-dropdown" onMouseDown={handleModelDropdownMouseDown}>
<button
type="button"
className="btn btn-sm"
onClick={() => {
setSelectedPresetId(undefined);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setShowPresets(false);
}}
>
Use default
</button>
{availablePresets.map((preset) => (
<button
key={preset.id}
type="button"
className="btn btn-sm"
onClick={() => {
const selection = applyPresetToSelection(preset);
const executor = parseModelSelection(selection.executorValue);
const validator = parseModelSelection(selection.validatorValue);
setSelectedPresetId(preset.id);
setExecutorProvider(executor.provider);
setExecutorModelId(executor.modelId);
setValidatorProvider(validator.provider);
setValidatorModelId(validator.modelId);
setShowPresets(false);
}}
>
{preset.name}
</button>
))}
<button
type="button"
className="btn btn-sm"
onClick={() => setShowPresets(false)}
>
Custom
</button>
</div>
)}
<button
type="button"
className="btn btn-sm inline-create-model-trigger"
onClick={toggleModelsDropdown}
aria-expanded={isModelModalOpen}
aria-haspopup="dialog"
>
<Brain size={12} style={{ verticalAlign: "middle" }} />
{selectedPreset
? ` ${selectedPreset.name} · ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: selectedModelCount > 0
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
: " Models"}
</button>
</div>
</div>
<div className="inline-create-actions">
<span className="inline-create-hint">Enter to create · Esc to cancel</span>
<button
type="button"
className="btn btn-task-create btn-sm"
onClick={handleSubmit}
disabled={!description.trim() || submitting}
data-testid="save-button"
>
{submitting ? "Creating..." : "Save"}
</button>
</div>
</div>
)}
{typeof document !== "undefined"
? createPortal(
<ModelSelectionModal
isOpen={isModelModalOpen}
onClose={() => setIsModelModalOpen(false)}
models={loadedModels}
executorValue={executorSelectionValue}
validatorValue={validatorSelectionValue}
onExecutorChange={handleExecutorChange}
onValidatorChange={handleValidatorChange}
modelsLoading={modelsLoading}
modelsError={modelsError}
onRetry={loadModels}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
presets={availablePresets}
selectedPresetId={selectedPresetId}
onPresetChange={(presetId) => setSelectedPresetId(presetId)}
/>,
document.body,
)
: null}
</div>
);
}