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