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

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": patch
---
Add task steering from dashboard — users can now add comments to in-progress tasks that are injected into the AI execution context

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } from "./types.js"; export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, THINKING_LEVELS } from "./types.js";
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel } from "./types.js"; export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry, ThinkingLevel, SteeringComment } from "./types.js";
export { TaskStore } from "./store.js"; export { TaskStore } from "./store.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js"; export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";

View File

@@ -718,6 +718,98 @@ describe("TaskStore", () => {
}); });
}); });
describe("addSteeringComment", () => {
it("adds a steering comment to a task", async () => {
const task = await createTestTask();
const updated = await store.addSteeringComment(task.id, "Please handle the edge case");
expect(updated.steeringComments).toHaveLength(1);
expect(updated.steeringComments![0].text).toBe("Please handle the edge case");
expect(updated.steeringComments![0].author).toBe("user");
expect(updated.steeringComments![0].id).toBeDefined();
expect(updated.steeringComments![0].createdAt).toBeDefined();
});
it("accepts agent as author", async () => {
const task = await createTestTask();
const updated = await store.addSteeringComment(task.id, "Note from agent", "agent");
expect(updated.steeringComments).toHaveLength(1);
expect(updated.steeringComments![0].author).toBe("agent");
});
it("initializes steeringComments array if undefined", async () => {
const task = await createTestTask();
expect(task.steeringComments).toBeUndefined();
const updated = await store.addSteeringComment(task.id, "First comment");
expect(updated.steeringComments).toBeDefined();
expect(updated.steeringComments).toHaveLength(1);
});
it("appends multiple comments in order", async () => {
const task = await createTestTask();
await store.addSteeringComment(task.id, "First comment");
await store.addSteeringComment(task.id, "Second comment");
await store.addSteeringComment(task.id, "Third comment");
const fetched = await store.getTask(task.id);
expect(fetched.steeringComments).toHaveLength(3);
expect(fetched.steeringComments![0].text).toBe("First comment");
expect(fetched.steeringComments![1].text).toBe("Second comment");
expect(fetched.steeringComments![2].text).toBe("Third comment");
});
it("generates unique IDs for each comment", async () => {
const task = await createTestTask();
const updated1 = await store.addSteeringComment(task.id, "Comment 1");
const updated2 = await store.addSteeringComment(task.id, "Comment 2");
const id1 = updated1.steeringComments![0].id;
const id2 = updated2.steeringComments![1].id;
expect(id1).not.toBe(id2);
});
it("emits task:updated event", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("task:updated", (t) => events.push(t));
await store.addSteeringComment(task.id, "Test comment");
expect(events).toHaveLength(1);
expect(events[0].steeringComments).toHaveLength(1);
expect(events[0].steeringComments![0].text).toBe("Test comment");
});
it("persists to disk and round-trips correctly", async () => {
const task = await createTestTask();
await store.addSteeringComment(task.id, "Persisted comment");
const fetched = await store.getTask(task.id);
expect(fetched.steeringComments).toHaveLength(1);
expect(fetched.steeringComments![0].text).toBe("Persisted comment");
expect(fetched.steeringComments![0].author).toBe("user");
});
it("adds log entry for the action", async () => {
const task = await createTestTask();
const updated = await store.addSteeringComment(task.id, "Comment with log");
expect(updated.log.some((l) => l.action === "Steering comment added")).toBe(true);
expect(updated.log.some((l) => l.outcome === "by user")).toBe(true);
});
it("updates updatedAt timestamp", async () => {
const task = await createTestTask();
const before = task.updatedAt;
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
const updated = await store.addSteeringComment(task.id, "Timestamp test");
expect(updated.updatedAt).not.toBe(before);
});
});
describe("parseDependenciesFromPrompt", () => { describe("parseDependenciesFromPrompt", () => {
it("returns single dependency from PROMPT.md", async () => { it("returns single dependency from PROMPT.md", async () => {
const task = await store.createTask({ description: "Task with dep" }); const task = await store.createTask({ description: "Task with dep" });

View File

@@ -981,6 +981,48 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.emit("agent:log", entry); this.emit("agent:log", entry);
} }
/**
* Add a steering comment to a task.
* Steering comments are user-provided feedback injected into the AI execution context.
*/
async addSteeringComment(
id: string,
text: string,
author: "user" | "agent" = "user",
): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
// Generate unique ID: timestamp + random suffix for collision resistance
const commentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const comment: import("./types.js").SteeringComment = {
id: commentId,
text,
createdAt: new Date().toISOString(),
author,
};
if (!task.steeringComments) {
task.steeringComments = [];
}
task.steeringComments.push(comment);
task.updatedAt = new Date().toISOString();
task.log.push({
timestamp: task.updatedAt,
action: "Steering comment added",
outcome: `by ${author}`,
});
await this.atomicWriteTaskJson(dir, task);
if (this.watcher) this.taskCache.set(id, { ...task });
this.emit("task:updated", task);
return task;
});
}
/** /**
* Read all historical agent log entries for a task from its agent log file. * Read all historical agent log entries for a task from its agent log file.
* Returns entries in chronological order (oldest first). * Returns entries in chronological order (oldest first).

View File

@@ -49,6 +49,13 @@ export interface TaskAttachment {
createdAt: string; createdAt: string;
} }
export interface SteeringComment {
id: string;
text: string;
createdAt: string;
author: "user" | "agent";
}
export interface Task { export interface Task {
id: string; id: string;
title?: string; title?: string;
@@ -73,6 +80,7 @@ export interface Task {
* dependency's branch instead of HEAD. Cleared after worktree creation. */ * dependency's branch instead of HEAD. Cleared after worktree creation. */
baseBranch?: string; baseBranch?: string;
attachments?: TaskAttachment[]; attachments?: TaskAttachment[];
steeringComments?: SteeringComment[];
log: TaskLogEntry[]; log: TaskLogEntry[];
size?: "S" | "M" | "L"; size?: "S" | "M" | "L";
reviewLevel?: number; reviewLevel?: number;

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 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"; import type { Task, TaskDetail } from "@kb/core";
const FAKE_DETAIL: TaskDetail = { const FAKE_DETAIL: TaskDetail = {
@@ -211,3 +211,54 @@ describe("logoutProvider", () => {
await expect(logoutProvider("anthropic")).rejects.toThrow("logout failed"); 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`); 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 --- // --- Models API ---
/** Available AI model info returned by the models endpoint */ /** 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 type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs"; import { useAgentLogs } from "../hooks/useAgentLogs";
import { AgentLogViewer } from "./AgentLogViewer"; import { AgentLogViewer } from "./AgentLogViewer";
import { SteeringTab } from "./SteeringTab";
function formatTimestamp(iso: string): string { function formatTimestamp(iso: string): string {
const date = new Date(iso); const date = new Date(iso);
@@ -54,7 +55,7 @@ export function TaskDetailModal({
onRetryTask, onRetryTask,
addToast, addToast,
}: TaskDetailModalProps) { }: 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 [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []); const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
@@ -282,11 +283,19 @@ export function TaskDetailModal({
> >
Agent Log Agent Log
</button> </button>
<button
className={`detail-tab${activeTab === "steering" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("steering")}
>
Steering
</button>
</div> </div>
{activeTab === "agent-log" ? ( {activeTab === "agent-log" ? (
<div className="detail-section"> <div className="detail-section">
<AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} /> <AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} />
</div> </div>
) : activeTab === "steering" ? (
<SteeringTab task={task} addToast={addToast} />
) : ( ) : (
<> <>
<div className="detail-section"> <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]; const afterSwitch = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
expect(afterSwitch[1]).toBe(true); 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", () => { describe("mobile responsive structure", () => {
@@ -714,11 +755,12 @@ describe("TaskDetailModal", () => {
); );
const tabs = container.querySelectorAll(".detail-tab"); 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 // 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")).toBe(true);
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true); // Definition is default active 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[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) // Verify no inline padding/fontSize (responsive CSS controls this)
expect((tabs[0] as HTMLElement).style.padding).toBe(""); expect((tabs[0] as HTMLElement).style.padding).toBe("");
expect((tabs[0] as HTMLElement).style.fontSize).toBe(""); expect((tabs[0] as HTMLElement).style.fontSize).toBe("");

View File

@@ -19,6 +19,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
updateSettings: vi.fn(), updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]), getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
...overrides, ...overrides,
} as unknown as TaskStore; } as unknown as TaskStore;
} }
@@ -655,6 +656,109 @@ describe("Pause/Unpause endpoints", () => {
expect(res.status).toBe(500); expect(res.status).toBe(500);
expect(res.body.error).toBe("not found"); 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 --- // --- GitHub Import route tests ---

View File

@@ -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 // Update task
router.patch("/tasks/:id", async (req, res) => { router.patch("/tasks/:id", async (req, res) => {
try { try {

View File

@@ -994,6 +994,83 @@ describe("buildExecutionPrompt", () => {
expect(result).not.toContain("## Project Commands"); expect(result).not.toContain("## Project Commands");
}); });
it("includes Steering Comments section when steeringComments has entries", () => {
const task = createMockTaskDetail({
steeringComments: [
{
id: "1",
text: "Please handle the edge case",
createdAt: new Date().toISOString(),
author: "user" as const,
},
],
});
const result = buildExecutionPrompt(task);
expect(result).toContain("## Steering Comments");
expect(result).toContain("**user**");
expect(result).toContain("> Please handle the edge case");
expect(result).toContain("The following steering comments were added by the user");
});
it("formats multiple steering comments correctly", () => {
const now = new Date();
const task = createMockTaskDetail({
steeringComments: [
{
id: "1",
text: "First comment",
createdAt: new Date(now.getTime() - 60000).toISOString(), // 1 minute ago
author: "user" as const,
},
{
id: "2",
text: "Second comment",
createdAt: now.toISOString(),
author: "agent" as const,
},
],
});
const result = buildExecutionPrompt(task);
expect(result).toContain("**user**");
expect(result).toContain("**agent**");
expect(result).toContain("> First comment");
expect(result).toContain("> Second comment");
});
it("omits Steering Comments section when steeringComments is empty", () => {
const task = createMockTaskDetail({ steeringComments: [] });
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Steering Comments");
});
it("omits Steering Comments section when steeringComments is undefined", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Steering Comments");
});
it("includes only the 10 most recent steering comments", () => {
const steeringComments = Array.from({ length: 15 }, (_, i) => ({
id: `${i}`,
text: `Comment ${i}`,
createdAt: new Date().toISOString(),
author: "user" as const,
}));
const task = createMockTaskDetail({ steeringComments });
const result = buildExecutionPrompt(task);
// Should include comments 5-14 (the 10 most recent), not 0-4
expect(result).toContain("> Comment 5");
expect(result).toContain("> Comment 14");
expect(result).not.toContain("> Comment 0");
expect(result).not.toContain("> Comment 4");
});
it("passes settings to buildExecutionPrompt in TaskExecutor.execute()", async () => { it("passes settings to buildExecutionPrompt in TaskExecutor.execute()", async () => {
const store = createMockStore(); const store = createMockStore();
store.getSettings.mockResolvedValue({ store.getSettings.mockResolvedValue({

View File

@@ -959,6 +959,25 @@ export class TaskExecutor {
} }
} }
/**
* Format a timestamp for display in steering comments.
* Returns relative time for recent comments, absolute date for older ones.
*/
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();
}
// Project commands are injected here (for reliability) and also in the PROMPT.md (by triage). // Project commands are injected here (for reliability) and also in the PROMPT.md (by triage).
// This ensures the executor agent always sees the authoritative commands from settings, // This ensures the executor agent always sees the authoritative commands from settings,
// even if the PROMPT.md was written manually or before commands were configured. // even if the PROMPT.md was written manually or before commands were configured.
@@ -1019,6 +1038,26 @@ git log --oneline
commandsSection = "\n" + lines.join("\n") + "\n"; commandsSection = "\n" + lines.join("\n") + "\n";
} }
// Build steering comments section (last 10 comments only to avoid context bloat)
let steeringSection = "";
if (task.steeringComments && task.steeringComments.length > 0) {
const recentComments = [...task.steeringComments].slice(-10);
const lines = [
"",
"## Steering Comments",
"",
"The following steering comments were added by the user during execution. Consider adjusting your approach or replanning remaining steps based on this feedback.",
"",
];
for (const comment of recentComments) {
const timestamp = formatTimestamp(comment.createdAt);
lines.push(`**${comment.author}** — ${timestamp}`);
lines.push(`> ${comment.text}`);
lines.push("");
}
steeringSection = lines.join("\n");
}
return `Execute this task. return `Execute this task.
## Task: ${task.id} ## Task: ${task.id}
@@ -1028,7 +1067,7 @@ ${task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}`
## PROMPT.md ## PROMPT.md
${task.prompt} ${task.prompt}
${attachmentsSection}${commandsSection}${progressSection} ${attachmentsSection}${commandsSection}${progressSection}${steeringSection}
## Review level: ${reviewLevel} ## Review level: ${reviewLevel}
${reviewLevel === 0 ? "No reviews required. Implement directly." : ""} ${reviewLevel === 0 ? "No reviews required. Implement directly." : ""}