feat(KB-163): add rich task creation UI to QuickEntryBox
- Update QuickEntryBox with rich creation UI including title, description, column selection, and dependency fields - Add CSS styles for new QuickEntryBox components - Update Column, Board, and ListView components with TaskCreateInput signature and allTasks prop - Add comprehensive tests for QuickEntryBox rich creation features - Remove settings CLI commands (no longer needed)
This commit is contained in:
@@ -100,9 +100,9 @@ function AppInner() {
|
||||
const handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
|
||||
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
|
||||
|
||||
const handleQuickCreate = useCallback(
|
||||
async (description: string): Promise<void> => {
|
||||
await createTask({ description, column: "triage" });
|
||||
const handleBoardQuickCreate = useCallback(
|
||||
async (input: TaskCreateInput): Promise<void> => {
|
||||
await createTask({ ...input, column: "triage" });
|
||||
},
|
||||
[createTask],
|
||||
);
|
||||
@@ -220,7 +220,7 @@ function AppInner() {
|
||||
onMoveTask={moveTask}
|
||||
onOpenDetail={handleDetailOpen}
|
||||
addToast={addToast}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onQuickCreate={handleBoardQuickCreate}
|
||||
onNewTask={handleNewTaskOpen}
|
||||
autoMerge={autoMerge}
|
||||
onToggleAutoMerge={handleToggleAutoMerge}
|
||||
@@ -240,7 +240,7 @@ function AppInner() {
|
||||
addToast={addToast}
|
||||
globalPaused={globalPaused}
|
||||
onNewTask={handleNewTaskOpen}
|
||||
onQuickCreate={handleQuickCreate}
|
||||
onQuickCreate={handleBoardQuickCreate}
|
||||
/>
|
||||
)}
|
||||
{detailTask && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Task, TaskDetail, Column as ColumnType } from "@kb/core";
|
||||
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput } from "@kb/core";
|
||||
import { COLUMNS } from "@kb/core";
|
||||
import { Column } from "./Column";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -11,7 +11,7 @@ interface BoardProps {
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onQuickCreate?: (description: string) => Promise<void>;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<void>;
|
||||
onNewTask: () => void;
|
||||
autoMerge: boolean;
|
||||
onToggleAutoMerge: () => void;
|
||||
@@ -146,6 +146,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
|
||||
onUpdateTask={onUpdateTask}
|
||||
onArchiveTask={onArchiveTask}
|
||||
onUnarchiveTask={onUnarchiveTask}
|
||||
allTasks={filteredTasks}
|
||||
{...(col === "triage" ? { onQuickCreate, onNewTask } : {})}
|
||||
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
|
||||
{...(col === "done" ? { onArchiveAllDone } : {})}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { useFlashOnIncrease } from "../hooks/useFlashOnIncrease";
|
||||
import type { Task, TaskDetail, Column as ColumnType } from "@kb/core";
|
||||
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput } from "@kb/core";
|
||||
import { COLUMN_LABELS, COLUMN_DESCRIPTIONS } from "@kb/core";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import { WorktreeGroup } from "./WorktreeGroup";
|
||||
@@ -20,7 +20,7 @@ interface ColumnProps {
|
||||
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onQuickCreate?: (description: string) => Promise<void>;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<void>;
|
||||
onNewTask?: () => void;
|
||||
autoMerge?: boolean;
|
||||
onToggleAutoMerge?: () => void;
|
||||
@@ -34,9 +34,10 @@ interface ColumnProps {
|
||||
onArchiveAllDone?: () => Promise<Task[]>;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
allTasks?: Task[];
|
||||
}
|
||||
|
||||
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse }: ColumnProps) {
|
||||
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks }: ColumnProps) {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
|
||||
const countFlashing = useFlashOnIncrease(tasks.length);
|
||||
@@ -169,7 +170,11 @@ function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetai
|
||||
{!isCollapsed && (
|
||||
<div className="column-body">
|
||||
{column === "triage" && onQuickCreate && (
|
||||
<QuickEntryBox onCreate={onQuickCreate} addToast={addToast} />
|
||||
<QuickEntryBox
|
||||
onCreate={onQuickCreate}
|
||||
addToast={addToast}
|
||||
tasks={allTasks ?? []}
|
||||
/>
|
||||
)}
|
||||
{column === "in-progress" ? (
|
||||
worktreeGroups.length === 0 ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
|
||||
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
|
||||
import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@kb/core";
|
||||
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
|
||||
import { fetchTaskDetail } from "../api";
|
||||
import { QuickEntryBox } from "./QuickEntryBox";
|
||||
@@ -31,7 +31,7 @@ interface ListViewProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
globalPaused?: boolean;
|
||||
onNewTask?: () => void;
|
||||
onQuickCreate?: (description: string) => Promise<void>;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<void>;
|
||||
}
|
||||
|
||||
function getStepProgress(steps: TaskStep[]): string {
|
||||
|
||||
@@ -1,21 +1,113 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import type { Task, TaskCreateInput } from "@kb/core";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { fetchModels } from "../api";
|
||||
import { Link, Brain } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
|
||||
interface QuickEntryBoxProps {
|
||||
onCreate?: (description: string) => Promise<void>;
|
||||
onCreate?: (input: TaskCreateInput) => Promise<void>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
tasks?: Task[];
|
||||
availableModels?: ModelInfo[];
|
||||
}
|
||||
|
||||
export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||||
return provider && modelId ? `${provider}/${modelId}` : "";
|
||||
}
|
||||
|
||||
function parseModelSelection(value: string): { provider?: string; modelId?: string } {
|
||||
if (!value) {
|
||||
return { provider: undefined, modelId: undefined };
|
||||
}
|
||||
|
||||
const slashIndex = value.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return { provider: undefined, modelId: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
provider: value.slice(0, slashIndex),
|
||||
modelId: value.slice(slashIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels }: QuickEntryBoxProps) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const justResetRef = useRef(false);
|
||||
|
||||
// Rich creation state (mirrors InlineCreateCard)
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [showDeps, setShowDeps] = useState(false);
|
||||
const [depSearch, setDepSearch] = useState("");
|
||||
const [showModels, setShowModels] = useState(false);
|
||||
const [executorProvider, setExecutorProvider] = useState<string | undefined>(undefined);
|
||||
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
|
||||
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
|
||||
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
||||
const [breakIntoSubtasks, setBreakIntoSubtasks] = useState(false);
|
||||
|
||||
// If onCreate is not provided, the component is disabled
|
||||
const isDisabled = !onCreate;
|
||||
|
||||
// Fetch models if not provided by parent
|
||||
useEffect(() => {
|
||||
if (availableModels) {
|
||||
setLoadedModels(availableModels);
|
||||
setModelsLoading(false);
|
||||
setModelsError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
fetchModels()
|
||||
.then((models) => {
|
||||
if (!cancelled) {
|
||||
setLoadedModels(models);
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (!cancelled) {
|
||||
setModelsError(err?.message || "Failed to load models");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [availableModels]);
|
||||
|
||||
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
|
||||
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
|
||||
|
||||
const hasExecutorOverride = Boolean(executorProvider && executorModelId);
|
||||
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
|
||||
const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride);
|
||||
|
||||
const getModelBadgeLabel = useCallback(
|
||||
(provider?: string, modelId?: string) => {
|
||||
if (!provider || !modelId) return "Using default";
|
||||
const matched = loadedModels.find((model) => model.provider === provider && model.id === modelId);
|
||||
return matched ? `${matched.provider}/${matched.id}` : `${provider}/${modelId}`;
|
||||
},
|
||||
[loadedModels],
|
||||
);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -55,19 +147,46 @@ export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
}
|
||||
}, [isSubmitting, description]);
|
||||
|
||||
// Clear dep search when dropdown closes
|
||||
useEffect(() => {
|
||||
if (!showDeps) setDepSearch("");
|
||||
}, [showDeps]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setDescription("");
|
||||
setDependencies([]);
|
||||
setBreakIntoSubtasks(false);
|
||||
setExecutorProvider(undefined);
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
setIsExpanded(false);
|
||||
justResetRef.current = true;
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed || isSubmitting || !onCreate) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onCreate(trimmed);
|
||||
await onCreate({
|
||||
description: trimmed,
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
breakIntoSubtasks,
|
||||
modelProvider: hasExecutorOverride ? executorProvider : undefined,
|
||||
modelId: hasExecutorOverride ? executorModelId : undefined,
|
||||
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
|
||||
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
|
||||
});
|
||||
// Clear input for rapid entry
|
||||
setDescription("");
|
||||
// Reset height after clearing
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
resetForm();
|
||||
// Note: Focus restoration is handled by useEffect when isSubmitting becomes false
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to create task", "error");
|
||||
@@ -75,7 +194,21 @@ export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, isSubmitting, onCreate, addToast]);
|
||||
}, [
|
||||
description,
|
||||
isSubmitting,
|
||||
onCreate,
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
hasExecutorOverride,
|
||||
executorProvider,
|
||||
executorModelId,
|
||||
hasValidatorOverride,
|
||||
validatorProvider,
|
||||
validatorModelId,
|
||||
addToast,
|
||||
resetForm,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -90,8 +223,14 @@ export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
handleSubmit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
// Close dropdowns first if open
|
||||
if (showDeps || showModels) {
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
return;
|
||||
}
|
||||
// Clear non-empty input on Escape
|
||||
if (description.trim()) {
|
||||
// Clear non-empty input on Escape
|
||||
setDescription("");
|
||||
// Reset height
|
||||
if (textareaRef.current) {
|
||||
@@ -99,7 +238,7 @@ export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
}
|
||||
}
|
||||
// Collapse on escape
|
||||
setIsExpanded(false);
|
||||
resetForm();
|
||||
// Clear any pending blur timeout
|
||||
if (blurTimeoutRef.current) {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
@@ -108,10 +247,15 @@ export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[handleSubmit, description, isExpanded],
|
||||
[handleSubmit, description, isExpanded, showDeps, showModels, resetForm],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
// Skip expanding if we just reset the form (prevents controls showing after successful creation)
|
||||
if (justResetRef.current) {
|
||||
justResetRef.current = false;
|
||||
return;
|
||||
}
|
||||
setIsExpanded(true);
|
||||
}, []);
|
||||
|
||||
@@ -121,11 +265,11 @@ export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
clearTimeout(blurTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Collapse if empty (after a short delay to allow click events)
|
||||
// Collapse if empty and no dropdowns are open (after a short delay to allow click events)
|
||||
blurTimeoutRef.current = setTimeout(() => {
|
||||
// Check current textarea value directly for most accurate state
|
||||
const currentValue = textareaRef.current?.value || "";
|
||||
if (!currentValue.trim()) {
|
||||
if (!currentValue.trim() && !showDeps && !showModels) {
|
||||
setIsExpanded(false);
|
||||
// Reset height when collapsing
|
||||
if (textareaRef.current) {
|
||||
@@ -134,8 +278,78 @@ export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
}
|
||||
blurTimeoutRef.current = null;
|
||||
}, 200);
|
||||
}, [showDeps, showModels]);
|
||||
|
||||
const toggleDep = useCallback((id: string) => {
|
||||
setDependencies((prev) =>
|
||||
prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleDepsDropdown = useCallback(() => {
|
||||
setShowDeps((prev) => {
|
||||
const next = !prev;
|
||||
if (next) setShowModels(false);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleModelsDropdown = useCallback(() => {
|
||||
setShowModels((prev) => {
|
||||
const next = !prev;
|
||||
if (next) setShowDeps(false);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExecutorChange = useCallback((value: string) => {
|
||||
const next = parseModelSelection(value);
|
||||
setExecutorProvider(next.provider);
|
||||
setExecutorModelId(next.modelId);
|
||||
}, []);
|
||||
|
||||
const handleValidatorChange = useCallback((value: string) => {
|
||||
const next = parseModelSelection(value);
|
||||
setValidatorProvider(next.provider);
|
||||
setValidatorModelId(next.modelId);
|
||||
}, []);
|
||||
|
||||
const handleModelDropdownMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target;
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
(target.closest("button") || target.closest("input"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const truncate = (s: string, len: number) =>
|
||||
s.length > len ? s.slice(0, len) + "…" : s;
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
if (availableModels) {
|
||||
setLoadedModels(availableModels);
|
||||
setModelsError(null);
|
||||
setModelsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
try {
|
||||
setLoadedModels(await fetchModels());
|
||||
} catch (err: any) {
|
||||
setModelsError(err?.message || "Failed to load models");
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
}, [availableModels]);
|
||||
|
||||
// Show expanded controls when there's content or user has interacted
|
||||
const showExpandedControls = isExpanded || description.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="quick-entry-box" data-testid="quick-entry-box">
|
||||
<textarea
|
||||
@@ -151,6 +365,163 @@ export function QuickEntryBox({ onCreate, addToast }: QuickEntryBoxProps) {
|
||||
data-testid="quick-entry-input"
|
||||
rows={1}
|
||||
/>
|
||||
{showExpandedControls && (
|
||||
<div 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={toggleModelsDropdown}
|
||||
aria-expanded={showModels}
|
||||
aria-haspopup="dialog"
|
||||
data-testid="quick-entry-models-button"
|
||||
>
|
||||
<Brain size={12} style={{ verticalAlign: "middle" }} />
|
||||
{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="quick-entry-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="quick-entry-executor-model"
|
||||
label="Executor Model"
|
||||
value={executorSelectionValue}
|
||||
onChange={handleExecutorChange}
|
||||
models={loadedModels}
|
||||
disabled={isSubmitting}
|
||||
placeholder="Select executor model…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inline-create-model-row">
|
||||
<label htmlFor="quick-entry-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="quick-entry-validator-model"
|
||||
label="Validator Model"
|
||||
value={validatorSelectionValue}
|
||||
onChange={handleValidatorChange}
|
||||
models={loadedModels}
|
||||
disabled={isSubmitting}
|
||||
placeholder="Select validator model…"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSubmitting && (
|
||||
<label
|
||||
className="quick-entry-subtasks-toggle"
|
||||
data-testid="quick-entry-subtasks-toggle"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={breakIntoSubtasks}
|
||||
onChange={(e) => setBreakIntoSubtasks(e.target.checked)}
|
||||
/>
|
||||
Break into subtasks
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="quick-entry-hint">
|
||||
Enter to create · Esc to cancel
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1340,7 +1340,11 @@ describe("ListView Quick Entry", () => {
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnQuickCreate).toHaveBeenCalledWith("New quick task");
|
||||
expect(mockOnQuickCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "New quick task",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1384,7 +1388,11 @@ describe("ListView Quick Entry", () => {
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnQuickCreate).toHaveBeenCalledWith("Task with spaces");
|
||||
expect(mockOnQuickCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task with spaces",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { QuickEntryBox } from "../QuickEntryBox";
|
||||
import type { Task } from "@kb/core";
|
||||
|
||||
function renderQuickEntryBox() {
|
||||
const props = {
|
||||
const MOCK_MODELS = [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
reasoning: true,
|
||||
contextWindow: 200_000,
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
reasoning: true,
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
];
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: "KB-001",
|
||||
title: "Test task 1",
|
||||
description: "First test task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "KB-002",
|
||||
title: "Test task 2",
|
||||
description: "Second test task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-02-01T00:00:00Z",
|
||||
updatedAt: "2026-02-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue([
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
reasoning: true,
|
||||
contextWindow: 200_000,
|
||||
},
|
||||
{
|
||||
provider: "openai",
|
||||
id: "gpt-4o",
|
||||
name: "GPT-4o",
|
||||
reasoning: true,
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
function renderQuickEntryBox(props = {}) {
|
||||
const defaultProps = {
|
||||
onCreate: vi.fn().mockResolvedValue(undefined),
|
||||
addToast: vi.fn(),
|
||||
tasks: mockTasks,
|
||||
availableModels: MOCK_MODELS,
|
||||
};
|
||||
const result = render(<QuickEntryBox {...props} />);
|
||||
return { ...result, props };
|
||||
const result = render(<QuickEntryBox {...defaultProps} {...props} />);
|
||||
return { ...result, props: { ...defaultProps, ...props } };
|
||||
}
|
||||
|
||||
describe("QuickEntryBox", () => {
|
||||
@@ -67,7 +134,7 @@ describe("QuickEntryBox", () => {
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||
});
|
||||
|
||||
it("creates task on Enter key", async () => {
|
||||
it("creates task on Enter key with TaskCreateInput", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -75,7 +142,12 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith("New task description");
|
||||
expect(props.onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "New task description",
|
||||
column: "triage",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +161,7 @@ describe("QuickEntryBox", () => {
|
||||
// Shift+Enter should not prevent default (allow newline)
|
||||
const event = fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true });
|
||||
|
||||
// Event should not be prevented (returns false if preventDefault was called)
|
||||
// Event should not be prevented (returns true if preventDefault was NOT called)
|
||||
expect(event).toBe(true);
|
||||
});
|
||||
|
||||
@@ -104,7 +176,11 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith("Task to submit");
|
||||
expect(props.onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task to submit",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,7 +314,11 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith("Task with spaces");
|
||||
expect(props.onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task with spaces",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,4 +336,204 @@ describe("QuickEntryBox", () => {
|
||||
// After successful creation, focus should be maintained
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
});
|
||||
|
||||
describe("Rich creation features", () => {
|
||||
it("shows dependency button when typing", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Initially, no controls are visible before focus
|
||||
expect(screen.queryByTestId("quick-entry-deps-button")).toBeNull();
|
||||
|
||||
// Type something
|
||||
fireEvent.change(textarea, { target: { value: "Task with deps" } });
|
||||
|
||||
// Now the dependency button should be visible
|
||||
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows model selector button when typing", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Initially, no controls are visible
|
||||
expect(screen.queryByTestId("quick-entry-models-button")).toBeNull();
|
||||
|
||||
// Type something
|
||||
fireEvent.change(textarea, { target: { value: "Task with models" } });
|
||||
|
||||
// Now the model selector button should be visible
|
||||
expect(screen.getByTestId("quick-entry-models-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows break-into-subtasks toggle when typing", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Initially, no controls are visible
|
||||
expect(screen.queryByTestId("quick-entry-subtasks-toggle")).toBeNull();
|
||||
|
||||
// Type something
|
||||
fireEvent.change(textarea, { target: { value: "Task to break" } });
|
||||
|
||||
// Now the subtasks toggle should be visible
|
||||
expect(screen.getByTestId("quick-entry-subtasks-toggle")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens dependency dropdown when clicking deps button", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with deps" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
|
||||
|
||||
// Dropdown should be visible with search input
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
expect(document.querySelector(".dep-dropdown-search")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens model dropdown when clicking models button", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with models" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
// Dropdown should be visible with model options
|
||||
expect(document.querySelector(".inline-create-model-dropdown")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("selects dependencies and includes them in submit payload", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with deps" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
|
||||
|
||||
// Click on a task to select it
|
||||
const taskItem = document.querySelector(".dep-dropdown-item");
|
||||
expect(taskItem).toBeTruthy();
|
||||
fireEvent.click(taskItem!);
|
||||
|
||||
// Close dropdown and submit
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task with deps",
|
||||
dependencies: expect.arrayContaining(["KB-002"]), // Most recent task
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles break-into-subtasks and includes it in submit payload", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to break" } });
|
||||
|
||||
const checkbox = screen.getByTestId("quick-entry-subtasks-toggle").querySelector("input");
|
||||
expect(checkbox).toBeTruthy();
|
||||
fireEvent.click(checkbox!);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task to break",
|
||||
breakIntoSubtasks: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes selected models in submit payload", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with model" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
|
||||
|
||||
// Select executor model
|
||||
const executorButton = screen.getByRole("button", { name: "Executor Model" });
|
||||
fireEvent.click(executorButton);
|
||||
|
||||
// Select the first model option
|
||||
const modelOption = screen.getByText("Claude Sonnet 4.5");
|
||||
fireEvent.click(modelOption);
|
||||
|
||||
// Submit the task
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task with model",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes dropdowns on Escape and preserves input", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with dropdown" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
|
||||
|
||||
// Dropdown should be open
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
|
||||
// Press Escape - should close dropdown but not clear input
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
// Dropdown should be closed
|
||||
expect(document.querySelector(".dep-dropdown")).toBeNull();
|
||||
|
||||
// Input should still have the value
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Task with dropdown");
|
||||
});
|
||||
|
||||
it("clears all state on second Escape after dropdowns are closed", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to clear" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-subtasks-toggle").querySelector("input")!);
|
||||
|
||||
// First Escape closes any dropdowns
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
// Second Escape clears everything
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
// Input should be cleared and collapsed
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
|
||||
});
|
||||
|
||||
it("resets all state after successful creation", async () => {
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to reset" } });
|
||||
fireEvent.click(screen.getByTestId("quick-entry-subtasks-toggle").querySelector("input")!);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// After creation, controls should be collapsed
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
expect(screen.queryByTestId("quick-entry-deps-button")).toBeNull();
|
||||
expect(screen.queryByTestId("quick-entry-subtasks-toggle")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7684,6 +7684,79 @@ html .column.drag-over * {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Quick Entry Box expanded controls */
|
||||
.quick-entry-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-entry-controls-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-entry-model-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quick-entry-subtasks-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.quick-entry-subtasks-toggle input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
accent-color: var(--todo);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-entry-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-entry-model-trigger {
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
/* Responsive layout for quick entry controls */
|
||||
@media (max-width: 640px) {
|
||||
.quick-entry-controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.quick-entry-controls-left {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-entry-hint {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* === New Task Modal === */
|
||||
.new-task-modal .modal-body {
|
||||
padding: 20px 24px;
|
||||
|
||||
Reference in New Issue
Block a user