feat(KB-622): unify steeringComments and comments into single comments field

- Merge steeringComments and comments into unified comments field in Task type
- Update TaskStore to use single comments array instead of separate steeringComments
- Add database migration to convert existing steeringComments to comments
- Update executor to inject all comments into AI execution context
- Update dashboard SteeringTab to use unified comments API
- Update CLI task steer command to use comments field
- Update PR comment handler to add comments via unified API
This commit is contained in:
gsxdsm
2026-04-01 07:05:23 -07:00
parent bace63b524
commit afc24408cc
22 changed files with 294 additions and 260 deletions

View File

@@ -8,7 +8,7 @@ import {
loginProvider,
logoutProvider,
fetchModels,
addSteeringComment,
addComment,
addTaskComment,
updateTaskComment,
deleteTaskComment,
@@ -494,7 +494,7 @@ describe("logoutProvider", () => {
});
});
describe("addSteeringComment", () => {
describe("addComment", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
@@ -511,7 +511,7 @@ describe("addSteeringComment", () => {
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
steeringComments: [
comments: [
{
id: "1234567890-abc123",
text: "Please handle the edge case",
@@ -524,11 +524,11 @@ describe("addSteeringComment", () => {
it("sends POST with text and returns updated task", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
const result = await addSteeringComment("FN-001", "Please handle the edge case");
const result = await addComment("FN-001", "Please handle the edge case");
expect(result.id).toBe("FN-001");
expect(result.steeringComments).toHaveLength(1);
expect(result.steeringComments![0].text).toBe("Please handle the edge case");
expect(result.comments).toHaveLength(1);
expect(result.comments![0].text).toBe("Please handle the edge case");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/steer", {
headers: { "Content-Type": "application/json" },
method: "POST",
@@ -541,7 +541,7 @@ describe("addSteeringComment", () => {
mockFetchResponse(false, { error: "Task not found" })
);
await expect(addSteeringComment("FN-001", "Test comment")).rejects.toThrow("Task not found");
await expect(addComment("FN-001", "Test comment")).rejects.toThrow("Task not found");
});
});

View File

@@ -307,7 +307,7 @@ export function deleteTaskComment(id: string, commentId: string): Promise<Task>
});
}
export function addSteeringComment(id: string, text: string): Promise<Task> {
export function addComment(id: string, text: string): Promise<Task> {
return api<Task>(`/tasks/${id}/steer`, {
method: "POST",
body: JSON.stringify({ text }),

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from "react";
import type { TaskDetail } from "@fusion/core";
import { addSteeringComment } from "../api";
import { addComment } from "../api";
import type { ToastType } from "../hooks/useToast";
function formatTimestamp(iso: string): string {
@@ -24,7 +24,7 @@ interface SteeringTabProps {
}
export function SteeringTab({ task, addToast }: SteeringTabProps) {
const [comments, setComments] = useState(task.steeringComments || []);
const [comments, setComments] = useState(task.comments || []);
const [newComment, setNewComment] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -35,10 +35,10 @@ export function SteeringTab({ task, addToast }: SteeringTabProps) {
setIsSubmitting(true);
try {
const updated = await addSteeringComment(task.id, newComment.trim());
setComments(updated.steeringComments || []);
const updated = await addComment(task.id, newComment.trim());
setComments(updated.comments || []);
setNewComment("");
addToast("Steering comment added", "success");
addToast("Comment added", "success");
} catch (err: any) {
addToast(err.message, "error");
} finally {
@@ -60,7 +60,7 @@ export function SteeringTab({ task, addToast }: SteeringTabProps) {
return (
<div className="detail-section">
<h4>Steering Comments</h4>
<h4>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>
@@ -113,7 +113,7 @@ export function SteeringTab({ task, addToast }: SteeringTabProps) {
))}
</div>
) : (
<div style={{ opacity: 0.5, marginBottom: "16px" }}>(no steering comments yet)</div>
<div style={{ opacity: 0.5, marginBottom: "16px" }}>(no comments yet)</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
@@ -121,7 +121,7 @@ export function SteeringTab({ task, addToast }: SteeringTabProps) {
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add a steering comment... (Ctrl+Enter to submit)"
placeholder="Add a comment... (Ctrl+Enter to submit)"
maxLength={MAX_LENGTH}
rows={4}
style={{
@@ -160,7 +160,7 @@ export function SteeringTab({ task, addToast }: SteeringTabProps) {
onClick={handleSubmit}
disabled={!isValid || isSubmitting}
>
{isSubmitting ? "Adding…" : "Add Steering Comment"}
{isSubmitting ? "Adding…" : "Add Comment"}
</button>
</div>
</div>

View File

@@ -109,7 +109,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.reviewLevel === nextTask.reviewLevel &&
previousTask.mergeRetries === nextTask.mergeRetries &&
JSON.stringify(previousTask.attachments ?? []) === JSON.stringify(nextTask.attachments ?? []) &&
JSON.stringify(previousTask.steeringComments ?? []) === JSON.stringify(nextTask.steeringComments ?? []) &&
JSON.stringify(previousTask.comments ?? []) === JSON.stringify(nextTask.comments ?? []) &&
areTaskDependenciesEqual(previousTask.dependencies, nextTask.dependencies) &&
areTaskStepsEqual(previousTask.steps, nextTask.steps) &&
areTaskBadgeInfosEqual(previousTask.prInfo, nextTask.prInfo) &&

View File

@@ -5,10 +5,10 @@ import type { TaskDetail } from "@fusion/core";
// Mock the API module
vi.mock("../../api", () => ({
addSteeringComment: vi.fn(),
addComment: vi.fn(),
}));
import { addSteeringComment } from "../../api";
import { addComment } from "../../api";
const mockAddToast = vi.fn();
@@ -36,13 +36,13 @@ describe("SteeringTab", () => {
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();
expect(screen.getByText("Comments")).toBeTruthy();
expect(screen.getByText(/no comments yet/)).toBeTruthy();
});
it("renders comments in reverse chronological order", () => {
const task = makeTask({
steeringComments: [
comments: [
{
id: "1",
text: "First comment",
@@ -69,7 +69,7 @@ describe("SteeringTab", () => {
it("shows author badges for comments", () => {
const task = makeTask({
steeringComments: [
comments: [
{ 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" },
],
@@ -84,7 +84,7 @@ describe("SteeringTab", () => {
it("shows character count", () => {
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
const textarea = screen.getByPlaceholderText(/Add a comment/);
fireEvent.change(textarea, { target: { value: "Hello" } });
expect(screen.getByText("5 / 2000")).toBeTruthy();
@@ -93,36 +93,36 @@ describe("SteeringTab", () => {
it("disables submit button when textarea is empty", () => {
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
const button = screen.getByRole("button", { name: /Add 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 textarea = screen.getByPlaceholderText(/Add a comment/);
const longText = "a".repeat(2001);
fireEvent.change(textarea, { target: { value: longText } });
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
const button = screen.getByRole("button", { name: /Add 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/);
const textarea = screen.getByPlaceholderText(/Add a comment/);
fireEvent.change(textarea, { target: { value: "Valid comment" } });
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
const button = screen.getByRole("button", { name: /Add Comment/ });
expect(button.hasAttribute("disabled")).toBe(false);
});
it("submits comment on button click", async () => {
const mockApi = vi.mocked(addSteeringComment);
const mockApi = vi.mocked(addComment);
mockApi.mockResolvedValue({
...makeTask(),
steeringComments: [
comments: [
{
id: "new-1",
text: "New comment",
@@ -134,10 +134,10 @@ describe("SteeringTab", () => {
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
const textarea = screen.getByPlaceholderText(/Add a comment/);
fireEvent.change(textarea, { target: { value: "New comment" } });
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
const button = screen.getByRole("button", { name: /Add Comment/ });
fireEvent.click(button);
await waitFor(() => {
@@ -146,10 +146,10 @@ describe("SteeringTab", () => {
});
it("submits comment on Ctrl+Enter", async () => {
const mockApi = vi.mocked(addSteeringComment);
const mockApi = vi.mocked(addComment);
mockApi.mockResolvedValue({
...makeTask(),
steeringComments: [
comments: [
{
id: "new-1",
text: "Keyboard comment",
@@ -161,7 +161,7 @@ describe("SteeringTab", () => {
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
const textarea = screen.getByPlaceholderText(/Add a comment/);
fireEvent.change(textarea, { target: { value: "Keyboard comment" } });
// Ctrl+Enter should submit
@@ -173,10 +173,10 @@ describe("SteeringTab", () => {
});
it("submits comment on Cmd+Enter (Mac)", async () => {
const mockApi = vi.mocked(addSteeringComment);
const mockApi = vi.mocked(addComment);
mockApi.mockResolvedValue({
...makeTask(),
steeringComments: [
comments: [
{
id: "new-1",
text: "Mac keyboard comment",
@@ -188,7 +188,7 @@ describe("SteeringTab", () => {
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
const textarea = screen.getByPlaceholderText(/Add a comment/);
fireEvent.change(textarea, { target: { value: "Mac keyboard comment" } });
// Cmd+Enter should submit (metaKey is Cmd on Mac)
@@ -200,10 +200,10 @@ describe("SteeringTab", () => {
});
it("clears textarea after successful submission", async () => {
const mockApi = vi.mocked(addSteeringComment);
const mockApi = vi.mocked(addComment);
mockApi.mockResolvedValue({
...makeTask(),
steeringComments: [
comments: [
{
id: "new-1",
text: "Cleared comment",
@@ -215,10 +215,10 @@ describe("SteeringTab", () => {
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
const textarea = screen.getByPlaceholderText(/Add a steering comment/) as HTMLTextAreaElement;
const textarea = screen.getByPlaceholderText(/Add a comment/) as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "Cleared comment" } });
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
const button = screen.getByRole("button", { name: /Add Comment/ });
fireEvent.click(button);
await waitFor(() => {
@@ -227,16 +227,16 @@ describe("SteeringTab", () => {
});
it("shows loading state during submission", async () => {
const mockApi = vi.mocked(addSteeringComment);
const mockApi = vi.mocked(addComment);
// 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/);
const textarea = screen.getByPlaceholderText(/Add a comment/);
fireEvent.change(textarea, { target: { value: "Loading test" } });
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
const button = screen.getByRole("button", { name: /Add Comment/ });
fireEvent.click(button);
// Should show loading text
@@ -244,15 +244,15 @@ describe("SteeringTab", () => {
});
it("shows error toast on API failure", async () => {
const mockApi = vi.mocked(addSteeringComment);
const mockApi = vi.mocked(addComment);
mockApi.mockRejectedValue(new Error("Network error"));
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
const textarea = screen.getByPlaceholderText(/Add a comment/);
fireEvent.change(textarea, { target: { value: "Error test" } });
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
const button = screen.getByRole("button", { name: /Add Comment/ });
fireEvent.click(button);
await waitFor(() => {
@@ -261,10 +261,10 @@ describe("SteeringTab", () => {
});
it("updates comment list after successful submission", async () => {
const mockApi = vi.mocked(addSteeringComment);
const mockApi = vi.mocked(addComment);
mockApi.mockResolvedValue({
...makeTask(),
steeringComments: [
comments: [
{
id: "new-1",
text: "Added comment",
@@ -276,10 +276,10 @@ describe("SteeringTab", () => {
render(<SteeringTab task={makeTask()} addToast={mockAddToast} />);
const textarea = screen.getByPlaceholderText(/Add a steering comment/);
const textarea = screen.getByPlaceholderText(/Add a comment/);
fireEvent.change(textarea, { target: { value: "Added comment" } });
const button = screen.getByRole("button", { name: /Add Steering Comment/ });
const button = screen.getByRole("button", { name: /Add Comment/ });
fireEvent.click(button);
await waitFor(() => {

View File

@@ -71,7 +71,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
addComment: vi.fn(),
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
@@ -1858,7 +1858,7 @@ describe("Pause/Unpause endpoints", () => {
it("adds a steering comment to a task", async () => {
const mockComment = {
id: "FN-001",
steeringComments: [
comments: [
{
id: "1234567890-abc123",
text: "Please handle the edge case",
@@ -1867,7 +1867,7 @@ describe("Pause/Unpause endpoints", () => {
},
],
};
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
(store.addComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
const res = await REQUEST(
buildApp(),
@@ -1879,7 +1879,7 @@ describe("Pause/Unpause endpoints", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual(mockComment);
expect(store.addSteeringComment).toHaveBeenCalledWith(
expect(store.addComment).toHaveBeenCalledWith(
"KB-001",
"Please handle the edge case",
"user"
@@ -1926,7 +1926,7 @@ describe("Pause/Unpause endpoints", () => {
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);
(store.addComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
const res = await REQUEST(
buildApp(),
@@ -1940,7 +1940,7 @@ describe("Pause/Unpause endpoints", () => {
});
it("returns 500 on unexpected errors", async () => {
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(
(store.addComment as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Database error")
);

View File

@@ -2114,7 +2114,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.status(400).json({ error: "text must be between 1 and 2000 characters" });
return;
}
const task = await store.addSteeringComment(req.params.id, text, "user");
const task = await store.addComment(req.params.id, text, "user");
res.json(task);
} catch (err: any) {
const status = err.code === "ENOENT" ? 404 : 500;

View File

@@ -6,5 +6,6 @@
"jsx": "react-jsx",
"types": ["node", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src/**/*"]
"include": ["src/**/*"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/__tests__/**/*"]
}