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:
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("");
|
||||
|
||||
Reference in New Issue
Block a user