Files
fusion/packages/dashboard/app/components/InlineCreateCard.tsx
gsxdsm e72629c251 FN-8126: add per-task merger model controls
Enable Quick Add and task editing to select merger models and thinking levels.

- Persist merger model and thinking overrides through task APIs, storage, and PostgreSQL migrations.
- Add merger-lane selection controls to Quick Add and model settings interfaces.
- Apply task merger settings to merger and PR fallback sessions, with regression coverage.
- Document the merger lane and include a release changeset.

Files changed:
 .changeset/fn-8126-quick-add-merger-lane.md        |   7 ++
 docs/dashboard-guide.md                            |   2 +
 docs/settings-reference.md                         |   7 +-
 .../core/src/__tests__/model-resolution.test.ts    |   9 ++
 packages/core/src/index.gate.ts                    |   1 +
 packages/core/src/index.ts                         |   1 +
 packages/core/src/model-resolution.ts              |  20 ++++
 .../core/src/postgres/migrations/0000_initial.sql  |   3 +
 .../migrations/0017_task_merger_model_lane.sql     |   4 +
 packages/core/src/postgres/schema-applier.ts       |  14 ++-
 packages/core/src/postgres/schema/project.ts       |   3 +
 packages/core/src/store.ts                         |   2 +-
 .../core/src/task-store/archive-lifecycle-2.ts     |   6 ++
 packages/core/src/task-store/persistence.ts        |   6 ++
 packages/core/src/task-store/remaining-ops-2.ts    |   4 +-
 packages/core/src/task-store/remaining-ops-6.ts    |   2 +-
 packages/core/src/task-store/serialization.ts      |   6 ++
 packages/core/src/task-store/task-creation.ts      |   6 ++
 packages/core/src/task-store/task-row-mappers.ts   |   4 +-
 packages/core/src/task-store/task-update.ts        |   6 ++
 packages/core/src/types.ts                         |  18 ++++
 packages/dashboard/app/api/tasks.ts                |  13 +++
 .../dashboard/app/components/InlineCreateCard.tsx  |  33 ++++++-
 .../app/components/ModelSelectionModal.tsx         |  29 ++++++
 .../dashboard/app/components/ModelSelectorTab.tsx  | 101 +++++++++++++++++++--
 .../dashboard/app/components/QuickEntryBox.tsx     |  44 +++++++--
 .../__tests__/ModelSelectionModal.test.tsx         |  20 ++++
 .../components/__tests__/ModelSelectorTab.test.tsx |  37 +++++++-
 .../src/routes/register-task-workflow-routes.ts    |  27 +++++-
 .../src/__tests__/agent-session-helpers.test.ts    |   8 ++
 packages/engine/src/agent-session-helpers.ts       |  16 +++-
 packages/engine/src/merger-ai.ts                   |  14 +--
 packages/engine/src/merger.ts                      |  35 ++++---
 packages/engine/src/pr-response-run-ops.ts         |   7 +-
 34 files changed, 451 insertions(+), 64 deletions(-)

Fusion-Task-Id: FN-8126

Fusion-Task-Lineage: 3fc81801-6d77-4e11-9cf0-3af37313930e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-16 21:07:06 -07:00

1278 lines
51 KiB
TypeScript

