feat(FN-671): preserve quick entry disclosure and normalize comment migration

- Keep QuickEntryBox disclosure state persisted and covered by updated UI tests
- Add normalized legacy comment migration logic to unify steering comments without duplication
- Update database migration tests to verify schema v5 comment handling behavior
- Carry merged dashboard, engine, and routing changes into the squash commit
This commit is contained in:
gsxdsm
2026-04-01 13:07:26 -07:00
parent 1ccaf34d9b
commit 0da3fea13c
14 changed files with 511 additions and 230 deletions

View File

@@ -466,6 +466,47 @@ describe("migrateFromLegacy", () => {
});
});
describe("comment migration", () => {
it("deduplicates overlapping steeringComments and comments during legacy import", async () => {
const tasksDir = join(kbDir, "tasks");
const taskDir = join(tasksDir, "FN-002");
await mkdir(taskDir, { recursive: true });
await writeFile(
join(taskDir, "task.json"),
JSON.stringify({
id: "FN-002",
description: "Comment overlap",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
steeringComments: [
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user" },
],
comments: [
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user", updatedAt: "2025-01-02T00:00:00.000Z" },
{ id: "c2", text: "General note", createdAt: "2025-01-03T00:00:00.000Z", author: "alice" },
],
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
}),
);
await migrateFromLegacy(kbDir, db);
const row = db.prepare("SELECT steeringComments, comments FROM tasks WHERE id = 'FN-002'").get() as any;
expect(JSON.parse(row.steeringComments)).toEqual([
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user" },
]);
expect(JSON.parse(row.comments)).toEqual([
{ id: "c1", text: "Use TypeScript", createdAt: "2025-01-01T00:00:00.000Z", author: "user", updatedAt: "2025-01-02T00:00:00.000Z" },
{ id: "c2", text: "General note", createdAt: "2025-01-03T00:00:00.000Z", author: "alice" },
]);
});
});
describe("data integrity", () => {
it("preserves all task fields through migration", async () => {
const tasksDir = join(kbDir, "tasks");
@@ -541,9 +582,10 @@ describe("migrateFromLegacy", () => {
expect(JSON.parse(row.steps)).toHaveLength(2);
expect(JSON.parse(row.log)).toHaveLength(1);
expect(JSON.parse(row.attachments)).toHaveLength(1);
// steeringComments are merged into comments during migration
expect(JSON.parse(row.steeringComments)).toEqual([]); // Now empty - merged into comments
expect(JSON.parse(row.comments)).toHaveLength(1); // Migrated from steeringComments
expect(JSON.parse(row.steeringComments)).toHaveLength(1);
expect(JSON.parse(row.comments)).toEqual([
{ id: "c1", text: "Fix this", createdAt: "2025-01-01", author: "user" },
]);
expect(JSON.parse(row.workflowStepResults)).toHaveLength(1);
expect(JSON.parse(row.prInfo).number).toBe(1);
expect(JSON.parse(row.issueInfo).number).toBe(10);

View File

@@ -12,7 +12,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { readFile, readdir, rename, stat } from "node:fs/promises";
import { join } from "node:path";
import type { Database } from "./db.js";
import { toJson, toJsonNullable } from "./db.js";
import { toJson, toJsonNullable, normalizeTaskComments } from "./db.js";
import type { Task, BoardConfig, ActivityLogEntry, ArchivedTaskEntry } from "./types.js";
import type { ScheduledTask } from "./automation.js";
@@ -184,20 +184,10 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
const raw = await readFile(taskJsonPath, "utf-8");
const task: Task = JSON.parse(raw);
const steeringComments = Array.isArray((task as any).steeringComments)
? (task as any).steeringComments
: [];
const comments = Array.isArray(task.comments) ? task.comments : [];
const mergedComments = [
...steeringComments.map((comment: any) => ({
id: comment.id,
text: comment.text,
author: comment.author,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt ?? comment.createdAt,
})),
...comments,
];
const normalizedComments = normalizeTaskComments(
task.steeringComments,
task.comments,
);
insertStmt.run(
task.id,
@@ -229,8 +219,8 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
toJson(task.steps || []),
toJson(task.log || []),
toJson(task.attachments || []),
"[]",
toJson(mergedComments),
toJson(normalizedComments.steeringComments),
toJson(normalizedComments.comments),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Database, createDatabase, toJson, toJsonNullable, fromJson } from "./db.js";
import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "./db.js";
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
import { mkdtempSync, existsSync } from "node:fs";
import { join } from "node:path";
@@ -491,6 +491,26 @@ describe("Database", () => {
});
});
describe("comment normalization", () => {
it("merges overlapping legacy and unified comments exactly once", () => {
const normalized = normalizeTaskComments(
[{ id: "c1", text: "Legacy note", author: "user", createdAt: "2025-01-01T00:00:00.000Z" }],
[{ id: "c1", text: "Legacy note", author: "user", createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-01-02T00:00:00.000Z" }],
);
expect(normalized.comments).toEqual([
{
id: "c1",
text: "Legacy note",
author: "user",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
},
]);
expect(normalized.steeringComments).toHaveLength(1);
});
});
describe("JSON helpers", () => {
describe("toJson", () => {
it("stringifies arrays", () => {
@@ -683,7 +703,7 @@ describe("schema migrations", () => {
// Now run init() which should trigger migration
db.init();
// Verify version bumped to 4 (includes v1→v2, v2→v3, and v3→v4 migrations)
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
expect(db.getSchemaVersion()).toBe(5);
// Verify new columns exist and existing data is intact
@@ -807,7 +827,7 @@ describe("schema migrations", () => {
// Now run init() which should trigger migrations v2→v3→v4
db.init();
// Verify version bumped to 4
// Verify version bumped to 5
expect(db.getSchemaVersion()).toBe(5);
// Verify new columns exist and existing data is intact
@@ -836,6 +856,159 @@ describe("schema migrations", () => {
db.close();
});
it("migrates pre-comments databases by copying steering comments into unified comments exactly once", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const db = new Database(kbDir);
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
steeringComments TEXT DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1),
nextId INTEGER DEFAULT 1,
nextWorkflowStepId INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
workflowSteps TEXT DEFAULT '[]',
updatedAt TEXT
);
CREATE TABLE IF NOT EXISTS activityLog (
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, type TEXT NOT NULL,
taskId TEXT, taskTitle TEXT, details TEXT NOT NULL, metadata TEXT
);
CREATE TABLE IF NOT EXISTS archivedTasks (id TEXT PRIMARY KEY, data TEXT NOT NULL, archivedAt TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS automations (
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT,
scheduleType TEXT NOT NULL, cronExpression TEXT NOT NULL, command TEXT NOT NULL,
enabled INTEGER DEFAULT 1, timeoutMs INTEGER, steps TEXT,
nextRunAt TEXT, lastRunAt TEXT, lastRunResult TEXT,
runCount INTEGER DEFAULT 0, runHistory TEXT DEFAULT '[]',
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'idle', taskId TEXT,
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
lastHeartbeatAt TEXT, metadata TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS agentHeartbeats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agentId TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, runId TEXT NOT NULL,
FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
);
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '1')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.prepare("INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt, steeringComments) VALUES (?, ?, ?, ?, ?, ?)")
.run(
"FN-100",
"legacy comments",
"todo",
"2025-01-01T00:00:00.000Z",
"2025-01-01T00:00:00.000Z",
JSON.stringify([{ id: "legacy-1", text: "Use TypeScript", author: "user", createdAt: "2025-01-01T00:00:00.000Z" }]),
);
db.init();
const row = db.prepare("SELECT steeringComments, comments FROM tasks WHERE id = 'FN-100'").get() as any;
expect(JSON.parse(row.steeringComments)).toHaveLength(1);
expect(JSON.parse(row.comments)).toEqual([
{
id: "legacy-1",
text: "Use TypeScript",
author: "user",
createdAt: "2025-01-01T00:00:00.000Z",
},
]);
db.close();
});
it("deduplicates overlapping steeringComments and comments during schema upgrade", () => {
tmpDir = makeTmpDir();
const kbDir = join(tmpDir, ".fusion");
const db = new Database(kbDir);
db.exec(`
CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
steeringComments TEXT DEFAULT '[]',
comments TEXT DEFAULT '[]',
mergeDetails TEXT
);
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY CHECK (id = 1),
nextId INTEGER DEFAULT 1,
nextWorkflowStepId INTEGER DEFAULT 1,
settings TEXT DEFAULT '{}',
workflowSteps TEXT DEFAULT '[]',
updatedAt TEXT
);
CREATE TABLE IF NOT EXISTS activityLog (
id TEXT PRIMARY KEY, timestamp TEXT NOT NULL, type TEXT NOT NULL,
taskId TEXT, taskTitle TEXT, details TEXT NOT NULL, metadata TEXT
);
CREATE TABLE IF NOT EXISTS archivedTasks (id TEXT PRIMARY KEY, data TEXT NOT NULL, archivedAt TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS automations (
id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT,
scheduleType TEXT NOT NULL, cronExpression TEXT NOT NULL, command TEXT NOT NULL,
enabled INTEGER DEFAULT 1, timeoutMs INTEGER, steps TEXT,
nextRunAt TEXT, lastRunAt TEXT, lastRunResult TEXT,
runCount INTEGER DEFAULT 0, runHistory TEXT DEFAULT '[]',
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'idle', taskId TEXT,
createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
lastHeartbeatAt TEXT, metadata TEXT DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS agentHeartbeats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agentId TEXT NOT NULL, timestamp TEXT NOT NULL, status TEXT NOT NULL, runId TEXT NOT NULL,
FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE
);
`);
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '4')");
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
db.prepare("INSERT INTO tasks (id, description, \"column\", createdAt, updatedAt, steeringComments, comments) VALUES (?, ?, ?, ?, ?, ?, ?)")
.run(
"FN-101",
"mixed comments",
"todo",
"2025-01-01T00:00:00.000Z",
"2025-01-01T00:00:00.000Z",
JSON.stringify([{ id: "c1", text: "Keep it simple", author: "user", createdAt: "2025-01-01T00:00:00.000Z" }]),
JSON.stringify([
{ id: "c1", text: "Keep it simple", author: "user", createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-01-02T00:00:00.000Z" },
{ id: "c2", text: "Already unified", author: "alice", createdAt: "2025-01-03T00:00:00.000Z" },
]),
);
db.init();
const row = db.prepare("SELECT comments FROM tasks WHERE id = 'FN-101'").get() as any;
expect(JSON.parse(row.comments)).toEqual([
{ id: "c1", text: "Keep it simple", author: "user", createdAt: "2025-01-01T00:00:00.000Z", updatedAt: "2025-01-02T00:00:00.000Z" },
{ id: "c2", text: "Already unified", author: "alice", createdAt: "2025-01-03T00:00:00.000Z" },
]);
db.close();
});
});
describe("createDatabase factory", () => {

View File

@@ -12,6 +12,7 @@ import { DatabaseSync } from "node:sqlite";
import { join } from "node:path";
import { mkdirSync, existsSync } from "node:fs";
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
import type { TaskComment } from "./types.js";
// ── Types ────────────────────────────────────────────────────────────
@@ -60,6 +61,64 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
const SCHEMA_VERSION = 5;
function normalizeTaskComments(
steeringComments: TaskComment[] | undefined,
comments: TaskComment[] | undefined,
): { steeringComments: TaskComment[]; comments: TaskComment[] } {
const normalizedComments: TaskComment[] = [];
const seenKeys = new Set<string>();
const pushComment = (comment: TaskComment) => {
const key = comment.id || `${comment.text}\u0000${comment.author}\u0000${comment.createdAt}`;
const existingIndex = normalizedComments.findIndex((entry) => {
if (comment.id && entry.id) {
return entry.id === comment.id;
}
return (
entry.text === comment.text &&
entry.author === comment.author &&
entry.createdAt === comment.createdAt
);
});
if (existingIndex !== -1) {
const existing = normalizedComments[existingIndex];
normalizedComments[existingIndex] = {
...existing,
...comment,
updatedAt: comment.updatedAt ?? existing.updatedAt,
};
seenKeys.add(key);
return;
}
if (!seenKeys.has(key)) {
normalizedComments.push(comment);
seenKeys.add(key);
}
};
for (const comment of comments || []) {
if (!comment || !comment.id || !comment.createdAt) continue;
pushComment(comment);
}
for (const comment of steeringComments || []) {
if (!comment || !comment.id || !comment.createdAt) continue;
pushComment({
id: comment.id,
text: comment.text,
author: comment.author,
createdAt: comment.createdAt,
});
}
return {
steeringComments: steeringComments || [],
comments: normalizedComments,
};
}
const SCHEMA_SQL = `
-- Tasks table with JSON columns for nested data
CREATE TABLE IF NOT EXISTS tasks (
@@ -336,8 +395,7 @@ export class Database {
if (version < 5) {
this.applyMigration(5, () => {
// Migrate steeringComments to comments (unified comments field)
this.migrateSteeringCommentsToComments();
this.migrateLegacyCommentsToUnifiedComments();
});
}
@@ -377,55 +435,34 @@ export class Database {
}
/**
* Migrate steeringComments data to the unified comments field.
* This is a one-way migration from schema version 4 to 5.
* Normalize legacy steering comments into the unified comments field exactly once.
*
* This migration is idempotent: rows already normalized remain unchanged on rerun.
* The legacy steeringComments column is preserved for backward compatibility, but
* migrated comments are represented canonically in the comments column.
*/
private migrateSteeringCommentsToComments(): void {
// Only run if steeringComments column exists
if (!this.hasColumn("tasks", "steeringComments")) {
private migrateLegacyCommentsToUnifiedComments(): void {
if (!this.hasColumn("tasks", "comments") || !this.hasColumn("tasks", "steeringComments")) {
return;
}
// Get all tasks that have steering comments
const tasksWithSteering = this.db
.prepare("SELECT id, steeringComments, comments FROM tasks WHERE steeringComments != '[]'")
.all() as Array<{ id: string; steeringComments: string; comments: string }>;
const rows = this.db.prepare("SELECT id, steeringComments, comments FROM tasks").all() as Array<{
id: string;
steeringComments: string | null;
comments: string | null;
}>;
for (const task of tasksWithSteering) {
try {
const steeringComments = JSON.parse(task.steeringComments) as Array<{
id: string;
text: string;
createdAt: string;
author: "user" | "agent";
}>;
const existingComments = JSON.parse(task.comments || "[]") as Array<{
id: string;
text: string;
author: string;
createdAt: string;
updatedAt?: string;
}>;
const updateStmt = this.db.prepare(
"UPDATE tasks SET comments = ? WHERE id = ?",
);
// Convert steering comments to the unified format
const migratedComments = steeringComments.map((sc) => ({
id: sc.id,
text: sc.text,
author: sc.author,
createdAt: sc.createdAt,
updatedAt: sc.createdAt, // Steering comments didn't have updatedAt
}));
// Merge: existing comments first, then migrated steering comments
const mergedComments = [...existingComments, ...migratedComments];
// Update the task with merged comments
this.db
.prepare("UPDATE tasks SET comments = ? WHERE id = ?")
.run(JSON.stringify(mergedComments), task.id);
} catch {
// Skip tasks with invalid JSON in steeringComments
continue;
for (const row of rows) {
const steeringComments = fromJson<TaskComment[]>(row.steeringComments) || [];
const comments = fromJson<TaskComment[]>(row.comments) || [];
const normalized = normalizeTaskComments(steeringComments, comments);
const nextCommentsJson = toJson(normalized.comments);
if ((row.comments || "[]") !== nextCommentsJson) {
updateStmt.run(nextCommentsJson, row.id);
}
}
}
@@ -545,3 +582,5 @@ export class Database {
export function createDatabase(kbDir: string): Database {
return new Database(kbDir);
}
export { normalizeTaskComments };

View File

@@ -537,6 +537,24 @@ export function Header({
<Clock size={16} />
<span>Scheduled Tasks</span>
</button>
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onToggleTerminal)}
role="menuitem"
data-testid="overflow-terminal-btn"
>
<Terminal size={16} />
<span>Open Terminal</span>
</button>
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenSchedules)}
role="menuitem"
data-testid="overflow-schedules-btn"
>
<Clock size={16} />
<span>Scheduled Tasks</span>
</button>
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenSettings)}

View File

@@ -151,6 +151,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
}, [description]);
// Persist disclosure state to localStorage whenever it changes
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem(DISCLOSURE_STORAGE_KEY, isDisclosureExpanded.toString());
}
}, [isDisclosureExpanded]);
// Cleanup on unmount
useEffect(() => {
return () => {
@@ -218,7 +225,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setIsModelModalOpen(false);
setIsRefineMenuOpen(false);
setIsRefining(false);
setIsExpanded(false); // Collapse on reset
setIsExpanded(false); // Collapse textarea height on reset
// Note: isDisclosureExpanded is NOT reset - user preference persists
justResetRef.current = true;
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
@@ -304,8 +312,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
localStorage.removeItem(STORAGE_KEY);
}
}
// Collapse on escape
// Collapse textarea and disclosure on escape
setIsExpanded(false);
setIsDisclosureExpanded(false);
textareaRef.current?.blur();
}
},
@@ -316,6 +325,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
showDeps,
isModelModalOpen,
isRefineMenuOpen,
setIsDisclosureExpanded,
],
);
@@ -442,15 +452,16 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
}, [availableModels]);
// Show expanded controls only when manually expanded (isExpanded)
const showExpandedControls = isExpanded;
// Show expanded controls based on disclosure state (user preference), not textarea focus
const showExpandedControls = isDisclosureExpanded;
const toggleExpanded = useCallback(() => {
setIsDisclosureExpanded((prev) => !prev);
setIsExpanded((prev) => !prev);
}, []);
return (
<div className={`quick-entry-box ${isExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
<div className="quick-entry-main-row">
<textarea
ref={textareaRef}
@@ -470,12 +481,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
type="button"
className="btn btn-sm quick-entry-toggle"
onClick={toggleExpanded}
aria-expanded={isExpanded}
aria-expanded={isDisclosureExpanded}
aria-controls="quick-entry-controls"
data-testid="quick-entry-toggle"
title={isExpanded ? "Collapse" : "Expand"}
title={isDisclosureExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
{isDisclosureExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
{showExpandedControls && (

View File

@@ -183,9 +183,7 @@ describe("QuickEntryBox", () => {
});
it("does NOT expand on focus", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
@@ -196,8 +194,7 @@ describe("QuickEntryBox", () => {
});
it("toggle button expands the view", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
const textarea = screen.getByTestId("quick-entry-input");
// Initially not expanded
@@ -221,7 +218,8 @@ describe("QuickEntryBox", () => {
});
it("toggle button collapses the view when expanded", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const textarea = screen.getByTestId("quick-entry-input");
// Expand first
@@ -236,7 +234,8 @@ describe("QuickEntryBox", () => {
});
it("does NOT collapse on blur when empty", async () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const textarea = screen.getByTestId("quick-entry-input");
// Expand manually
@@ -255,9 +254,7 @@ describe("QuickEntryBox", () => {
});
it("does NOT collapse on blur when has content", async () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
const textarea = screen.getByTestId("quick-entry-input");
// Expand manually and add content
@@ -294,9 +291,7 @@ describe("QuickEntryBox", () => {
});
it("allows Shift+Enter to insert newline when expanded", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -405,9 +400,7 @@ describe("QuickEntryBox", () => {
});
it("collapses and blurs on Escape key", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -498,7 +491,7 @@ describe("QuickEntryBox", () => {
describe("Rich creation features", () => {
it("shows dependency button when expanded", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Initially, no controls are visible
expect(screen.queryByTestId("quick-entry-deps-button")).toBeNull();
@@ -515,7 +508,7 @@ describe("QuickEntryBox", () => {
it("shows model selector button when expanded", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Initially, no controls are visible
@@ -534,7 +527,7 @@ describe("QuickEntryBox", () => {
it("shows Plan and Subtask buttons when expanded", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Initially, no controls are visible
@@ -554,9 +547,7 @@ describe("QuickEntryBox", () => {
});
it("opens dependency dropdown when clicking deps button", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -569,9 +560,7 @@ describe("QuickEntryBox", () => {
});
it("opens model modal when clicking models button", () => {
const { container } = renderQuickEntryBox();
const { container } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -591,9 +580,7 @@ describe("QuickEntryBox", () => {
});
it("modal receives correct props (models, loading state, etc.)", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -612,9 +599,7 @@ describe("QuickEntryBox", () => {
});
it("selects dependencies and includes them in submit payload", async () => {
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -642,7 +627,6 @@ describe("QuickEntryBox", () => {
it("calls onPlanningMode and clears input when Plan clicked", async () => {
const onPlanningMode = vi.fn();
const { props } = renderQuickEntryBox({ onPlanningMode });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Plan this task" } });
@@ -659,7 +643,6 @@ describe("QuickEntryBox", () => {
it("calls onSubtaskBreakdown and clears input when Subtask clicked", async () => {
const onSubtaskBreakdown = vi.fn();
const { props } = renderQuickEntryBox({ onSubtaskBreakdown });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Break this down" } });
@@ -674,9 +657,7 @@ describe("QuickEntryBox", () => {
});
it("disables Plan and Subtask buttons when description is empty", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -702,9 +683,7 @@ describe("QuickEntryBox", () => {
});
it("Plan button prevents textarea blur on mousedown", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -722,9 +701,7 @@ describe("QuickEntryBox", () => {
});
it("Subtask button prevents textarea blur on mousedown", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -762,9 +739,7 @@ describe("QuickEntryBox", () => {
});
it("includes selected models in submit payload", async () => {
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -795,9 +770,7 @@ describe("QuickEntryBox", () => {
});
it("closes modal on Escape when open", async () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -818,9 +791,7 @@ describe("QuickEntryBox", () => {
});
it("clears all state on second Escape after dropdowns are closed", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -837,10 +808,8 @@ describe("QuickEntryBox", () => {
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
});
it("resets all state after successful creation", async () => {
const { props } = renderQuickEntryBox();
it("resets all state after successful creation (preserves disclosure preference)", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -991,9 +960,7 @@ describe("QuickEntryBox", () => {
});
it("clears localStorage when Escape clears non-empty input", async () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1012,9 +979,7 @@ describe("QuickEntryBox", () => {
});
it("does not clear localStorage on first Escape when closing dropdowns", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1036,8 +1001,7 @@ describe("QuickEntryBox", () => {
describe("AI Refine feature", () => {
it("shows refine button when expanded and text is entered", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Initially, refine button is not visible
@@ -1053,9 +1017,7 @@ describe("QuickEntryBox", () => {
});
it("refine button is hidden when textarea is empty", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1074,9 +1036,7 @@ describe("QuickEntryBox", () => {
});
it("opens refine menu on button click", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1091,9 +1051,7 @@ describe("QuickEntryBox", () => {
});
it("closes refine menu on Escape key", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1115,9 +1073,7 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1140,9 +1096,7 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1171,9 +1125,7 @@ describe("QuickEntryBox", () => {
const { getRefineErrorMessage } = await import("../../api");
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1194,9 +1146,7 @@ describe("QuickEntryBox", () => {
// Slow down the promise to see loading state
vi.mocked(refineText).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1218,9 +1168,7 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined description with much more content here");
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1237,9 +1185,7 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined text");
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1263,8 +1209,7 @@ describe("QuickEntryBox", () => {
describe("Save button", () => {
it("shows save button when expanded and text is entered", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
// Initially, save button is not visible
@@ -1280,9 +1225,7 @@ describe("QuickEntryBox", () => {
});
it("save button is disabled when textarea is empty", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1324,9 +1267,7 @@ describe("QuickEntryBox", () => {
});
it("clicking save button persists to localStorage", async () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1342,9 +1283,7 @@ describe("QuickEntryBox", () => {
});
it("clicking save button creates the task", async () => {
const { props } = renderQuickEntryBox();
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1365,9 +1304,7 @@ describe("QuickEntryBox", () => {
});
it("save button has correct test id", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1379,9 +1316,7 @@ describe("QuickEntryBox", () => {
});
it("save button has correct title attribute", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
@@ -1392,9 +1327,7 @@ describe("QuickEntryBox", () => {
});
it("save button prevents textarea blur on mousedown", () => {
renderQuickEntryBox();
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");

View File

@@ -10309,8 +10309,32 @@ html .column.drag-over * {
height: 32px;
width: 32px;
flex-shrink: 0;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.quick-entry-toggle:hover {
background: var(--card-hover);
border-color: var(--border);
color: var(--text);
}
.quick-entry-toggle:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* Expanded state - normal padding */
.quick-entry-box--expanded {
padding: 8px 10px;
}
.quick-entry-box--expanded .quick-entry-input {
min-height: 36px;
}
/* Collapsed state - minimal padding */
@@ -10327,7 +10351,30 @@ html .column.drag-over * {
border-bottom-color: var(--triage);
}
/* Responsive adjustments for quick entry */
@media (max-width: 640px) {
.quick-entry-controls {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.quick-entry-controls-left {
width: 100%;
}
.quick-entry-hint {
margin-left: 0;
width: 100%;
text-align: center;
}
.quick-entry-toggle {
height: 28px;
width: 28px;
padding: 4px;
}
}
/* === New Task Modal === */
.new-task-modal {

View File

@@ -1993,43 +1993,45 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
let files: TaskFileDiff[] = [];
const parseNameStatus = (output: string): TaskFileDiff[] => {
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split("\t");
const rawStatus = parts[0] ?? "M";
const statusCode = rawStatus[0];
const entries: TaskFileDiff[] = [];
if (statusCode === "R") {
const oldPath = parts[1];
const path = parts[2];
return path
? {
path,
oldPath,
status: "renamed" as const,
diff: "",
}
: null;
}
for (const rawLine of output.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const path = parts[1];
return path
? {
path,
status:
statusCode === "A"
? ("added" as const)
: statusCode === "D"
? ("deleted" as const)
: ("modified" as const),
diff: "",
}
: null;
})
.filter((entry): entry is TaskFileDiff => entry !== null);
const parts = line.split("\t");
const rawStatus = parts[0] ?? "M";
const statusCode = rawStatus[0];
if (statusCode === "R") {
const oldPath = parts[1];
const path = parts[2];
if (!path) continue;
entries.push({
path,
oldPath,
status: "renamed",
diff: "",
});
continue;
}
const path = parts[1];
if (!path) continue;
entries.push({
path,
status:
statusCode === "A"
? "added"
: statusCode === "D"
? "deleted"
: "modified",
diff: "",
});
}
return entries;
};
try {

View File

@@ -1587,6 +1587,8 @@ describe("buildExecutionPrompt", () => {
expect(result).toContain("## Project Commands");
expect(result).toContain("- **Build:** `pnpm build`");
expect(result).not.toContain("- **Test:**");
expect(result).toContain("run that exact command in this worktree before calling `task_done()`");
expect(result).toContain("Do not claim success without a real passing run");
});
it("includes both commands when both are set", () => {

View File

@@ -140,7 +140,13 @@ model, read-only access) to independently assess your work.
## Completion
After all steps are done, tests pass, and docs are updated:
\`\`\`bash
Call \`task_done()\` to signal completion.`;
Call \`task_done()\` to signal completion.
\`\`\`
If a project build command is listed in the prompt, it is a hard completion gate:
- Run the exact build command in the current worktree before \`task_done()\`
- Do not claim the build passes unless you actually ran it and got exit code 0
- If the build fails, do NOT call \`task_done()\`; keep working until it passes`;
export interface TaskExecutorOptions {
semaphore?: AgentSemaphore;
@@ -1892,7 +1898,8 @@ Use \`task_create\` if you find out-of-scope work that needs doing.
Commit at step boundaries: \`git commit -m "feat(${task.id}): complete Step N — description"\`
When all steps are complete: call \`task_done()\`
Verify build passes using the configured build command before calling \`task_done()\`.`;
If a build command is configured, run that exact command in this worktree before calling \`task_done()\`.
Treat a non-zero exit code as a blocking failure. Do not claim success without a real passing run.`;
}
/**

View File

@@ -1704,18 +1704,21 @@ describe("aiMergeTask — build verification", () => {
await aiMergeTask(store, "/tmp/root", "FN-050");
expect(capturedSystemPrompt).toContain("## Build verification");
expect(capturedSystemPrompt).toContain("If a build command is configured for this project, you MUST run it");
expect(capturedSystemPrompt).toContain("BUILD FAILED:");
expect(capturedSystemPrompt).toContain("build verification is a hard gate");
expect(capturedSystemPrompt).toContain("Do not assume the build passes");
expect(capturedSystemPrompt).toContain("report_build_failure");
});
it("includes build command in merge prompt when configured", async () => {
let capturedArgs: any;
let capturedPrompt: string | undefined;
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedArgs = opts;
// Simulate agent committing by returning session that results in clean state
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
prompt: vi.fn().mockImplementation(async (prompt: string) => {
capturedPrompt = prompt;
// Simulate commit happening by making staged check return "0" (clean)
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
@@ -1749,6 +1752,10 @@ describe("aiMergeTask — build verification", () => {
// Verify custom tool was passed
expect(capturedArgs.customTools).toBeDefined();
expect(capturedArgs.customTools.some((t: any) => t.name === "report_build_failure")).toBe(true);
expect(capturedPrompt).toContain("Build command: `pnpm build`");
expect(capturedPrompt).toContain("This command is mandatory before commit.");
expect(capturedPrompt).toContain("Only commit if it exits 0.");
expect(capturedPrompt).toContain("call `report_build_failure`");
});
it("merge succeeds when build passes (agent reports success)", async () => {

View File

@@ -477,13 +477,17 @@ Base the message on the ACTUAL work done in the branch commits.
## Build verification
If a build command is configured for this project, you MUST run it before committing.
If a build command is configured for this project, build verification is a hard gate.
You MUST run the exact configured build command in this worktree before committing.
Do not assume the build passes. Do not describe it as passing unless you actually ran it
and the bash tool returned exit code 0.
1. Run the build command (shown in the prompt context below)
2. If the build succeeds (exit code 0), proceed with the commit
3. If the build fails (non-zero exit code), DO NOT commit. Instead:
- Respond with "BUILD FAILED: <error details>"
- Stop and do not proceed further
- Call the \`report_build_failure\` tool with the real error details
- Stop immediately and do not run \`git commit\`
- Do not claim success in plain text
The merge will only be completed if the build passes or no build command is configured.`;
}
@@ -1254,7 +1258,10 @@ function buildMergePrompt(params: MergePromptParams): string {
"## Build command",
`Build command: \`${buildCommand}\``,
"",
"Run this command via bash tool before committing to verify the build passes.",
"This command is mandatory before commit.",
"Run it with the bash tool in the current worktree and inspect the actual exit code.",
"Only commit if it exits 0.",
"If it exits non-zero, call `report_build_failure` with the concrete error output and stop without committing.",
);
}