feat(KB-225): add AI text refinement feature to dashboard
- Add AI text refinement backend service with OpenAI/Anthropic integration - Add /api/refine-text API endpoint with error handling - Add QuickEntryBox AI refine button with style presets menu - Add NewTaskModal AI refine feature for task description editing - Add comprehensive tests for backend service, API, and components - Add changeset for the new feature
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import type { Task, TaskCreateInput, ModelPreset, Settings, WorkflowStep } from "@kb/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps } from "../api";
|
||||
import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, type RefinementType } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { filterModels } from "../utils/modelFilter";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
|
||||
@@ -320,6 +321,11 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
|
||||
|
||||
// AI Refinement state
|
||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
const refineMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const depDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const descTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -392,6 +398,18 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showDepDropdown]);
|
||||
|
||||
// Close refine menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!isRefineMenuOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (refineMenuRef.current && !refineMenuRef.current.contains(e.target as Node)) {
|
||||
setIsRefineMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isRefineMenuOpen]);
|
||||
|
||||
// Handle paste for images
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
@@ -462,6 +480,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
setPresetMode("default");
|
||||
setEnablePlanningMode(false);
|
||||
setSelectedWorkflowSteps([]);
|
||||
setIsRefineMenuOpen(false);
|
||||
setIsRefining(false);
|
||||
setHasDirtyState(false);
|
||||
onClose();
|
||||
}, [hasDirtyState, onClose, pendingImages]);
|
||||
@@ -568,6 +588,30 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
}, []);
|
||||
|
||||
// AI Refinement handler
|
||||
const handleRefine = useCallback(async (type: RefinementType) => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed || isRefining) return;
|
||||
|
||||
setIsRefining(true);
|
||||
try {
|
||||
const refined = await refineText(trimmed, type);
|
||||
setDescription(refined);
|
||||
setIsRefineMenuOpen(false);
|
||||
addToast("Description refined with AI", "success");
|
||||
// Auto-resize textarea after content update
|
||||
if (descTextareaRef.current) {
|
||||
descTextareaRef.current.style.height = "auto";
|
||||
descTextareaRef.current.style.height = descTextareaRef.current.scrollHeight + "px";
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = getRefineErrorMessage(err);
|
||||
addToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
}, [description, isRefining, addToast]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const availableDeps = tasks
|
||||
@@ -608,15 +652,69 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
{/* Description field */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-task-description">Description</label>
|
||||
<textarea
|
||||
ref={descTextareaRef}
|
||||
id="new-task-description"
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
placeholder="What needs to be done?"
|
||||
rows={3}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="description-with-refine" ref={refineMenuRef}>
|
||||
<textarea
|
||||
ref={descTextareaRef}
|
||||
id="new-task-description"
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
placeholder="What needs to be done?"
|
||||
rows={3}
|
||||
disabled={isSubmitting || isRefining}
|
||||
/>
|
||||
{description.trim() && !isSubmitting && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`}
|
||||
onClick={() => setIsRefineMenuOpen((prev) => !prev)}
|
||||
disabled={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 refine-menu--modal"
|
||||
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>
|
||||
|
||||
{/* Dependencies */}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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, Lightbulb, ListTree } from "lucide-react";
|
||||
import type { ModelInfo, RefinementType } from "../api";
|
||||
import { fetchModels, refineText, getRefineErrorMessage } from "../api";
|
||||
import { Link, Brain, Lightbulb, ListTree, Sparkles } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
|
||||
const STORAGE_KEY = "kb-quick-entry-text";
|
||||
@@ -69,6 +69,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
||||
|
||||
// AI Refinement state
|
||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
const refineMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// If onCreate is not provided, the component is disabled
|
||||
const isDisabled = !onCreate;
|
||||
|
||||
@@ -173,6 +178,20 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
if (!showDeps) setDepSearch("");
|
||||
}, [showDeps]);
|
||||
|
||||
// Close refine menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!isRefineMenuOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (refineMenuRef.current && !refineMenuRef.current.contains(e.target as Node)) {
|
||||
setIsRefineMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isRefineMenuOpen]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setDescription("");
|
||||
setDependencies([]);
|
||||
@@ -182,6 +201,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setValidatorModelId(undefined);
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
setIsRefineMenuOpen(false);
|
||||
setIsRefining(false);
|
||||
setIsExpanded(false);
|
||||
justResetRef.current = true;
|
||||
if (textareaRef.current) {
|
||||
@@ -246,9 +267,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
// Close dropdowns first if open
|
||||
if (showDeps || showModels) {
|
||||
if (showDeps || showModels || isRefineMenuOpen) {
|
||||
setShowDeps(false);
|
||||
setShowModels(false);
|
||||
setIsRefineMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
// Clear non-empty input on Escape and clear localStorage
|
||||
@@ -273,7 +295,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
},
|
||||
[handleSubmit, description, isExpanded, showDeps, showModels, resetForm],
|
||||
[handleSubmit, description, isExpanded, showDeps, showModels, isRefineMenuOpen, resetForm],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
@@ -294,7 +316,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
// Collapse after a short delay to allow click events on dropdowns
|
||||
// Collapse regardless of content - only check if dropdowns are open
|
||||
blurTimeoutRef.current = setTimeout(() => {
|
||||
if (!showDeps && !showModels) {
|
||||
if (!showDeps && !showModels && !isRefineMenuOpen) {
|
||||
setIsExpanded(false);
|
||||
// Reset height when collapsing
|
||||
if (textareaRef.current) {
|
||||
@@ -303,7 +325,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
}
|
||||
blurTimeoutRef.current = null;
|
||||
}, 200);
|
||||
}, [showDeps, showModels]);
|
||||
}, [showDeps, showModels, isRefineMenuOpen]);
|
||||
|
||||
const toggleDep = useCallback((id: string) => {
|
||||
setDependencies((prev) =>
|
||||
@@ -372,6 +394,29 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
resetForm();
|
||||
}, [description, onSubtaskBreakdown, addToast, resetForm]);
|
||||
|
||||
const handleRefine = useCallback(async (type: RefinementType) => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed || isRefining) return;
|
||||
|
||||
setIsRefining(true);
|
||||
try {
|
||||
const refined = await refineText(trimmed, type);
|
||||
setDescription(refined);
|
||||
setIsRefineMenuOpen(false);
|
||||
addToast("Description refined with AI", "success");
|
||||
// Auto-resize textarea after content update
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = getRefineErrorMessage(err);
|
||||
addToast(errorMessage, "error");
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
}, [description, isRefining, addToast]);
|
||||
|
||||
const truncate = (s: string, len: number) =>
|
||||
s.length > len ? s.slice(0, len) + "…" : s;
|
||||
|
||||
@@ -574,6 +619,58 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
<ListTree size={12} style={{ verticalAlign: "middle" }} />
|
||||
Subtask
|
||||
</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>
|
||||
|
||||
@@ -17,6 +17,8 @@ vi.mock("../../api", () => ({
|
||||
defaultPresetBySize: {},
|
||||
}),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
}));
|
||||
|
||||
function makeTask(id: string): Task {
|
||||
@@ -537,4 +539,133 @@ describe("NewTaskModal", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI Refine feature", () => {
|
||||
it("shows refine button when description has content", async () => {
|
||||
renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
// Initially, refine button is not visible
|
||||
expect(screen.queryByTestId("refine-button")).toBeNull();
|
||||
|
||||
// Type something
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
|
||||
// Now the refine button should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides refine button when description is empty", async () => {
|
||||
renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
// Type something first
|
||||
fireEvent.change(textarea, { target: { value: "Some text" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Clear the input
|
||||
fireEvent.change(textarea, { target: { value: "" } });
|
||||
|
||||
// Button should be hidden
|
||||
expect(screen.queryByTestId("refine-button")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens refine menu on button click", async () => {
|
||||
renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
// Menu should be visible with all options
|
||||
expect(screen.getByTestId("refine-clarify")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-add-details")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-expand")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-simplify")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("successful refinement updates description and shows toast", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineText).toHaveBeenCalledWith("Original text", "clarify");
|
||||
});
|
||||
|
||||
// Textarea should be updated
|
||||
await waitFor(() => {
|
||||
expect(textarea).toHaveValue("Refined description");
|
||||
});
|
||||
|
||||
// Success toast should be shown
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Description refined with AI", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast on refinement failure and preserves original text", async () => {
|
||||
const { refineText, getRefineErrorMessage } = await import("../../api");
|
||||
vi.mocked(refineText).mockRejectedValueOnce(new Error("Rate limit exceeded"));
|
||||
vi.mocked(getRefineErrorMessage).mockReturnValue("Too many refinement requests. Please wait an hour.");
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Too many refinement requests. Please wait an hour.", "error");
|
||||
});
|
||||
|
||||
// Original text should be preserved
|
||||
expect(textarea).toHaveValue("Original text");
|
||||
});
|
||||
|
||||
it("shows loading state during refinement", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
// Slow down the promise to see loading state
|
||||
vi.mocked(refineText).mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve("Refined"), 100)));
|
||||
|
||||
renderNewTaskModal();
|
||||
const textarea = screen.getByLabelText(/Description/i);
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-expand"));
|
||||
|
||||
// Button should show loading text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Refining...")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,8 @@ vi.mock("../../api", () => ({
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
]),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
}));
|
||||
|
||||
// Mock lucide-react
|
||||
@@ -73,6 +75,7 @@ vi.mock("lucide-react", () => ({
|
||||
Brain: () => null,
|
||||
Lightbulb: () => null,
|
||||
ListTree: () => null,
|
||||
Sparkles: () => null,
|
||||
}));
|
||||
|
||||
function renderQuickEntryBox(props = {}) {
|
||||
@@ -713,4 +716,211 @@ describe("QuickEntryBox", () => {
|
||||
expect(localStorage.getItem("kb-quick-entry-text")).toBe("Task with dropdown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI Refine feature", () => {
|
||||
it("shows refine button when text is entered", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Initially, refine button is not visible
|
||||
expect(screen.queryByTestId("refine-button")).toBeNull();
|
||||
|
||||
// Focus and type something
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
|
||||
// Now the refine button should be visible
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refine button is hidden when textarea is empty", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Focus and type something
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Some text" } });
|
||||
expect(screen.getByTestId("refine-button")).toBeTruthy();
|
||||
|
||||
// Clear the input
|
||||
fireEvent.change(textarea, { target: { value: "" } });
|
||||
|
||||
// Button should be hidden/disabled (might be hidden when controls collapse)
|
||||
const refineButton = screen.queryByTestId("refine-button");
|
||||
if (refineButton) {
|
||||
expect((refineButton as HTMLButtonElement).disabled).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens refine menu on button click", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
// Menu should be visible with all options
|
||||
expect(screen.getByTestId("refine-clarify")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-add-details")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-expand")).toBeTruthy();
|
||||
expect(screen.getByTestId("refine-simplify")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes refine menu on Escape key", () => {
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task to refine" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
// Menu should be open
|
||||
expect(screen.getByTestId("refine-clarify")).toBeTruthy();
|
||||
|
||||
// Press Escape
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
// Menu should be closed but input preserved
|
||||
expect(screen.queryByTestId("refine-clarify")).toBeNull();
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Task to refine");
|
||||
});
|
||||
|
||||
it("closes refine menu when option is selected", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
|
||||
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
// Menu should be open
|
||||
expect(screen.getByTestId("refine-clarify")).toBeTruthy();
|
||||
|
||||
// Click on an option
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
// Menu should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("refine-clarify")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("successful refinement updates textarea content", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
|
||||
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineText).toHaveBeenCalledWith("Original text", "clarify");
|
||||
});
|
||||
|
||||
// Textarea should be updated
|
||||
await waitFor(() => {
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Refined description");
|
||||
});
|
||||
|
||||
// Success toast should be shown
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalledWith("Description refined with AI", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("failed refinement shows toast and preserves original text", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockRejectedValueOnce(new Error("Rate limit exceeded"));
|
||||
|
||||
const { getRefineErrorMessage } = await import("../../api");
|
||||
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.addToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Original text should be preserved
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("Original text");
|
||||
});
|
||||
|
||||
it("loading state disables button during refinement", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
// Slow down the promise to see loading state
|
||||
vi.mocked(refineText).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Original text" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
// Button should show loading text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Refining...")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Button should be disabled
|
||||
const refineButton = screen.getByTestId("refine-button");
|
||||
expect((refineButton as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-resizes textarea after refinement", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined description with much more content here");
|
||||
|
||||
renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Short" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
fireEvent.click(screen.getByTestId("refine-expand"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineText).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("resets refine state when form is reset after creation", async () => {
|
||||
const { refineText } = await import("../../api");
|
||||
vi.mocked(refineText).mockResolvedValueOnce("Refined text");
|
||||
|
||||
const { props } = renderQuickEntryBox();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Open refine menu but don't select anything
|
||||
fireEvent.focus(textarea);
|
||||
fireEvent.change(textarea, { target: { value: "Task" } });
|
||||
fireEvent.click(screen.getByTestId("refine-button"));
|
||||
|
||||
expect(screen.getByTestId("refine-clarify")).toBeTruthy();
|
||||
|
||||
// Submit the form
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// After reset, refine menu should be closed
|
||||
expect(screen.queryByTestId("refine-clarify")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user