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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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__/**/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user