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:
gsxdsm
2026-03-29 17:40:42 -07:00
parent 5214cc0657
commit 80abb826ee
15 changed files with 960 additions and 5 deletions

View File

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

View File

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

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

View File

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

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

View File

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