Files
fusion/packages/dashboard/app/components/QuickEntryBox.tsx
Fusion Agent 612195fde6 FN-022: add workflow selection and start action
Add workflow-aware task creation controls and a guarded Start action for quick task intake.

- Add workflow selection and resolved board metadata loading to New Task modal.
- Support starting eligible tasks in their workflow destination with clear success or partial-failure feedback.
- Wire move-task handling through modal callers and cover workflow/start behavior with tests and documentation.

Files changed:
 .changeset/fn-022-new-task-workflow-start.md       |   7 +
 docs/dashboard-guide.md                            |   4 +-
 packages/dashboard/app/components/AppModals.tsx    |   1 +
 packages/dashboard/app/components/NewTaskModal.tsx | 158 +++++++++++++++--
 .../dashboard/app/components/QuickEntryBox.tsx     |   5 +-
 packages/dashboard/app/components/TaskForm.tsx     |  28 ++-
 .../app/components/__tests__/AppModals.test.tsx    |  33 +++-
 .../app/components/__tests__/NewTaskModal.test.tsx | 192 ++++++++++++++++++++-
 .../app/utils/__tests__/quickAddStart.test.ts      |  16 +-
 packages/dashboard/app/utils/quickAddStart.ts      |  15 ++
 10 files changed, 441 insertions(+), 18 deletions(-)

Fusion-Task-Id: FN-022

Fusion-Task-Lineage: ce794b44-3c31-4bfc-a259-ecfd78531be1

Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-20 03:55:38 +00:00

2633 lines
117 KiB
TypeScript

