feat(KB-657): add manual quick-add expand toggles
- Add chevron expand/collapse toggles to dashboard quick-add flows in list and board views - Keep advanced quick-add controls hidden until expanded and collapse them after successful submission - Tighten quick-add accessibility and blur behavior around overlays and empty drafts - Add dashboard regression coverage and a published changeset for the quick-add interaction update
This commit is contained in:
@@ -190,21 +190,33 @@ export function InlineCreateCard({
|
||||
[loadedModels],
|
||||
);
|
||||
|
||||
// Track focus-out for justResetRef cleanup only (no auto-cancel on blur)
|
||||
// Track focus-out for conditional cancel behavior and justResetRef cleanup.
|
||||
useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
if (!card) return;
|
||||
|
||||
const handleFocusOut = (e: FocusEvent) => {
|
||||
// relatedTarget is the element receiving focus — if it's inside the card, ignore
|
||||
if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) return;
|
||||
// Clear justResetRef flag when focus actually leaves the card
|
||||
|
||||
if (justResetRef.current) {
|
||||
justResetRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const hasOpenOverlay = showDeps || showModels || showPresets;
|
||||
const hasDescription = description.trim().length > 0;
|
||||
const shouldCancelWhenCollapsed = !isExpanded && !hasDescription && !hasOpenOverlay;
|
||||
const shouldCancelWhenExpanded = isExpanded && !hasDescription && !hasOpenOverlay;
|
||||
|
||||
if (shouldCancelWhenCollapsed || shouldCancelWhenExpanded) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
card.addEventListener("focusout", handleFocusOut);
|
||||
return () => card.removeEventListener("focusout", handleFocusOut);
|
||||
}, []);
|
||||
}, [description, isExpanded, onCancel, showDeps, showModels, showPresets]);
|
||||
|
||||
// Clean up object URLs on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
@@ -289,6 +301,10 @@ export function InlineCreateCard({
|
||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||
setPendingImages([]);
|
||||
|
||||
setDescription("");
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = "auto";
|
||||
}
|
||||
setSelectedPresetId(undefined);
|
||||
setExecutorProvider(undefined);
|
||||
setExecutorModelId(undefined);
|
||||
@@ -324,6 +340,7 @@ export function InlineCreateCard({
|
||||
pendingImages,
|
||||
onSubmit,
|
||||
addToast,
|
||||
selectedPresetId,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -425,6 +442,7 @@ export function InlineCreateCard({
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
setShowPresets(false);
|
||||
setIsExpanded(false);
|
||||
}, [description, onPlanningMode, addToast]);
|
||||
|
||||
const handleSubtaskClick = useCallback(() => {
|
||||
@@ -445,17 +463,13 @@ export function InlineCreateCard({
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
setShowPresets(false);
|
||||
setIsExpanded(false);
|
||||
}, [description, onSubtaskBreakdown, addToast]);
|
||||
|
||||
const truncate = (s: string, len: number) =>
|
||||
s.length > len ? s.slice(0, len) + "…" : s;
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
// Skip if we just reset the form (prevents re-expanding after successful creation)
|
||||
if (justResetRef.current) {
|
||||
justResetRef.current = false;
|
||||
return;
|
||||
}
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
@@ -477,14 +491,15 @@ export function InlineCreateCard({
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
disabled={submitting}
|
||||
aria-controls="inline-create-controls"
|
||||
aria-controls={isExpanded ? "inline-create-controls" : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm inline-create-toggle"
|
||||
onClick={toggleExpanded}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls="inline-create-controls"
|
||||
aria-controls={isExpanded ? "inline-create-controls" : undefined}
|
||||
aria-label={isExpanded ? "Collapse advanced task options" : "Expand advanced task options"}
|
||||
data-testid="inline-create-toggle"
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
@@ -533,224 +548,224 @@ export function InlineCreateCard({
|
||||
).sort((a, b) => {
|
||||
const cmp = b.createdAt.localeCompare(a.createdAt);
|
||||
if (cmp !== 0) return cmp;
|
||||
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
return bNum - aNum;
|
||||
});
|
||||
return (
|
||||
<div className="dep-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<input
|
||||
className="dep-dropdown-search"
|
||||
placeholder="Search tasks…"
|
||||
autoFocus
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No existing tasks</div>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => toggleDep(t.id)}
|
||||
>
|
||||
<span className="dep-dropdown-id">{t.id}</span>
|
||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="inline-create-model-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm inline-create-model-trigger"
|
||||
onClick={() => {
|
||||
setShowPresets((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
}
|
||||
return next;
|
||||
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;
|
||||
});
|
||||
}}
|
||||
aria-expanded={showPresets}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Zap size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedPreset ? ` ${selectedPreset.name}` : " Preset"}
|
||||
</button>
|
||||
{showPresets && (
|
||||
<div className="inline-create-model-dropdown" onMouseDown={handleModelDropdownMouseDown}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
setSelectedPresetId(undefined);
|
||||
setExecutorProvider(undefined);
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setShowPresets(false);
|
||||
}}
|
||||
>
|
||||
Use default
|
||||
</button>
|
||||
{availablePresets.map((preset) => (
|
||||
return (
|
||||
<div className="dep-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<input
|
||||
className="dep-dropdown-search"
|
||||
placeholder="Search tasks…"
|
||||
autoFocus
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No existing tasks</div>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => toggleDep(t.id)}
|
||||
>
|
||||
<span className="dep-dropdown-id">{t.id}</span>
|
||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="inline-create-model-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm inline-create-model-trigger"
|
||||
onClick={() => {
|
||||
setShowPresets((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
aria-expanded={showPresets}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Zap size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedPreset ? ` ${selectedPreset.name}` : " Preset"}
|
||||
</button>
|
||||
{showPresets && (
|
||||
<div className="inline-create-model-dropdown" onMouseDown={handleModelDropdownMouseDown}>
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
const selection = applyPresetToSelection(preset);
|
||||
const executor = parseModelSelection(selection.executorValue);
|
||||
const validator = parseModelSelection(selection.validatorValue);
|
||||
setSelectedPresetId(preset.id);
|
||||
setExecutorProvider(executor.provider);
|
||||
setExecutorModelId(executor.modelId);
|
||||
setValidatorProvider(validator.provider);
|
||||
setValidatorModelId(validator.modelId);
|
||||
setSelectedPresetId(undefined);
|
||||
setExecutorProvider(undefined);
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setShowPresets(false);
|
||||
}}
|
||||
>
|
||||
{preset.name}
|
||||
Use default
|
||||
</button>
|
||||
))}
|
||||
{availablePresets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => {
|
||||
const selection = applyPresetToSelection(preset);
|
||||
const executor = parseModelSelection(selection.executorValue);
|
||||
const validator = parseModelSelection(selection.validatorValue);
|
||||
setSelectedPresetId(preset.id);
|
||||
setExecutorProvider(executor.provider);
|
||||
setExecutorModelId(executor.modelId);
|
||||
setValidatorProvider(validator.provider);
|
||||
setValidatorModelId(validator.modelId);
|
||||
setShowPresets(false);
|
||||
}}
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setShowPresets(false)}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm inline-create-model-trigger"
|
||||
onClick={toggleModelsDropdown}
|
||||
aria-expanded={showModels}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Brain size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedPreset
|
||||
? ` ${selectedPreset.name} · ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: selectedModelCount > 0
|
||||
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: " Models"}
|
||||
</button>
|
||||
{showModels && (
|
||||
<div
|
||||
className="inline-create-model-dropdown"
|
||||
onMouseDown={handleModelDropdownMouseDown}
|
||||
>
|
||||
{modelsLoading ? (
|
||||
<div className="inline-create-model-empty">Loading models…</div>
|
||||
) : modelsError ? (
|
||||
<div className="inline-create-model-empty">
|
||||
<span>Failed to load models.</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => void loadModels()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : loadedModels.length === 0 ? (
|
||||
<div className="inline-create-model-empty">
|
||||
No models available. Configure authentication in Settings to enable model selection.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="inline-create-model-row">
|
||||
<label htmlFor="inline-create-executor-model" className="inline-create-model-label">
|
||||
Executor Model
|
||||
</label>
|
||||
<span className={`model-badge ${hasExecutorOverride ? "model-badge-custom" : "model-badge-default"}`}>
|
||||
{getModelBadgeLabel(executorProvider, executorModelId)}
|
||||
</span>
|
||||
<CustomModelDropdown
|
||||
id="inline-create-executor-model"
|
||||
label="Executor Model"
|
||||
value={executorSelectionValue}
|
||||
onChange={handleExecutorChange}
|
||||
models={loadedModels}
|
||||
disabled={submitting}
|
||||
placeholder="Select executor model…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inline-create-model-row">
|
||||
<label htmlFor="inline-create-validator-model" className="inline-create-model-label">
|
||||
Validator Model
|
||||
</label>
|
||||
<span className={`model-badge ${hasValidatorOverride ? "model-badge-custom" : "model-badge-default"}`}>
|
||||
{getModelBadgeLabel(validatorProvider, validatorModelId)}
|
||||
</span>
|
||||
<CustomModelDropdown
|
||||
id="inline-create-validator-model"
|
||||
label="Validator Model"
|
||||
value={validatorSelectionValue}
|
||||
onChange={handleValidatorChange}
|
||||
models={loadedModels}
|
||||
disabled={submitting}
|
||||
placeholder="Select validator model…"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!submitting && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setShowPresets(false)}
|
||||
onClick={handlePlanClick}
|
||||
disabled={!description.trim()}
|
||||
data-testid="plan-button"
|
||||
title="Open planning mode with current description"
|
||||
>
|
||||
Custom
|
||||
<Lightbulb size={12} style={{ verticalAlign: "middle" }} />
|
||||
Plan
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm inline-create-model-trigger"
|
||||
onClick={toggleModelsDropdown}
|
||||
aria-expanded={showModels}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Brain size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedPreset
|
||||
? ` ${selectedPreset.name} · ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: selectedModelCount > 0
|
||||
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: " Models"}
|
||||
</button>
|
||||
{showModels && (
|
||||
<div
|
||||
className="inline-create-model-dropdown"
|
||||
onMouseDown={handleModelDropdownMouseDown}
|
||||
>
|
||||
{modelsLoading ? (
|
||||
<div className="inline-create-model-empty">Loading models…</div>
|
||||
) : modelsError ? (
|
||||
<div className="inline-create-model-empty">
|
||||
<span>Failed to load models.</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => void loadModels()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : loadedModels.length === 0 ? (
|
||||
<div className="inline-create-model-empty">
|
||||
No models available. Configure authentication in Settings to enable model selection.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="inline-create-model-row">
|
||||
<label htmlFor="inline-create-executor-model" className="inline-create-model-label">
|
||||
Executor Model
|
||||
</label>
|
||||
<span className={`model-badge ${hasExecutorOverride ? "model-badge-custom" : "model-badge-default"}`}>
|
||||
{getModelBadgeLabel(executorProvider, executorModelId)}
|
||||
</span>
|
||||
<CustomModelDropdown
|
||||
id="inline-create-executor-model"
|
||||
label="Executor Model"
|
||||
value={executorSelectionValue}
|
||||
onChange={handleExecutorChange}
|
||||
models={loadedModels}
|
||||
disabled={submitting}
|
||||
placeholder="Select executor model…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inline-create-model-row">
|
||||
<label htmlFor="inline-create-validator-model" className="inline-create-model-label">
|
||||
Validator Model
|
||||
</label>
|
||||
<span className={`model-badge ${hasValidatorOverride ? "model-badge-custom" : "model-badge-default"}`}>
|
||||
{getModelBadgeLabel(validatorProvider, validatorModelId)}
|
||||
</span>
|
||||
<CustomModelDropdown
|
||||
id="inline-create-validator-model"
|
||||
label="Validator Model"
|
||||
value={validatorSelectionValue}
|
||||
onChange={handleValidatorChange}
|
||||
models={loadedModels}
|
||||
disabled={submitting}
|
||||
placeholder="Select validator model…"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleSubtaskClick}
|
||||
disabled={!description.trim()}
|
||||
data-testid="subtask-button"
|
||||
title="Break down into AI-generated subtasks"
|
||||
>
|
||||
<ListTree size={12} style={{ verticalAlign: "middle" }} />
|
||||
Subtask
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!submitting && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handlePlanClick}
|
||||
disabled={!description.trim()}
|
||||
data-testid="plan-button"
|
||||
title="Open planning mode with current description"
|
||||
>
|
||||
<Lightbulb size={12} style={{ verticalAlign: "middle" }} />
|
||||
Plan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleSubtaskClick}
|
||||
disabled={!description.trim()}
|
||||
data-testid="subtask-button"
|
||||
title="Break down into AI-generated subtasks"
|
||||
>
|
||||
<ListTree size={12} style={{ verticalAlign: "middle" }} />
|
||||
Subtask
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="inline-create-actions">
|
||||
<span className="inline-create-hint">Enter to create · Esc to cancel</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!description.trim() || submitting}
|
||||
>
|
||||
{submitting ? "Creating..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-create-actions">
|
||||
<span className="inline-create-hint">Enter to create · Esc to cancel</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!description.trim() || submitting}
|
||||
>
|
||||
{submitting ? "Creating..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ModelSelectionModal } from "./ModelSelectionModal";
|
||||
|
||||
const STORAGE_KEY = "kb-quick-entry-text";
|
||||
const DISCLOSURE_STORAGE_KEY = "kb-quick-entry-expanded";
|
||||
|
||||
interface QuickEntryBoxProps {
|
||||
onCreate?: (input: TaskCreateInput) => Promise<void>;
|
||||
@@ -24,11 +23,6 @@ interface QuickEntryBoxProps {
|
||||
* Called when the user clicks the "Subtask" button to trigger subtask breakdown.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
/**
|
||||
* When false, the component will not auto-expand on focus.
|
||||
* Defaults to true for backward compatibility.
|
||||
*/
|
||||
autoExpand?: boolean;
|
||||
}
|
||||
|
||||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||||
@@ -51,7 +45,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
||||
};
|
||||
}
|
||||
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, autoExpand = true }: QuickEntryBoxProps) {
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown }: QuickEntryBoxProps) {
|
||||
const [description, setDescription] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem(STORAGE_KEY) || "";
|
||||
@@ -59,17 +53,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
return "";
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// isExpanded controls textarea height styling (auto-resize)
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// isDisclosureExpanded controls visibility of the controls panel (Deps, Models, etc.)
|
||||
const [isDisclosureExpanded, setIsDisclosureExpanded] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem(DISCLOSURE_STORAGE_KEY);
|
||||
// Default to true (expanded) for backward compatibility - only collapse if explicitly set to "false"
|
||||
return saved !== "false";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const justResetRef = useRef(false);
|
||||
|
||||
@@ -151,13 +135,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}
|
||||
}, [description]);
|
||||
|
||||
// Persist disclosure state to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(DISCLOSURE_STORAGE_KEY, isDisclosureExpanded.toString());
|
||||
}
|
||||
}, [isDisclosureExpanded]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -225,8 +202,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setIsModelModalOpen(false);
|
||||
setIsRefineMenuOpen(false);
|
||||
setIsRefining(false);
|
||||
setIsExpanded(false); // Collapse textarea height on reset
|
||||
// Note: isDisclosureExpanded is NOT reset - user preference persists
|
||||
setIsExpanded(false);
|
||||
justResetRef.current = true;
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
@@ -312,35 +288,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
// Collapse textarea and disclosure on escape
|
||||
// Collapse on escape
|
||||
setIsExpanded(false);
|
||||
setIsDisclosureExpanded(false);
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[
|
||||
handleSubmit,
|
||||
description,
|
||||
isExpanded,
|
||||
showDeps,
|
||||
isModelModalOpen,
|
||||
isRefineMenuOpen,
|
||||
setIsDisclosureExpanded,
|
||||
],
|
||||
[handleSubmit, description, isExpanded, showDeps, isModelModalOpen, isRefineMenuOpen],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
// Skip expanding if we just reset the form (prevents controls showing after successful creation)
|
||||
if (justResetRef.current) {
|
||||
justResetRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Only auto-expand if autoExpand prop is true (defaults to true for backward compatibility)
|
||||
if (autoExpand) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [autoExpand]);
|
||||
|
||||
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
|
||||
@@ -452,16 +407,15 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}
|
||||
}, [availableModels]);
|
||||
|
||||
// Show expanded controls based on disclosure state (user preference), not textarea focus
|
||||
const showExpandedControls = isDisclosureExpanded;
|
||||
// Show expanded controls only when manually expanded (isExpanded)
|
||||
const showExpandedControls = isExpanded;
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsDisclosureExpanded((prev) => !prev);
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
|
||||
<div className={`quick-entry-box ${isExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
|
||||
<div className="quick-entry-main-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -470,198 +424,201 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={isSubmitting || isDisabled}
|
||||
data-testid="quick-entry-input"
|
||||
rows={1}
|
||||
aria-controls="quick-entry-controls"
|
||||
aria-expanded={isExpanded}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm quick-entry-toggle"
|
||||
onClick={toggleExpanded}
|
||||
aria-expanded={isDisclosureExpanded}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls="quick-entry-controls"
|
||||
data-testid="quick-entry-toggle"
|
||||
title={isDisclosureExpanded ? "Collapse" : "Expand"}
|
||||
title={isExpanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{isDisclosureExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{showExpandedControls && (
|
||||
<div id="quick-entry-controls" className="quick-entry-controls">
|
||||
<div className="quick-entry-controls-left">
|
||||
<div className="dep-trigger-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger"
|
||||
onClick={toggleDepsDropdown}
|
||||
data-testid="quick-entry-deps-button"
|
||||
>
|
||||
<Link size={12} style={{ verticalAlign: "middle" }} />
|
||||
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
|
||||
</button>
|
||||
{showDeps && (() => {
|
||||
const term = depSearch.toLowerCase();
|
||||
const filtered = (term
|
||||
? tasks.filter((t) =>
|
||||
t.id.toLowerCase().includes(term) ||
|
||||
(t.title && t.title.toLowerCase().includes(term)) ||
|
||||
(t.description && t.description.toLowerCase().includes(term))
|
||||
)
|
||||
: [...tasks]
|
||||
).sort((a, b) => {
|
||||
const cmp = b.createdAt.localeCompare(a.createdAt);
|
||||
if (cmp !== 0) return cmp;
|
||||
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
return bNum - aNum;
|
||||
});
|
||||
return (
|
||||
<div className="dep-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<input
|
||||
className="dep-dropdown-search"
|
||||
placeholder="Search tasks…"
|
||||
autoFocus
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No existing tasks</div>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => toggleDep(t.id)}
|
||||
>
|
||||
<span className="dep-dropdown-id">{t.id}</span>
|
||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="quick-entry-model-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm quick-entry-model-trigger"
|
||||
onClick={openModelModal}
|
||||
aria-expanded={isModelModalOpen}
|
||||
aria-haspopup="dialog"
|
||||
data-testid="quick-entry-models-button"
|
||||
>
|
||||
<Brain size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedModelCount > 0
|
||||
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: " Models"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isSubmitting && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handlePlanClick}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={!description.trim()}
|
||||
data-testid="plan-button"
|
||||
title="Open planning mode with current description"
|
||||
>
|
||||
<Lightbulb size={12} style={{ verticalAlign: "middle" }} />
|
||||
Plan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleSubtaskClick}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={!description.trim()}
|
||||
data-testid="subtask-button"
|
||||
title="Break down into AI-generated subtasks"
|
||||
>
|
||||
<ListTree size={12} style={{ verticalAlign: "middle" }} />
|
||||
Subtask
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleSaveClick}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={!description.trim() || isSubmitting}
|
||||
data-testid="save-button"
|
||||
title="Create task"
|
||||
>
|
||||
<Save size={12} style={{ verticalAlign: "middle" }} />
|
||||
Save
|
||||
</button>
|
||||
<div className="refine-trigger-wrap" ref={refineMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`}
|
||||
onClick={() => setIsRefineMenuOpen((prev) => !prev)}
|
||||
disabled={!description.trim() || isRefining}
|
||||
data-testid="refine-button"
|
||||
title="Refine description with AI"
|
||||
>
|
||||
<Sparkles size={12} style={{ verticalAlign: "middle" }} />
|
||||
{isRefining ? "Refining..." : "Refine"}
|
||||
</button>
|
||||
{isRefineMenuOpen && (
|
||||
<div
|
||||
className="refine-menu"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
id="quick-entry-controls"
|
||||
className="quick-entry-controls"
|
||||
hidden={!showExpandedControls}
|
||||
aria-hidden={!showExpandedControls}
|
||||
>
|
||||
<div className="quick-entry-controls-left">
|
||||
<div className="dep-trigger-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm dep-trigger"
|
||||
onClick={toggleDepsDropdown}
|
||||
data-testid="quick-entry-deps-button"
|
||||
>
|
||||
<Link size={12} style={{ verticalAlign: "middle" }} />
|
||||
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
|
||||
</button>
|
||||
{showDeps && (() => {
|
||||
const term = depSearch.toLowerCase();
|
||||
const filtered = (term
|
||||
? tasks.filter((t) =>
|
||||
t.id.toLowerCase().includes(term) ||
|
||||
(t.title && t.title.toLowerCase().includes(term)) ||
|
||||
(t.description && t.description.toLowerCase().includes(term))
|
||||
)
|
||||
: [...tasks]
|
||||
).sort((a, b) => {
|
||||
const cmp = b.createdAt.localeCompare(a.createdAt);
|
||||
if (cmp !== 0) return cmp;
|
||||
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
|
||||
return bNum - aNum;
|
||||
});
|
||||
return (
|
||||
<div className="dep-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
<input
|
||||
className="dep-dropdown-search"
|
||||
placeholder="Search tasks…"
|
||||
autoFocus
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="dep-dropdown-empty">No existing tasks</div>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("clarify")}
|
||||
data-testid="refine-clarify"
|
||||
key={t.id}
|
||||
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => toggleDep(t.id)}
|
||||
>
|
||||
<div className="refine-menu-item-title">Clarify</div>
|
||||
<div className="refine-menu-item-desc">Make the description clearer and more specific</div>
|
||||
<span className="dep-dropdown-id">{t.id}</span>
|
||||
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("add-details")}
|
||||
data-testid="refine-add-details"
|
||||
>
|
||||
<div className="refine-menu-item-title">Add details</div>
|
||||
<div className="refine-menu-item-desc">Add implementation details and context</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("expand")}
|
||||
data-testid="refine-expand"
|
||||
>
|
||||
<div className="refine-menu-item-title">Expand</div>
|
||||
<div className="refine-menu-item-desc">Expand into a more comprehensive description</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("simplify")}
|
||||
data-testid="refine-simplify"
|
||||
>
|
||||
<div className="refine-menu-item-title">Simplify</div>
|
||||
<div className="refine-menu-item-desc">Simplify and make more concise</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="quick-entry-hint">
|
||||
Enter to create · Esc to cancel
|
||||
|
||||
<div className="quick-entry-model-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm quick-entry-model-trigger"
|
||||
onClick={openModelModal}
|
||||
aria-expanded={isModelModalOpen}
|
||||
aria-haspopup="dialog"
|
||||
data-testid="quick-entry-models-button"
|
||||
>
|
||||
<Brain size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedModelCount > 0
|
||||
? ` ${selectedModelCount} model${selectedModelCount === 1 ? "" : "s"}`
|
||||
: " Models"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isSubmitting && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handlePlanClick}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={!description.trim()}
|
||||
data-testid="plan-button"
|
||||
title="Open planning mode with current description"
|
||||
>
|
||||
<Lightbulb size={12} style={{ verticalAlign: "middle" }} />
|
||||
Plan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleSubtaskClick}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={!description.trim()}
|
||||
data-testid="subtask-button"
|
||||
title="Break down into AI-generated subtasks"
|
||||
>
|
||||
<ListTree size={12} style={{ verticalAlign: "middle" }} />
|
||||
Subtask
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={handleSaveClick}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
disabled={!description.trim() || isSubmitting}
|
||||
data-testid="save-button"
|
||||
title="Create task"
|
||||
>
|
||||
<Save size={12} style={{ verticalAlign: "middle" }} />
|
||||
Save
|
||||
</button>
|
||||
<div className="refine-trigger-wrap" ref={refineMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`}
|
||||
onClick={() => setIsRefineMenuOpen((prev) => !prev)}
|
||||
disabled={!description.trim() || isRefining}
|
||||
data-testid="refine-button"
|
||||
title="Refine description with AI"
|
||||
>
|
||||
<Sparkles size={12} style={{ verticalAlign: "middle" }} />
|
||||
{isRefining ? "Refining..." : "Refine"}
|
||||
</button>
|
||||
{isRefineMenuOpen && (
|
||||
<div
|
||||
className="refine-menu"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("clarify")}
|
||||
data-testid="refine-clarify"
|
||||
>
|
||||
<div className="refine-menu-item-title">Clarify</div>
|
||||
<div className="refine-menu-item-desc">Make the description clearer and more specific</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("add-details")}
|
||||
data-testid="refine-add-details"
|
||||
>
|
||||
<div className="refine-menu-item-title">Add details</div>
|
||||
<div className="refine-menu-item-desc">Add implementation details and context</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("expand")}
|
||||
data-testid="refine-expand"
|
||||
>
|
||||
<div className="refine-menu-item-title">Expand</div>
|
||||
<div className="refine-menu-item-desc">Expand into a more comprehensive description</div>
|
||||
</div>
|
||||
<div
|
||||
className="refine-menu-item"
|
||||
onClick={() => handleRefine("simplify")}
|
||||
data-testid="refine-simplify"
|
||||
>
|
||||
<div className="refine-menu-item-title">Simplify</div>
|
||||
<div className="refine-menu-item-desc">Simplify and make more concise</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="quick-entry-hint">
|
||||
Enter to create · Esc to cancel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{typeof document !== "undefined"
|
||||
? createPortal(
|
||||
|
||||
@@ -110,30 +110,64 @@ beforeEach(() => {
|
||||
describe("InlineCreateCard toggle button", () => {
|
||||
it("toggle button expands the view", () => {
|
||||
renderCard();
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Initially, footer controls are not visible
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--collapsed")).toBe(true);
|
||||
expect(document.querySelector(".inline-create-card")?.className).toContain("inline-create-card");
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(toggleButton.getAttribute("aria-controls")).toBeNull();
|
||||
expect(textarea.getAttribute("aria-controls")).toBeNull();
|
||||
|
||||
// Click toggle to expand
|
||||
expandInlineCreate();
|
||||
|
||||
// Now footer controls should be visible
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--expanded")).toBe(true);
|
||||
expect(screen.getByText(/Deps/)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Models/i })).toBeTruthy();
|
||||
expect(screen.getByTestId("plan-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("subtask-button")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Save/i })).toBeTruthy();
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(toggleButton.getAttribute("aria-controls")).toBe("inline-create-controls");
|
||||
expect(textarea.getAttribute("aria-controls")).toBe("inline-create-controls");
|
||||
});
|
||||
|
||||
it("toggle button collapses the view when expanded", () => {
|
||||
renderCard();
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
|
||||
// Expand first
|
||||
expandInlineCreate();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
// Click toggle again to collapse
|
||||
expandInlineCreate();
|
||||
|
||||
// Footer should be hidden
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--collapsed")).toBe(true);
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("maintains the collapsed and expanded styling contract on the inline create container", () => {
|
||||
renderCard();
|
||||
const card = document.querySelector(".inline-create-card");
|
||||
|
||||
expect(card?.classList.contains("inline-create-card--collapsed")).toBe(true);
|
||||
expect(card?.classList.contains("inline-create-card--expanded")).toBe(false);
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
|
||||
expandInlineCreate();
|
||||
|
||||
expect(card?.classList.contains("inline-create-card--expanded")).toBe(true);
|
||||
expect(card?.classList.contains("inline-create-card--collapsed")).toBe(false);
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT expand on focus", () => {
|
||||
@@ -145,18 +179,46 @@ describe("InlineCreateCard toggle button", () => {
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT collapse on blur", () => {
|
||||
renderCard();
|
||||
it("calls onCancel on blur when collapsed and empty", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cancel on blur when collapsed and has content", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Keep collapsed draft" } });
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onCancel on blur when expanded and empty", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Expand first
|
||||
expandInlineCreate();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
|
||||
// Blur should not collapse
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
// Should still be expanded
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cancel on blur when expanded and has content", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
expandInlineCreate();
|
||||
fireEvent.change(textarea, { target: { value: "Keep drafting" } });
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -316,6 +378,17 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when expanded, empty, and a dropdown is open", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when focus leaves while the preset dropdown is open", () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }],
|
||||
@@ -361,7 +434,7 @@ describe("InlineCreateCard model selector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
|
||||
it("calls onCancel after a model override is selected and focus leaves the card while empty", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
@@ -373,7 +446,7 @@ describe("InlineCreateCard model selector", () => {
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prevents default on model option mouseDown to retain focus while selecting", () => {
|
||||
@@ -539,30 +612,34 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
expect(subtaskButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("calls onPlanningMode with description and clears input when Plan clicked", () => {
|
||||
it("calls onPlanningMode and collapses after Plan clicked", () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
renderCard([], { onPlanningMode });
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Plan this task" } });
|
||||
fireEvent.click(screen.getByTestId("plan-button"));
|
||||
|
||||
expect(onPlanningMode).toHaveBeenCalledWith("Plan this task");
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
expect(textarea.value).toBe("");
|
||||
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onSubtaskBreakdown with description and clears input when Subtask clicked", () => {
|
||||
it("calls onSubtaskBreakdown and collapses after Subtask clicked", () => {
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderCard([], { onSubtaskBreakdown });
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Break this down" } });
|
||||
fireEvent.click(screen.getByTestId("subtask-button"));
|
||||
|
||||
expect(onSubtaskBreakdown).toHaveBeenCalledWith("Break this down");
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
expect(textarea.value).toBe("");
|
||||
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows toast when Plan clicked with empty description (via direct handler call)", () => {
|
||||
@@ -627,10 +704,10 @@ describe("InlineCreateCard localStorage persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears localStorage after successful task creation", async () => {
|
||||
it("clears textarea and localStorage after successful task creation", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
|
||||
// Type something to set localStorage
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
@@ -645,10 +722,30 @@ describe("InlineCreateCard localStorage persistence", () => {
|
||||
expect(props.onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(textarea.value).toBe("");
|
||||
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
// localStorage should be cleared
|
||||
expect(localStorage.getItem("kb-inline-create-text")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows immediately re-expanding after successful submit", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears localStorage when cancelling via Escape key", async () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
@@ -138,11 +138,7 @@ vi.mock("../ModelSelectionModal", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function renderQuickEntryBox(props = {}, { startCollapsed = false } = {}) {
|
||||
// Set disclosure state if needed
|
||||
if (startCollapsed) {
|
||||
localStorage.setItem("kb-quick-entry-expanded", "false");
|
||||
}
|
||||
function renderQuickEntryBox(props = {}) {
|
||||
const defaultProps = {
|
||||
onCreate: vi.fn().mockResolvedValue(undefined),
|
||||
addToast: vi.fn(),
|
||||
@@ -153,9 +149,7 @@ function renderQuickEntryBox(props = {}, { startCollapsed = false } = {}) {
|
||||
return { ...result, props: { ...defaultProps, ...props } };
|
||||
}
|
||||
|
||||
|
||||
// Helper to expand the QuickEntryBox by clicking the toggle button
|
||||
|
||||
function expandQuickEntry() {
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
fireEvent.click(toggleButton);
|
||||
@@ -164,18 +158,15 @@ function expandQuickEntry() {
|
||||
describe("QuickEntryBox", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders textarea with placeholder", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(textarea.tagName.toLowerCase()).toBe("textarea");
|
||||
@@ -183,44 +174,44 @@ describe("QuickEntryBox", () => {
|
||||
});
|
||||
|
||||
it("does NOT expand on focus", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
|
||||
// Should NOT auto-expand on focus (manual toggle only)
|
||||
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
|
||||
it("toggle button expands the view", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
const controls = document.getElementById("quick-entry-controls");
|
||||
|
||||
// Initially not expanded
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
expect(screen.getByTestId("quick-entry-box").classList.contains("quick-entry-box--collapsed")).toBe(true);
|
||||
expect(screen.getByTestId("quick-entry-box").className).toContain("quick-entry-box");
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(textarea.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(controls?.hasAttribute("hidden")).toBe(true);
|
||||
|
||||
// Click toggle to expand
|
||||
expandQuickEntry();
|
||||
|
||||
// Now expanded
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expand on focus when autoExpand is false", () => {
|
||||
renderQuickEntryBox({ autoExpand: false });
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
|
||||
// Should not expand when autoExpand is false
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
expect(screen.getByTestId("quick-entry-box").classList.contains("quick-entry-box--expanded")).toBe(true);
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(textarea.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(controls?.hasAttribute("hidden")).toBe(false);
|
||||
});
|
||||
|
||||
it("toggle button collapses the view when expanded", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
const box = screen.getByTestId("quick-entry-box");
|
||||
|
||||
// Expand first
|
||||
expandQuickEntry();
|
||||
@@ -231,11 +222,27 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
|
||||
// Now collapsed
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
expect(box.classList.contains("quick-entry-box--collapsed")).toBe(true);
|
||||
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true);
|
||||
});
|
||||
|
||||
it("maintains the collapsed and expanded styling contract on the root container", () => {
|
||||
renderQuickEntryBox();
|
||||
const box = screen.getByTestId("quick-entry-box");
|
||||
|
||||
expect(box.classList.contains("quick-entry-box--collapsed")).toBe(true);
|
||||
expect(box.classList.contains("quick-entry-box--expanded")).toBe(false);
|
||||
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true);
|
||||
|
||||
expandQuickEntry();
|
||||
|
||||
expect(box.classList.contains("quick-entry-box--expanded")).toBe(true);
|
||||
expect(box.classList.contains("quick-entry-box--collapsed")).toBe(false);
|
||||
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT collapse on blur when empty", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Expand manually
|
||||
@@ -254,7 +261,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("does NOT collapse on blur when has content", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Expand manually and add content
|
||||
@@ -273,8 +280,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("creates task on Enter key with TaskCreateInput", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "New task description" } });
|
||||
@@ -291,7 +297,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("allows Shift+Enter to insert newline when expanded", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -305,8 +311,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("submits on Enter even when expanded (without Shift)", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
@@ -325,8 +330,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("prevents default on Enter key (without Shift)", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task" } });
|
||||
@@ -337,8 +341,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("shows loading state during creation", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
// Slow down the promise to see loading state
|
||||
props.onCreate.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
@@ -356,8 +359,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("clears input after successful creation", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
@@ -371,8 +373,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("shows error toast on failure and keeps input content", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
props.onCreate.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
@@ -388,8 +389,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("clears non-empty input on Escape key", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Some text" } });
|
||||
@@ -400,7 +400,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("collapses and blurs on Escape key", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -412,8 +412,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("does not clear empty input on Escape key", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
@@ -421,8 +420,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("does not submit on Enter if input is empty", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
@@ -434,8 +432,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("does not submit on Enter if input is only whitespace", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: " " } });
|
||||
@@ -447,8 +444,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("updates textarea value on change", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Updated text" } });
|
||||
@@ -456,8 +452,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("trims whitespace when creating task", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: " Task with spaces " } });
|
||||
@@ -473,8 +468,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("maintains focus after successful creation", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
@@ -489,65 +483,54 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
describe("Rich creation features", () => {
|
||||
|
||||
it("shows dependency button when expanded", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
|
||||
// Initially, no controls are visible
|
||||
expect(screen.queryByTestId("quick-entry-deps-button")).toBeNull();
|
||||
// Initially, controls region is collapsed/hidden
|
||||
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true);
|
||||
|
||||
// Expand and type something
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "Task with deps" } });
|
||||
|
||||
|
||||
// Now the dependency button should be visible
|
||||
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Now the dependency button should be visible
|
||||
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows model selector button when expanded", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
|
||||
|
||||
// Initially, no controls are visible
|
||||
expect(screen.queryByTestId("quick-entry-models-button")).toBeNull();
|
||||
renderQuickEntryBox();
|
||||
|
||||
// Initially, controls region is collapsed/hidden
|
||||
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true);
|
||||
|
||||
// Expand and type something
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "Task with models" } });
|
||||
|
||||
|
||||
// Now the model selector button should be visible
|
||||
expect(screen.getByTestId("quick-entry-models-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Now the model selector button should be visible
|
||||
expect(screen.getByTestId("quick-entry-models-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Plan and Subtask buttons when expanded", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
|
||||
|
||||
// Initially, no controls are visible
|
||||
expect(screen.queryByTestId("plan-button")).toBeNull();
|
||||
expect(screen.queryByTestId("subtask-button")).toBeNull();
|
||||
renderQuickEntryBox();
|
||||
|
||||
// Initially, controls region is collapsed/hidden
|
||||
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true);
|
||||
|
||||
// Expand and type something
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(textarea, { target: { value: "Task to plan" } });
|
||||
|
||||
|
||||
// Now the Plan and Subtask buttons should be visible
|
||||
expect(screen.getByTestId("plan-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("subtask-button")).toBeTruthy();
|
||||
});
|
||||
// Now the Plan and Subtask buttons should be visible
|
||||
expect(screen.getByTestId("plan-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("subtask-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens dependency dropdown when clicking deps button", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -560,7 +543,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("opens model modal when clicking models button", () => {
|
||||
const { container } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -572,15 +555,12 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Click the models button
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
// Modal should now be visible via portal outside the component container
|
||||
const modal = screen.getByTestId("model-selection-modal");
|
||||
expect(modal).toBeTruthy();
|
||||
expect(container.contains(modal)).toBe(false);
|
||||
expect(document.body.contains(modal)).toBe(true);
|
||||
// Modal should now be visible
|
||||
expect(screen.getByTestId("model-selection-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("modal receives correct props (models, loading state, etc.)", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -599,7 +579,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("selects dependencies and includes them in submit payload", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const { props } = renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -627,6 +607,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
it("calls onPlanningMode and clears input when Plan clicked", async () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
const { props } = renderQuickEntryBox({ onPlanningMode });
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Plan this task" } });
|
||||
@@ -643,8 +624,10 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
it("calls onSubtaskBreakdown and clears input when Subtask clicked", async () => {
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
const { props } = renderQuickEntryBox({ onSubtaskBreakdown });
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Break this down" } });
|
||||
fireEvent.click(screen.getByTestId("subtask-button"));
|
||||
|
||||
@@ -657,7 +640,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("disables Plan and Subtask buttons when description is empty", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -683,7 +666,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("Plan button prevents textarea blur on mousedown", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -701,7 +684,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("Subtask button prevents textarea blur on mousedown", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -739,7 +722,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("includes selected models in submit payload", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const { props } = renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -770,7 +753,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("closes modal on Escape when open", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -791,7 +774,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("clears all state on second Escape after dropdowns are closed", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -808,8 +791,8 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
|
||||
it("resets all state after successful creation (preserves disclosure preference)", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
it("resets all state after successful creation", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -821,12 +804,10 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// After creation, input should be cleared
|
||||
// After creation, controls should be collapsed
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
// Disclosure preference persists - controls remain visible since we expanded earlier
|
||||
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("plan-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("subtask-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("quick-entry-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -840,84 +821,11 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("restores disclosure state from localStorage on mount", () => {
|
||||
// Pre-populate localStorage with expanded state
|
||||
localStorage.setItem("kb-quick-entry-expanded", "true");
|
||||
|
||||
renderQuickEntryBox();
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
|
||||
// Should restore the saved disclosure state (expanded)
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
// Controls should be visible
|
||||
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("defaults to expanded when localStorage is empty", () => {
|
||||
renderQuickEntryBox();
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
|
||||
// Should default to expanded (true) for backward compatibility
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
// Controls should be visible
|
||||
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("updates localStorage when toggling disclosure", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
|
||||
// Wait for initial state to be persisted (useEffect runs after mount)
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-quick-entry-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
// Initially collapsed
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Should be expanded
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
// localStorage should be updated
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-quick-entry-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(toggleButton);
|
||||
|
||||
// Should be collapsed
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
// localStorage should be updated
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-quick-entry-expanded")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
it("aria-expanded attribute updates correctly when toggling", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
|
||||
// Initially collapsed
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(toggleButton);
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(toggleButton);
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("restores description from localStorage on mount", () => {
|
||||
// Pre-populate localStorage
|
||||
localStorage.setItem("kb-quick-entry-text", "Saved task description");
|
||||
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Should restore the saved description
|
||||
@@ -925,8 +833,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("updates localStorage when typing", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Typing this task" } });
|
||||
@@ -938,8 +845,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("clears localStorage after successful task creation", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Type something to set localStorage
|
||||
@@ -960,7 +866,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("clears localStorage when Escape clears non-empty input", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -979,7 +885,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("does not clear localStorage on first Escape when closing dropdowns", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1001,11 +907,10 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
|
||||
describe("AI Refine feature", () => {
|
||||
it("shows refine button when expanded and text is entered", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
|
||||
|
||||
// Initially, refine button is not visible
|
||||
expect(screen.queryByTestId("refine-button")).toBeNull();
|
||||
// Initially, controls region is collapsed/hidden
|
||||
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true);
|
||||
|
||||
// Expand and type something
|
||||
expandQuickEntry();
|
||||
@@ -1017,7 +922,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("refine button is hidden when textarea is empty", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1036,7 +941,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("opens refine menu on button click", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1051,7 +956,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("closes refine menu on Escape key", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1073,7 +978,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
|
||||
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1096,7 +1001,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
|
||||
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const { props } = renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1125,7 +1030,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
|
||||
const { getRefineErrorMessage } = await import("../../api");
|
||||
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const { props } = renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1146,7 +1051,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Slow down the promise to see loading state
|
||||
vi.mocked(refineText).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1168,7 +1073,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description with much more content here");
|
||||
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1185,7 +1090,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined text");
|
||||
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const { props } = renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1209,11 +1114,10 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
|
||||
describe("Save button", () => {
|
||||
it("shows save button when expanded and text is entered", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
|
||||
|
||||
// Initially, save button is not visible
|
||||
expect(screen.queryByTestId("save-button")).toBeNull();
|
||||
// Initially, controls region is collapsed/hidden
|
||||
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true);
|
||||
|
||||
// Expand and type something
|
||||
expandQuickEntry();
|
||||
@@ -1225,7 +1129,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("save button is disabled when textarea is empty", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1244,8 +1148,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("save button is disabled during submission", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
// Component starts with disclosure expanded by default
|
||||
const { props } = renderQuickEntryBox();
|
||||
// Slow down the promise to see loading state
|
||||
props.onCreate.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
@@ -1266,24 +1169,30 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking save button persists to localStorage", async () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
it("clicking save button clears localStorage after successful submission", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Draft task description" } });
|
||||
|
||||
// Click the save button
|
||||
fireEvent.click(screen.getByTestId("save-button"));
|
||||
|
||||
// localStorage should have the value
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-quick-entry-text")).toBe("Draft task description");
|
||||
});
|
||||
|
||||
// Click the save button
|
||||
fireEvent.click(screen.getByTestId("save-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// localStorage should be cleared after successful submission
|
||||
expect(localStorage.getItem("kb-quick-entry-text")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking save button creates the task", async () => {
|
||||
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
|
||||
const { props } = renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1304,7 +1213,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("save button has correct test id", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1316,7 +1225,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("save button has correct title attribute", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -1327,7 +1236,7 @@ renderQuickEntryBox({}, { startCollapsed: true });
|
||||
});
|
||||
|
||||
it("save button prevents textarea blur on mousedown", () => {
|
||||
renderQuickEntryBox({}, { startCollapsed: true });
|
||||
renderQuickEntryBox();
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user