feat(KB-003): add task steering feature with UI and prompt injection
- Add SteeringComment type to core types for user steering input - Add addSteeringComment method to TaskStore with persistence - Add POST /api/tasks/:id/steer endpoint for adding steering comments - Add frontend API client and SteeringTab UI component - Inject steering comments into execution prompt for agent guidance - Add comprehensive tests for store, API, UI, and executor components - Include changeset for patch release
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "./api";
|
||||
import { fetchTaskDetail, updateTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment } from "./api";
|
||||
import type { Task, TaskDetail } from "@kb/core";
|
||||
|
||||
const FAKE_DETAIL: TaskDetail = {
|
||||
@@ -211,3 +211,54 @@ describe("logoutProvider", () => {
|
||||
await expect(logoutProvider("anthropic")).rejects.toThrow("logout failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSteeringComment", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const FAKE_TASK: Task = {
|
||||
id: "KB-001",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
steeringComments: [
|
||||
{
|
||||
id: "1234567890-abc123",
|
||||
text: "Please handle the edge case",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("sends POST with text and returns updated task", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
||||
|
||||
const result = await addSteeringComment("KB-001", "Please handle the edge case");
|
||||
|
||||
expect(result.id).toBe("KB-001");
|
||||
expect(result.steeringComments).toHaveLength(1);
|
||||
expect(result.steeringComments![0].text).toBe("Please handle the edge case");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/steer", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "Please handle the edge case" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error response", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Task not found" })
|
||||
);
|
||||
|
||||
await expect(addSteeringComment("KB-001", "Test comment")).rejects.toThrow("Task not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,13 @@ export function fetchAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
|
||||
return api<AgentLogEntry[]>(`/tasks/${taskId}/logs`);
|
||||
}
|
||||
|
||||
export function addSteeringComment(id: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/steer`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Models API ---
|
||||
|
||||
/** Available AI model info returned by the models endpoint */
|
||||
|
||||
169
packages/dashboard/app/components/SteeringTab.tsx
Normal file
169
packages/dashboard/app/components/SteeringTab.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { TaskDetail } from "@kb/core";
|
||||
import { addSteeringComment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
interface SteeringTabProps {
|
||||
task: TaskDetail;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function SteeringTab({ task, addToast }: SteeringTabProps) {
|
||||
const [comments, setComments] = useState(task.steeringComments || []);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const MAX_LENGTH = 2000;
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!newComment.trim() || newComment.length > MAX_LENGTH || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const updated = await addSteeringComment(task.id, newComment.trim());
|
||||
setComments(updated.steeringComments || []);
|
||||
setNewComment("");
|
||||
addToast("Steering comment added", "success");
|
||||
} catch (err: any) {
|
||||
addToast(err.message, "error");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [task.id, newComment, isSubmitting, addToast]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
const isValid = newComment.trim().length > 0 && newComment.length <= MAX_LENGTH;
|
||||
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<h4>Steering Comments</h4>
|
||||
<p style={{ fontSize: "13px", opacity: 0.7, marginBottom: "12px" }}>
|
||||
Add comments to guide the AI during task execution. These are injected into the execution context.
|
||||
</p>
|
||||
|
||||
{comments.length > 0 ? (
|
||||
<div className="detail-activity-list" style={{ marginBottom: "16px" }}>
|
||||
{[...comments].reverse().map((comment) => (
|
||||
<div key={comment.id} className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span
|
||||
className="detail-log-timestamp"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
background:
|
||||
comment.author === "user"
|
||||
? "var(--accent-primary, #6366f1)"
|
||||
: "var(--accent-secondary, #8b5cf6)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{comment.author}
|
||||
</span>
|
||||
{formatTimestamp(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "4px",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-secondary, #1a1a2e)",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid var(--border, #333)",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.5",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{comment.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ opacity: 0.5, marginBottom: "16px" }}>(no steering comments yet)</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a steering comment... (Ctrl+Enter to submit)"
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={4}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
fontSize: "14px",
|
||||
fontFamily: "inherit",
|
||||
background: "var(--bg-secondary, #1a1a2e)",
|
||||
border: "1px solid var(--border, #333)",
|
||||
borderRadius: "6px",
|
||||
color: "inherit",
|
||||
resize: "vertical",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
opacity: newComment.length > MAX_LENGTH ? 0.9 : 0.5,
|
||||
color:
|
||||
newComment.length > MAX_LENGTH
|
||||
? "var(--error, #ef4444)"
|
||||
: "inherit",
|
||||
}}
|
||||
>
|
||||
{newComment.length} / {MAX_LENGTH}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Adding…" : "Add Steering Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
import { SteeringTab } from "./SteeringTab";
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
@@ -54,7 +55,7 @@ export function TaskDetailModal({
|
||||
onRetryTask,
|
||||
addToast,
|
||||
}: TaskDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "agent-log">("definition");
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "agent-log" | "steering">("definition");
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
@@ -282,11 +283,19 @@ export function TaskDetailModal({
|
||||
>
|
||||
Agent Log
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "steering" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("steering")}
|
||||
>
|
||||
Steering
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === "agent-log" ? (
|
||||
<div className="detail-section">
|
||||
<AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} />
|
||||
</div>
|
||||
) : activeTab === "steering" ? (
|
||||
<SteeringTab task={task} addToast={addToast} />
|
||||
) : (
|
||||
<>
|
||||
<div className="detail-section">
|
||||
|
||||
290
packages/dashboard/app/components/__tests__/SteeringTab.test.tsx
Normal file
290
packages/dashboard/app/components/__tests__/SteeringTab.test.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SteeringTab } from "../SteeringTab";
|
||||
import type { TaskDetail } from "@kb/core";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
addSteeringComment: vi.fn(),
|
||||
}));
|
||||
|
||||
import { addSteeringComment } from "../../api";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
return {
|
||||
id: "KB-001",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
prompt: "# Test\n\nTest prompt",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SteeringTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty state when no comments", () => {
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
expect(screen.getByText("Steering Comments")).toBeTruthy();
|
||||
expect(screen.getByText(/no steering comments yet/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders comments in reverse chronological order", () => {
|
||||
const task = makeTask({
|
||||
steeringComments: [
|
||||
{
|
||||
id: "1",
|
||||
text: "First comment",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
text: "Second comment",
|
||||
createdAt: "2024-01-02T00:00:00Z",
|
||||
author: "agent",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={task} addToast={mockAddToast} />);
|
||||
|
||||
// Comments should be in reverse order (newest first)
|
||||
const comments = screen.getAllByText(/comment$/);
|
||||
expect(comments.length).toBe(2);
|
||||
expect(comments[0].textContent).toBe("Second comment");
|
||||
expect(comments[1].textContent).toBe("First comment");
|
||||
});
|
||||
|
||||
it("shows author badges for comments", () => {
|
||||
const task = makeTask({
|
||||
steeringComments: [
|
||||
{ id: "1", text: "User comment", createdAt: "2024-01-01T00:00:00Z", author: "user" },
|
||||
{ id: "2", text: "Agent comment", createdAt: "2024-01-02T00:00:00Z", author: "agent" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={task} addToast={mockAddToast} />);
|
||||
|
||||
expect(screen.getByText("user")).toBeTruthy();
|
||||
expect(screen.getByText("agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows character count", () => {
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Hello" } });
|
||||
|
||||
expect(screen.getByText("5 / 2000")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables submit button when textarea is empty", () => {
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
expect(button.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("disables submit button when text exceeds 2000 characters", () => {
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
const longText = "a".repeat(2001);
|
||||
fireEvent.change(textarea, { target: { value: longText } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
expect(button.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("enables submit button when text is valid", () => {
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Valid comment" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
expect(button.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("submits comment on button click", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockResolvedValue({
|
||||
...makeTask(),
|
||||
steeringComments: [
|
||||
{
|
||||
id: "new-1",
|
||||
text: "New comment",
|
||||
createdAt: "2024-01-03T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "New comment" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApi).toHaveBeenCalledWith("KB-001", "New comment");
|
||||
});
|
||||
});
|
||||
|
||||
it("submits comment on Ctrl+Enter", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockResolvedValue({
|
||||
...makeTask(),
|
||||
steeringComments: [
|
||||
{
|
||||
id: "new-1",
|
||||
text: "Keyboard comment",
|
||||
createdAt: "2024-01-03T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Keyboard comment" } });
|
||||
|
||||
// Ctrl+Enter should submit
|
||||
fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApi).toHaveBeenCalledWith("KB-001", "Keyboard comment");
|
||||
});
|
||||
});
|
||||
|
||||
it("submits comment on Cmd+Enter (Mac)", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockResolvedValue({
|
||||
...makeTask(),
|
||||
steeringComments: [
|
||||
{
|
||||
id: "new-1",
|
||||
text: "Mac keyboard comment",
|
||||
createdAt: "2024-01-03T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Mac keyboard comment" } });
|
||||
|
||||
// Cmd+Enter should submit (metaKey is Cmd on Mac)
|
||||
fireEvent.keyDown(textarea, { key: "Enter", metaKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApi).toHaveBeenCalledWith("KB-001", "Mac keyboard comment");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears textarea after successful submission", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockResolvedValue({
|
||||
...makeTask(),
|
||||
steeringComments: [
|
||||
{
|
||||
id: "new-1",
|
||||
text: "Cleared comment",
|
||||
createdAt: "2024-01-03T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/) as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "Cleared comment" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textarea.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state during submission", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
// Delay the resolution to see loading state
|
||||
mockApi.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Loading test" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
// Should show loading text
|
||||
expect(screen.getByRole("button", { name: /Adding…/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error toast on API failure", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Error test" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Network error", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates comment list after successful submission", async () => {
|
||||
const mockApi = vi.mocked(addSteeringComment);
|
||||
mockApi.mockResolvedValue({
|
||||
...makeTask(),
|
||||
steeringComments: [
|
||||
{
|
||||
id: "new-1",
|
||||
text: "Added comment",
|
||||
createdAt: "2024-01-03T00:00:00Z",
|
||||
author: "user",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
|
||||
fireEvent.change(textarea, { target: { value: "Added comment" } });
|
||||
|
||||
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Added comment")).toBeTruthy();
|
||||
expect(screen.getByText("user")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -646,6 +646,47 @@ describe("TaskDetailModal", () => {
|
||||
const afterSwitch = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
|
||||
expect(afterSwitch[1]).toBe(true);
|
||||
});
|
||||
|
||||
it("switches to Steering tab", async () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Steering tab
|
||||
fireEvent.click(screen.getByText("Steering"));
|
||||
|
||||
// Steering content should appear
|
||||
expect(screen.getByText("Steering Comments")).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText(/Add a steering comment/)).toBeTruthy();
|
||||
// Definition content should be hidden
|
||||
expect(container.querySelector(".markdown-body")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Steering tab as third tab", async () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tabs = screen.getAllByRole("button").filter((b) =>
|
||||
["Definition", "Agent Log", "Steering"].includes(b.textContent || "")
|
||||
);
|
||||
expect(tabs.length).toBe(3);
|
||||
expect(tabs[2].textContent).toBe("Steering");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile responsive structure", () => {
|
||||
@@ -714,11 +755,12 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(tabs.length).toBe(2);
|
||||
expect(tabs.length).toBe(3);
|
||||
// Tabs should use class-based styling, not inline styles
|
||||
expect(tabs[0].classList.contains("detail-tab")).toBe(true);
|
||||
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); // Definition is default active
|
||||
expect(tabs[1].classList.contains("detail-tab-active")).toBe(false);
|
||||
expect(tabs[2].classList.contains("detail-tab-active")).toBe(false);
|
||||
// Verify no inline padding/fontSize (responsive CSS controls this)
|
||||
expect((tabs[0] as HTMLElement).style.padding).toBe("");
|
||||
expect((tabs[0] as HTMLElement).style.fontSize).toBe("");
|
||||
|
||||
@@ -19,6 +19,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updateSettings: vi.fn(),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
addSteeringComment: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
@@ -655,6 +656,109 @@ describe("Pause/Unpause endpoints", () => {
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe("not found");
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/steer", () => {
|
||||
it("adds a steering comment to a task", async () => {
|
||||
const mockComment = {
|
||||
id: "KB-001",
|
||||
steeringComments: [
|
||||
{
|
||||
id: "1234567890-abc123",
|
||||
text: "Please handle the edge case",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
author: "user" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/steer",
|
||||
JSON.stringify({ text: "Please handle the edge case" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(mockComment);
|
||||
expect(store.addSteeringComment).toHaveBeenCalledWith(
|
||||
"KB-001",
|
||||
"Please handle the edge case",
|
||||
"user"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 when text is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/steer", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("text is required");
|
||||
});
|
||||
|
||||
it("returns 400 when text is empty", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/steer",
|
||||
JSON.stringify({ text: "" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
// Empty string fails the "!text" check, not the length check
|
||||
expect(res.body.error).toContain("text is required");
|
||||
});
|
||||
|
||||
it("returns 400 when text exceeds 2000 characters", async () => {
|
||||
const longText = "a".repeat(2001);
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/steer",
|
||||
JSON.stringify({ text: longText }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("text must be between 1 and 2000 characters");
|
||||
});
|
||||
|
||||
it("returns 404 when task not found", async () => {
|
||||
const error = new Error("Task not found") as Error & { code?: string };
|
||||
error.code = "ENOENT";
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/steer",
|
||||
JSON.stringify({ text: "Valid comment" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 500 on unexpected errors", async () => {
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Database error")
|
||||
);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/steer",
|
||||
JSON.stringify({ text: "Valid comment" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe("Database error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- GitHub Import route tests ---
|
||||
|
||||
@@ -259,6 +259,26 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// Add steering comment to task
|
||||
router.post("/tasks/:id/steer", async (req, res) => {
|
||||
try {
|
||||
const { text } = req.body;
|
||||
if (!text || typeof text !== "string") {
|
||||
res.status(400).json({ error: "text is required and must be a string" });
|
||||
return;
|
||||
}
|
||||
if (text.length === 0 || text.length > 2000) {
|
||||
res.status(400).json({ error: "text must be between 1 and 2000 characters" });
|
||||
return;
|
||||
}
|
||||
const task = await store.addSteeringComment(req.params.id, text, "user");
|
||||
res.json(task);
|
||||
} catch (err: any) {
|
||||
const status = err.code === "ENOENT" ? 404 : 500;
|
||||
res.status(status).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update task
|
||||
router.patch("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user