import "./InlineCreateCard.css";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Brain, Link, ListTree, Zap, ChevronDown, ChevronUp, Bot, Maximize2, Minimize2, Server } from "lucide-react";
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskPriority, type Settings, type ResolvedWorkflowOptionalStep, type ThinkingLevel } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { checkDuplicateTasks, fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents, fetchWorkflowOptionalSteps, DuplicateCandidatesError } from "../api";
import type { CreateTaskInput, ModelInfo, Agent, NodeInfo, DuplicateMatch } from "../api";
import { useNodes } from "../hooks/useNodes";
import { ModelSelectionModal } from "./ModelSelectionModal";
import { NodeHealthDot } from "./NodeHealthDot";
import { DuplicateWarningModal } from "./DuplicateWarningModal";
import { LoadingSpinner } from "./LoadingSpinner";
import { applyPresetToSelection } from "../utils/modelPresets";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { WorkflowSelector } from "./WorkflowSelector";
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
import { PendingImagePreviews } from "./PendingImagePreviews";
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: CreateTaskInput) => 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[];
/**
* Preserved for shared create-surface prop compatibility. Inline quick-create intentionally omits Plan.
*/
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
/**
* Called when the user clicks the "Subtask" button to trigger subtask breakdown.
*/
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
}
function getNodeStatusLabel(status: NodeInfo["status"], t?: (key: string, defaultValue: string) => string): string {
if (status === "online") return t ? t("inline.online", "Online") : "Online";
if (status === "connecting") return t ? t("inline.connecting", "Connecting") : "Connecting";
if (status === "error") return t ? t("inline.error", "Error") : "Error";
return t ? t("inline.offline", "Offline") : "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),
};
}
function hasMeaningfulNodeChoice(nodes: NodeInfo[]): boolean {
/*
FNXC:QuickAddNodeRouting 2026-06-30-00:00:
Local-only inline quick-create should not show a Node button because the project-default route already means local execution. Keep the picker only when a remote or second registered node makes routing a real choice.
*/
return nodes.length > 1 || nodes.some((node) => node.type !== "local");
}
export function InlineCreateCard({
tasks,
onSubmit,
onCancel,
addToast,
projectId,
availableModels,
onSubtaskBreakdown,
}: InlineCreateCardProps) {
const { t } = useTranslation("app");
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 [showNodePicker, setShowNodePicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const [isModelModalOpen, setIsModelModalOpen] = useState(false);
const [showPresets, setShowPresets] = useState(false);
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
const { nodes } = useNodes();
const shouldShowNodePicker = useMemo(() => hasMeaningfulNodeChoice(nodes), [nodes]);
const selectedNode = shouldShowNodePicker && nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
const effectiveNodeId = shouldShowNodePicker && selectedNode ? selectedNode.id : undefined;
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 [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [mergerProvider, setMergerProvider] = useState<string | undefined>(undefined);
const [mergerModelId, setMergerModelId] = useState<string | undefined>(undefined);
/* FNXC:Settings-ThinkingLevel 2026-07-09-00:00: quick-create board card carries the same per-task thinking-level override as the full New Task modal; "" means "use default". */
const [thinkingLevel, setThinkingLevel] = useState<string>("");
const [validatorThinkingLevel, setValidatorThinkingLevel] = useState<string>("");
const [planningThinkingLevel, setPlanningThinkingLevel] = useState<string>("");
const [mergerThinkingLevel, setMergerThinkingLevel] = useState<string>("");
const [optionalSteps, setOptionalSteps] = useState<ResolvedWorkflowOptionalStep[]>([]);
const [enabledOptionalStepIds, setEnabledOptionalStepIds] = useState<string[]>([]);
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
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 [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
// isDescriptionExpanded controls fullscreen description editing mode
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
// Track textarea focus for expand button visibility
const [isDescriptionFocused, setIsDescriptionFocused] = useState(false);
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
const [pendingSubmit, setPendingSubmit] = useState<CreateTaskInput | null>(null);
const justResetRef = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const agentPickerRef = useRef<HTMLDivElement>(null);
const nodePickerRef = 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]);
// Clear agents cache when projectId changes to prevent stale agents from leaking across projects
useEffect(() => {
setAgents([]);
setSelectedAgentId(null);
}, [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) {
setModelsError(getErrorMessage(err) || "Failed to load models");
} finally {
setModelsLoading(false);
}
}, [availableModels]);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
if (!showDeps) setDepSearch("");
}, [showDeps]);
useEffect(() => {
if (shouldShowNodePicker && (!nodeId || selectedNode)) {
return;
}
setNodeId(undefined);
setShowNodePicker(false);
}, [nodeId, selectedNode, shouldShowNodePicker]);
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(() => {
if (!showNodePicker) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (nodePickerRef.current?.contains(target)) return;
setShowNodePicker(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showNodePicker]);
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) => {
if (!cancelled) {
setModelsError(getErrorMessage(err) || "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 planningSelectionValue = getModelSelectionValue(planningProvider, planningModelId);
const availablePresets = settings?.modelPresets || [];
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
const hasExecutorOverride = Boolean(executorProvider && executorModelId);
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
const hasPlanningOverride = Boolean(planningProvider && planningModelId);
const hasMergerOverride = Boolean(mergerProvider && mergerModelId);
const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride) + Number(hasPlanningOverride) + Number(hasMergerOverride);
const effectiveWorkflowId = selectedWorkflowId || settings?.defaultWorkflowId || "builtin:coding";
useEffect(() => {
let cancelled = false;
setOptionalSteps([]);
setEnabledOptionalStepIds([]);
fetchWorkflowOptionalSteps(effectiveWorkflowId, projectId)
.then((steps) => {
if (cancelled) return;
setOptionalSteps(steps);
setEnabledOptionalStepIds(steps.filter((step) => step.defaultOn).map((step) => step.templateId));
})
.catch(() => {
if (cancelled) return;
setOptionalSteps([]);
setEnabledOptionalStepIds([]);
});
return () => {
cancelled = true;
};
}, [effectiveWorkflowId, projectId]);
const toggleOptionalStep = useCallback((templateId: string) => {
setEnabledOptionalStepIds((prev) => (
prev.includes(templateId)
? prev.filter((id) => id !== templateId)
: [...prev, templateId]
));
}, []);
// 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);
}
}
};
}, [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);
});
}, []);
/*
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
submitTask no longer applies the selected workflow post-create via selectTaskWorkflow — handleSubmit now forwards workflowId inside the CreateTaskInput so the store materializes the workflow and resolves the intake column (e.g. Coding (Ideas) → "ideas") atomically at create time. A post-create selectTaskWorkflow call would race the store's intake-column resolution and re-introduce the auto-triage bug this task fixes.
*/
const submitTask = useCallback(async (input: CreateTaskInput) => {
setSubmitting(true);
try {
const task = await onSubmit(input);
// 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);
setPlanningProvider(undefined);
setPlanningModelId(undefined);
setMergerProvider(undefined);
setMergerModelId(undefined);
setThinkingLevel("");
setValidatorThinkingLevel("");
setPlanningThinkingLevel("");
setMergerThinkingLevel("");
setEnabledOptionalStepIds([]);
setPriority(DEFAULT_TASK_PRIORITY);
setDependencies([]);
setSelectedAgentId(null);
setNodeId(undefined);
setShowDeps(false);
setShowNodePicker(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
setSelectedWorkflowId(null);
setEnabledOptionalStepIds([]);
addToast(`Created ${task.id}`, "success");
// Collapse and clear localStorage after successful task creation
setIsExpanded(false);
setIsDescriptionExpanded(false); // Exit fullscreen mode on submit
justResetRef.current = true;
if (typeof window !== "undefined") {
removeScopedItem(STORAGE_KEY, projectId);
}
} catch (err) {
if (err instanceof DuplicateCandidatesError && err.matches.length > 0) {
setDuplicateMatches(err.matches);
addToast(`Linked existing ${err.matches[0]?.id ?? "task"}`, "success");
} else {
addToast(getErrorMessage(err), "error");
}
} finally {
setSubmitting(false);
}
}, [
pendingImages,
onSubmit,
addToast,
projectId,
]);
const handleSubmit = useCallback(async () => {
if (!description.trim() || submitting) return;
/*
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
Do not hard-code `column: "triage"` here — the store resolves the landing column from the forwarded (or project-default) workflow's intake column, e.g. Coding (Ideas) → "ideas". Forwarding `workflowId` at create time (instead of applying it post-create via selectTaskWorkflow) lets the store materialize the workflow and land the card in its resolved intake column atomically, so a manual-intake workflow parks the card for the operator instead of being auto-triaged.
*/
const input: CreateTaskInput = {
description: description.trim(),
...(selectedWorkflowId ? { workflowId: selectedWorkflowId } : {}),
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,
mergerModelProvider: hasMergerOverride ? mergerProvider : undefined,
mergerModelId: hasMergerOverride ? mergerModelId : undefined,
validatorThinkingLevel: validatorThinkingLevel !== "" ? validatorThinkingLevel as ThinkingLevel : undefined,
planningThinkingLevel: planningThinkingLevel !== "" ? planningThinkingLevel as ThinkingLevel : undefined,
mergerThinkingLevel: mergerThinkingLevel !== "" ? mergerThinkingLevel as ThinkingLevel : undefined,
thinkingLevel: thinkingLevel !== "" ? (thinkingLevel as ThinkingLevel) : undefined,
/*
FNXC:InlineCreateWorkflowSteps 2026-06-29-02:45:
Inline create optional-step toggles are explicit task intent. When a workflow exposes optional steps and the operator unchecks all of them, submit `[]` so default-on Plan Review / Code Review stay disabled on the created task instead of reappearing from workflow defaults.
*/
enabledWorkflowSteps: optionalSteps.length > 0 ? enabledOptionalStepIds : undefined,
priority,
nodeId: effectiveNodeId,
};
try {
const matches = await checkDuplicateTasks({ description: description.trim() }, projectId);
if (matches.length > 0) {
setDuplicateMatches(matches);
setPendingSubmit(input);
return;
}
} catch {
addToast("Duplicate check failed; creating task anyway.", "error");
}
await submitTask(input);
}, [description, submitting, selectedWorkflowId, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, hasPlanningOverride, planningProvider, planningModelId, hasMergerOverride, mergerProvider, mergerModelId, thinkingLevel, validatorThinkingLevel, planningThinkingLevel, mergerThinkingLevel, optionalSteps.length, enabledOptionalStepIds, priority, effectiveNodeId, projectId, addToast, submitTask]);
const handleDuplicateProceed = useCallback(async () => {
const matches = duplicateMatches;
const input = pendingSubmit;
setDuplicateMatches(null);
setPendingSubmit(null);
if (!matches || !input || matches.length === 0) return;
await submitTask({
...input,
acknowledgedDuplicates: matches.map((match) => match.id),
});
}, [duplicateMatches, pendingSubmit, submitTask]);
const handleDuplicateCancel = useCallback(() => {
setDuplicateMatches(null);
setPendingSubmit(null);
}, []);
const handleDuplicateOpen = useCallback((taskId: string) => {
if (typeof window !== "undefined") {
window.location.hash = `#/tasks/${taskId}`;
}
setDuplicateMatches(null);
setPendingSubmit(null);
}, []);
const handleKeyDown = useCallback(
async (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
// Exit fullscreen mode first - highest priority
if (isDescriptionExpanded) {
setIsDescriptionExpanded(false);
return;
}
// Close dropdowns first if open
if (showDeps || showNodePicker || showAgentPicker || isModelModalOpen || showPresets) {
setShowDeps(false);
setShowNodePicker(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,
isDescriptionExpanded,
showDeps,
showNodePicker,
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);
setShowNodePicker(false);
setShowAgentPicker(false);
}
return next;
});
}, []);
const toggleModelsDropdown = useCallback(() => {
setIsModelModalOpen(true);
setShowDeps(false);
setShowNodePicker(false);
setShowAgentPicker(false);
}, []);
const loadAgents = useCallback(async () => {
if (agents.length > 0) {
setShowNodePicker(false);
setShowAgentPicker(true);
return;
}
setAgentsLoading(true);
try {
const result = await fetchAgents(undefined, projectId);
setAgents(result);
setShowNodePicker(false);
setShowAgentPicker(true);
} 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, 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 handlePlanningModelChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setPlanningProvider(next.provider);
setPlanningModelId(next.modelId);
}, []);
const handleThinkingLevelChange = useCallback((value: string) => {
setThinkingLevel(value);
}, []);
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();
}, []);
/*
FNXC:InlineCreate 2026-06-30-00:00:
Inline quick-create intentionally omits the Plan button, icon, disabled state, tooltip, and click target while preserving Subtask and task creation controls.
*/
const handleSubtaskClick = useCallback(() => {
const trimmed = description.trim();
if (!trimmed) {
addToast(t("inline.enterDescriptionFirst", "Enter a description first"), "error");
return;
}
if (selectedWorkflowId !== null) {
onSubtaskBreakdown?.(trimmed, selectedWorkflowId);
} else {
onSubtaskBreakdown?.(trimmed);
}
// Clear the input after triggering subtask breakdown
setDescription("");
setSelectedWorkflowId(null);
setDependencies([]);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setPlanningProvider(undefined);
setPlanningModelId(undefined);
setMergerProvider(undefined);
setMergerModelId(undefined);
setThinkingLevel("");
setValidatorThinkingLevel("");
setPlanningThinkingLevel("");
setMergerThinkingLevel("");
setEnabledOptionalStepIds([]);
setSelectedPresetId(undefined);
setSelectedAgentId(null);
setNodeId(undefined);
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
setIsExpanded(false);
}, [description, onSubtaskBreakdown, selectedWorkflowId, addToast]);
const truncate = (s: string, len: number) =>
s.length > len ? s.slice(0, len) + "…" : s;
const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
const handleToggleDescriptionExpand = useCallback(() => {
setIsDescriptionExpanded((prev) => {
const next = !prev;
// Focus the fullscreen textarea after it renders
if (next && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 0);
}
return next;
});
}, []);
const handleDescriptionFullscreenKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (!isDescriptionExpanded || e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
setIsDescriptionExpanded(false);
}, [isDescriptionExpanded]);
return (
<div className={`inline-create-card ${isExpanded ? "inline-create-card--expanded" : "inline-create-card--collapsed"}`} ref={cardRef}>
<div
className={`description-with-refine${isDescriptionExpanded ? " description--fullscreen" : ""}`}
onKeyDown={handleDescriptionFullscreenKeyDown}
>
{isDescriptionExpanded && (
<div className="description-fullscreen-header">
<span>{t("inline.editingDescription", "Editing Description")}</span>
<button
type="button"
className="btn btn-sm description-expand-btn"
onClick={handleToggleDescriptionExpand}
aria-label={t("inline.collapseDescription", "Collapse description")}
title={t("inline.collapseDescription", "Collapse description")}
data-testid="inline-create-collapse"
>
<Minimize2 size={14} />
</button>
</div>
)}
{!isDescriptionExpanded && (
<div className="inline-create-main-row">
<div className="inline-create-textarea-wrap">
<textarea
ref={inputRef}
rows={1}
className="inline-create-input"
placeholder={t("inline.whatNeedsToBeDone", "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}
onFocus={() => setIsDescriptionFocused(true)}
onBlur={() => setIsDescriptionFocused(false)}
disabled={submitting}
aria-controls={isExpanded ? "inline-create-controls" : undefined}
/>
{isDescriptionFocused && description.trim() && !submitting && (
<button
type="button"
className="btn btn-sm inline-create-expand-btn"
onClick={handleToggleDescriptionExpand}
onMouseDown={(e) => e.preventDefault()}
aria-label={t("inline.expandDescription", "Expand description")}
title={t("inline.expandDescription", "Expand description")}
data-testid="inline-create-expand"
>
<Maximize2 size={14} />
</button>
)}
</div>
<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 ? t("inline.collapseTaskOptions", "Collapse advanced task options") : t("inline.expandTaskOptions", "Expand advanced task options")}
data-testid="inline-create-toggle"
title={isExpanded ? t("inline.collapse", "Collapse") : t("inline.expand", "Expand")}
>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
)}
{isDescriptionExpanded && (
<textarea
ref={inputRef}
rows={10}
className="inline-create-input inline-create-input--fullscreen"
placeholder={t("inline.whatNeedsToBeDone", "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}
onFocus={() => setIsDescriptionFocused(true)}
onBlur={() => setIsDescriptionFocused(false)}
disabled={submitting}
data-testid="inline-create-input-fullscreen"
/>
)}
</div>
<PendingImagePreviews
images={pendingImages}
onRemove={removeImage}
disabled={submitting}
removeLabel={t("inline.removeImage", "Remove image")}
testIdPrefix="inline-create-preview"
/>
{isExpanded && (
<div id="inline-create-controls" className="inline-create-footer">
<div className="inline-create-controls">
{/* FNXC:QuickAddSubtaskFlag 2026-06-21-00:00: Render no Subtask button or orphaned inline-create click target unless the default-off `subtaskBreakdown` experiment wires this callback. */}
{onSubtaskBreakdown && (
<button
type="button"
className="btn btn-sm"
onClick={handleSubtaskClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim()}
data-testid="subtask-button"
title={t("inline.breakDownSubtasks", "Break down into AI-generated subtasks")}
>
<ListTree size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("inline.subtask", "Subtask")}
</button>
)}
<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} ${t("inline.deps", "deps")}` : ` ${t("inline.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={t("inline.searchTasks", "Search tasks…")}
autoFocus
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{filtered.length === 0 ? (
<div className="dep-dropdown-empty">{t("inline.noExistingTasks", "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>
{shouldShowNodePicker && (
<div className="node-trigger-wrap" ref={nodePickerRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
data-testid="inline-create-node-button"
onClick={() => {
setShowNodePicker((prev) => {
const next = !prev;
if (next) {
setShowDeps(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
setShowPresets(false);
}
return next;
});
}}
>
<Server size={12} style={{ verticalAlign: "middle" }} />
{selectedNode ? ` ${selectedNode.name}` : ` ${t("inline.node", "Node")}`}
{selectedNode && <NodeHealthDot status={selectedNode.status} showLabel />}
</button>
{showNodePicker && (
<div className="dep-dropdown node-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">{t("inline.selectExecutionNode", "Select execution node")}</div>
<button
type="button"
className={`dep-dropdown-item node-picker-item${nodeId === undefined ? " selected" : ""}`}
onClick={() => {
setNodeId(undefined);
setShowNodePicker(false);
}}
>
<span className="dep-dropdown-title">{t("inline.projectDefaultLocal", "Project default / local")}</span>
</button>
{nodes.map((node) => (
<button
key={node.id}
type="button"
className={`dep-dropdown-item node-picker-item${nodeId === node.id ? " selected" : ""}`}
onClick={() => {
setNodeId(node.id);
setShowNodePicker(false);
}}
>
<NodeHealthDot status={node.status} />
<span className="dep-dropdown-title">{node.name}</span>
<span className="node-picker-status-label">{getNodeStatusLabel(node.status, t)}</span>
</button>
))}
</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}` : ` ${t("inline.agent", "Agent")}`}
</button>
{showAgentPicker && (
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">{t("inline.selectAgent", "Select agent")}</div>
{agentsLoading && <div className="dep-dropdown-empty"><LoadingSpinner label={t("inline.loadingAgents", "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);
}}
>
<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">{t("inline.noAgentsAvailable", "No agents available")}</div>
)}
{selectedAgentId && (
<div
className="dep-dropdown-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedAgentId(null);
setShowAgentPicker(false);
}}
>
<span className="dep-dropdown-title">{t("inline.clearSelection", "Clear selection")}</span>
</div>
)}
</div>
)}
</div>
{optionalSteps.length > 0 && (
<div
className="inline-create-optional-steps"
aria-label={t("inline.optionalWorkflowSteps", "Optional workflow steps")}
>
{/* FNXC:TaskCreation 2026-06-21-00:00:
Inline quick-add uses the shared optional-steps dropdown so it matches
the modal/quick-add keyboard + a11y behavior and toggles the same
enabledWorkflowSteps set submitted on create. */}
<WorkflowOptionalStepsDropdown
steps={optionalSteps}
enabledIds={enabledOptionalStepIds}
onToggle={toggleOptionalStep}
triggerTestId="inline-create-optional-steps-trigger"
/>
</div>
)}
<WorkflowSelector
value={selectedWorkflowId}
onChange={(id) => setSelectedWorkflowId(id)}
projectId={projectId}
addToast={addToast}
label="Workflow"
disabled={submitting}
/>
<label className="inline-create-priority-wrap" htmlFor="inline-create-priority-select">
<span className="visually-hidden">{t("inline.priority", "Priority")}</span>
<select
id="inline-create-priority-select"
className="select inline-create-priority-select"
data-testid="inline-create-priority-select"
value={priority}
onChange={(e) => setPriority(e.target.value as TaskPriority)}
>
{TASK_PRIORITIES.map((taskPriority) => (
<option key={taskPriority} value={taskPriority}>
{t("inline.priorityLabel", "Priority: {{level}}", { level: `${taskPriority[0].toUpperCase()}${taskPriority.slice(1)}` })}
</option>
))}
</select>
</label>
<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);
setShowNodePicker(false);
setShowAgentPicker(false);
setIsModelModalOpen(false);
}
return next;
});
}}
aria-expanded={showPresets}
aria-haspopup="listbox"
>
<Zap size={12} style={{ verticalAlign: "middle" }} />
{selectedPreset ? ` ${selectedPreset.name}` : ` ${t("inline.preset", "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);
setPlanningProvider(undefined);
setPlanningModelId(undefined);
setShowPresets(false);
}}
>
{t("inline.useDefault", "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)}
>
{t("inline.custom", "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} ${t("inline.model", "model", { count: selectedModelCount })}`
: selectedModelCount > 0
? ` ${selectedModelCount} ${t("inline.model", "model", { count: selectedModelCount })}`
: ` ${t("inline.models", "Models")}`}
</button>
</div>
</div>
<div className="inline-create-actions">
<span className="inline-create-hint">{t("inline.hintEnterEsc", "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 ? t("inline.creating", "Creating...") : t("inline.save", "Save")}
</button>
</div>
</div>
)}
{typeof document !== "undefined"
? createPortal(
<ModelSelectionModal
isOpen={isModelModalOpen}
onClose={() => setIsModelModalOpen(false)}
models={loadedModels}
executorValue={executorSelectionValue}
validatorValue={validatorSelectionValue}
planningValue={planningSelectionValue}
mergerValue={getModelSelectionValue(mergerProvider, mergerModelId)}
onExecutorChange={handleExecutorChange}
onValidatorChange={handleValidatorChange}
onPlanningChange={handlePlanningModelChange}
onMergerChange={(value) => { const next = parseModelSelection(value); setMergerProvider(next.provider); setMergerModelId(next.modelId); }}
mergerThinkingLevel={mergerThinkingLevel}
onMergerThinkingLevelChange={setMergerThinkingLevel}
validatorThinkingLevel={validatorThinkingLevel}
onValidatorThinkingLevelChange={setValidatorThinkingLevel}
planningThinkingLevel={planningThinkingLevel}
onPlanningThinkingLevelChange={setPlanningThinkingLevel}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={handleThinkingLevelChange}
defaultThinkingLevel={settings?.defaultThinkingLevel}
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}
{duplicateMatches && duplicateMatches.length > 0 ? (
<DuplicateWarningModal
matches={duplicateMatches}
onProceed={handleDuplicateProceed}
onCancel={handleDuplicateCancel}
onOpen={handleDuplicateOpen}
/>
) : null}
</div>
);
}