feat(KB-657): add expand/collapse toggle to quick add components

- Add toggle button (ChevronDown/ChevronUp) to QuickEntryBox (list view) and InlineCreateCard (board view)

- Replace auto-expand behavior with manual toggle control

- Add CSS styles for collapsed/expanded states and toggle button positioning

- Update tests to verify toggle behavior

- Provide cleaner default UI while keeping advanced options discoverable
This commit is contained in:
gsxdsm
2026-04-01 07:13:14 -07:00
parent 005eb6fca6
commit a8dbd95d8c
7 changed files with 439 additions and 369 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Brain, Link, Lightbulb, ListTree, Zap } from "lucide-react";
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp } from "lucide-react";
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, uploadAttachment, fetchSettings } from "../api";
@@ -87,6 +87,8 @@ export function InlineCreateCard({
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
const [submitting, setSubmitting] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const justResetRef = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
@@ -188,40 +190,21 @@ export function InlineCreateCard({
[loadedModels],
);
// Cancel when focus leaves the card entirely and there's no content
// Track focus-out for justResetRef cleanup only (no auto-cancel on blur)
useEffect(() => {
const card = cardRef.current;
if (!card) return;
const handleFocusOut = (e: FocusEvent) => {
// relatedTarget is the element receiving focus — if it's inside the card, ignore
if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) return;
// Only cancel if empty and dropdowns are not open
if (
description.trim() === "" &&
pendingImages.length === 0 &&
dependencies.length === 0 &&
!hasExecutorOverride &&
!hasValidatorOverride &&
!showDeps &&
!showModels &&
!showPresets
) {
onCancel();
// Clear justResetRef flag when focus actually leaves the card
if (justResetRef.current) {
justResetRef.current = false;
}
};
card.addEventListener("focusout", handleFocusOut);
return () => card.removeEventListener("focusout", handleFocusOut);
}, [
description,
pendingImages,
dependencies,
hasExecutorOverride,
hasValidatorOverride,
showDeps,
showModels,
showPresets,
onCancel,
]);
}, []);
// Clean up object URLs on unmount to prevent memory leaks
useEffect(() => {
@@ -307,9 +290,19 @@ export function InlineCreateCard({
setPendingImages([]);
setSelectedPresetId(undefined);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setDependencies([]);
setShowDeps(false);
setShowModels(false);
setShowPresets(false);
addToast(`Created ${task.id}`, "success");
// Clear localStorage after successful task creation
// Collapse and clear localStorage after successful task creation
setIsExpanded(false);
justResetRef.current = true;
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
@@ -337,10 +330,27 @@ export function InlineCreateCard({
async (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
// Clear localStorage when user explicitly cancels
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
// Close dropdowns first if open
if (showDeps || showModels || showPresets) {
setShowDeps(false);
setShowModels(false);
setShowPresets(false);
return;
}
// Clear non-empty input on Escape and clear localStorage
if (description.trim()) {
setDescription("");
// Reset height
if (inputRef.current) {
inputRef.current.style.height = "auto";
}
// Clear localStorage when user explicitly clears input
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
}
// Collapse and cancel on escape
setIsExpanded(false);
onCancel();
return;
}
@@ -349,7 +359,7 @@ export function InlineCreateCard({
handleSubmit();
}
},
[handleSubmit, onCancel],
[handleSubmit, onCancel, description, showDeps, showModels, showPresets],
);
const toggleDep = useCallback((id: string) => {
@@ -440,24 +450,47 @@ export function InlineCreateCard({
const truncate = (s: string, len: number) =>
s.length > len ? s.slice(0, len) + "…" : s;
const toggleExpanded = useCallback(() => {
// Skip if we just reset the form (prevents re-expanding after successful creation)
if (justResetRef.current) {
justResetRef.current = false;
return;
}
setIsExpanded((prev) => !prev);
}, []);
return (
<div className="inline-create-card" ref={cardRef}>
<textarea
ref={inputRef}
rows={1}
className="inline-create-input"
placeholder="What needs to be done?"
value={description}
onChange={(e) => {
setDescription(e.target.value);
const el = e.target;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
disabled={submitting}
/>
<div className={`inline-create-card ${isExpanded ? "inline-create-card--expanded" : "inline-create-card--collapsed"}`} ref={cardRef}>
<div className="inline-create-main-row">
<textarea
ref={inputRef}
rows={1}
className="inline-create-input"
placeholder="What needs to be done?"
value={description}
onChange={(e) => {
setDescription(e.target.value);
const el = e.target;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
disabled={submitting}
aria-controls="inline-create-controls"
/>
<button
type="button"
className="btn btn-sm inline-create-toggle"
onClick={toggleExpanded}
aria-expanded={isExpanded}
aria-controls="inline-create-controls"
data-testid="inline-create-toggle"
title={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
{pendingImages.length > 0 && (
<div className="inline-create-previews">
{pendingImages.map((img, i) => (
@@ -476,29 +509,30 @@ export function InlineCreateCard({
))}
</div>
)}
<div className="inline-create-footer">
<div className="inline-create-controls">
<div className="dep-trigger-wrap">
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={toggleDepsDropdown}
>
<Link size={12} style={{ verticalAlign: "middle" }} />
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
</button>
{showDeps && (() => {
const term = depSearch.toLowerCase();
const filtered = (term
? tasks.filter((t) =>
t.id.toLowerCase().includes(term) ||
(t.title && t.title.toLowerCase().includes(term)) ||
(t.description && t.description.toLowerCase().includes(term))
)
: [...tasks]
).sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
{isExpanded && (
<div id="inline-create-controls" className="inline-create-footer">
<div className="inline-create-controls">
<div className="dep-trigger-wrap">
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={toggleDepsDropdown}
>
<Link size={12} style={{ verticalAlign: "middle" }} />
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
</button>
{showDeps && (() => {
const term = depSearch.toLowerCase();
const filtered = (term
? tasks.filter((t) =>
t.id.toLowerCase().includes(term) ||
(t.title && t.title.toLowerCase().includes(term)) ||
(t.description && t.description.toLowerCase().includes(term))
)
: [...tasks]
).sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return bNum - aNum;
@@ -716,6 +750,7 @@ export function InlineCreateCard({
</button>
</div>
</div>
)}
</div>
);
}