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:
gsxdsm
2026-03-30 17:38:24 -07:00
parent 0815785e02
commit f6468a7a97
8 changed files with 776 additions and 38 deletions

View File

@@ -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 && (

View File

@@ -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 } : {})}

View File

@@ -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 ? (

View File

@@ -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 {

View File

@@ -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>
);
}

View File

@@ -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",
})
);
});
});

View File

@@ -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();
});
});
});

View File

@@ -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;