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:
gsxdsm
2026-03-31 04:20:37 -07:00
parent 23964bf0c0
commit 62f87947f6
11 changed files with 1565 additions and 17 deletions

View File

@@ -1172,3 +1172,127 @@ describe("API Error Handling", () => {
});
});
});
// ── AI Text Refinement API Tests ───────────────────────────────────────────
import { refineText, getRefineErrorMessage, REFINE_ERROR_MESSAGES, type RefinementType } from "./api";
describe("refineText", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("sends POST with text and type, returns refined text", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(true, { refined: "Refined task description" })
);
const result = await refineText("Original text", "clarify");
expect(result).toBe("Refined task description");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/ai/refine-text", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ text: "Original text", type: "clarify" }),
});
});
it("works with all four refinement types", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(true, { refined: "Refined" })
);
const types: RefinementType[] = ["clarify", "add-details", "expand", "simplify"];
for (const type of types) {
const result = await refineText("Test text", type);
expect(result).toBe("Refined");
}
});
it("throws on rate limit error (429)", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Rate limit exceeded. Maximum 10 refinement requests per hour." }, 429)
);
await expect(refineText("Test", "clarify")).rejects.toThrow("Rate limit exceeded");
});
it("throws on invalid type error (422)", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "type must be one of: clarify, add-details, expand, simplify" }, 422)
);
await expect(refineText("Test", "invalid" as RefinementType)).rejects.toThrow("type must be one of");
});
it("throws on validation error (400)", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "text must be at least 1 character" }, 400)
);
await expect(refineText("", "clarify")).rejects.toThrow("text must be at least 1 character");
});
it("throws on server error (500)", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "AI service error" }, 500)
);
await expect(refineText("Test", "clarify")).rejects.toThrow("AI service error");
});
});
describe("getRefineErrorMessage", () => {
it("returns rate limit message for rate limit errors", () => {
const error = new Error("Rate limit exceeded");
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.RATE_LIMIT);
});
it("returns rate limit message for 429 status", () => {
const error = new Error("429 Too Many Requests");
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.RATE_LIMIT);
});
it("returns invalid type message for invalid type errors", () => {
const error = new Error("Invalid type selected");
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.INVALID_TYPE);
});
it("passes through text validation errors", () => {
const error = new Error("text must be at least 1 character");
expect(getRefineErrorMessage(error)).toBe("text must be at least 1 character");
});
it("passes through text length errors", () => {
const error = new Error("text must not exceed 2000 characters");
expect(getRefineErrorMessage(error)).toBe("text must not exceed 2000 characters");
});
it("passes through type required errors", () => {
const error = new Error("type is required");
expect(getRefineErrorMessage(error)).toBe("type is required");
});
it("returns network message for unknown errors", () => {
const error = new Error("Network failure");
expect(getRefineErrorMessage(error)).toBe(REFINE_ERROR_MESSAGES.NETWORK);
});
it("returns network message for non-Error values", () => {
expect(getRefineErrorMessage("string error")).toBe(REFINE_ERROR_MESSAGES.NETWORK);
expect(getRefineErrorMessage(null)).toBe(REFINE_ERROR_MESSAGES.NETWORK);
expect(getRefineErrorMessage(undefined)).toBe(REFINE_ERROR_MESSAGES.NETWORK);
});
});
describe("REFINE_ERROR_MESSAGES", () => {
it("has the expected error messages", () => {
expect(REFINE_ERROR_MESSAGES.RATE_LIMIT).toBe("Too many refinement requests. Please wait an hour.");
expect(REFINE_ERROR_MESSAGES.INVALID_TYPE).toBe("Invalid refinement option selected.");
expect(REFINE_ERROR_MESSAGES.NETWORK).toBe("Failed to refine text. Please try again.");
});
});

View File

@@ -1180,3 +1180,76 @@ export function refineWorkflowStepPrompt(id: string): Promise<{ prompt: string;
method: "POST",
});
}
// ── AI Text Refinement API ────────────────────────────────────────────
/** Refinement types for AI text refinement */
export type RefinementType = "clarify" | "add-details" | "expand" | "simplify";
/** Response from text refinement endpoint */
export interface RefineTextResponse {
refined: string;
}
/**
* Refine task description text using AI.
* @param text - The text to refine (1-2000 characters)
* @param type - The refinement type: clarify, add-details, expand, or simplify
* @returns The refined text
* @throws Error with message for rate limit (429), invalid type (422), validation (400), or server errors
*/
export async function refineText(text: string, type: RefinementType): Promise<string> {
const response = await api<RefineTextResponse>("/ai/refine-text", {
method: "POST",
body: JSON.stringify({ text, type }),
});
return response.refined;
}
/**
* Error messages for refineText failures (to use with toast notifications).
*/
export const REFINE_ERROR_MESSAGES = {
/** Rate limit exceeded (429) */
RATE_LIMIT: "Too many refinement requests. Please wait an hour.",
/** Invalid refinement type (422) */
INVALID_TYPE: "Invalid refinement option selected.",
/** Network or server errors */
NETWORK: "Failed to refine text. Please try again.",
} as const;
/**
* Get user-friendly error message for a refineText error.
* @param error - The error thrown by refineText
* @returns A user-friendly error message suitable for toast display
*/
export function getRefineErrorMessage(error: unknown): string {
if (!(error instanceof Error)) {
return REFINE_ERROR_MESSAGES.NETWORK;
}
const message = error.message.toLowerCase();
// Rate limit errors (429)
if (message.includes("rate limit") || message.includes("429")) {
return REFINE_ERROR_MESSAGES.RATE_LIMIT;
}
// Invalid type errors (422)
if (message.includes("invalid") && message.includes("type")) {
return REFINE_ERROR_MESSAGES.INVALID_TYPE;
}
// Text validation errors (400) - pass through from backend
if (
message.startsWith("text must") ||
message.includes("text is required") ||
message.includes("type is required")
) {
return error.message;
}
// Default network/server error
return REFINE_ERROR_MESSAGES.NETWORK;
}

View File

@@ -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 */}

View File

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

View File

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

View File

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

View File

@@ -2355,6 +2355,86 @@ body {
white-space: nowrap;
}
/* === AI Refine Menu === */
.refine-trigger-wrap {
position: relative;
}
.refine-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
max-width: 280px;
z-index: 100;
overflow: hidden;
}
.refine-button {
display: inline-flex;
align-items: center;
gap: 4px;
}
.refine-button--loading {
opacity: 0.7;
cursor: not-allowed;
}
.refine-menu-item {
padding: 10px 14px;
cursor: pointer;
transition: background 0.1s ease;
border-bottom: 1px solid var(--border);
}
.refine-menu-item:last-child {
border-bottom: none;
}
.refine-menu-item:hover {
background: var(--card-hover);
}
.refine-menu-item-title {
font-weight: 500;
font-size: 13px;
color: var(--text);
margin-bottom: 2px;
}
.refine-menu-item-desc {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
/* NewTaskModal description with refine button */
.description-with-refine {
position: relative;
}
.description-with-refine .refine-button {
position: absolute;
top: 8px;
right: 8px;
}
.description-with-refine textarea {
padding-right: 70px; /* Make room for the refine button */
}
.refine-menu--modal {
right: 0;
left: auto;
top: calc(100% + 4px);
}
.inline-create-model-dropdown {
position: absolute;
top: 100%;