import "./QuickEntryBox.css";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
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, ResolvedWorkflowOptionalStep, ThinkingLevel, ColumnId } from "@fusion/core";
import type { ModelInfo, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition, NodeInfo } from "../api";
import { checkDuplicateTasks, fetchModels, fetchSettings, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api";
import { DuplicateWarningModal } from "./DuplicateWarningModal";
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Zap, Eye, EyeOff, Play } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { LoadingSpinner } from "./LoadingSpinner";
import { getScopedItem, MAX_PERSISTED_DRAFT_BYTES, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { useNodes } from "../hooks/useNodes";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { MicButton } from "./MicButton";
import { NodeHealthDot } from "./NodeHealthDot";
import { ProviderIcon } from "./ProviderIcon";
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
import { WorkflowIcon } from "./WorkflowIcon";
import { PendingAttachmentPreviews } from "./PendingAttachmentPreviews";
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
import { validateQuickAddStartWorkflow, workflowSupportsQuickAddStart, resolveQuickAddStartInitialColumn, resolveQuickAddStartWorkflowTarget, resolveQuickAddStartTargetColumn, type ValidatedQuickAddWorkflow } from "../utils/quickAddStart";
import { computeFixedMenuPosition, getLayoutViewportSize } from "../utils/fixedMenuPosition";
import { isInsidePortaledModelMenu } from "../utils/portalSurfaces";
import { useQuickAddSubmitOnEnter } from "../hooks/useQuickAddSubmitOnEnter";
const STORAGE_KEY = "kb-quick-entry-text";
const ALLOWED_TASK_ATTACHMENT_TYPES = new Set([
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"video/mp4",
"video/webm",
"video/quicktime",
"text/plain",
"text/markdown",
"application/json",
"text/yaml",
"text/x-toml",
"text/csv",
"application/xml",
]);
const TASK_ATTACHMENT_ACCEPT = "image/png,image/jpeg,image/gif,image/webp,video/mp4,video/webm,video/quicktime,.txt,.md,.json,.yaml,.yml,.toml,.csv,.xml";
interface PendingAttachment {
file: File;
previewUrl?: string;
}
interface QuickEntryBoxProps {
onCreate?: (input: CreateTaskInput) => Promise<Task | void>;
/** Host-owned state-updating move path; Quick Add never calls the raw API. */
onMoveTask?: (taskId: string, column: ColumnId) => Promise<unknown>;
addToast: (message: string, type?: ToastType) => void;
tasks?: Task[];
availableModels?: ModelInfo[];
/**
* Preserved for callers that still pass planning handoff props through shared quick-create plumbing.
* QuickEntryBox intentionally does not render a quick-add Plan button.
*/
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;
/** Selected workflow lane for AI-assisted create actions. Omit in legacy board mode to preserve project-default inheritance. */
workflowId?: string | null;
/** Real workflows available to the quick-add workflow selector. Board-only aggregate sentinels must be filtered before rendering/submission. */
workflowOptions?: BoardWorkflowDefinition[];
/** Project/default workflow id used when the parent view is aggregate or stale. */
defaultWorkflowId?: string | null;
/** 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;
/*
FNXC:QuickEntry 2026-06-22-01:10:
Initial disclosure (expanded controls) state. List view passes false so quick-add starts COLLAPSED; Board/columns keep the default true so quick-add stays OPEN. This is independent of autoExpand (which only governs expand-on-focus).
*/
defaultExpanded?: boolean;
/*
FNXC:QuickEntry 2026-06-22-19:25:
List view renders quick-add as a COMPACT single-line input so the box isn't tall. When true, the textarea stays one line: isExpanded initializes false, focus does NOT auto-expand it, and auto-resize-to-scrollHeight is short-circuited (capped to the one-line min-height). Board/columns omit singleLine, preserving the tall 80px + auto-grow behavior. singleLine governs only textarea height, not the disclosure/controls panel (which List already collapses via defaultExpanded={false}).
*/
singleLine?: boolean;
/** Explicit override of the global Enter-submit preference. */
submitOnEnter?: 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 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 getRealWorkflowOptions(workflowOptions: BoardWorkflowDefinition[] | undefined): BoardWorkflowDefinition[] {
return (workflowOptions ?? []).filter((workflow) => workflow.id !== "__all_workflows__");
}
function resolveQuickAddWorkflowId(
parentWorkflowId: string | null | undefined,
defaultWorkflowId: string | null | undefined,
workflowOptions: BoardWorkflowDefinition[],
): string | null | undefined {
if (parentWorkflowId === undefined) return undefined;
if (parentWorkflowId === null) return null;
if (workflowOptions.length === 0) return parentWorkflowId === "__all_workflows__" ? null : parentWorkflowId;
const validIds = new Set(workflowOptions.map((workflow) => workflow.id));
if (validIds.has(parentWorkflowId)) return parentWorkflowId;
if (defaultWorkflowId && validIds.has(defaultWorkflowId)) return defaultWorkflowId;
return workflowOptions[0]?.id ?? null;
}
function hasMeaningfulNodeChoice(nodes: NodeInfo[]): boolean {
/*
FNXC:QuickAddNodeRouting 2026-06-30-00:00:
Local-only projects 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 QuickEntryBox({ onCreate, onMoveTask, addToast, tasks = [], availableModels, onSubtaskBreakdown, workflowId, workflowOptions, defaultWorkflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, submitOnEnter, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
const { t } = useTranslation("app");
const contextSubmitOnEnter = useQuickAddSubmitOnEnter();
const enterSubmits = submitOnEnter ?? contextSubmitOnEnter;
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)
// FNXC:QuickEntry 2026-06-22-19:25: singleLine (List view) starts collapsed so the textarea is one line, not the tall 80px variant.
const [isExpanded, setIsExpanded] = useState(!singleLine);
// isDisclosureExpanded controls visibility of the controls panel (Deps, Models, etc.)
// Starts expanded by default — controls visible immediately
const [isDisclosureExpanded, setIsDisclosureExpanded] = useState(defaultExpanded);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dictation = useComposerDictation({ textareaRef, value: description, onChange: setDescription, projectId });
const fileInputRef = useRef<HTMLInputElement>(null);
const touchButtonRef = useRef<HTMLButtonElement | null>(null);
const startIntentRef = useRef<ValidatedQuickAddWorkflow | null>(null);
const justResetRef = useRef(false);
const draftPersistenceWarningShownRef = useRef(false);
const previousProjectIdRef = useRef(projectId);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
const [isFileDragOver, setIsFileDragOver] = useState(false);
const dragDepthRef = useRef(0);
// 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" | "merger" | null>(null);
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
const [credentialInstanceId, setCredentialInstanceId] = useState<string | undefined>(undefined);
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined);
const [validatorCredentialInstanceId, setValidatorCredentialInstanceId] = useState<string | undefined>(undefined);
const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined);
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
const [planningCredentialInstanceId, setPlanningCredentialInstanceId] = useState<string | undefined>(undefined);
const [mergerProvider, setMergerProvider] = useState<string | undefined>(undefined);
const [mergerModelId, setMergerModelId] = useState<string | undefined>(undefined);
const [mergerCredentialInstanceId, setMergerCredentialInstanceId] = useState<string | undefined>(undefined);
/* FNXC:Settings-ThinkingLevel 2026-07-09-00:00: inline quick-entry bar 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>("");
/* FNXC:QuickAddModels 2026-07-16-12:00: Quick Add reuses CustomModelDropdown for merger and each lane's independent thinking override. */
const modelTriggerRef = useRef<HTMLButtonElement>(null);
const modelMenuPortalRef = useRef<HTMLDivElement>(null);
const agentPickerRef = useRef<HTMLDivElement>(null);
const agentPickerPortalRef = useRef<HTMLDivElement>(null);
/** Bumps on open/close so a late fetchAgents resolution cannot re-open a dismissed picker. */
const agentPickerOpenTokenRef = useRef(0);
const nodePickerRef = useRef<HTMLDivElement>(null);
const nodePickerPortalRef = useRef<HTMLDivElement>(null);
const priorityPickerRef = useRef<HTMLDivElement>(null);
const priorityPickerPortalRef = useRef<HTMLDivElement>(null);
/*
FNXC:QuickAddMenuAnchor 2026-08-01-07:11:
Preserve both shared-helper vertical anchors in Quick Add state. Portal styles must use `bottom`
with `top: auto` upward, because a natural-height menu cannot remain attached when top is derived
from its independent max-height scroll cap.
*/
const [agentPickerPosition, setAgentPickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null);
const [nodePickerPosition, setNodePickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null);
const [priorityPickerPosition, setPriorityPickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null);
const [modelMenuPosition, setModelMenuPosition] = useState<{ top: number | null; bottom: number | null; 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 | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null);
const [portalRoot] = useState<HTMLElement | null>(() =>
typeof document !== "undefined" ? document.body : null,
);
const realWorkflowOptions = useMemo(() => getRealWorkflowOptions(workflowOptions), [workflowOptions]);
const workflowPickerRef = useRef<HTMLDivElement>(null);
const workflowTriggerRef = useRef<HTMLButtonElement>(null);
const workflowPickerPortalRef = useRef<HTMLDivElement>(null);
const [workflowPickerPosition, setWorkflowPickerPosition] = useState<{ top: number | null; bottom: number | null; left: number; width: number; maxHeight?: number } | null>(null);
const previousWorkflowDefaultRef = useRef<{ workflowId: string | null | undefined; defaultWorkflowId: string | null | undefined }>({ workflowId, defaultWorkflowId });
const [showWorkflowPicker, setShowWorkflowPicker] = useState(false);
/*
FNXC:QuickAddWorkflow 2026-06-30-00:00:
Quick-add needs an independent real workflow target because the main Board selector can be on the aggregate "All workflows" read view. Resolve only against real workflow ids and keep the aggregate sentinel out of task create, Plan, Subtask, and optional-step requests.
*/
const [quickEntryWorkflowId, setQuickEntryWorkflowId] = useState<string | null | undefined>(() => (
resolveQuickAddWorkflowId(workflowId, defaultWorkflowId, getRealWorkflowOptions(workflowOptions))
));
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 [optionalSteps, setOptionalSteps] = useState<ResolvedWorkflowOptionalStep[]>([]);
const [enabledOptionalStepIds, setEnabledOptionalStepIds] = useState<string[]>([]);
const [isFastMode, setIsFastMode] = useState(false);
const isFastModeRef = useRef(isFastMode);
useEffect(() => {
isFastModeRef.current = isFastMode;
}, [isFastMode]);
const [githubTrackingOverride, setGithubTrackingOverride] = useState<boolean | null>(null);
// FNXC:PlannerOversight 2026-07-14-18:11: null = follow project sessionAdvisorEnabledByDefault.
const [sessionAdvisorOverride, setSessionAdvisorOverride] = 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();
const shouldShowNodePicker = useMemo(() => hasMeaningfulNodeChoice(nodes), [nodes]);
const selectedNode = shouldShowNodePicker && nodeId ? nodes.find((node) => node.id === nodeId) : undefined;
const effectiveNodeId = shouldShowNodePicker && selectedNode ? selectedNode.id : undefined;
useEffect(() => {
if (shouldShowNodePicker && (!nodeId || selectedNode)) {
return;
}
setNodeId(undefined);
setShowNodePicker(false);
setNodePickerPosition(null);
}, [nodeId, selectedNode, shouldShowNodePicker]);
// 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) || t("tasks.loadModelsFailed", "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]);
/*
FNXC:WorkflowOptionalSteps 2026-06-25-00:00:
Quick-add creation needs the active workflow's optional steps in the immediate action row, seeded from each step's `defaultOn`, and forwarded through `enabledWorkflowSteps`. `null` workflow is an explicit no-workflow opt-out, while `undefined` inherits the project default so Board and List quick-add match TaskForm's create-time resolution.
FNXC:WorkflowOptionalSteps 2026-06-26-05:10:
Resolution MUST mirror the executor/store (explicit workflowId → project default → `builtin:coding`). The earlier `?? null` tail hid `builtin:coding`'s optional steps (browser-verification, code-review) whenever no project default workflow was configured, so operators never saw the toggles even though the unselected task runs `builtin:coding` (FN-7039). Fall back to `builtin:coding` once settings have loaded; stay `null` while settings are still loading so we don't fetch the wrong workflow then refetch.
*/
const selectedQuickEntryWorkflow = typeof quickEntryWorkflowId === "string"
? realWorkflowOptions.find((option) => option.id === quickEntryWorkflowId)
: undefined;
const quickEntryWorkflowNameCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const option of realWorkflowOptions) {
counts.set(option.name, (counts.get(option.name) ?? 0) + 1);
}
return counts;
}, [realWorkflowOptions]);
const showWorkflowSelector = workflowId !== undefined && realWorkflowOptions.length >= 2 && quickEntryWorkflowId !== null;
const quickEntryWorkflowLabel = selectedQuickEntryWorkflow?.name ?? t("tasks.workflow", "Workflow");
const selectedQuickEntryWorkflowIcon = selectedQuickEntryWorkflow?.icon;
const selectedWorkflowForCreate = workflowId === undefined ? undefined : quickEntryWorkflowId;
const validatedStartWorkflow = useMemo(() => validateQuickAddStartWorkflow(selectedQuickEntryWorkflow), [selectedQuickEntryWorkflow]);
const startInitialColumn = validatedStartWorkflow ? resolveQuickAddStartInitialColumn(validatedStartWorkflow) : null;
const startWorkflowTarget = validatedStartWorkflow ? resolveQuickAddStartWorkflowTarget(validatedStartWorkflow) : null;
/*
FNXC:QuickAddStart 2026-07-31-23:51:
Start is a VISIBLE button in the quick-add action row for eligible workflows only, replacing the hidden
long-press/right-click Save menu that operators could not discover. Eligibility is `workflowSupportsQuickAddStart`:
Coding (Ideas), or any workflow whose first visible lane is a server-derived manual-intake/"waiting" column.
A provable target is still required (`startInitialColumn` for the create-time column override, or `onMoveTask`
for the follow-up move). Workflows without a waiting lane render no Start button at all — Save stays the single
create affordance there.
*/
const canQuickAddStart = Boolean(validatedStartWorkflow && workflowSupportsQuickAddStart(validatedStartWorkflow) && startWorkflowTarget && (startInitialColumn || onMoveTask));
const canQuickAddStartNow = canQuickAddStart && Boolean(description.trim()) && !isSubmitting;
useEffect(() => {
const parentChanged = previousWorkflowDefaultRef.current.workflowId !== workflowId
|| previousWorkflowDefaultRef.current.defaultWorkflowId !== defaultWorkflowId;
previousWorkflowDefaultRef.current = { workflowId, defaultWorkflowId };
setQuickEntryWorkflowId((current) => {
const resolved = resolveQuickAddWorkflowId(workflowId, defaultWorkflowId, realWorkflowOptions);
if (parentChanged) return resolved;
if (typeof current === "string" && realWorkflowOptions.some((option) => option.id === current)) {
return current;
}
return resolved;
});
}, [defaultWorkflowId, realWorkflowOptions, workflowId]);
const effectiveOptionalWorkflowId =
selectedWorkflowForCreate === null
? null
: (selectedWorkflowForCreate ?? settings?.defaultWorkflowId ?? (settings ? "builtin:coding" : null));
useEffect(() => {
let cancelled = false;
setOptionalSteps([]);
setEnabledOptionalStepIds([]);
if (!effectiveOptionalWorkflowId) {
return () => {
cancelled = true;
};
}
fetchWorkflowOptionalSteps(effectiveOptionalWorkflowId, projectId)
.then((steps) => {
if (cancelled) return;
setOptionalSteps(steps);
/*
FNXC:FastOptionalSteps 2026-06-30-10:24:
Optional-step metadata can resolve after the operator has already selected Fast. Seed `[]` in that race so async defaultOn loading cannot undo Fast's speed-first opt-out; later dropdown clicks still add explicit selections normally.
*/
setEnabledOptionalStepIds(isFastModeRef.current ? [] : steps.filter((step) => step.defaultOn).map((step) => step.templateId));
})
.catch(() => {
if (cancelled) return;
setOptionalSteps([]);
setEnabledOptionalStepIds([]);
});
return () => {
cancelled = true;
};
}, [effectiveOptionalWorkflowId, projectId]);
const toggleOptionalStep = useCallback((templateId: string) => {
setEnabledOptionalStepIds((prev) => (
prev.includes(templateId)
? prev.filter((id) => id !== templateId)
: [...prev, templateId]
));
}, []);
/*
FNXC:FastOptionalSteps 2026-06-30-09:05:
Fast task creation is speed-first: switching standard → fast clears currently enabled optional workflow steps. The dropdown remains enabled so the operator can manually opt Browser Verification, Plan Review, Code Review, or a custom optional group back in before create.
FNXC:FastOptionalSteps 2026-06-30-10:41:
A Fast create must submit explicit `[]` even if optional-step metadata has not loaded yet; otherwise the store/engine see `enabledWorkflowSteps` as omitted and re-seed default-on gates.
*/
const toggleFastMode = useCallback(() => {
setIsFastMode((prev) => {
const next = !prev;
if (next) setEnabledOptionalStepIds([]);
return next;
});
}, []);
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
const planningSelectionValue = getModelSelectionValue(planningProvider, planningModelId);
const mergerSelectionValue = getModelSelectionValue(mergerProvider, mergerModelId);
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 modelMenuLabel = selectedPresetId
? settings?.modelPresets?.find((p) => p.id === selectedPresetId)?.name ?? t("tasks.models", "Models")
: selectedModelCount > 0
? t("tasks.modelsCount", { count: selectedModelCount, defaultValue_one: "{{count}} model", defaultValue_other: "{{count}} models" })
: t("tasks.models", "Models");
const getModelBadgeLabel = useCallback(
(provider?: string, modelId?: string) => {
if (!provider || !modelId) return t("tasks.usingDefault", "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]);
/*
FNXC:QuickAddDraftPersistence 2026-08-20-00:43:
Quick Add's in-memory draft remains authoritative for task creation. Browser restoration is best-effort and capped so a pasted description cannot exhaust localStorage or interrupt typing (Runfusion/Fusion#3477).
*/
useEffect(() => {
if (description.length === 0) {
removeScopedItem(STORAGE_KEY, projectId);
return;
}
const persisted = setScopedItem(STORAGE_KEY, description, projectId, {
maxBytes: MAX_PERSISTED_DRAFT_BYTES,
});
if (persisted) {
draftPersistenceWarningShownRef.current = false;
} else if (!draftPersistenceWarningShownRef.current) {
draftPersistenceWarningShownRef.current = true;
addToast(t("tasks.draftTooLargeToSave", "Draft is too large to save in this browser — it will not be restored after a reload."), "warning");
}
}, [addToast, description, projectId, t]);
// 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);
/*
FNXC:QuickAddAttachments 2026-07-23-00:00:
Pending files belong to the project where they were selected. Clear them on a project switch so
an attachment cannot be uploaded into the next project's newly created task, and release image URLs.
*/
setPendingAttachments((pending) => {
for (const attachment of pending) {
if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
}
return [];
});
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(() => {
pendingAttachmentsRef.current = pendingAttachments;
}, [pendingAttachments]);
// Clean up image preview URLs on unmount
useEffect(() => {
return () => {
pendingAttachmentsRef.current.forEach((attachment) => { if (attachment.previewUrl) URL.revokeObjectURL(attachment.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)
// FNXC:QuickEntry 2026-06-22-19:25: singleLine (List view) must stay one line — skip auto-resize-to-scrollHeight so the textarea never grows tall with content; CSS clamps it to the one-line height.
useEffect(() => {
if (isExpanded && !singleLine) {
autoResize();
}
}, [description, isExpanded, autoResize, singleLine]);
/*
FNXC:QuickEntryFocus 2026-06-25-00:00:
After a successful task creation, the quick-entry textarea must not re-focus itself on any surface, desktop or mobile. The user explicitly does not want focus to return to the input after adding a task, superseding FN-6217/FN-6219; keep clearing the form on submit.
*/
// Clear dep search when dropdown closes
useEffect(() => {
if (!showDeps) setDepSearch("");
}, [showDeps]);
// 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 = isInsidePortaledModelMenu(target);
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;
/*
FNXC:QuickEntry 2026-07-18-08:45:
Listen in the capture phase so outside mousedown closes the agent portal even
when a nested control calls preventDefault/stopPropagation on bubble. Full-
suite + local tests observed the bubble-only listener leaving "Select agent"
mounted after a true outside click.
*/
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;
agentPickerOpenTokenRef.current += 1;
setShowAgentPicker(false);
setAgentPickerPosition(null);
};
document.addEventListener("mousedown", handleClickOutside, true);
return () => document.removeEventListener("mousedown", handleClickOutside, true);
}, [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]);
useEffect(() => {
if (!showWorkflowPicker) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node;
if (workflowPickerRef.current?.contains(target)) return;
if (workflowPickerPortalRef.current?.contains(target)) return;
setShowWorkflowPicker(false);
setWorkflowPickerPosition(null);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showWorkflowPicker]);
const resetForm = useCallback(() => {
pendingAttachments.forEach((attachment) => { if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl); });
setPendingAttachments([]);
dragDepthRef.current = 0;
setIsFileDragOver(false);
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);
setCredentialInstanceId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setValidatorCredentialInstanceId(undefined);
setPlanningProvider(undefined);
setPlanningModelId(undefined);
setPlanningCredentialInstanceId(undefined);
setMergerProvider(undefined);
setMergerModelId(undefined);
setMergerCredentialInstanceId(undefined);
setThinkingLevel("");
setValidatorThinkingLevel("");
setPlanningThinkingLevel("");
setMergerThinkingLevel("");
setSelectedPresetId(undefined);
setEnabledOptionalStepIds(optionalSteps.filter((step) => step.defaultOn).map((step) => step.templateId));
setIsFastMode(false);
setGithubTrackingOverride(null);
setSessionAdvisorOverride(null);
setPriority(DEFAULT_TASK_PRIORITY);
setNodeId(undefined);
setShowDeps(false);
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
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);
}
}, [pendingAttachments, projectId, optionalSteps]);
const handleAttachmentFiles = useCallback((files: FileList | File[] | null | undefined) => {
if (!files || files.length === 0) return;
const newAttachments: PendingAttachment[] = [];
for (const file of Array.from(files)) {
if (!ALLOWED_TASK_ATTACHMENT_TYPES.has(file.type)) continue;
newAttachments.push({
file,
previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
});
}
if (newAttachments.length > 0) {
setPendingAttachments((prev) => [...prev, ...newAttachments]);
}
}, []);
const isFileDrag = useCallback((dataTransfer: DataTransfer | null) => {
if (!dataTransfer) return false;
return Array.from(dataTransfer.types ?? []).includes("Files");
}, []);
/*
FNXC:QuickAddAttachments 2026-07-23-00:00:
Quick Add must accept exactly the task-store attachment MIME set through picker, paste, and drop.
Only images receive object URLs and preview controls; all other accepted files remain uploadable file chips.
*/
const attachLabel = pendingAttachments.length > 0
? t("tasks.attachFilesCount", "Attach photos or files ({{count}} pending)", { count: pendingAttachments.length })
: t("tasks.attachFiles", "Attach photos or files");
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
dragDepthRef.current += 1;
setIsFileDragOver(true);
}, [isFileDrag]);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsFileDragOver(true);
}, [isFileDrag]);
const clearFileDragState = useCallback(() => {
dragDepthRef.current = 0;
setIsFileDragOver(false);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsFileDragOver(false);
}
}, [isFileDrag]);
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
if (!isFileDrag(e.dataTransfer)) return;
e.preventDefault();
clearFileDragState();
if (isSubmitting) return;
handleAttachmentFiles(e.dataTransfer.files);
}, [clearFileDragState, handleAttachmentFiles, isFileDrag, isSubmitting]);
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (isSubmitting) return;
handleAttachmentFiles(e.clipboardData?.files);
}, [handleAttachmentFiles, isSubmitting]);
const removeAttachment = useCallback((index: number) => {
setPendingAttachments((prev) => {
const removed = prev[index];
if (removed) {
if (removed.previewUrl) 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;
const startWorkflow = startIntentRef.current;
const startInitialColumn = startWorkflow ? resolveQuickAddStartInitialColumn(startWorkflow) : null;
setDescription("");
try {
/*
FNXC:CodingIdeasWorkflow 2026-07-05-00:00:
Do not hard-code `column: "triage"` — the store resolves the landing column from the forwarded (or project-default) workflow's intake column, so a manual-intake workflow (e.g. Coding (Ideas) → "ideas") parks the card for the operator instead of being auto-triaged.
*/
const createdTask = await onCreate({
description: trimmed,
...(startWorkflow ? { workflowId: startWorkflow.id } : selectedWorkflowForCreate !== undefined ? { workflowId: selectedWorkflowForCreate } : {}),
...(startInitialColumn ? { column: startInitialColumn as ColumnId } : {}),
dependencies: dependencies.length ? dependencies : undefined,
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
modelPresetId: selectedPresetId,
modelProvider: hasExecutorOverride ? executorProvider : undefined,
modelId: hasExecutorOverride ? executorModelId : undefined,
...(hasExecutorOverride && credentialInstanceId ? { credentialInstanceId } : {}),
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
...(hasValidatorOverride && validatorCredentialInstanceId ? { validatorCredentialInstanceId } : {}),
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
planningModelId: hasPlanningOverride ? planningModelId : undefined,
...(hasPlanningOverride && planningCredentialInstanceId ? { planningCredentialInstanceId } : {}),
mergerModelProvider: hasMergerOverride ? mergerProvider : undefined,
mergerModelId: hasMergerOverride ? mergerModelId : undefined,
...(hasMergerOverride && mergerCredentialInstanceId ? { mergerCredentialInstanceId } : {}),
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:QuickAddWorkflowSteps 2026-06-29-01:31:
Quick Add optional-step toggles are explicit task intent. When the workflow exposes optional steps and the user unchecks every one, submit an empty array instead of omitting the field so default-on Plan Review / Code Review do not reappear on the created task.
*/
enabledWorkflowSteps: isFastMode || optionalSteps.length > 0 ? enabledOptionalStepIds : undefined,
...(isFastMode ? { executionMode: "fast" } : {}),
githubTracking: githubTrackingOverride !== null ? { enabled: githubTrackingOverride } : undefined,
// FNXC:PlannerOversight 2026-07-14-18:11: only send when user toggled away from project default.
sessionAdvisorEnabled: sessionAdvisorOverride !== null ? sessionAdvisorOverride : undefined,
priority,
nodeId: effectiveNodeId,
acknowledgedDuplicates: overrides?.acknowledgedDuplicates,
});
/*
FNXC:QuickAddStart 2026-07-22-17:45:
Coding (Ideas) Start submits its validated Todo destination in the original create request.
Custom hold workflows retain their returned-task promotion path; unprovable targets stay
create-only, so Save/Enter and malformed metadata never guess a transition.
*/
if (startWorkflow && !startInitialColumn && createdTask && typeof createdTask === "object"
&& typeof createdTask.id === "string" && createdTask.id.trim()
&& typeof createdTask.column === "string" && createdTask.column.trim()
&& typeof (createdTask as Task & { workflowId?: unknown }).workflowId === "string"
&& (createdTask as Task & { workflowId?: string }).workflowId === startWorkflow.id) {
const target = resolveQuickAddStartTargetColumn(startWorkflow, createdTask.column);
if (target && onMoveTask) {
try {
await onMoveTask(createdTask.id, target as ColumnId);
/*
FNXC:CodingIdeasWorkflow 2026-07-25-12:05:
Honest copy: this path performs a column move, not a plan dispatch. The old "Started
planning" claim was optimistic — planning begins when the engine admits the card, which
a busy maxConcurrent pool can defer indefinitely. Same wording as TaskCard's Start.
*/
addToast(t("tasks.queuedForPlanning", "Queued {{taskId}} for planning", { taskId: createdTask.id }), "success");
} catch (moveError) {
addToast(getErrorMessage(moveError) || t("tasks.createFailed", "Failed to create task"), "error");
}
}
}
if (createdTask && typeof createdTask.id === "string" && createdTask.id.trim() && pendingAttachments.length > 0) {
const failures: string[] = [];
for (const pendingAttachment of pendingAttachments) {
try {
await uploadAttachment(createdTask.id, pendingAttachment.file, projectId);
} catch {
failures.push(pendingAttachment.file.name);
}
}
if (failures.length > 0) {
addToast(t("tasks.uploadFailed", "Failed to upload: {{files}}", { files: failures.join(", ") }), "error");
}
}
resetForm();
} catch (err) {
setDescription(originalDescription);
addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error");
} finally {
startIntentRef.current = null;
submitInFlightRef.current = false;
setIsSubmitting(false);
}
}, [
onCreate,
onMoveTask,
description,
dependencies,
selectedWorkflowForCreate,
selectedAgentId,
selectedPresetId,
hasExecutorOverride,
executorProvider,
executorModelId,
credentialInstanceId,
hasValidatorOverride,
validatorProvider,
validatorModelId,
validatorCredentialInstanceId,
hasPlanningOverride,
planningProvider,
planningModelId,
planningCredentialInstanceId,
mergerCredentialInstanceId,
thinkingLevel,
enabledOptionalStepIds,
isFastMode,
settings,
githubTrackingOverride,
sessionAdvisorOverride,
priority,
effectiveNodeId,
pendingAttachments,
projectId,
addToast,
resetForm,
t,
]);
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(t("tasks.duplicateCheckFailed", "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(() => {
/*
FNXC:QuickAddStart 2026-07-22-16:10:
Cancelling duplicate confirmation discards the saved Start intent. A later ordinary Save
must remain create-only rather than reusing a promotion snapshot from the cancelled action.
*/
startIntentRef.current = null;
setDuplicateMatches(null);
submitInFlightRef.current = false;
setIsSubmitting(false);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter") {
if (e.shiftKey) {
// Allow Shift+Enter to insert a newline in any quick-entry state
// Don't prevent default - let the newline be inserted
// FNXC:QuickEntry 2026-06-22-19:25: singleLine (List view) stays one line even on Shift+Enter — do not expand the textarea.
if (!singleLine) {
setIsExpanded(true);
}
return;
}
if (duplicateMatches || submitInFlightRef.current) {
e.preventDefault();
return;
}
/*
FNXC:QuickEntry 2026-08-16-03:15:
The global Quick Add option defaults on to preserve historical Enter submission. When disabled, plain Enter remains a browser newline while Cmd/Ctrl+Enter stays the explicit save accelerator.
*/
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
handleSubmit();
return;
}
if (!enterSubmits) {
return;
}
e.preventDefault();
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 (showNodePicker) {
setShowNodePicker(false);
setNodePickerPosition(null);
return;
}
if (showPriorityPicker) {
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
return;
}
if (showWorkflowPicker) {
setShowWorkflowPicker(false);
setWorkflowPickerPosition(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,
showPriorityPicker,
showWorkflowPicker,
projectId,
setIsDisclosureExpanded,
duplicateMatches,
enterSubmits,
singleLine,
],
);
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)
// FNXC:QuickEntry 2026-06-22-19:25: never auto-expand the textarea on focus when singleLine (List view) — it must stay one line.
if (autoExpand && !singleLine) {
setIsExpanded(true);
}
}, [autoExpand, singleLine]);
const toggleDep = useCallback((id: string) => {
setDependencies((prev) =>
prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id],
);
}, []);
/*
FNXC:QuickAddDepsMenu 2026-07-25-12:00:
All Quick Add portaled menus (Deps, Models, workflow, agent, node, priority) share anchor-first
layout-viewport positioning. Mixing visualViewport offsets with getBoundingClientRect, or deriving
upward `top` from a height cap, made short menus float too high; upward portals consume `bottom`
and `top: auto` so their rendered bottom remains attached regardless of content height.
*/
const updateModelMenuPosition = useCallback(() => {
const trigger = modelTriggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize();
const horizontalPadding = 16;
const isMobile = viewportWidth <= 768;
const preferredHeight = isMobile
? Math.min(viewportHeight * 0.6, 360)
: Math.min(viewportHeight * 0.5, 360);
const preferredWidth = isMobile
? Math.min(viewportWidth - horizontalPadding * 2, 360)
: Math.max(rect.width * 1.35, 320);
const position = computeFixedMenuPosition({
triggerRect: rect,
viewportWidth,
viewportHeight,
preferredWidth,
preferredHeight,
minWidth: 240,
horizontalPadding,
});
setModelMenuPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
});
}, []);
const updateWorkflowPickerPosition = useCallback(() => {
const trigger = workflowTriggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize();
const horizontalPadding = 16;
const isMobile = viewportWidth <= 768;
const preferredHeight = Math.min(viewportHeight * (isMobile ? 0.6 : 0.5), 360);
/*
FNXC:QuickAddWorkflow 2026-06-30-16:16:
The workflow menu is wider than its compact trigger, so portal and clamp it against the layout viewport instead of anchoring it to the trigger's inline start. This keeps right-side Board columns and wrapped mobile action rows readable without horizontal overflow.
FNXC:QuickAddDepsMenu 2026-07-25-12:00:
Width still expands past the compact trigger; vertical placement uses the shared anchor-first helper so the menu stays attached like Deps/Models.
*/
const preferredWidth = isMobile
? Math.min(viewportWidth - horizontalPadding * 2, 448)
: Math.min(Math.max(rect.width * 3, 448), 512);
const position = computeFixedMenuPosition({
triggerRect: rect,
viewportWidth,
viewportHeight,
preferredWidth,
preferredHeight,
minWidth: 240,
horizontalPadding,
});
setWorkflowPickerPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
});
}, []);
const updateDepDropdownPosition = useCallback(() => {
const trigger = depTriggerRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize();
const horizontalPadding = 16;
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 position = computeFixedMenuPosition({
triggerRect: rect,
viewportWidth,
viewportHeight,
preferredWidth,
preferredHeight,
minWidth: 240,
horizontalPadding,
});
setDepDropdownPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
});
}, []);
const updateAgentPickerPosition = useCallback(() => {
const trigger = agentPickerRef.current?.querySelector("button") as HTMLButtonElement | null;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize();
const horizontalPadding = 16;
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 position = computeFixedMenuPosition({
triggerRect: rect,
viewportWidth,
viewportHeight,
preferredWidth,
preferredHeight,
minWidth: 200,
horizontalPadding,
});
setAgentPickerPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
});
}, []);
const updateNodePickerPosition = useCallback(() => {
const trigger = nodePickerRef.current?.querySelector("button") as HTMLButtonElement | null;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize();
const horizontalPadding = 16;
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 position = computeFixedMenuPosition({
triggerRect: rect,
viewportWidth,
viewportHeight,
preferredWidth,
preferredHeight,
minWidth: 200,
horizontalPadding,
});
setNodePickerPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
});
}, []);
const updatePriorityPickerPosition = useCallback(() => {
const trigger = priorityPickerRef.current?.querySelector("button") as HTMLButtonElement | null;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const { width: viewportWidth, height: viewportHeight } = getLayoutViewportSize();
const position = computeFixedMenuPosition({
triggerRect: rect,
viewportWidth,
viewportHeight,
preferredWidth: Math.max(rect.width, 200),
preferredHeight: 220,
minWidth: 200,
});
setPriorityPickerPosition({
top: position.top,
bottom: position.bottom,
left: position.left,
width: position.width,
maxHeight: position.maxHeight,
});
}, []);
// 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 workflow picker portal anchored during scroll/resize
useEffect(() => {
if (!showWorkflowPicker) return;
const handleReposition = () => updateWorkflowPickerPosition();
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);
}
};
}, [showWorkflowPicker, updateWorkflowPickerPosition]);
// 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);
setPlanningCredentialInstanceId(undefined);
setPlanningProvider(next.provider);
setPlanningModelId(next.modelId);
}, []);
const handleExecutorChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setCredentialInstanceId(undefined);
setExecutorProvider(next.provider);
setExecutorModelId(next.modelId);
}, []);
const handleValidatorChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setValidatorCredentialInstanceId(undefined);
setValidatorProvider(next.provider);
setValidatorModelId(next.modelId);
}, []);
const handleThinkingLevelChange = useCallback((value: string) => setThinkingLevel(value), []);
const handleMergerModelChange = useCallback((value: string) => {
const next = parseModelSelection(value);
setMergerCredentialInstanceId(undefined);
setMergerProvider(next.provider);
setMergerModelId(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]);
/*
FNXC:QuickEntry 2026-06-30-00:00:
Quick-add intentionally exposes no Plan button, disabled Plan state, tooltip, test id, or click target. Keep non-quick-add planning entry points such as the New Task dialog and model-menu planning lane intact.
*/
const handleSubtaskClick = useCallback(() => {
const trimmed = description.trim();
if (!trimmed) {
addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error");
return;
}
if (selectedWorkflowForCreate !== undefined) {
onSubtaskBreakdown?.(trimmed, selectedWorkflowForCreate);
} else {
onSubtaskBreakdown?.(trimmed);
}
// Clear the form after triggering subtask breakdown
resetForm();
}, [description, onSubtaskBreakdown, selectedWorkflowForCreate, addToast, resetForm]);
/*
FNXC:QuickAddStart 2026-07-24-11:20:
Start stashes the workflow snapshot validated at click time in `startIntentRef` and then runs the SAME submit
path as Save. The snapshot (not live state) is what `submitCreateTask` reads, so a workflow list refreshed
mid-duplicate-confirmation cannot retarget an in-flight Start.
*/
const handleStartClick = useCallback(() => {
if (!canQuickAddStartNow || !validatedStartWorkflow) return;
startIntentRef.current = validatedStartWorkflow;
handleSubmit();
}, [canQuickAddStartNow, handleSubmit, validatedStartWorkflow]);
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) || t("tasks.loadModelsFailed", "Failed to load models"));
} finally {
setModelsLoading(false);
}
}, [availableModels, parentFavoriteProviders, parentFavoriteModels]);
const loadAgents = useCallback(async () => {
if (agents.length > 0 && agentsProjectId === projectId) {
agentPickerOpenTokenRef.current += 1;
setShowAgentPicker(true);
updateAgentPickerPosition();
return;
}
/*
FNXC:QuickEntry 2026-07-18-08:45:
Open the picker immediately (with loading) so outside-click listeners arm
before fetchAgents resolves. Bump a token on open/close so a late fetch
does not re-open the portal after the operator dismissed it.
*/
const openToken = ++agentPickerOpenTokenRef.current;
setAgentsLoading(true);
setShowAgentPicker(true);
updateAgentPickerPosition();
try {
const result = await fetchAgents(undefined, projectId);
if (openToken !== agentPickerOpenTokenRef.current) return;
setAgents(result);
setAgentsProjectId(projectId);
setShowAgentPicker(true);
updateAgentPickerPosition();
} catch (err) {
if (openToken !== agentPickerOpenTokenRef.current) return;
const msg = getErrorMessage(err);
addToast(msg ? t("tasks.loadAgentsFailed", "Failed to load agents: {{msg}}", { msg }) : t("tasks.loadAgentsFailedGeneric", "Failed to load agents"), "error");
setShowAgentPicker(false);
} finally {
if (openToken === agentPickerOpenTokenRef.current) {
setAgentsLoading(false);
}
}
}, [agents.length, agentsProjectId, projectId, addToast, updateAgentPickerPosition]);
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
const projectGithubTrackingDefault = settings?.githubTrackingEnabledByDefault === true;
const effectiveGithubTracking = githubTrackingOverride ?? projectGithubTrackingDefault;
const githubToggleLabel = effectiveGithubTracking
? t("tasks.githubTrackingOn", "GitHub tracking ON for next task (project default: {{default}})", { default: projectGithubTrackingDefault ? t("tasks.githubTrackingDefaultOn", "on") : t("tasks.githubTrackingDefaultOff", "off") })
: t("tasks.githubTrackingOff", "GitHub tracking OFF for next task (project default: {{default}})", { default: projectGithubTrackingDefault ? t("tasks.githubTrackingDefaultOn", "on") : t("tasks.githubTrackingDefaultOff", "off") });
/*
FNXC:PlannerOversight 2026-07-14-18:11:
Quick Add eye toggle for session advisor. null override follows project default;
toggle flips effective on/off and stores an explicit override for the next create.
*/
const projectSessionAdvisorDefault = settings?.sessionAdvisorEnabledByDefault === true;
const effectiveSessionAdvisor = sessionAdvisorOverride ?? projectSessionAdvisorDefault;
const sessionAdvisorToggleLabel = effectiveSessionAdvisor
? t("tasks.sessionAdvisorOn", "Session advisor ON for next task (project default: {{default}})", { default: projectSessionAdvisorDefault ? t("tasks.sessionAdvisorDefaultOn", "on") : t("tasks.sessionAdvisorDefaultOff", "off") })
: t("tasks.sessionAdvisorOff", "Session advisor OFF for next task (project default: {{default}})", { default: projectSessionAdvisorDefault ? t("tasks.sessionAdvisorDefaultOn", "on") : t("tasks.sessionAdvisorDefaultOff", "off") });
const PriorityIcon = getPriorityIcon(priority);
const priorityLabel = getPriorityLabel(priority);
const priorityButtonLabel = t("tasks.quickEntryPriorityLabel", "Priority: {{priority}}", { priority: priorityLabel });
const fastToggleLabel = t("tasks.toggleFastMode", "Toggle fast execution mode");
// 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"}${singleLine ? " quick-entry--single-line" : ""}${isFileDragOver ? " quick-entry-box--drag-over" : ""}`}
data-testid="quick-entry-box"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDragEnd={clearFileDragState}
onDrop={handleDrop}
>
{isFileDragOver && (
<div className="quick-entry-drop-target" data-testid="quick-entry-drop-target" aria-hidden="true">
<Paperclip size={16} aria-hidden="true" />
<span>{t("tasks.dropFilesToAttach", "Drop photos or files to attach")}</span>
</div>
)}
<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 && !singleLine ? "quick-entry-input--expanded" : ""}`}
placeholder={isSubmitting ? t("tasks.creating", "Creating...") : t("tasks.addTaskPlaceholder", "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={singleLine ? 1 : 2}
aria-controls="quick-entry-controls"
aria-expanded={isDisclosureExpanded}
/>
</div>
<MicButton {...dictation.micProps} disabled={isSubmitting || isDisabled} />
<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 ? t("tasks.collapse", "Collapse") : t("tasks.expand", "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"
onTouchStart={(e: React.TouchEvent) => {
const target = e.target;
if (!(target instanceof Element)) return;
const button = target.closest("button");
if (button && !button.disabled) {
if (document.activeElement === textareaRef.current) {
e.preventDefault();
}
touchButtonRef.current = button;
}
}}
onTouchEnd={() => {
touchButtonRef.current = null;
}}
onTouchCancel={() => {
touchButtonRef.current = null;
}}
>
{/*
FNXC:BoardComposer 2026-07-10-12:00:
First-run review flagged the quick-add composer as disorganized: chips wrapped into four
arbitrary-looking rows with Save buried mid-row. Reorganize into two logical clusters inside
the single wrapping action row: an options group (workflow, optional steps, subtask, deps,
models, node, agent) and a right-aligned primary group (attach, GitHub tracking, Priority,
Fast, Save) so status controls sit beside attach and Save still reads last/right.
FNXC:QuickAddActionRow 2026-07-10-21:45:
Priority and Fast are icon-only in the bottom primary group: priority uses the shared
up/high, down/low, flag/normal, alert/urgent glyph helper, and Fast uses Zap while retaining
title/aria-label/test-id semantics.
*/}
<div className="quick-entry-options-group" data-testid="quick-entry-options-group">
{showWorkflowSelector && (
<div className="quick-entry-workflow-wrap" ref={workflowPickerRef}>
<button
ref={workflowTriggerRef}
type="button"
className="btn btn-sm dep-trigger quick-entry-workflow-trigger"
data-testid="quick-entry-workflow-trigger"
aria-haspopup="listbox"
aria-expanded={showWorkflowPicker}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setShowDeps(false);
setShowAgentPicker(false);
setAgentPickerPosition(null);
setShowNodePicker(false);
setNodePickerPosition(null);
setShowPriorityPicker(false);
setPriorityPickerPosition(null);
setIsModelMenuOpen(false);
setModelMenuPosition(null);
setActiveModelSubmenu(null);
setShowWorkflowPicker((prev) => {
const next = !prev;
if (next) {
updateWorkflowPickerPosition();
} else {
setWorkflowPickerPosition(null);
}
return next;
});
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
setShowWorkflowPicker(false);
setWorkflowPickerPosition(null);
}
}}
title={t("tasks.quickEntryWorkflowTitle", "Workflow for the next task")}
>
<WorkflowIcon
workflowId={selectedQuickEntryWorkflow?.id ?? quickEntryWorkflowId ?? ""}
icon={selectedQuickEntryWorkflowIcon}
className="quick-entry-workflow-icon"
decorative
/>
<span className="quick-entry-workflow-label">{quickEntryWorkflowLabel}</span>
<ChevronDown size={12} aria-hidden="true" />
</button>
{showWorkflowPicker && portalRoot && workflowPickerPosition && createPortal(
<div
ref={workflowPickerPortalRef}
className="dep-dropdown quick-entry-workflow-menu"
role="listbox"
data-testid="quick-entry-workflow-menu"
style={{
position: "fixed",
top: workflowPickerPosition.bottom === null ? `${workflowPickerPosition.top}px` : "auto",
bottom: workflowPickerPosition.bottom === null ? undefined : `${workflowPickerPosition.bottom}px`,
left: `${workflowPickerPosition.left}px`,
width: `${workflowPickerPosition.width}px`,
maxHeight: workflowPickerPosition.maxHeight ? `${workflowPickerPosition.maxHeight}px` : undefined,
overflowY: workflowPickerPosition.maxHeight ? "auto" : undefined,
}}
>
<div className="dep-dropdown-search-header">{t("tasks.quickEntryWorkflowHeader", "Create in workflow")}</div>
{realWorkflowOptions.map((option) => {
const duplicateName = (quickEntryWorkflowNameCounts.get(option.name) ?? 0) > 1;
const optionLabel = duplicateName
? t("tasks.quickEntryWorkflowDuplicateLabel", "{{name}} ({{id}})", { name: option.name, id: option.id })
: option.name;
return (
<button
key={option.id}
type="button"
role="option"
aria-selected={quickEntryWorkflowId === option.id}
aria-label={optionLabel}
className={`dep-dropdown-item quick-entry-workflow-option${quickEntryWorkflowId === option.id ? " selected" : ""}`}
data-testid={`quick-entry-workflow-option-${option.id}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setQuickEntryWorkflowId(option.id);
setShowWorkflowPicker(false);
setWorkflowPickerPosition(null);
}}
>
<WorkflowIcon workflowId={option.id} icon={option.icon} className="quick-entry-workflow-icon" decorative />
<span className="quick-entry-workflow-option-copy">
<span className="dep-dropdown-title quick-entry-workflow-option-name">{option.name}</span>
{duplicateName ? <span className="dep-dropdown-subtitle quick-entry-workflow-option-id">{option.id}</span> : null}
</span>
</button>
);
})}
</div>,
portalRoot,
)}
</div>
)}
<WorkflowOptionalStepsDropdown
steps={optionalSteps}
enabledIds={enabledOptionalStepIds}
onToggle={toggleOptionalStep}
disabled={isSubmitting || isDisabled}
triggerTestId="quick-entry-optional-steps-trigger"
/>
{/* FNXC:QuickAddSubtaskFlag 2026-06-21-00:00: Render no Subtask button or click target unless App wires the default-off `subtaskBreakdown` experiment callback. */}
{onSubtaskBreakdown && (
<button
type="button"
className="btn btn-sm"
onClick={handleSubtaskClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim()}
data-testid="subtask-button"
title={t("tasks.subtaskButtonTitle", "Break down into AI-generated subtasks")}
>
<ListTree size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("tasks.subtask", "Subtask")}
</button>
)}
{/*
FNXC:QuickAddRefine 2026-06-30-00:00:
Quick Add intentionally omits AI Refine so the compact create row has no refine button, menu, loading state, or /ai/refine-text path. New Task and TaskForm keep their dedicated refine affordance for richer task drafting.
*/}
<div className="dep-trigger-wrap">
<button
ref={depTriggerRef}
type="button"
onMouseDown={(e) => e.preventDefault()}
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 ? t("tasks.depsCount", "{{count}} deps", { count: dependencies.length }) : t("tasks.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.bottom === null ? `${depDropdownPosition.top}px` : "auto",
bottom: depDropdownPosition.bottom === null ? undefined : `${depDropdownPosition.bottom}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={t("tasks.searchTasksPlaceholder", "Search tasks…")}
autoFocus={typeof document === "undefined" || document.activeElement !== textareaRef.current}
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{filtered.length === 0 ? (
<div className="dep-dropdown-empty">{t("tasks.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, 60)}</span>
</div>
))
)}
</div>,
portalRoot,
);
})()}
<button
ref={modelTriggerRef}
type="button"
onMouseDown={(e) => e.preventDefault()}
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>
{shouldShowNodePicker && (
<div className="node-trigger-wrap" ref={nodePickerRef}>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
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 ?? t("tasks.node", "Node")}`}
{selectedNode && (
<span className="quick-entry-node-status">
<NodeHealthDot status={selectedNode.status} showLabel />
</span>
)}
</button>
</div>
)}
{shouldShowNodePicker && 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.bottom === null ? `${nodePickerPosition.top}px` : "auto",
bottom: nodePickerPosition.bottom === null ? undefined : `${nodePickerPosition.bottom}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">{t("tasks.selectExecutionNode", "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">{t("tasks.projectDefaultLocal", "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">{
node.status === "online" ? t("nodes.statusOnline", "Online") :
node.status === "connecting" ? t("nodes.statusConnecting", "Connecting") :
node.status === "error" ? t("nodes.statusError", "Error") :
t("nodes.statusOffline", "Offline")
}</span>
</div>
))}
</div>,
portalRoot,
)}
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
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}` : ` ${t("tasks.agent", "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.bottom === null ? `${agentPickerPosition.top}px` : "auto",
bottom: agentPickerPosition.bottom === null ? undefined : `${agentPickerPosition.bottom}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">{t("tasks.selectAgent", "Select agent")}</div>
{agentsLoading && <div className="dep-dropdown-empty"><LoadingSpinner label={t("tasks.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);
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">{t("tasks.noAgentsAvailable", "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">{t("tasks.clearSelection", "Clear selection")}</span>
</div>
)}
</div>,
portalRoot,
)}
{/*
FNXC:QuickAddStart 2026-07-31-23:51:
Start renders as the last chip in the options group so it wraps onto the same line as Models/Agent and
reads as an alternate create action beside Save (which stays right-aligned in the primary group). It is
present ONLY for manual-intake/"waiting"-first workflows — `hold` alone is not eligibility because the
merged auto-triaging Planning lane also holds cards. With an empty description it stays visible but
DISABLED (matching Save) so the affordance does not appear and vanish as the operator types; the whole
action row still unmounts while a create is in flight.
*/}
{canQuickAddStart && (
<button
type="button"
className="btn btn-sm quick-entry-start-button"
onClick={handleStartClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!canQuickAddStartNow}
data-testid="quick-entry-save-start"
title={t("tasks.startTaskTitle", "Create and start the task")}
>
<Play size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("tasks.start", "Start")}
</button>
)}
</div>
{/*
FNXC:BoardComposer 2026-07-10-12:00:
Primary action cluster: attach + GitHub tracking + Priority + Fast sit directly beside Save,
and Save is the LAST control in DOM order so it is right-aligned (margin-left auto on the
cluster) and reads as the composer's primary action. Save keeps its distinct `btn-task-create`
styling.
FNXC:QuickAddAttachments 2026-06-30-00:00 (relocated 2026-07-10): the attachment affordance
stays adjacent to Save (now immediately to its LEFT) preserving the icon-only label, hidden
file input trigger, and pending-count badge.
FNXC:QuickAddActionRow 2026-07-15-00:00:
Attach, GitHub, session advisor, Priority, and Fast are one icon-only cluster. Every control
uses `btn-icon` so its SVG resolves to the shared `--icon-size-sm` token; do not fork
ProviderIcon's shared size map to size this one GitHub use case.
*/}
<div className="quick-entry-primary-group" data-testid="quick-entry-primary-group">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
className="btn btn-icon btn-sm quick-entry-attach-button"
data-testid="quick-entry-attach"
onClick={() => fileInputRef.current?.click()}
aria-label={attachLabel}
title={attachLabel}
>
<Paperclip size={12} aria-hidden="true" />
{pendingAttachments.length > 0 && (
<span className="quick-entry-attach-count" aria-hidden="true">{pendingAttachments.length}</span>
)}
</button>
<button
type="button"
className={`btn btn-icon btn-sm ${effectiveGithubTracking ? "btn-primary" : ""}`}
onClick={() => {
setGithubTrackingOverride((prev) => !(prev ?? projectGithubTrackingDefault));
}}
onMouseDown={(e) => e.preventDefault()}
aria-pressed={effectiveGithubTracking}
data-testid="quick-entry-github-toggle"
title={githubToggleLabel}
aria-label={githubToggleLabel}
>
<ProviderIcon provider="github" size="sm" />
</button>
{/*
FNXC:PlannerOversight 2026-07-14-18:11:
Compact eye toggle next to GitHub for session advisor (overseer agent).
Default follows project setting; press stores an explicit per-create override.
FNXC:PlannerOversight 2026-07-14-19:34:
CodeRabbit: when the flipped effective value matches the project default,
clear override to null (inherit) — same as TaskDetailModal — instead of
permanently hardcoding true/false after a double-click.
*/}
<button
type="button"
className={`btn btn-icon btn-sm ${effectiveSessionAdvisor ? "btn-primary" : ""}`}
onClick={() => {
setSessionAdvisorOverride((prev) => {
const currentEffective = prev ?? projectSessionAdvisorDefault;
const nextEnabled = !currentEffective;
return nextEnabled === projectSessionAdvisorDefault ? null : nextEnabled;
});
}}
onMouseDown={(e) => e.preventDefault()}
aria-pressed={effectiveSessionAdvisor}
data-testid="quick-entry-session-advisor-toggle"
title={sessionAdvisorToggleLabel}
aria-label={sessionAdvisorToggleLabel}
>
{effectiveSessionAdvisor ? <Eye size={14} aria-hidden="true" /> : <EyeOff size={14} aria-hidden="true" />}
</button>
<div className="priority-trigger-wrap" ref={priorityPickerRef}>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
className="btn btn-icon btn-sm dep-trigger"
data-testid="quick-entry-priority-button"
title={priorityButtonLabel}
aria-label={priorityButtonLabel}
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;
});
}}
>
{/* FNXC:PriorityColorCoding 2026-07-11-00:00: The quick-add icon-only priority trigger must preview urgency color from priorityIndicator without changing its label, test id, or picker behavior. */}
<PriorityIcon size={14} aria-hidden="true" style={{ color: getPriorityColorVar(priority) }} />
</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.bottom === null ? `${priorityPickerPosition.top}px` : "auto",
bottom: priorityPickerPosition.bottom === null ? undefined : `${priorityPickerPosition.bottom}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">{t("tasks.selectPriority", "Select priority")}</div>
{TASK_PRIORITIES.map((taskPriority) => {
const label = getPriorityLabel(taskPriority);
const OptionPriorityIcon = getPriorityIcon(taskPriority);
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);
}}
>
<OptionPriorityIcon size={12} aria-hidden="true" style={{ color: getPriorityColorVar(taskPriority) }} />
<span className="dep-dropdown-title">{label}</span>
</div>
);
})}
</div>,
portalRoot,
)}
<button
type="button"
className={`btn btn-icon btn-sm ${isFastMode ? "btn-primary" : ""}`}
onClick={toggleFastMode}
onMouseDown={(e) => e.preventDefault()}
aria-pressed={isFastMode}
data-testid="quick-entry-fast-toggle"
title={fastToggleLabel}
aria-label={fastToggleLabel}
>
<Zap size={14} aria-hidden="true" />
</button>
<button
type="button"
className="btn btn-task-create btn-sm"
onClick={handleSubmit}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim() || isSubmitting}
data-testid="quick-entry-save"
title={t("tasks.createTaskTitle", "Create task")}
>
<Save size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
{t("tasks.save", "Save")}
</button>
</div>
</div>
)}
<PendingAttachmentPreviews
attachments={pendingAttachments}
onRemove={removeAttachment}
disabled={isSubmitting}
removeLabel={t("tasks.removeAttachment", "Remove image")}
testIdPrefix="quick-entry-preview"
/>
{isModelMenuOpen && portalRoot && modelMenuPosition && createPortal(
<div
ref={modelMenuPortalRef}
className="model-nested-menu model-nested-menu--portal"
onMouseDown={(e) => {
/*
FNXC:QuickAddModels 2026-08-12-21:51:
React synthetic mouse events cross createPortal boundaries, so this composer-focus guard
must exempt form controls and the portaled model dropdown or preventDefault suppresses
focus for its filter input. Plain menu chrome still preserves the quick-entry focus.
*/
const target = e.target as Element;
if (target.closest("input, textarea, select, [contenteditable]") || isInsidePortaledModelMenu(target)) {
return;
}
e.preventDefault();
}}
data-testid="model-nested-menu"
style={{
position: "fixed",
top: modelMenuPosition.bottom === null ? `${modelMenuPosition.top}px` : "auto",
bottom: modelMenuPosition.bottom === null ? undefined : `${modelMenuPosition.bottom}px`,
left: `${modelMenuPosition.left}px`,
width: `${modelMenuPosition.width}px`,
maxHeight: modelMenuPosition.maxHeight ? `${modelMenuPosition.maxHeight}px` : undefined,
overflowY: modelMenuPosition.maxHeight ? "auto" : undefined,
}}
>
{activeModelSubmenu === null ? (
/*
* FNXC:QuickAddModelMenu 2026-08-12-22:04:
* Top-level model rows use bare role names and matching icon alignment because
* .model-menu-item-label has no gap. Submenu headers retain the "<Role> Model" form.
*/
<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 }} />
{t("tasks.modelPlan", "Plan")}
</span>
<span className="model-menu-item-value">
{hasPlanningOverride
? getModelBadgeLabel(planningProvider, planningModelId)
: t("tasks.usingDefault", "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 }} />
{t("tasks.modelExecutor", "Executor")}
</span>
<span className="model-menu-item-value">
{hasExecutorOverride
? getModelBadgeLabel(executorProvider, executorModelId)
: t("tasks.usingDefault", "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 }} />
{t("tasks.modelReviewer", "Reviewer")}
</span>
<span className="model-menu-item-value">
{hasValidatorOverride
? getModelBadgeLabel(validatorProvider, validatorModelId)
: t("tasks.usingDefault", "Using default")}
</span>
<ChevronRight size={12} style={{ marginLeft: "auto", color: "var(--text-dim)" }} />
</button>
<button
type="button"
className={`model-menu-item ${hasMergerOverride ? "model-menu-item--active" : ""}`}
onClick={() => setActiveModelSubmenu("merger")}
data-testid="model-menu-merger"
>
<span className="model-menu-item-label">
<Brain size={12} style={{ verticalAlign: "middle", marginRight: 6 }} />
{t("tasks.modelMerger", "Merger")}
</span>
<span className="model-menu-item-value">
{hasMergerOverride
? getModelBadgeLabel(mergerProvider, mergerModelId)
: t("tasks.usingDefault", "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 }} />
{t("common.back", "Back")}
</button>
<div className="model-submenu-header">
{activeModelSubmenu === "plan" && t("tasks.planModel", "Plan Model")}
{activeModelSubmenu === "executor" && t("tasks.executorModel", "Executor Model")}
{activeModelSubmenu === "validator" && t("tasks.reviewerModel", "Reviewer Model")}
{activeModelSubmenu === "merger" && t("tasks.mergerModel", "Merger Model")}
</div>
<CustomModelDropdown
models={loadedModels}
value={
activeModelSubmenu === "plan"
? planningSelectionValue
: activeModelSubmenu === "executor"
? executorSelectionValue
: activeModelSubmenu === "validator" ? validatorSelectionValue : mergerSelectionValue
}
onChange={
activeModelSubmenu === "plan"
? handlePlanningModelChange
: activeModelSubmenu === "executor"
? handleExecutorChange
: activeModelSubmenu === "validator" ? handleValidatorChange : handleMergerModelChange
}
placeholder={t("tasks.usingDefault", "Using default")}
disabled={modelsLoading}
id={`model-${activeModelSubmenu}-select`}
label={`${activeModelSubmenu} model`}
favoriteProviders={effectiveFavoriteProviders}
onToggleFavorite={handleToggleFavorite}
favoriteModels={effectiveFavoriteModels}
onToggleModelFavorite={handleToggleModelFavorite}
thinkingLevel={activeModelSubmenu === "executor" ? thinkingLevel : activeModelSubmenu === "plan" ? planningThinkingLevel : activeModelSubmenu === "validator" ? validatorThinkingLevel : mergerThinkingLevel}
onThinkingLevelChange={activeModelSubmenu === "executor" ? handleThinkingLevelChange : activeModelSubmenu === "plan" ? setPlanningThinkingLevel : activeModelSubmenu === "validator" ? setValidatorThinkingLevel : setMergerThinkingLevel}
defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"}
credentialInstanceId={activeModelSubmenu === "plan" ? planningCredentialInstanceId : activeModelSubmenu === "executor" ? credentialInstanceId : activeModelSubmenu === "validator" ? validatorCredentialInstanceId : mergerCredentialInstanceId}
onCredentialInstanceChange={(instanceId) => {
const next = instanceId || undefined;
if (activeModelSubmenu === "plan") setPlanningCredentialInstanceId(next);
else if (activeModelSubmenu === "executor") setCredentialInstanceId(next);
else if (activeModelSubmenu === "validator") setValidatorCredentialInstanceId(next);
else setMergerCredentialInstanceId(next);
}}
/>
{modelsError && (
<div className="model-submenu-error">
<span>{modelsError}</span>
<button type="button" className="btn btn-sm" onClick={loadModels}>
{t("common.retry", "Retry")}
</button>
</div>
)}
</div>
)}
</div>,
portalRoot,
)}
</div>
<input
ref={fileInputRef}
type="file"
accept={TASK_ATTACHMENT_ACCEPT}
multiple
style={{ display: "none" }}
onChange={(e) => {
handleAttachmentFiles(e.target.files);
e.currentTarget.value = "";
}}
data-testid="quick-entry-file-input"
/>
{/* FNXC:QuickEntry 2026-06-25-00:00: FN-7047 removes the persistent "Enter to create · Esc to cancel" hint so quick entry has no leftover affordance shell after the help text was retired. */}
</div>
{duplicateMatches && (
<DuplicateWarningModal
matches={duplicateMatches}
onOpen={handleDuplicateOpen}
onProceed={handleDuplicateProceed}
onCancel={handleDuplicateCancel}
/>
)}
</>
);
}