feat(KB-648): enable parallel test execution and optimize test performance

- Optimize backup tests using fake timers instead of real timeouts

- Enable parallel file execution in core, engine, CLI, and dashboard packages

- Add inline test helpers to reduce dependencies in dashboard routes tests

- Update executor tests with exact command matching and improved assertions

- Update AGENTS.md with test optimization patterns (fake timers, unique temp dirs)
This commit is contained in:
gsxdsm
2026-04-01 06:54:50 -07:00
parent cf5b4e55d6
commit 54767f60eb
79 changed files with 1568 additions and 7618 deletions

View File

@@ -56,7 +56,7 @@ When execution finishes and the reviewer signs off, the task moves to "in review
`autoMerge` still controls whether Fusion performs completion automatically at all. If `autoMerge` is disabled, tasks stay in **In Review** until you finish the merge yourself.
For PR-first mode, authenticate GitHub with `gh auth login` or `GITHUB_TOKEN`, and make sure the task branch already exists on GitHub as `fusion/<task-id-lower>`. Fusion does **not** push branches for you before PR creation.
For PR-first mode, authenticate GitHub with `gh auth login` or `GITHUB_TOKEN`, and make sure the task branch already exists on GitHub as `kb/<task-id-lower>`. Fusion does **not** push branches for you before PR creation.
Worktrees can be cleaned up after merge or reused by the next task to keep build caches warm.

View File

@@ -90,16 +90,6 @@ describe("kb pi extension", () => {
"kb_task_unarchive",
"kb_task_delete",
"kb_task_plan",
// Mission tools
"kb_mission_create",
"kb_mission_list",
"kb_mission_show",
"kb_mission_delete",
"kb_milestone_add",
"kb_slice_add",
"kb_feature_add",
"kb_slice_activate",
"kb_feature_link_task",
];
for (const name of expected) {
@@ -401,144 +391,4 @@ describe("kb pi extension", () => {
expect(unpauseResult.content[0].text).toContain("Unpaused FN-001");
});
});
describe("kb_mission_create", () => {
it("creates mission and returns mission data", async () => {
const tool = api.tools.get("kb_mission_create")!;
const result = await tool.execute(
"call-1",
{ title: "Test Mission", description: "Test description" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.missionId).toBeDefined();
expect(result.details.title).toBe("Test Mission");
expect(result.content[0].text).toContain("Created");
expect(result.content[0].text).toContain("Test Mission");
});
});
describe("kb_mission_list", () => {
it("returns formatted list of missions", async () => {
// First create a mission
const createTool = api.tools.get("kb_mission_create")!;
await createTool.execute(
"c1",
{ title: "Mission A" },
undefined,
undefined,
makeCtx(tmpDir),
);
const listTool = api.tools.get("kb_mission_list")!;
const result = await listTool.execute(
"call-1",
{},
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.count).toBeGreaterThanOrEqual(1);
expect(result.content[0].text).toContain("Missions");
});
});
describe("kb_mission_show", () => {
it("returns mission with hierarchy", async () => {
// Create mission
const createTool = api.tools.get("kb_mission_create")!;
const created = await createTool.execute(
"c1",
{ title: "Test Mission" },
undefined,
undefined,
makeCtx(tmpDir),
);
const showTool = api.tools.get("kb_mission_show")!;
const result = await showTool.execute(
"call-1",
{ id: created.details.missionId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.mission).toBeDefined();
expect(result.content[0].text).toContain("Test Mission");
});
it("returns error when mission not found", async () => {
const showTool = api.tools.get("kb_mission_show")!;
const result = await showTool.execute(
"call-1",
{ id: "M-999" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not found");
});
});
describe("kb_mission_delete", () => {
it("deletes mission and confirms", async () => {
// Create mission
const createTool = api.tools.get("kb_mission_create")!;
const created = await createTool.execute(
"c1",
{ title: "Mission to Delete" },
undefined,
undefined,
makeCtx(tmpDir),
);
const deleteTool = api.tools.get("kb_mission_delete")!;
const result = await deleteTool.execute(
"call-1",
{ id: created.details.missionId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.missionId).toBe(created.details.missionId);
expect(result.content[0].text).toContain("Deleted");
});
});
describe("kb_slice_activate", () => {
it("activates slice and updates status", async () => {
// This test would need a full mission hierarchy setup
// For now, verify the tool exists and has correct parameters
const tool = api.tools.get("kb_slice_activate")!;
expect(tool).toBeDefined();
expect(tool.parameters.properties.id).toBeDefined();
});
});
describe("kb_feature_link_task", () => {
it("links feature to task", async () => {
// Create a task first
const createTaskTool = api.tools.get("kb_task_create")!;
const taskResult = await createTaskTool.execute(
"c1",
{ description: "Task for feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
// Verify the tool exists with correct parameters
const tool = api.tools.get("kb_feature_link_task")!;
expect(tool).toBeDefined();
expect(tool.parameters.properties.featureId).toBeDefined();
expect(tool.parameters.properties.taskId).toBeDefined();
});
});
});

View File

@@ -45,9 +45,6 @@ const { runSettingsExport } = await import("./commands/settings-export.js");
const { runSettingsImport } = await import("./commands/settings-import.js");
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectInfo } = await import("./commands/project.js");
const { getResolvedProject } = await import("./project-resolver.js");
const HELP = `
fn — AI-orchestrated task board
@@ -82,10 +79,10 @@ Usage:
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
Create a GitHub PR for an in-review task
fn task import <owner/repo> [opts] Import GitHub issues as tasks
fn project list [--json] List all registered projects
fn project add [dir] [--name <name>] [--isolation <mode>] Register a project
fn project remove <name> [--force] Unregister a project
fn project info [name] Show project details
fn settings Show current Fusion configuration
fn settings set <key> <value> Update a configuration setting
fn settings export [opts] Export settings to a JSON file
fn settings import <file> [opts] Import settings from a JSON file
fn git status Show current branch, commit, dirty state, ahead/behind
fn git push Push current branch
@@ -95,16 +92,8 @@ Usage:
fn backup --list List all database backups
fn backup --restore <file> Restore database from a backup file
fn backup --cleanup Remove old backups exceeding retention limit
fn mission create [title] [description] Create a new mission
fn mission list List all missions
fn mission show <id> Show mission with hierarchy
fn mission delete <id> [--force] Delete mission
fn mission activate-slice <slice-id> Activate a pending slice
fn mission delete <id> [--force] Delete a mission
fn mission activate-slice <slice-id> Activate a pending slice
Options:
--project <name> Target a specific project (for task/settings commands)
--port, -p <port> Dashboard port (default: 4040)
--interactive Interactive mode (port selection for dashboard, issue selection for import)
--paused Start with engine paused (automation disabled)
@@ -126,74 +115,15 @@ Requires configured API keys — run "pi" first to set up authentication.
`.trim();
async function main() {
let args = process.argv.slice(2);
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
console.log(HELP);
process.exit(0);
}
// Extract --project flag before command routing
let projectName: string | undefined;
const projectFlagIdx = args.indexOf("--project");
const projectFlagShortIdx = args.indexOf("-P");
const projectIdx = projectFlagIdx !== -1 ? projectFlagIdx : projectFlagShortIdx;
if (projectIdx !== -1 && projectIdx + 1 < args.length) {
projectName = args[projectIdx + 1];
// Remove --project and its value from args
args.splice(projectIdx, 2);
}
// Store for subcommands to access via resolveProject
if (projectName) {
process.env.FN_PROJECT = projectName;
}
// Extract command early (needed for migration check)
const command = args[0];
// ── First-Run Auto-Migration ─────────────────────────────────────────────
// Check if this is a fresh installation or if projects need to be migrated
// Skip migration check for 'project' commands to avoid circular issues
if (command !== "project" && !process.env.KB_SKIP_MIGRATION) {
try {
const { createMigrationOrchestrator, createFirstRunExperience, CentralCore } = await import("@fusion/core");
const centralCore = new CentralCore();
await centralCore.init();
const migration = createMigrationOrchestrator(centralCore);
if (await migration.needsMigration()) {
const firstRun = createFirstRunExperience(centralCore);
const state = await firstRun.getSetupState();
if (state.isFirstRun && state.hasDetectedProjects) {
console.log("[kb] First run detected. Auto-registering projects...");
const result = await migration.runMigration({
startPath: process.cwd(),
autoRegister: true
});
if (result.projectsRegistered.length > 0) {
console.log(`[kb] Auto-registered ${result.projectsRegistered.length} project(s):`);
for (const p of result.projectsRegistered) {
console.log(` - ${p.name}: ${p.path}`);
}
}
if (result.projectsSkipped.length > 0) {
console.log(`[kb] Skipped ${result.projectsSkipped.length} project(s) (already registered or invalid)`);
}
}
}
await centralCore.close();
} catch (err) {
// Migration is best-effort: log warning but don't block command execution
console.warn("[kb] Warning: Migration check failed:", (err as Error).message);
}
}
try {
switch (command) {
case "dashboard": {
@@ -215,51 +145,6 @@ async function main() {
break;
}
case "project": {
const subcommand = args[1];
switch (subcommand) {
case "list":
case "ls": {
const json = args.includes("--json");
await runProjectList({ json });
break;
}
case "add": {
const dir = args[2];
const nameIdx = args.indexOf("--name");
const name = nameIdx !== -1 && nameIdx + 1 < args.length ? args[nameIdx + 1] : undefined;
const isolationIdx = args.indexOf("--isolation");
const isolation = isolationIdx !== -1 && isolationIdx + 1 < args.length
? args[isolationIdx + 1] as "in-process" | "child-process"
: undefined;
await runProjectAdd(dir, { name, isolation });
break;
}
case "remove":
case "rm": {
const name = args[2];
if (!name) {
console.error("Usage: fn project remove <name> [--force]");
process.exit(1);
}
const force = args.includes("--force");
await runProjectRemove(name, { force });
break;
}
case "info":
case "show": {
const name = args[2];
await runProjectInfo(name);
break;
}
default:
console.error(`Unknown subcommand: project ${subcommand || ""}`);
console.error("Try: fn project list | add [dir] | remove <name> | info [name]");
process.exit(1);
}
break;
}
case "task": {
const subcommand = args[1];
switch (subcommand) {
@@ -280,7 +165,7 @@ async function main() {
}
}
const title = descParts.join(" ");
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined, projectName);
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined);
break;
}
case "plan": {
@@ -295,12 +180,12 @@ async function main() {
}
}
const initialPlan = descParts.join(" ");
await runTaskPlan(initialPlan || undefined, yesFlag, projectName);
await runTaskPlan(initialPlan || undefined, yesFlag);
break;
}
case "list":
case "ls":
await runTaskList(projectName);
await runTaskList();
break;
case "move": {
const id = args[2];
@@ -309,13 +194,13 @@ async function main() {
console.error("Usage: fn task move <id> <column>");
process.exit(1);
}
await runTaskMove(id, column, projectName);
await runTaskMove(id, column);
break;
}
case "show": {
const id = args[2];
if (!id) { console.error("Usage: fn task show <id>"); process.exit(1); }
await runTaskShow(id, projectName);
await runTaskShow(id);
break;
}
case "update": {
@@ -325,13 +210,13 @@ async function main() {
console.error("Status: pending | in-progress | done | skipped");
process.exit(1);
}
await runTaskUpdate(id, step, status, projectName);
await runTaskUpdate(id, step, status);
break;
}
case "log": {
const id = args[2], message = args.slice(3).join(" ");
if (!id || !message) { console.error("Usage: fn task log <id> <message>"); process.exit(1); }
await runTaskLog(id, message, undefined, projectName);
await runTaskLog(id, message);
break;
}
case "logs": {
@@ -356,19 +241,19 @@ async function main() {
type = args[typeIdx + 1];
}
await runTaskLogs(id, { follow, limit, type: type as "text" | "thinking" | "tool" | "tool_result" | "tool_error" | undefined }, projectName);
await runTaskLogs(id, { follow, limit, type: type as "text" | "thinking" | "tool" | "tool_result" | "tool_error" | undefined });
break;
}
case "merge": {
const id = args[2];
if (!id) { console.error("Usage: fn task merge <id>"); process.exit(1); }
await runTaskMerge(id, projectName);
await runTaskMerge(id);
break;
}
case "duplicate": {
const id = args[2];
if (!id) { console.error("Usage: fn task duplicate <id>"); process.exit(1); }
await runTaskDuplicate(id, projectName);
await runTaskDuplicate(id);
break;
}
case "refine": {
@@ -379,26 +264,26 @@ async function main() {
const feedback = feedbackIdx !== -1 && feedbackIdx + 1 < args.length
? args[feedbackIdx + 1]
: undefined;
await runTaskRefine(id, feedback, projectName);
await runTaskRefine(id, feedback);
break;
}
case "archive": {
const id = args[2];
if (!id) { console.error("Usage: fn task archive <id>"); process.exit(1); }
await runTaskArchive(id, projectName);
await runTaskArchive(id);
break;
}
case "unarchive": {
const id = args[2];
if (!id) { console.error("Usage: fn task unarchive <id>"); process.exit(1); }
await runTaskUnarchive(id, projectName);
await runTaskUnarchive(id);
break;
}
case "delete": {
const id = args[2];
if (!id) { console.error("Usage: fn task delete <id> [--force]"); process.exit(1); }
const force = args.includes("--force");
await runTaskDelete(id, force, projectName);
await runTaskDelete(id, force);
break;
}
case "attach": {
@@ -407,19 +292,19 @@ async function main() {
console.error("Usage: fn task attach <id> <file>");
process.exit(1);
}
await runTaskAttach(id, file, projectName);
await runTaskAttach(id, file);
break;
}
case "pause": {
const id = args[2];
if (!id) { console.error("Usage: fn task pause <id>"); process.exit(1); }
await runTaskPause(id, projectName);
await runTaskPause(id);
break;
}
case "unpause": {
const id = args[2];
if (!id) { console.error("Usage: fn task unpause <id>"); process.exit(1); }
await runTaskUnpause(id, projectName);
await runTaskUnpause(id);
break;
}
case "comment": {
@@ -432,20 +317,20 @@ async function main() {
return absoluteIndex !== authorIdx && absoluteIndex !== authorIdx + 1;
});
const message = messageParts.join(" ");
await runTaskComment(id, message || undefined, author || process.env.USER || "user", projectName);
await runTaskComment(id, message || undefined, author || process.env.USER || "user");
break;
}
case "comments": {
const id = args[2];
if (!id) { console.error("Usage: fn task comments <id>"); process.exit(1); }
await runTaskComments(id, projectName);
await runTaskComments(id);
break;
}
case "steer": {
const id = args[2];
const message = args.slice(3).join(" ");
if (!id) { console.error("Usage: fn task steer <id> [message]"); process.exit(1); }
await runTaskSteer(id, message || undefined, projectName);
await runTaskSteer(id, message || undefined);
break;
}
case "retry": {
@@ -454,7 +339,7 @@ async function main() {
console.error("Usage: fn task retry <id>");
process.exit(1);
}
await runTaskRetry(id, projectName);
await runTaskRetry(id);
break;
}
case "pr-create": {
@@ -484,7 +369,7 @@ async function main() {
body = args[bodyIdx + 1];
}
await runTaskPrCreate(id, { title, base, body }, projectName);
await runTaskPrCreate(id, { title, base, body });
break;
}
case "import": {
@@ -522,9 +407,9 @@ async function main() {
if (interactive) {
const { runTaskImportGitHubInteractive } = await import("./commands/task.js");
await runTaskImportGitHubInteractive(ownerRepo, { limit, labels }, projectName);
await runTaskImportGitHubInteractive(ownerRepo, { limit, labels });
} else {
await runTaskImportFromGitHub(ownerRepo, { limit, labels }, projectName);
await runTaskImportFromGitHub(ownerRepo, { limit, labels });
}
break;
}
@@ -539,7 +424,7 @@ async function main() {
case "settings": {
const subcommand = args[1];
if (!subcommand || subcommand === "show") {
await runSettingsShow(projectName);
await runSettingsShow();
break;
}
if (subcommand === "set") {
@@ -550,7 +435,7 @@ async function main() {
console.error("Example: fn settings set maxConcurrent 4");
process.exit(1);
}
await runSettingsSet(key, value, projectName);
await runSettingsSet(key, value);
break;
}
if (subcommand === "export") {
@@ -597,21 +482,21 @@ async function main() {
const subcommand = args[1];
switch (subcommand) {
case "status":
await runGitStatus(projectName);
await runGitStatus();
break;
case "fetch": {
const remote = args[2];
await runGitFetch(remote, projectName);
await runGitFetch(remote);
break;
}
case "pull": {
const skipConfirm = args.includes("--yes");
await runGitPull({ skipConfirm, projectName });
await runGitPull({ skipConfirm });
break;
}
case "push": {
const skipConfirm = args.includes("--yes");
await runGitPush({ skipConfirm, projectName });
await runGitPush({ skipConfirm });
break;
}
default:
@@ -630,13 +515,13 @@ async function main() {
const restoreFile = restoreIdx !== -1 && restoreIdx + 1 < args.length ? args[restoreIdx + 1] : undefined;
if (create) {
await runBackupCreate(projectName);
await runBackupCreate();
} else if (list) {
await runBackupList(projectName);
await runBackupList();
} else if (cleanup) {
await runBackupCleanup(projectName);
await runBackupCleanup();
} else if (restoreFile) {
await runBackupRestore(restoreFile, projectName);
await runBackupRestore(restoreFile);
} else {
console.error("Usage: fn backup --create | --list | --cleanup | --restore <filename>");
process.exit(1);
@@ -644,70 +529,6 @@ async function main() {
break;
}
case "mission": {
const subcommand = args[1];
switch (subcommand) {
case "create": {
const titleParts: string[] = [];
for (let i = 2; i < args.length; i++) {
titleParts.push(args[i]);
}
const fullInput = titleParts.join(" ");
// Split on first space to separate title and description if provided
const firstSpaceIdx = fullInput.indexOf(" ");
let title: string | undefined;
let description: string | undefined;
if (firstSpaceIdx > 0) {
title = fullInput.slice(0, firstSpaceIdx);
description = fullInput.slice(firstSpaceIdx + 1).trim();
} else {
title = fullInput || undefined;
}
await runMissionCreate(title, description, projectName);
break;
}
case "list":
case "ls":
await runMissionList(projectName);
break;
case "show":
case "info": {
const id = args[2];
if (!id) {
console.error("Usage: fn mission show <id>");
process.exit(1);
}
await runMissionShow(id, projectName);
break;
}
case "delete":
case "rm": {
const id = args[2];
if (!id) {
console.error("Usage: fn mission delete <id> [--force]");
process.exit(1);
}
const force = args.includes("--force");
await runMissionDelete(id, force, projectName);
break;
}
case "activate-slice": {
const id = args[2];
if (!id) {
console.error("Usage: fn mission activate-slice <slice-id>");
process.exit(1);
}
await runMissionActivateSlice(id, projectName);
break;
}
default:
console.error(`Unknown subcommand: mission ${subcommand || ""}`);
console.error("Try: fn mission create | list | show <id> | delete <id> | activate-slice <id>");
process.exit(1);
}
break;
}
default:
console.error(`Unknown command: ${command}`);
console.log(HELP);

View File

@@ -4,22 +4,17 @@ import {
runBackupCommand,
TaskStore,
} from "@fusion/core";
import { resolveProject } from "../project-context.js";
/**
* Find the project root and create a backup manager.
*/
async function getBackupManager(projectName?: string): Promise<{
async function getBackupManager(): Promise<{
manager: BackupManager;
store: TaskStore;
kbDir: string;
}> {
const store = projectName
? (await resolveProject(projectName)).store
: new TaskStore(process.cwd());
if (!projectName) {
await store.init();
}
const store = new TaskStore(process.cwd());
await store.init();
// Access the private kbDir property via type assertion
const kbDir = (store as unknown as { kbDir: string }).kbDir;
const settings = await store.getSettings();
@@ -31,8 +26,8 @@ async function getBackupManager(projectName?: string): Promise<{
* Create a database backup immediately.
* Usage: kb backup --create
*/
export async function runBackupCreate(projectName?: string): Promise<void> {
const { manager, kbDir, store } = await getBackupManager(projectName);
export async function runBackupCreate(): Promise<void> {
const { manager, kbDir, store } = await getBackupManager();
const settings = await store.getSettings();
console.log("Creating database backup...");
@@ -52,8 +47,8 @@ export async function runBackupCreate(projectName?: string): Promise<void> {
* List all database backups.
* Usage: kb backup --list
*/
export async function runBackupList(projectName?: string): Promise<void> {
const { manager } = await getBackupManager(projectName);
export async function runBackupList(): Promise<void> {
const { manager } = await getBackupManager();
const backups = await manager.listBackups();
@@ -85,8 +80,8 @@ export async function runBackupList(projectName?: string): Promise<void> {
* Restore database from a backup file.
* Usage: kb backup --restore <filename>
*/
export async function runBackupRestore(filename: string, projectName?: string): Promise<void> {
const { manager } = await getBackupManager(projectName);
export async function runBackupRestore(filename: string): Promise<void> {
const { manager } = await getBackupManager();
console.log(`Restoring backup: ${filename}`);
console.log("A pre-restore backup will be created first.\n");
@@ -105,8 +100,8 @@ export async function runBackupRestore(filename: string, projectName?: string):
* Remove old backups exceeding retention limit.
* Usage: kb backup --cleanup
*/
export async function runBackupCleanup(projectName?: string): Promise<void> {
const { manager } = await getBackupManager(projectName);
export async function runBackupCleanup(): Promise<void> {
const { manager } = await getBackupManager();
console.log("Cleaning up old backups...");

View File

@@ -36,7 +36,7 @@ describe("isGitRepo", () => {
it("returns true when in a git repository", () => {
mockExecSync.mockReturnValueOnce(".git");
expect(isGitRepo()).toBe(true);
expect(mockExecSync).toHaveBeenCalledWith("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000, cwd: process.cwd() });
expect(mockExecSync).toHaveBeenCalledWith("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000 });
});
it("returns false when not in a git repository", () => {
@@ -378,7 +378,7 @@ describe("runGitFetch", () => {
await runGitFetch("upstream");
expect(mockExecSync).toHaveBeenLastCalledWith("git fetch upstream", { encoding: "utf-8", timeout: 30000, cwd: process.cwd() });
expect(mockExecSync).toHaveBeenLastCalledWith("git fetch upstream", { encoding: "utf-8", timeout: 30000 });
});
it("exits with error when not a git repo", async () => {

View File

@@ -1,6 +1,5 @@
import { execSync } from "node:child_process";
import { createInterface } from "node:readline/promises";
import { resolveProject } from "../project-context.js";
// ── Types ────────────────────────────────────────────────────────────────
@@ -35,11 +34,11 @@ export type GitPushResult = {
// ── Core Git Functions ─────────────────────────────────────────────────
/**
* Check if a directory is a git repository.
* Check if the current directory is a git repository.
*/
export function isGitRepo(cwd: string = process.cwd()): boolean {
export function isGitRepo(): boolean {
try {
execSync("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000, cwd });
execSync("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000 });
return true;
} catch {
return false;
@@ -73,24 +72,24 @@ export function isValidBranchName(name: string): boolean {
* Get the current git status including branch, commit hash, and dirty state.
* Returns structured data for CLI display.
*/
export function getGitStatus(cwd: string = process.cwd()): GitStatus | null {
export function getGitStatus(): GitStatus | null {
try {
// Get current branch (empty string means detached HEAD)
const branchOutput = execSync("git branch --show-current", { encoding: "utf-8", timeout: 5000, cwd }).trim();
const branchOutput = execSync("git branch --show-current", { encoding: "utf-8", timeout: 5000 }).trim();
const branch = branchOutput || "HEAD detached";
// Get current commit hash (short)
const commit = execSync("git rev-parse --short HEAD", { encoding: "utf-8", timeout: 5000, cwd }).trim();
const commit = execSync("git rev-parse --short HEAD", { encoding: "utf-8", timeout: 5000 }).trim();
// Check if working directory is dirty
const statusOutput = execSync("git status --porcelain", { encoding: "utf-8", timeout: 5000, cwd }).trim();
const statusOutput = execSync("git status --porcelain", { encoding: "utf-8", timeout: 5000 }).trim();
const isDirty = statusOutput.length > 0;
// Get ahead/behind counts from upstream
let ahead = 0;
let behind = 0;
try {
const revListOutput = execSync("git rev-list --left-right --count HEAD...@{u}", { encoding: "utf-8", timeout: 5000, cwd }).trim();
const revListOutput = execSync("git rev-list --left-right --count HEAD...@{u}", { encoding: "utf-8", timeout: 5000 }).trim();
const match = revListOutput.match(/(\d+)\s+(\d+)/);
if (match) {
ahead = parseInt(match[1], 10);
@@ -193,17 +192,14 @@ export function pushGitBranch(): GitPushResult {
/**
* Run the git status command and display formatted output.
*/
export async function runGitStatus(projectName?: string): Promise<void> {
// Resolve project path
const { projectPath } = projectName ? await resolveProject(projectName) : { projectPath: process.cwd() };
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {
export async function runGitStatus(): Promise<void> {
// Validate current directory is a git repo
if (!isGitRepo()) {
console.error("Error: Not a git repository");
process.exit(1);
}
const status = getGitStatus(projectPath);
const status = getGitStatus();
if (!status) {
console.error("Error: Failed to get git status");
process.exit(1);
@@ -241,16 +237,12 @@ export async function runGitStatus(projectName?: string): Promise<void> {
/**
* Run the git fetch command.
* @param remote - The remote to fetch from (default: "origin")
* @param projectName - Optional project name to target
*/
export async function runGitFetch(remote?: string, projectName?: string): Promise<void> {
export async function runGitFetch(remote?: string): Promise<void> {
const targetRemote = remote || "origin";
// Resolve project path
const { projectPath } = projectName ? await resolveProject(projectName) : { projectPath: process.cwd() };
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {
// Validate current directory is a git repo
if (!isGitRepo()) {
console.error("Error: Not a git repository");
process.exit(1);
}
@@ -262,9 +254,16 @@ export async function runGitFetch(remote?: string, projectName?: string): Promis
}
try {
execSync(`git fetch ${targetRemote}`, { encoding: "utf-8", timeout: 30000, cwd: projectPath });
const result = fetchGitRemote(targetRemote);
console.log();
console.log(` ✓ Fetched from ${targetRemote}`);
if (result.fetched && result.message && result.message !== "Fetch completed") {
console.log(` ✓ Fetched from ${targetRemote}`);
if (result.message) {
console.log(` ${result.message}`);
}
} else {
console.log(` ✓ Fetched from ${targetRemote} (no new changes)`);
}
console.log();
} catch (err: any) {
console.error(`Error: ${err.message}`);
@@ -275,20 +274,16 @@ export async function runGitFetch(remote?: string, projectName?: string): Promis
/**
* Run the git pull command.
* @param options.skipConfirm - Skip confirmation when there are uncommitted changes
* @param options.projectName - Optional project name to target
*/
export async function runGitPull(options: { skipConfirm?: boolean; projectName?: string } = {}): Promise<void> {
// Resolve project path
const { projectPath } = options.projectName ? await resolveProject(options.projectName) : { projectPath: process.cwd() };
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {
export async function runGitPull(options: { skipConfirm?: boolean } = {}): Promise<void> {
// Validate current directory is a git repo
if (!isGitRepo()) {
console.error("Error: Not a git repository");
process.exit(1);
}
// Check for dirty state
const status = getGitStatus(projectPath);
const status = getGitStatus();
if (!status) {
console.error("Error: Failed to get git status");
process.exit(1);
@@ -312,20 +307,21 @@ export async function runGitPull(options: { skipConfirm?: boolean; projectName?:
}
try {
const output = execSync("git pull", { encoding: "utf-8", timeout: 30000, cwd: projectPath });
const result = pullGitBranch();
console.log();
console.log(` ✓ Pulled latest changes for ${status.branch}`);
if (output.trim() && output.trim() !== "Already up to date.") {
console.log(` ${output.trim()}`);
}
console.log();
} catch (err: any) {
const message = err.message || String(err);
if (message.includes("CONFLICT") || message.includes("Merge conflict")) {
if (result.conflict) {
console.error(" ✗ Merge conflict detected. Resolve manually.");
process.exit(1);
}
console.error(`Error: ${message || "Pull failed"}`);
console.log(` ✓ Pulled latest changes for ${status.branch}`);
if (result.message && result.message !== "Already up to date.") {
console.log(` ${result.message}`);
}
console.log();
} catch (err: any) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
}
@@ -333,20 +329,16 @@ export async function runGitPull(options: { skipConfirm?: boolean; projectName?:
/**
* Run the git push command.
* @param options.skipConfirm - Skip confirmation prompt
* @param options.projectName - Optional project name to target
*/
export async function runGitPush(options: { skipConfirm?: boolean; projectName?: string } = {}): Promise<void> {
// Resolve project path
const { projectPath } = options.projectName ? await resolveProject(options.projectName) : { projectPath: process.cwd() };
// Validate directory is a git repo
if (!isGitRepo(projectPath)) {
export async function runGitPush(options: { skipConfirm?: boolean } = {}): Promise<void> {
// Validate current directory is a git repo
if (!isGitRepo()) {
console.error("Error: Not a git repository");
process.exit(1);
}
// Get current branch
const status = getGitStatus(projectPath);
const status = getGitStatus();
if (!status) {
console.error("Error: Failed to get git status");
process.exit(1);
@@ -359,7 +351,7 @@ export async function runGitPush(options: { skipConfirm?: boolean; projectName?:
// Check for upstream
try {
execSync("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { encoding: "utf-8", timeout: 5000, cwd: projectPath });
execSync("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { encoding: "utf-8", timeout: 5000 });
} catch {
console.error("Error: No upstream configured for current branch");
console.error(` Run: git push -u origin ${status.branch}`);
@@ -381,22 +373,15 @@ export async function runGitPush(options: { skipConfirm?: boolean; projectName?:
}
try {
const output = execSync("git push", { encoding: "utf-8", timeout: 30000, cwd: projectPath });
const result = pushGitBranch();
console.log();
console.log(` ✓ Pushed ${status.branch} to origin`);
if (output.trim()) {
console.log(` ${output.trim()}`);
if (result.message && result.message !== "Push completed") {
console.log(` ${result.message}`);
}
console.log();
} catch (err: any) {
const message = err.message || String(err);
if (message.includes("rejected") || message.includes("non-fast-forward")) {
console.error("Error: Push rejected. Pull latest changes first.");
} else if (message.includes("Could not resolve host") || message.includes("Connection refused")) {
console.error("Error: Failed to connect to remote");
} else {
console.error(`Error: ${message || "Push failed"}`);
}
console.error(`Error: ${err.message}`);
process.exit(1);
}
}

View File

@@ -15,74 +15,13 @@ vi.mock("@fusion/core", () => {
githubTokenConfigured: false,
};
// Mock CentralCore for project-resolver
const mockCentralCore = vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
listProjects: vi.fn().mockResolvedValue([]),
getProject: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue(undefined),
registerProject: vi.fn(),
unregisterProject: vi.fn(),
getProjectHealth: vi.fn().mockResolvedValue(undefined),
isInitialized: vi.fn().mockReturnValue(true),
}));
return {
TaskStore: vi.fn(),
CentralCore: mockCentralCore,
DEFAULT_SETTINGS,
};
});
// Mock project-resolver to return a simple getStore that returns a mock store
vi.mock("../project-resolver.js", async () => {
// Create a mock store with the methods tests expect
const createMockStore = () => ({
init: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
autoResolveConflicts: true,
smartConflictResolution: true,
requirePlanApproval: false,
ntfyEnabled: false,
taskPrefix: undefined,
ntfyTopic: undefined,
worktreeNaming: "random",
githubTokenConfigured: false,
defaultProvider: undefined,
defaultModelId: undefined,
defaultThinkingLevel: undefined,
}),
updateSettings: vi.fn().mockResolvedValue(undefined),
});
return {
getStore: vi.fn().mockImplementation(createMockStore),
resolveProject: vi.fn().mockRejectedValue(new Error("Not implemented in mock")),
ProjectResolutionError: class ProjectResolutionError extends Error {
code: string;
context?: Record<string, unknown>;
constructor(message: string, code: string, context?: Record<string, unknown>) {
super(message);
this.name = "ProjectResolutionError";
this.code = code;
this.context = context;
}
},
getCentralCore: vi.fn(),
getProjectManager: vi.fn(),
findKbDir: vi.fn().mockReturnValue(null),
isKbProject: vi.fn().mockReturnValue(true),
suggestProjectName: vi.fn().mockReturnValue("test-project"),
formatLastActivity: vi.fn().mockReturnValue("just now"),
resetProjectResolution: vi.fn(),
};
});
import { TaskStore, DEFAULT_SETTINGS } from "@fusion/core";
import { getStore } from "../project-resolver.js";
import {
runSettingsShow,
runSettingsSet,
@@ -284,10 +223,10 @@ describe("runSettingsShow", () => {
taskPrefix: "CUSTOM",
});
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getSettings: vi.fn().mockResolvedValue(mockSettings),
});
}));
await runSettingsShow();
@@ -315,10 +254,10 @@ describe("runSettingsShow", () => {
githubTokenConfigured: true,
});
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getSettings: vi.fn().mockResolvedValue(mockSettings),
});
}));
await runSettingsShow();
@@ -333,10 +272,10 @@ describe("runSettingsShow", () => {
githubTokenConfigured: false,
});
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getSettings: vi.fn().mockResolvedValue(mockSettings),
});
}));
await runSettingsShow();
@@ -363,7 +302,7 @@ describe("runSettingsSet", () => {
maxWorktrees: 4,
});
(getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
updateSettings: mockUpdateSettings,
getSettings: vi.fn().mockResolvedValue({
@@ -371,7 +310,7 @@ describe("runSettingsSet", () => {
maxWorktrees: 4,
taskPrefix: "TEST",
}),
});
}));
});
afterEach(() => {

View File

@@ -1,5 +1,4 @@
import { TaskStore, type Settings, DEFAULT_SETTINGS } from "@fusion/core";
import { getStore } from "../project-resolver.js";
// Settings that can be updated via CLI
export const VALID_SETTINGS = [
@@ -39,6 +38,12 @@ const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
maxWorktrees: { min: 1, max: 20 },
};
async function getStore(): Promise<TaskStore> {
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
/**
* Parse and validate a setting value based on its key's expected type
*/
@@ -154,8 +159,8 @@ function getSettingLabel(key: string): string {
/**
* Run settings show command - displays all settings
*/
export async function runSettingsShow(projectName?: string): Promise<void> {
const store = await getStore(projectName);
export async function runSettingsShow(): Promise<void> {
const store = await getStore();
const settings = await store.getSettings();
console.log();
@@ -212,7 +217,7 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
/**
* Run settings set command - updates a single setting
*/
export async function runSettingsSet(key: string, value: string, projectName?: string): Promise<void> {
export async function runSettingsSet(key: string, value: string): Promise<void> {
// Validate the setting key is allowed
if (!VALID_SETTINGS.includes(key as ValidSettingKey)) {
console.error(`Error: Unknown setting "${key}"`);
@@ -221,7 +226,7 @@ export async function runSettingsSet(key: string, value: string, projectName?: s
return; // Required for tests where process.exit is mocked
}
const store = await getStore(projectName);
const store = await getStore();
try {
const parsedValue = parseValue(key as ValidSettingKey, value);

View File

@@ -29,16 +29,6 @@ vi.mock("@fusion/core", () => {
TaskStore: vi.fn(),
COLUMNS,
COLUMN_LABELS,
CentralCore: vi.fn().mockImplementation(function() {
return {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
listProjects: vi.fn().mockResolvedValue([]),
getProject: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue(undefined),
registerProject: vi.fn().mockResolvedValue({ id: "proj_test", name: "test", path: "/test" }),
};
}),
};
});
@@ -59,19 +49,6 @@ vi.mock("@fusion/core/gh-cli", () => ({
getCurrentRepo: vi.fn(),
}));
// Mock project-context
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn().mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "test",
store: {},
}),
getStore: vi.fn().mockResolvedValue({}),
getDefaultProject: vi.fn().mockResolvedValue(undefined),
setDefaultProject: vi.fn().mockResolvedValue(undefined),
}));
import { createInterface } from "node:readline/promises";
import { TaskStore } from "@fusion/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";

View File

@@ -7,11 +7,16 @@ import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node
import { join } from "node:path";
import { GitHubClient } from "@fusion/dashboard";
import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@fusion/core/gh-cli";
import { getStore, resolveProject } from "../project-resolver.js";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string) {
async function getStore(): Promise<TaskStore> {
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[]) {
let description = descriptionArg;
if (!description) {
@@ -25,7 +30,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
process.exit(1);
}
const store = await getStore({ project: projectName });
const store = await getStore();
const task = await store.createTask({ description: description.trim(), dependencies: depends });
const label = task.description.length > 60
@@ -72,8 +77,8 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
console.log();
}
export async function runTaskList(projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskList() {
const store = await getStore();
const tasks = await store.listTasks();
if (tasks.length === 0) {
@@ -106,7 +111,7 @@ export async function runTaskList(projectName?: string) {
process.exit(0);
}
export async function runTaskUpdate(id: string, stepStr: string, status: string, projectName?: string) {
export async function runTaskUpdate(id: string, stepStr: string, status: string) {
const stepIndex = parseInt(stepStr, 10);
if (isNaN(stepIndex)) {
console.error(`Invalid step number: ${stepStr}`);
@@ -118,7 +123,7 @@ export async function runTaskUpdate(id: string, stepStr: string, status: string,
process.exit(1);
}
const store = await getStore({ project: projectName });
const store = await getStore();
const task = await store.updateStep(id, stepIndex, status as StepStatus);
const step = task.steps[stepIndex];
@@ -128,8 +133,8 @@ export async function runTaskUpdate(id: string, stepStr: string, status: string,
console.log();
}
export async function runTaskLog(id: string, message: string, outcome?: string, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskLog(id: string, message: string, outcome?: string) {
const store = await getStore();
await store.logEntry(id, message, outcome);
console.log();
@@ -210,8 +215,8 @@ function filterEntries(entries: AgentLogEntry[], options: LogsOptions): AgentLog
return result;
}
export async function runTaskLogs(id: string, options: LogsOptions = {}, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskLogs(id: string, options: LogsOptions = {}) {
const store = await getStore();
// Verify task exists
try {
@@ -235,9 +240,8 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}, project
// Follow mode: watch for new entries
if (options.follow) {
const store = await getStore({ project: projectName });
const projectPath = (await resolveProject({ project: projectName })).directory;
const logPath = join(projectPath, ".fusion", "tasks", id, "agent.log");
const cwd = process.cwd();
const logPath = join(cwd, ".fusion", "tasks", id, "agent.log");
if (!existsSync(logPath)) {
console.log(`\n Waiting for log file to be created...`);
@@ -316,8 +320,8 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}, project
}
}
export async function runTaskShow(id: string, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskShow(id: string) {
const store = await getStore();
const task = await store.getTask(id);
console.log();
@@ -355,14 +359,14 @@ export async function runTaskShow(id: string, projectName?: string) {
}
}
export async function runTaskMerge(id: string, projectName?: string) {
const store = await getStore({ project: projectName });
const { directory } = await resolveProject({ project: projectName });
export async function runTaskMerge(id: string) {
const cwd = process.cwd();
const store = await getStore();
console.log(`\n Merging ${id} with AI...\n`);
try {
const result = await aiMergeTask(store, directory, id, {
const result = await aiMergeTask(store, cwd, id, {
onAgentText: (delta) => process.stdout.write(delta),
});
@@ -399,7 +403,7 @@ const MIME_TYPES: Record<string, string> = {
".xml": "application/xml",
};
export async function runTaskAttach(id: string, filePath: string, projectName?: string) {
export async function runTaskAttach(id: string, filePath: string) {
const { readFile } = await import("node:fs/promises");
const { basename, extname } = await import("node:path");
const { resolve } = await import("node:path");
@@ -423,7 +427,7 @@ export async function runTaskAttach(id: string, filePath: string, projectName?:
process.exit(1);
}
const store = await getStore({ project: projectName });
const store = await getStore();
const attachment = await store.addAttachment(id, filename, content, mimeType);
const sizeKB = (attachment.size / 1024).toFixed(1);
@@ -434,8 +438,8 @@ export async function runTaskAttach(id: string, filePath: string, projectName?:
console.log();
}
export async function runTaskPause(id: string, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskPause(id: string) {
const store = await getStore();
const task = await store.pauseTask(id, true);
console.log();
@@ -443,8 +447,8 @@ export async function runTaskPause(id: string, projectName?: string) {
console.log();
}
export async function runTaskUnpause(id: string, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskUnpause(id: string) {
const store = await getStore();
const task = await store.pauseTask(id, false);
console.log();
@@ -452,14 +456,14 @@ export async function runTaskUnpause(id: string, projectName?: string) {
console.log();
}
export async function runTaskMove(id: string, column: string, projectName?: string) {
export async function runTaskMove(id: string, column: string) {
if (!COLUMNS.includes(column as Column)) {
console.error(`Invalid column: ${column}`);
console.error(`Valid columns: ${COLUMNS.join(", ")}`);
process.exit(1);
}
const store = await getStore({ project: projectName });
const store = await getStore();
const task = await store.moveTask(id, column as Column);
console.log();
@@ -467,8 +471,8 @@ export async function runTaskMove(id: string, column: string, projectName?: stri
console.log();
}
export async function runTaskDuplicate(id: string, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskDuplicate(id: string) {
const store = await getStore();
const newTask = await store.duplicateTask(id);
console.log();
@@ -477,8 +481,8 @@ export async function runTaskDuplicate(id: string, projectName?: string) {
console.log();
}
export async function runTaskRefine(id: string, feedbackArg?: string, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskRefine(id: string, feedbackArg?: string) {
const store = await getStore();
// Get feedback interactively only if not provided (undefined)
let feedback = feedbackArg;
@@ -509,8 +513,8 @@ export async function runTaskRefine(id: string, feedbackArg?: string, projectNam
console.log();
}
export async function runTaskArchive(id: string, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskArchive(id: string) {
const store = await getStore();
const task = await store.archiveTask(id);
console.log();
@@ -518,8 +522,8 @@ export async function runTaskArchive(id: string, projectName?: string) {
console.log();
}
export async function runTaskUnarchive(id: string, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskUnarchive(id: string) {
const store = await getStore();
const task = await store.unarchiveTask(id);
console.log();
@@ -527,8 +531,8 @@ export async function runTaskUnarchive(id: string, projectName?: string) {
console.log();
}
export async function runTaskRetry(id: string, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskRetry(id: string) {
const store = await getStore();
// Fetch task and validate it exists
let task;
@@ -557,8 +561,8 @@ export async function runTaskRetry(id: string, projectName?: string) {
console.log();
}
export async function runTaskDelete(id: string, force?: boolean, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskDelete(id: string, force?: boolean) {
const store = await getStore();
// Check if task exists first
let task;
@@ -595,8 +599,7 @@ export async function runTaskDelete(id: string, force?: boolean, projectName?: s
export async function runTaskImportGitHubInteractive(
ownerRepo: string,
options: TaskImportOptions = {},
projectName?: string
options: TaskImportOptions = {}
): Promise<void> {
// Parse owner/repo
const match = ownerRepo.match(/^([^/]+)\/([^/]+)$/);
@@ -611,7 +614,7 @@ export async function runTaskImportGitHubInteractive(
console.log(`\n Fetching issues from ${owner}/${repo}...\n`);
const store = await getStore({ project: projectName });
const store = await getStore();
const existingTasks = await store.listTasks();
// Build a set of already-imported issue URLs
@@ -813,8 +816,7 @@ export interface TaskImportOptions {
export async function runTaskImportFromGitHub(
ownerRepo: string,
options: TaskImportOptions = {},
projectName?: string
options: TaskImportOptions = {}
): Promise<void> {
// Parse owner/repo
const match = ownerRepo.match(/^([^/]+)\/([^/]+)$/);
@@ -829,7 +831,7 @@ export async function runTaskImportFromGitHub(
console.log(`\n Importing issues from ${owner}/${repo}...\n`);
const store = await getStore({ project: projectName });
const store = await getStore();
const existingTasks = await store.listTasks();
// Build a set of already-imported issue URLs
@@ -892,8 +894,8 @@ export async function runTaskImportFromGitHub(
console.log();
}
export async function runTaskComment(id: string, message?: string, author = "user", projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskComment(id: string, message?: string, author = "user") {
const store = await getStore();
let text = message;
if (text === undefined) {
@@ -924,8 +926,8 @@ export async function runTaskComment(id: string, message?: string, author = "use
console.log();
}
export async function runTaskComments(id: string, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskComments(id: string) {
const store = await getStore();
const task = await store.getTask(id);
const comments = task.comments || [];
@@ -944,8 +946,8 @@ export async function runTaskComments(id: string, projectName?: string) {
console.log();
}
export async function runTaskSteer(id: string, message?: string, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskSteer(id: string, message?: string) {
const store = await getStore();
// Get message interactively if not provided as argument
let text = message;
@@ -995,8 +997,8 @@ export interface PrCreateOptions {
body?: string;
}
export async function runTaskPrCreate(id: string, options: PrCreateOptions = {}, projectName?: string) {
const store = await getStore({ project: projectName });
export async function runTaskPrCreate(id: string, options: PrCreateOptions = {}) {
const store = await getStore();
// Fetch task and validate it exists
let task;
@@ -1316,7 +1318,7 @@ function wrapText(text: string, width: number): string[] {
}
/** Run the planning mode */
export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, projectName?: string): Promise<void> {
export async function runTaskPlan(initialPlanArg?: string, yesFlag = false): Promise<void> {
let initialPlan = initialPlanArg;
// If no initial plan, prompt interactively
@@ -1332,7 +1334,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
}
}
const store = await getStore({ project: projectName });
const store = await getStore();
// Create planning session
let sessionId: string;
@@ -1372,7 +1374,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
try {
while (!cancelled) {
// Get user response based on question type
let response: Record<string, unknown>;
let response: unknown;
try {
switch (currentQuestion.type) {
@@ -1414,7 +1416,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
try {
showThinking();
result = await submitResponse(sessionId, response as Record<string, unknown>) as typeof result;
result = await submitResponse(sessionId, response) as typeof result;
clearThinking();
} catch (err) {
clearThinking();

View File

@@ -975,445 +975,6 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── Mission Tools ───────────────────────────────────────────────
// Mission hierarchy management for multi-phase project planning
// ── kb_mission_create ───────────────────────────────────────────
pi.registerTool({
name: "kb_mission_create",
label: "KB: Create Mission",
description:
"Create a new mission — a high-level objective that can span multiple milestones. " +
"Missions contain milestones that break down work into phases.",
promptSnippet: "Create a new mission for high-level project planning",
promptGuidelines: [
"Use for high-level project objectives that span multiple work phases",
"Missions are broken down into milestones → slices → features → tasks",
"Be descriptive so the mission purpose is clear",
],
parameters: Type.Object({
title: Type.String({ description: "Mission title — brief but descriptive" }),
description: Type.Optional(
Type.String({ description: "Detailed mission objectives and context" })
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const mission = missionStore.createMission({
title: params.title.trim(),
description: params.description?.trim(),
});
return {
content: [
{
type: "text",
text: `Created ${mission.id}: ${mission.title}\nStatus: ${mission.status}`,
},
],
details: { missionId: mission.id, title: mission.title, status: mission.status },
};
},
});
// ── kb_mission_list ──────────────────────────────────────────────
pi.registerTool({
name: "kb_mission_list",
label: "KB: List Missions",
description: "List all missions with their current status.",
promptSnippet: "List all missions",
promptGuidelines: [
"Use to see all missions and their current status",
"Missions are grouped by status (active, planning, complete, etc.)",
"Use before kb_mission_show to find a specific mission ID",
],
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const missions = missionStore.listMissions();
if (missions.length === 0) {
return {
content: [{ type: "text", text: "No missions yet." }],
details: { count: 0 },
};
}
const lines: string[] = [];
lines.push(`Missions (${missions.length}):\n`);
for (const mission of missions) {
const statusIcon = mission.status === "complete" ? "✓" : mission.status === "active" ? "●" : "○";
lines.push(` ${statusIcon} ${mission.id}: ${mission.title} (${mission.status})`);
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { count: missions.length, missions: missions.map((m) => ({ id: m.id, title: m.title, status: m.status })) },
};
},
});
// ── kb_mission_show ──────────────────────────────────────────────
pi.registerTool({
name: "kb_mission_show",
label: "KB: Show Mission",
description: "Show mission details with full hierarchy: milestones → slices → features.",
promptSnippet: "Show mission details with hierarchy",
promptGuidelines: [
"Use to see the full mission structure before planning work",
"Shows milestones, slices, and features in hierarchical order",
"Check slice status to see if features can be linked to tasks",
],
parameters: Type.Object({
id: Type.String({ description: "Mission ID (e.g., M-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const mission = missionStore.getMissionWithHierarchy(params.id);
if (!mission) {
return {
content: [{ type: "text", text: `Mission ${params.id} not found` }],
isError: true,
details: { error: "Mission not found" },
};
}
const lines: string[] = [];
lines.push(`${mission.id}: ${mission.title}`);
lines.push(`Status: ${mission.status}`);
if (mission.description) {
lines.push(`Description: ${mission.description}`);
}
lines.push("");
if (mission.milestones.length === 0) {
lines.push("No milestones yet.");
} else {
lines.push("Milestones:");
for (const milestone of mission.milestones) {
const mIcon = milestone.status === "complete" ? "✓" : milestone.status === "active" ? "●" : "○";
lines.push(` ${mIcon} ${milestone.id}: ${milestone.title} (${milestone.status})`);
for (const slice of milestone.slices) {
const sIcon = slice.status === "complete" ? "✓" : slice.status === "active" ? "●" : "○";
lines.push(` ${sIcon} ${slice.id}: ${slice.title} (${slice.status})`);
for (const feature of slice.features) {
const fIcon = feature.status === "done" ? "✓" : feature.status === "in-progress" ? "▸" : feature.status === "triaged" ? "●" : "○";
const taskLink = feature.taskId ? ` → ${feature.taskId}` : "";
lines.push(` ${fIcon} ${feature.id}: ${feature.title} (${feature.status})${taskLink}`);
}
}
}
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { mission },
};
},
});
// ── kb_mission_delete ───────────────────────────────────────────
pi.registerTool({
name: "kb_mission_delete",
label: "KB: Delete Mission",
description: "Delete a mission and all its milestones, slices, and features. Cannot be undone.",
promptSnippet: "Delete a mission and all its contents",
promptGuidelines: [
"Use for cleaning up test missions or mistakenly created missions",
"Permanently deletes all milestones, slices, and features within the mission",
"Tasks linked to features are NOT deleted — only the feature links are removed",
],
parameters: Type.Object({
id: Type.String({ description: "Mission ID to delete (e.g., M-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const mission = missionStore.getMission(params.id);
if (!mission) {
return {
content: [{ type: "text", text: `Mission ${params.id} not found` }],
isError: true,
details: { error: "Mission not found" },
};
}
missionStore.deleteMission(params.id);
return {
content: [{ type: "text", text: `Deleted ${params.id}: "${mission.title}"` }],
details: { missionId: params.id, title: mission.title },
};
},
});
// ── kb_milestone_add ────────────────────────────────────────────
pi.registerTool({
name: "kb_milestone_add",
label: "KB: Add Milestone",
description: "Add a milestone to a mission. Milestones represent phases of work.",
promptSnippet: "Add a milestone to a mission",
promptGuidelines: [
"Use to break down a mission into manageable phases",
"Milestones are ordered and contain slices (work units)",
],
parameters: Type.Object({
missionId: Type.String({ description: "Parent mission ID (e.g., M-001)" }),
title: Type.String({ description: "Milestone title" }),
description: Type.Optional(Type.String({ description: "Milestone description" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const mission = missionStore.getMission(params.missionId);
if (!mission) {
return {
content: [{ type: "text", text: `Mission ${params.missionId} not found` }],
isError: true,
details: { error: "Mission not found" },
};
}
const milestone = missionStore.addMilestone(params.missionId, {
title: params.title.trim(),
description: params.description?.trim(),
});
return {
content: [
{ type: "text", text: `Added ${milestone.id}: "${milestone.title}" to ${params.missionId}` },
],
details: { milestoneId: milestone.id, missionId: params.missionId, title: milestone.title },
};
},
});
// ── kb_slice_add ─────────────────────────────────────────────────
pi.registerTool({
name: "kb_slice_add",
label: "KB: Add Slice",
description: "Add a slice to a milestone. Slices are work units that can be activated for implementation.",
promptSnippet: "Add a work slice to a milestone",
promptGuidelines: [
"Slices represent work units within a milestone",
"Slices are activated for implementation, linking features to tasks",
"Order slices by priority — they execute in sequence",
],
parameters: Type.Object({
milestoneId: Type.String({ description: "Parent milestone ID (e.g., MS-001)" }),
title: Type.String({ description: "Slice title" }),
description: Type.Optional(Type.String({ description: "Slice description" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const milestone = missionStore.getMilestone(params.milestoneId);
if (!milestone) {
return {
content: [{ type: "text", text: `Milestone ${params.milestoneId} not found` }],
isError: true,
details: { error: "Milestone not found" },
};
}
const slice = missionStore.addSlice(params.milestoneId, {
title: params.title.trim(),
description: params.description?.trim(),
});
return {
content: [
{ type: "text", text: `Added ${slice.id}: "${slice.title}" to ${params.milestoneId}` },
],
details: { sliceId: slice.id, milestoneId: params.milestoneId, title: slice.title },
};
},
});
// ── kb_feature_add ────────────────────────────────────────────────
pi.registerTool({
name: "kb_feature_add",
label: "KB: Add Feature",
description: "Add a feature to a slice. Features are deliverables that can be linked to tasks.",
promptSnippet: "Add a feature to a slice",
promptGuidelines: [
"Features represent deliverables within a slice",
"Features start as 'defined' and progress through 'triaged' → 'in-progress' → 'done'",
"Link features to tasks using kb_feature_link_task",
],
parameters: Type.Object({
sliceId: Type.String({ description: "Parent slice ID (e.g., SL-001)" }),
title: Type.String({ description: "Feature title" }),
description: Type.Optional(Type.String({ description: "Feature description" })),
acceptanceCriteria: Type.Optional(
Type.String({ description: "Acceptance criteria for completing the feature" })
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const slice = missionStore.getSlice(params.sliceId);
if (!slice) {
return {
content: [{ type: "text", text: `Slice ${params.sliceId} not found` }],
isError: true,
details: { error: "Slice not found" },
};
}
const feature = missionStore.addFeature(params.sliceId, {
title: params.title.trim(),
description: params.description?.trim(),
acceptanceCriteria: params.acceptanceCriteria?.trim(),
});
return {
content: [
{ type: "text", text: `Added ${feature.id}: "${feature.title}" to ${params.sliceId}` },
],
details: { featureId: feature.id, sliceId: params.sliceId, title: feature.title },
};
},
});
// ── kb_slice_activate ────────────────────────────────────────────
pi.registerTool({
name: "kb_slice_activate",
label: "KB: Activate Slice",
description:
"Activate a pending slice for implementation. " +
"Sets status to 'active' and enables task linking for its features.",
promptSnippet: "Activate a slice for implementation",
promptGuidelines: [
"Activating a slice allows its features to be linked to tasks",
"Only pending slices can be activated",
"Slice activation triggers auto-advance when linked tasks complete",
],
parameters: Type.Object({
id: Type.String({ description: "Slice ID to activate (e.g., SL-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const slice = missionStore.getSlice(params.id);
if (!slice) {
return {
content: [{ type: "text", text: `Slice ${params.id} not found` }],
isError: true,
details: { error: "Slice not found" },
};
}
if (slice.status !== "pending") {
return {
content: [{ type: "text", text: `Slice ${params.id} is not pending (status: ${slice.status})` }],
isError: true,
details: { error: "Slice not pending", currentStatus: slice.status },
};
}
const activated = missionStore.activateSlice(params.id);
return {
content: [
{
type: "text",
text: `Activated ${activated.id}: "${activated.title}"\nStatus: ${activated.status}`,
},
],
details: { sliceId: activated.id, title: activated.title, status: activated.status },
};
},
});
// ── kb_feature_link_task ──────────────────────────────────────────
pi.registerTool({
name: "kb_feature_link_task",
label: "KB: Link Feature to Task",
description:
"Link a feature to a kb task for implementation. " +
"Updates the feature status to 'triaged' and associates it with the task.",
promptSnippet: "Link a feature to a task",
promptGuidelines: [
"Use when a feature is ready for implementation and has a corresponding task",
"The feature's slice must be active to link tasks",
"Linking updates the feature status to 'triaged'",
"When the linked task moves to 'done', the feature status becomes 'done'",
],
parameters: Type.Object({
featureId: Type.String({ description: "Feature ID to link (e.g., F-001)" }),
taskId: Type.String({ description: "Task ID to link to (e.g., KB-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const feature = missionStore.getFeature(params.featureId);
if (!feature) {
return {
content: [{ type: "text", text: `Feature ${params.featureId} not found` }],
isError: true,
details: { error: "Feature not found" },
};
}
// Check if task exists
try {
await store.getTask(params.taskId);
} catch {
return {
content: [{ type: "text", text: `Task ${params.taskId} not found` }],
isError: true,
details: { error: "Task not found" },
};
}
const updated = missionStore.linkFeatureToTask(params.featureId, params.taskId);
return {
content: [
{
type: "text",
text: `Linked ${updated.id}: "${updated.title}" → ${params.taskId}\nStatus: ${updated.status}`,
},
],
details: { featureId: updated.id, taskId: params.taskId, title: updated.title, status: updated.status },
};
},
});
// ── /fn command — start the dashboard + engine ───────────────────
let dashboardProcess: ChildProcess | null = null;

View File

@@ -541,8 +541,7 @@ describe("migrateFromLegacy", () => {
expect(JSON.parse(row.steps)).toHaveLength(2);
expect(JSON.parse(row.log)).toHaveLength(1);
expect(JSON.parse(row.attachments)).toHaveLength(1);
// steeringComments should be merged into comments
expect(JSON.parse(row.comments)).toHaveLength(1); // 1 steering comment migrated
expect(JSON.parse(row.steeringComments)).toHaveLength(1);
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

@@ -165,12 +165,12 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
worktree, blockedBy, paused, baseBranch, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, mergeRetries, error,
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
dependencies, steps, log, attachments,
dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`);
@@ -184,17 +184,6 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
const raw = await readFile(taskJsonPath, "utf-8");
const task: Task = JSON.parse(raw);
// Merge steeringComments into comments (unified comments field)
const existingComments = task.comments || [];
const steeringComments = (task as any).steeringComments || [];
const mergedComments = [...existingComments, ...steeringComments.map((sc: any) => ({
id: sc.id,
text: sc.text,
author: sc.author,
createdAt: sc.createdAt,
updatedAt: sc.createdAt, // Steering comments didn't have updatedAt
}))];
insertStmt.run(
task.id,
task.title ?? null,
@@ -224,7 +213,8 @@ async function migrateTasks(kbDir: string, db: Database): Promise<void> {
toJson(task.steps || []),
toJson(task.log || []),
toJson(task.attachments || []),
toJson(mergedComments),
toJson(task.steeringComments || []),
toJson(task.comments || []),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
toJsonNullable(task.issueInfo),

View File

@@ -86,7 +86,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(5);
expect(db.getSchemaVersion()).toBe(3);
});
it("seeds lastModified", () => {
@@ -109,7 +109,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(5);
expect(db.getSchemaVersion()).toBe(3);
});
it("does not overwrite existing config on re-init", () => {
@@ -683,8 +683,8 @@ 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)
expect(db.getSchemaVersion()).toBe(5);
// Verify version bumped to 3 (includes both v1→v2 and v2→v3 migrations)
expect(db.getSchemaVersion()).toBe(3);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -709,11 +709,11 @@ describe("schema migrations", () => {
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(5);
expect(db.getSchemaVersion()).toBe(3);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(5);
expect(db.getSchemaVersion()).toBe(3);
db.close();
});
@@ -804,11 +804,11 @@ describe("schema migrations", () => {
// Insert a task on the v2 schema
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('KB-2', 'test v2', 'triage', '2025-01-01', '2025-01-01')`);
// Now run init() which should trigger migrations v2→v3→v4
// Now run init() which should trigger v2→v3 migration
db.init();
// Verify version bumped to 4
expect(db.getSchemaVersion()).toBe(5);
// Verify version bumped to 3
expect(db.getSchemaVersion()).toBe(3);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -864,7 +864,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(5);
expect(db.getSchemaVersion()).toBe(3);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -58,7 +58,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 5;
const SCHEMA_VERSION = 3;
const SCHEMA_SQL = `
-- Tasks table with JSON columns for nested data
@@ -75,7 +75,6 @@ CREATE TABLE IF NOT EXISTS tasks (
blockedBy TEXT,
paused INTEGER DEFAULT 0,
baseBranch TEXT,
baseCommitSha TEXT,
modelPresetId TEXT,
modelProvider TEXT,
modelId TEXT,
@@ -100,8 +99,7 @@ CREATE TABLE IF NOT EXISTS tasks (
issueInfo TEXT,
mergeDetails TEXT,
breakIntoSubtasks INTEGER DEFAULT 0,
enabledWorkflowSteps TEXT DEFAULT '[]',
modifiedFiles TEXT DEFAULT '[]'
enabledWorkflowSteps TEXT DEFAULT '[]'
);
-- Config table (single row with project settings)
@@ -324,24 +322,8 @@ export class Database {
});
}
if (version < 4) {
this.applyMigration(4, () => {
// Add modifiedFiles column to track files changed during agent execution
this.addColumnIfMissing("tasks", "modifiedFiles", "TEXT DEFAULT '[]'");
// Add baseCommitSha column to store the base commit for diff computation
this.addColumnIfMissing("tasks", "baseCommitSha", "TEXT");
});
}
if (version < 5) {
this.applyMigration(5, () => {
// Migrate steeringComments to comments (unified comments field)
this.migrateSteeringCommentsToComments();
});
}
// Future migrations go here:
// if (version < 6) { this.applyMigration(6, () => { ... }); }
// if (version < 3) { this.applyMigration(3, () => { ... }); }
}
/**
@@ -375,60 +357,6 @@ export class Database {
}
}
/**
* Migrate steeringComments data to the unified comments field.
* This is a one-way migration from schema version 4 to 5.
*/
private migrateSteeringCommentsToComments(): void {
// Only run if steeringComments column exists
if (!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 }>;
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;
}>;
// 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;
}
}
}
/**
* Close the database connection.
*/

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskCreateInput, TaskDetail, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, TaskStep, StepStatus, TaskLogEntry, ActivityLogEntry, ActivityEventType, ThinkingLevel, SteeringComment, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentCapability, AgentHeartbeatEvent } from "./types.js";
export { AgentStore } from "./agent-store.js";
export type { AgentStoreEvents } from "./agent-store.js";
export { TaskStore } from "./store.js";
@@ -121,26 +121,5 @@ export type {
ProjectStatus,
ProjectHealth,
CentralActivityLogEntry,
GlobalConcurrencyState,
DetectedProject,
MigrationOptions,
MigrationResult,
ProjectSetupInput,
SetupState,
SetupCompletionResult,
GlobalConcurrencyState
} from "./types.js";
// ── Migration & First-Run (Multi-Project Support) ───────────────────────────
export {
MigrationOrchestrator,
createMigrationOrchestrator,
MAX_AUTO_REGISTER_PROJECTS,
DEFAULT_MAX_DEPTH,
EXCLUDED_DIRS,
} from "./migration-orchestrator.js";
export {
FirstRunExperience,
createFirstRunExperience,
} from "./first-run.js";

View File

@@ -725,29 +725,6 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
return updated;
}
/**
* Find the next pending slice in a mission.
* Iterates milestones by orderIndex, then slices by orderIndex,
* and returns the first slice with status "pending".
*
* @param missionId - Mission ID
* @returns The next pending slice, or undefined if none found
*/
findNextPendingSlice(missionId: string): Slice | undefined {
const milestones = this.listMilestones(missionId);
for (const milestone of milestones) {
const slices = this.listSlices(milestone.id);
for (const slice of slices) {
if (slice.status === "pending") {
return slice;
}
}
}
return undefined;
}
// ── Feature Operations ─────────────────────────────────────────────
/**
@@ -952,29 +929,6 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
return updated;
}
/**
* Update a feature's status.
* Recomputes slice status after update.
*
* @param featureId - Feature ID
* @param status - New status
* @returns The updated feature
* @throws Error if feature not found
*/
updateFeatureStatus(featureId: string, status: FeatureStatus): MissionFeature {
const feature = this.getFeature(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
const updated = this.updateFeature(featureId, { status });
// Recompute slice status
this.recomputeSliceStatus(updated.sliceId);
return updated;
}
/**
* Find a feature by its linked task ID.
*

View File

@@ -48,8 +48,6 @@ export interface Mission {
status: MissionStatus;
/** State of the AI specification interview process */
interviewState: InterviewState;
/** When true, automatically activate the next pending slice when current slice completes */
autoAdvance?: boolean;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */

View File

@@ -856,18 +856,18 @@ describe("TaskStore", () => {
expect(fetched.steps[0].status).toBe("in-progress");
});
it("addComment recreates missing task directory before persisting metadata", async () => {
it("addSteeringComment recreates missing task directory before persisting metadata", async () => {
const task = await createTestTask();
const dir = await deleteTaskDir(task.id);
const updated = await store.addComment(task.id, "Please recover from missing directory");
const updated = await store.addSteeringComment(task.id, "Please recover from missing directory");
expect(updated.comments).toHaveLength(1);
expect(updated.steeringComments).toHaveLength(1);
expect(existsSync(dir)).toBe(true);
expect(existsSync(join(dir, "task.json"))).toBe(true);
const fetched = await store.getTask(task.id);
expect(fetched.comments).toHaveLength(1);
expect(fetched.steeringComments).toHaveLength(1);
});
it("appendAgentLog recreates missing task directory before writing agent.log", async () => {
@@ -1276,68 +1276,68 @@ describe("TaskStore", () => {
);
});
it("unifies task comments and steering comments into single comments field", async () => {
it("persists task comments independently from steering comments", async () => {
const task = await createTestTask();
await store.addTaskComment(task.id, "General note", "alice");
await store.addComment(task.id, "Execution note");
await store.addSteeringComment(task.id, "Execution note");
const reopened = await store.getTask(task.id);
// Both comments should now be in the unified comments field
expect(reopened.comments).toHaveLength(2);
expect(reopened.comments).toHaveLength(1);
expect(reopened.comments![0].text).toBe("General note");
expect(reopened.comments![1].text).toBe("Execution note");
expect(reopened.steeringComments).toHaveLength(1);
expect(reopened.steeringComments![0].text).toBe("Execution note");
});
});
describe("addComment", () => {
describe("addSteeringComment", () => {
it("adds a steering comment to a task", async () => {
const task = await createTestTask();
const updated = await store.addComment(task.id, "Please handle the edge case");
const updated = await store.addSteeringComment(task.id, "Please handle the edge case");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("Please handle the edge case");
expect(updated.comments![0].author).toBe("user");
expect(updated.comments![0].id).toBeDefined();
expect(updated.comments![0].createdAt).toBeDefined();
expect(updated.steeringComments).toHaveLength(1);
expect(updated.steeringComments![0].text).toBe("Please handle the edge case");
expect(updated.steeringComments![0].author).toBe("user");
expect(updated.steeringComments![0].id).toBeDefined();
expect(updated.steeringComments![0].createdAt).toBeDefined();
});
it("accepts agent as author", async () => {
const task = await createTestTask();
const updated = await store.addComment(task.id, "Note from agent", "agent");
const updated = await store.addSteeringComment(task.id, "Note from agent", "agent");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].author).toBe("agent");
expect(updated.steeringComments).toHaveLength(1);
expect(updated.steeringComments![0].author).toBe("agent");
});
it("initializes comments array if undefined", async () => {
it("initializes steeringComments array if undefined", async () => {
const task = await createTestTask();
expect(task.comments).toBeUndefined();
expect(task.steeringComments).toBeUndefined();
const updated = await store.addComment(task.id, "First comment");
expect(updated.comments).toBeDefined();
expect(updated.comments).toHaveLength(1);
const updated = await store.addSteeringComment(task.id, "First comment");
expect(updated.steeringComments).toBeDefined();
expect(updated.steeringComments).toHaveLength(1);
});
it("appends multiple comments in order", async () => {
const task = await createTestTask();
await store.addComment(task.id, "First comment");
await store.addComment(task.id, "Second comment");
await store.addComment(task.id, "Third comment");
await store.addSteeringComment(task.id, "First comment");
await store.addSteeringComment(task.id, "Second comment");
await store.addSteeringComment(task.id, "Third comment");
const fetched = await store.getTask(task.id);
expect(fetched.comments).toHaveLength(3);
expect(fetched.comments![0].text).toBe("First comment");
expect(fetched.comments![1].text).toBe("Second comment");
expect(fetched.comments![2].text).toBe("Third comment");
expect(fetched.steeringComments).toHaveLength(3);
expect(fetched.steeringComments![0].text).toBe("First comment");
expect(fetched.steeringComments![1].text).toBe("Second comment");
expect(fetched.steeringComments![2].text).toBe("Third comment");
});
it("generates unique IDs for each comment", async () => {
const task = await createTestTask();
const updated1 = await store.addComment(task.id, "Comment 1");
const updated2 = await store.addComment(task.id, "Comment 2");
const updated1 = await store.addSteeringComment(task.id, "Comment 1");
const updated2 = await store.addSteeringComment(task.id, "Comment 2");
const id1 = updated1.comments![0].id;
const id2 = updated2.comments![1].id;
const id1 = updated1.steeringComments![0].id;
const id2 = updated2.steeringComments![1].id;
expect(id1).not.toBe(id2);
});
@@ -1346,28 +1346,28 @@ describe("TaskStore", () => {
const events: any[] = [];
store.on("task:updated", (t) => events.push(t));
await store.addComment(task.id, "Test comment");
await store.addSteeringComment(task.id, "Test comment");
expect(events).toHaveLength(1);
expect(events[0].comments).toHaveLength(1);
expect(events[0].comments![0].text).toBe("Test comment");
expect(events[0].steeringComments).toHaveLength(1);
expect(events[0].steeringComments![0].text).toBe("Test comment");
});
it("persists to disk and round-trips correctly", async () => {
const task = await createTestTask();
await store.addComment(task.id, "Persisted comment");
await store.addSteeringComment(task.id, "Persisted comment");
const fetched = await store.getTask(task.id);
expect(fetched.comments).toHaveLength(1);
expect(fetched.comments![0].text).toBe("Persisted comment");
expect(fetched.comments![0].author).toBe("user");
expect(fetched.steeringComments).toHaveLength(1);
expect(fetched.steeringComments![0].text).toBe("Persisted comment");
expect(fetched.steeringComments![0].author).toBe("user");
});
it("adds log entry for the action", async () => {
const task = await createTestTask();
const updated = await store.addComment(task.id, "Comment with log");
const updated = await store.addSteeringComment(task.id, "Comment with log");
expect(updated.log.some((l) => l.action === "Comment added")).toBe(true);
expect(updated.log.some((l) => l.action === "Steering comment added")).toBe(true);
expect(updated.log.some((l) => l.outcome === "by user")).toBe(true);
});
@@ -1376,7 +1376,7 @@ describe("TaskStore", () => {
const before = task.updatedAt;
await new Promise((r) => setTimeout(r, 10)); // Ensure time passes
const updated = await store.addComment(task.id, "Timestamp test");
const updated = await store.addSteeringComment(task.id, "Timestamp test");
expect(updated.updatedAt).not.toBe(before);
});
@@ -1389,7 +1389,7 @@ describe("TaskStore", () => {
const allTasksBefore = await store.listTasks();
await store.addComment(task.id, "Need to fix edge case");
await store.addSteeringComment(task.id, "Need to fix edge case");
const allTasksAfter = await store.listTasks();
expect(allTasksAfter).toHaveLength(allTasksBefore.length + 1);
@@ -1406,7 +1406,7 @@ describe("TaskStore", () => {
const allTasksBefore = await store.listTasks();
await store.addComment(task.id, "Some feedback");
await store.addSteeringComment(task.id, "Some feedback");
const allTasksAfter = await store.listTasks();
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
@@ -1419,7 +1419,7 @@ describe("TaskStore", () => {
const allTasksBefore = await store.listTasks();
await store.addComment(task.id, "Some feedback");
await store.addSteeringComment(task.id, "Some feedback");
const allTasksAfter = await store.listTasks();
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
@@ -1433,7 +1433,7 @@ describe("TaskStore", () => {
const allTasksBefore = await store.listTasks();
await store.addComment(task.id, "Some feedback");
await store.addSteeringComment(task.id, "Some feedback");
const allTasksAfter = await store.listTasks();
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
@@ -1446,10 +1446,10 @@ describe("TaskStore", () => {
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const updated = await store.addComment(task.id, "Need to fix edge case");
const updated = await store.addSteeringComment(task.id, "Need to fix edge case");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe("Need to fix edge case");
expect(updated.steeringComments).toHaveLength(1);
expect(updated.steeringComments![0].text).toBe("Need to fix edge case");
});
it("refinement task has correct dependency on original done task", async () => {
@@ -1459,7 +1459,7 @@ describe("TaskStore", () => {
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.addComment(task.id, "Need to fix edge case");
await store.addSteeringComment(task.id, "Need to fix edge case");
const allTasks = await store.listTasks();
const refinement = allTasks.find((t) => t.id !== task.id && t.dependencies?.includes(task.id));
@@ -1477,7 +1477,7 @@ describe("TaskStore", () => {
const allTasksBefore = await store.listTasks();
await store.addComment(task.id, "Agent feedback", "agent");
await store.addSteeringComment(task.id, "Agent feedback", "agent");
const allTasksAfter = await store.listTasks();
expect(allTasksAfter).toHaveLength(allTasksBefore.length);
@@ -1491,10 +1491,10 @@ describe("TaskStore", () => {
await store.moveTask(task.id, "done");
// Should not throw - refineTask will reject empty feedback but we catch it
const updated = await store.addComment(task.id, " ");
const updated = await store.addSteeringComment(task.id, " ");
expect(updated.comments).toHaveLength(1);
expect(updated.comments![0].text).toBe(" ");
expect(updated.steeringComments).toHaveLength(1);
expect(updated.steeringComments![0].text).toBe(" ");
});
});
@@ -1504,7 +1504,7 @@ describe("TaskStore", () => {
const reopened = await store.getTask(task.id);
expect(reopened.comments).toBeUndefined();
expect(reopened.comments).toBeUndefined();
expect(reopened.steeringComments).toBeUndefined();
});
it("supports the task comment and merge details shapes", async () => {
@@ -2240,11 +2240,11 @@ describe("TaskStore", () => {
it("does NOT copy steering comments", async () => {
const task = await store.createTask({ description: "Test task" });
await store.addComment(task.id, "Test comment");
await store.addSteeringComment(task.id, "Test comment");
const duplicated = await store.duplicateTask(task.id);
expect(duplicated.comments).toBeUndefined();
expect(duplicated.steeringComments).toBeUndefined();
});
it("emits task:created event", async () => {

View File

@@ -133,7 +133,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
blockedBy: row.blockedBy || undefined,
paused: row.paused ? true : undefined,
baseBranch: row.baseBranch || undefined,
baseCommitSha: row.baseCommitSha || undefined,
modelPresetId: row.modelPresetId || undefined,
modelProvider: row.modelProvider || undefined,
modelId: row.modelId || undefined,
@@ -150,6 +149,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
steps: fromJson<import("./types.js").TaskStep[]>(row.steps) || [],
log: fromJson<import("./types.js").TaskLogEntry[]>(row.log) || [],
attachments: (() => { const a = fromJson<TaskAttachment[]>(row.attachments); return a && a.length > 0 ? a : undefined; })(),
steeringComments: (() => { const s = fromJson<import("./types.js").SteeringComment[]>(row.steeringComments); return s && s.length > 0 ? s : undefined; })(),
comments: (() => { const c = fromJson<import("./types.js").TaskComment[]>(row.comments); return c && c.length > 0 ? c : undefined; })(),
workflowStepResults: (() => { const w = fromJson<import("./types.js").WorkflowStepResult[]>(row.workflowStepResults); return w && w.length > 0 ? w : undefined; })(),
prInfo: fromJson<import("./types.js").PrInfo>(row.prInfo),
@@ -157,7 +157,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
mergeDetails: fromJson<import("./types.js").MergeDetails>(row.mergeDetails),
breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined,
enabledWorkflowSteps: (() => { const e = fromJson<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
modifiedFiles: (() => { const m = fromJson<string[]>(row.modifiedFiles); return m && m.length > 0 ? m : undefined; })(),
};
}
@@ -168,15 +167,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.db.prepare(`
INSERT OR REPLACE INTO tasks (
id, title, description, "column", status, size, reviewLevel, currentStep,
worktree, blockedBy, paused, baseBranch, baseCommitSha, modelPresetId, modelProvider,
worktree, blockedBy, paused, baseBranch, modelPresetId, modelProvider,
modelId, validatorModelProvider, validatorModelId, mergeRetries, error,
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
dependencies, steps, log, attachments,
dependencies, steps, log, attachments, steeringComments,
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles
breakIntoSubtasks, enabledWorkflowSteps
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`).run(
task.id,
@@ -191,7 +190,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.blockedBy ?? null,
task.paused ? 1 : 0,
task.baseBranch ?? null,
task.baseCommitSha ?? null,
task.modelPresetId ?? null,
task.modelProvider ?? null,
task.modelId ?? null,
@@ -208,6 +206,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
toJson(task.steps || []),
toJson(task.log || []),
toJson(task.attachments || []),
toJson(task.steeringComments || []),
toJson(task.comments || []),
toJson(task.workflowStepResults || []),
toJsonNullable(task.prInfo),
@@ -215,7 +214,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
toJsonNullable(task.mergeDetails),
task.breakIntoSubtasks ? 1 : 0,
toJson(task.enabledWorkflowSteps || []),
toJson(task.modifiedFiles || []),
);
this.db.bumpLastModified();
}
@@ -689,7 +687,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
createdAt: now,
updatedAt: now,
// Explicitly NOT copied: worktree, status, blockedBy, paused, baseBranch,
// attachments, comments, prInfo, agent logs, size, reviewLevel
// attachments, steeringComments, prInfo, agent logs, size, reviewLevel
};
const newDir = this.taskDir(newId);
@@ -877,7 +875,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; baseCommitSha?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; modifiedFiles?: string[] | null },
updates: { title?: string; description?: string; prompt?: string; worktree?: string; status?: string | null; dependencies?: string[]; blockedBy?: string | null; paused?: boolean; baseBranch?: string; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; error?: string | null; summary?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null },
): Promise<Task> {
return this.withTaskLock(id, async () => {
// Validate that task doesn't depend on itself
@@ -927,7 +925,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
if (updates.paused !== undefined) task.paused = updates.paused || undefined;
if (updates.baseBranch !== undefined) task.baseBranch = updates.baseBranch;
if (updates.baseCommitSha !== undefined) task.baseCommitSha = updates.baseCommitSha;
if (updates.size !== undefined) task.size = updates.size;
if (updates.reviewLevel !== undefined) task.reviewLevel = updates.reviewLevel;
if (updates.mergeRetries !== undefined) task.mergeRetries = updates.mergeRetries;
@@ -966,11 +963,6 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
} else if (updates.workflowStepResults !== undefined) {
task.workflowStepResults = updates.workflowStepResults;
}
if (updates.modifiedFiles === null) {
task.modifiedFiles = undefined;
} else if (updates.modifiedFiles !== undefined) {
task.modifiedFiles = updates.modifiedFiles;
}
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
@@ -1473,10 +1465,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
breakIntoSubtasks: task.breakIntoSubtasks,
paused: task.paused,
baseBranch: task.baseBranch,
baseCommitSha: task.baseCommitSha,
mergeRetries: task.mergeRetries,
error: task.error,
modifiedFiles: task.modifiedFiles,
};
// Write to archivedTasks table in SQLite
@@ -2023,17 +2013,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
/**
* Add a comment to a task.
* Comments are injected into the AI execution context.
* When a comment is added to a task in the "done" column by a user,
* Add a steering comment to a task.
* Steering comments are user-provided feedback injected into the AI execution context.
* When a steering comment is added to a task in the "done" column by a user,
* automatically creates a refinement task with the comment text as feedback.
*/
async addComment(
async addSteeringComment(
id: string,
text: string,
author: "user" | "agent" = "user",
): Promise<Task> {
// Phase 1: Add comment under lock
// Phase 1: Add steering comment under lock
const task = await this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
@@ -2046,22 +2036,21 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Generate unique ID: timestamp + random suffix for collision resistance
const commentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const comment: import("./types.js").TaskComment = {
const comment: import("./types.js").SteeringComment = {
id: commentId,
text,
author,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
author,
};
if (!task.comments) {
task.comments = [];
if (!task.steeringComments) {
task.steeringComments = [];
}
task.comments.push(comment);
task.steeringComments.push(comment);
task.updatedAt = new Date().toISOString();
task.log.push({
timestamp: task.updatedAt,
action: "Comment added",
action: "Steering comment added",
outcome: `by ${author}`,
});
@@ -2079,7 +2068,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await this.refineTask(id, text);
} catch {
// Silently ignore - refinement is best-effort and shouldn't fail
// the comment addition. refineTask already validates
// the steering comment addition. refineTask already validates
// feedback text, so empty/whitespace comments won't create refinements.
}
}
@@ -2306,10 +2295,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
breakIntoSubtasks: task.breakIntoSubtasks,
paused: task.paused,
baseBranch: task.baseBranch,
baseCommitSha: task.baseCommitSha,
mergeRetries: task.mergeRetries,
error: task.error,
modifiedFiles: task.modifiedFiles,
};
// Write to archivedTasks table
@@ -2372,8 +2359,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
validatorModelProvider: entry.validatorModelProvider,
validatorModelId: entry.validatorModelId,
breakIntoSubtasks: entry.breakIntoSubtasks,
modifiedFiles: entry.modifiedFiles,
// Intentionally NOT restoring: worktree, status, blockedBy, paused, baseBranch, baseCommitSha, error, comments
// Intentionally NOT restoring: worktree, status, blockedBy, paused, baseBranch, error, steeringComments
};
// Write task.json
@@ -2714,97 +2700,4 @@ ${notificationsSection}`;
}
return this.missionStore;
}
// ── Backward Compatibility (Multi-Project Support) ────────────────────────
/**
* Get or create a TaskStore for a project, supporting backward-compatible
* single-project mode and multi-project resolution.
*
* Resolution logic:
* - If `projectId` provided: look up in central registry, create store for that path
* - If no `projectId` and single project registered: use that project
* - If no `projectId` and multiple projects: throw requiring explicit selection
* - If no central DB available: fall back to legacy behavior (current directory)
*
* @param projectId — Optional project ID to resolve
* @param centralCore — Optional CentralCore instance (creates new if not provided)
* @returns TaskStore initialized for the resolved project
* @throws Error if project resolution fails or multiple projects require explicit selection
*/
static async getOrCreateForProject(
projectId?: string,
centralCore?: import("./central-core.js").CentralCore
): Promise<TaskStore> {
// If no centralCore provided, try to create one
let core = centralCore;
let shouldCleanupCore = false;
if (!core) {
try {
const { CentralCore } = await import("./central-core.js");
core = new CentralCore();
await core.init();
shouldCleanupCore = true;
} catch {
// Central core not available - fall back to legacy mode
}
}
// Legacy mode: no central core available
if (!core) {
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
try {
// If projectId provided, look it up directly
if (projectId) {
const project = await core.getProject(projectId);
if (!project) {
// Try to find by name
const allProjects = await core.listProjects();
const byName = allProjects.find(p => p.name === projectId);
if (!byName) {
throw new Error(`Project "${projectId}" not found`);
}
const store = new TaskStore(byName.path);
await store.init();
return store;
}
const store = new TaskStore(project.path);
await store.init();
return store;
}
// No projectId provided - check registered projects
const projects = await core.listProjects();
if (projects.length === 0) {
// No projects registered - fall back to legacy mode (current directory)
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
if (projects.length === 1) {
// Exactly one project - use it
const store = new TaskStore(projects[0].path);
await store.init();
return store;
}
// Multiple projects - require explicit selection
const projectList = projects.map(p => ` - ${p.name}: ${p.path}`).join("\n");
throw new Error(
`Multiple projects registered. Use --project <name> to specify one.\n\nAvailable projects:\n${projectList}`
);
} finally {
// Clean up the central core if we created it
if (shouldCleanupCore && core) {
await core.close();
}
}
}
}

View File

@@ -327,6 +327,13 @@ export interface TaskAttachment {
createdAt: string;
}
export interface SteeringComment {
id: string;
text: string;
createdAt: string;
author: "user" | "agent";
}
export interface TaskComment {
id: string;
text: string;
@@ -380,11 +387,8 @@ export interface Task {
* unmerged branch. The executor reads this to branch from the
* dependency's branch instead of HEAD. Cleared after worktree creation. */
baseBranch?: string;
/** Commit SHA of the base branch at worktree creation time.
* Used for computing file diffs when reviewing task changes.
* Set by the executor when creating the worktree. */
baseCommitSha?: string;
attachments?: TaskAttachment[];
steeringComments?: SteeringComment[];
comments?: TaskComment[];
/** PR information for tasks linked to GitHub pull requests */
prInfo?: PrInfo;
@@ -424,10 +428,6 @@ export interface Task {
error?: string;
/** Optional summary of what was changed/fixed when task is completed */
summary?: string;
/** Files modified during agent execution, captured at task completion time */
modifiedFiles?: string[];
/** Optional ID of the slice this task is linked to (for mission-based work) */
sliceId?: string;
/** ISO-8601 timestamp of when the task last entered its current column.
* Used to sort cards within a column so that recently-moved cards appear at the top. */
columnMovedAt?: string;
@@ -523,13 +523,6 @@ export interface GlobalSettings {
/** ntfy.sh topic name for push notifications. When set along with ntfyEnabled,
* notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */
ntfyTopic?: string;
/** Default project ID to use when no explicit project is specified and
* no project can be auto-detected from the current directory.
* Used for multi-project CLI workflows. */
defaultProjectId?: string;
/** When true, indicates the first-run setup wizard has been completed.
* Set by FirstRunExperience.completeSetup() after successful migration. */
setupComplete?: boolean;
}
/**
@@ -663,10 +656,6 @@ export interface ProjectSettings {
* Must be set together with `titleSummarizerProvider`. Falls back to planningModelId,
* then defaultModelId if not specified. */
titleSummarizerModelId?: string;
/** Project-defined shell scripts for quick command execution.
* Key is the script name, value is the shell command to execute.
* Script names must be alphanumeric with hyphens and underscores only. */
scripts?: Record<string, string>;
}
/**
@@ -693,7 +682,6 @@ export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode"
defaultThinkingLevel: undefined,
ntfyEnabled: false,
ntfyTopic: undefined,
defaultProjectId: undefined,
};
/** Default values for project-level settings. */
@@ -731,7 +719,6 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
autoSummarizeTitles: false,
titleSummarizerProvider: undefined,
titleSummarizerModelId: undefined,
scripts: {},
};
/**
@@ -753,7 +740,6 @@ export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
"defaultThinkingLevel",
"ntfyEnabled",
"ntfyTopic",
"defaultProjectId",
] as const;
/** Keys that belong to the project settings scope. */
@@ -793,7 +779,6 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
"autoSummarizeTitles",
"titleSummarizerProvider",
"titleSummarizerModelId",
"scripts",
] as const;
export interface BoardConfig {
@@ -876,11 +861,8 @@ export interface ArchivedTaskEntry {
breakIntoSubtasks?: boolean;
paused?: boolean;
baseBranch?: string;
baseCommitSha?: string;
mergeRetries?: number;
error?: string;
/** Files modified during agent execution, captured at task completion time */
modifiedFiles?: string[];
}
/** Type of planning question presented to the user */
@@ -1095,75 +1077,3 @@ export interface AgentUpdateInput {
role?: AgentCapability;
metadata?: Record<string, unknown>;
}
// ── Migration & First-Run Types (Multi-Project Support) ───────────────────
/** A project detected during filesystem scanning for auto-migration */
export interface DetectedProject {
/** Absolute path to the project directory */
path: string;
/** Project name (derived from directory basename) */
name: string;
/** Whether the project has a valid kb database */
hasDb: boolean;
}
/** Options for migration orchestration */
export interface MigrationOptions {
/** Starting path for project detection (default: process.cwd()) */
startPath?: string;
/** Whether to auto-register detected projects (default: false) */
autoRegister?: boolean;
/** Whether to perform a dry run (detect only, don't register) */
dryRun?: boolean;
/** Maximum depth to scan (default: 5) */
maxDepth?: number;
/** Progress callback for UI feedback */
onProgress?: (current: number, total: number, projectPath: string) => void;
}
/** Result of migration execution */
export interface MigrationResult {
/** Projects detected during scan */
projectsDetected: DetectedProject[];
/** Projects successfully registered */
projectsRegistered: RegisteredProject[];
/** Projects skipped (already registered or invalid) */
projectsSkipped: Array<{ path: string; reason: string }>;
/** Errors encountered during migration */
errors: Array<{ path: string; error: string }>;
}
/** Input for setting up a project during first-run wizard */
export interface ProjectSetupInput {
/** Absolute path to project directory */
path: string;
/** Display name for the project */
name: string;
/** Execution isolation mode (default: 'in-process') */
isolationMode?: IsolationMode;
}
/** Complete setup state for first-run experience */
export interface SetupState {
/** Whether this is a fresh installation (no projects registered) */
isFirstRun: boolean;
/** Whether any projects were detected during scan */
hasDetectedProjects: boolean;
/** Projects detected but not yet registered */
detectedProjects: DetectedProject[];
/** Projects already registered in the system */
registeredProjects: RegisteredProject[];
/** Recommended action based on current state */
recommendedAction: 'auto-detect' | 'manual-setup' | 'create-new';
}
/** Result of completing the setup wizard */
export interface SetupCompletionResult {
/** Whether setup completed successfully */
success: boolean;
/** Projects that were registered */
projects: RegisteredProject[];
/** Suggested next steps for the user */
nextSteps: string[];
}

View File

@@ -1,6 +1,6 @@
# @fusion/dashboard
# @kb/dashboard
Web-based dashboard for managing fusion tasks. Provides a visual kanban board, list view, and git repository management tools.
Web-based dashboard for managing kb tasks. Provides a visual kanban board, list view, and git repository management tools.
## Features
@@ -46,7 +46,6 @@ AI-guided interactive planning for creating well-specified tasks from high-level
- **Model Selection at Creation**: Choose executor and validator AI models while creating tasks from the board or list view, or leave them unset to use the global defaults.
- **Bulk Model Editing**: Update AI model configuration for multiple tasks at once in the list view. Select tasks via checkboxes (archived tasks excluded), then use the "Bulk Edit Models" toolbar to apply executor and/or validator model changes to all selected tasks. Selection persists in localStorage across page reloads.
- **Task Details**: View full task specifications, agent logs, and attachments
- **Changed Files Viewer**: Click a task card's "files changed" button to open a dedicated diff viewer showing only files changed in that task worktree, with per-file statuses and sidebar navigation
- **GitHub Import**: Import issues directly from GitHub repositories
- **PR Management**: Create, monitor, and merge pull requests for in-review tasks

View File

@@ -1,16 +1,13 @@
import { useState, useCallback, useEffect } from "react";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode, ProjectInfo } from "@fusion/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels, fetchTaskDetail } from "./api";
import type { TaskDetail, TaskCreateInput, Task, ThemeMode } from "@fusion/core";
import { fetchConfig, fetchSettings, fetchAuthStatus, updateSettings, fetchModels } from "./api";
import type { ModelInfo } from "./api";
import { Header } from "./components/Header";
import { Board } from "./components/Board";
import { ListView } from "./components/ListView";
import { ProjectOverview } from "./components/ProjectOverview";
import { SetupWizardModal } from "./components/SetupWizardModal";
import { TaskDetailModal } from "./components/TaskDetailModal";
import { TerminalModal } from "./components/TerminalModal";
import { FileBrowserModal } from "./components/FileBrowserModal";
import { ChangedFilesModal } from "./components/ChangedFilesModal";
import { SettingsModal } from "./components/SettingsModal";
import { PlanningModeModal } from "./components/PlanningModeModal";
import { SubtaskBreakdownModal } from "./components/SubtaskBreakdownModal";
@@ -24,11 +21,7 @@ import { ScheduledTasksModal } from "./components/ScheduledTasksModal";
import { ActivityLogModal } from "./components/ActivityLogModal";
import { WorkflowStepManager } from "./components/WorkflowStepManager";
import { AgentListModal } from "./components/AgentListModal";
import { AgentsView } from "./components/AgentsView";
import { ScriptsModal } from "./components/ScriptsModal";
import { useTasks } from "./hooks/useTasks";
import { useProjects } from "./hooks/useProjects";
import { useCurrentProject } from "./hooks/useCurrentProject";
import { ToastProvider, useToast } from "./hooks/useToast";
import { useTheme } from "./hooks/useTheme";
@@ -46,82 +39,34 @@ function AppInner() {
const [terminalOpen, setTerminalOpen] = useState(false);
const [filesOpen, setFilesOpen] = useState(false);
const [fileBrowserWorkspace, setFileBrowserWorkspace] = useState("project");
const [changedFilesState, setChangedFilesState] = useState<{ taskId: string; worktree: string | undefined; column: string } | null>(null);
const [activityLogOpen, setActivityLogOpen] = useState(false);
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
const [agentsOpen, setAgentsOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [rootDir, setRootDir] = useState<string>(".");
const [autoMerge, setAutoMerge] = useState(true);
const [globalPaused, setGlobalPaused] = useState(false);
const [enginePaused, setEnginePaused] = useState(false);
// Multi-project state
const { projects, loading: projectsLoading, register, update: updateProject, unregister: unregisterProject } = useProjects();
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
// View state: "overview" for all projects, "project" for single project task view
const [viewMode, setViewMode] = useState<"overview" | "project">(() => {
const [view, setView] = useState<"board" | "list">(() => {
// Initialize from localStorage if available
if (typeof window !== "undefined") {
const saved = localStorage.getItem("kb-dashboard-view-mode");
if (saved === "overview" || saved === "project") {
return saved;
}
}
return "overview";
});
// Task view state (only meaningful when viewMode="project")
const [taskView, setTaskView] = useState<"board" | "list" | "agents">(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("kb-dashboard-task-view");
if (saved === "list" || saved === "board" || saved === "agents") {
const saved = localStorage.getItem("kb-dashboard-view");
if (saved === "list" || saved === "board") {
return saved;
}
}
return "board";
});
const [searchQuery, setSearchQuery] = useState("");
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
// Setup wizard state
const [setupWizardOpen, setSetupWizardOpen] = useState(false);
// Tasks hook with project context
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks(
currentProject ? { projectId: currentProject.id } : undefined
);
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone } = useTasks();
// Theme management
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
// Auto-open setup wizard on first run (no projects)
useEffect(() => {
if (!projectsLoading && projects.length === 0 && !setupWizardOpen) {
// Delay slightly to allow initial render
const timer = setTimeout(() => {
setSetupWizardOpen(true);
}, 500);
return () => clearTimeout(timer);
}
}, [projectsLoading, projects.length, setupWizardOpen]);
// Persist view mode
useEffect(() => {
localStorage.setItem("kb-dashboard-view-mode", viewMode);
}, [viewMode]);
// Persist task view
useEffect(() => {
localStorage.setItem("kb-dashboard-task-view", taskView);
}, [taskView]);
// Theme toggle handler: cycles Dark → Light → System → Dark
const handleToggleTheme = useCallback(() => {
const cycle: ThemeMode[] = ["dark", "light", "system"];
@@ -163,74 +108,15 @@ function AppInner() {
}, []);
const { toasts, addToast, removeToast } = useToast();
// Handle deep link to task on mount
// Persist view preference to localStorage
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const taskId = params.get("task");
if (!taskId) return;
localStorage.setItem("kb-dashboard-view", view);
}, [view]);
// Clean URL immediately without reloading
const url = new URL(window.location.href);
url.searchParams.delete("task");
window.history.replaceState({}, "", url.toString());
// Load and open the task directly
fetchTaskDetail(taskId)
.then((task) => {
handleDetailOpen(task);
})
.catch(() => {
addToast(`Task ${taskId} not found`, "error");
});
const handleChangeView = useCallback((newView: "board" | "list") => {
setView(newView);
}, []);
// Project selection handlers
const handleSelectProject = useCallback((project: ProjectInfo) => {
setCurrentProject(project);
setViewMode("project");
}, [setCurrentProject]);
const handleViewAllProjects = useCallback(() => {
clearCurrentProject();
setViewMode("overview");
}, [clearCurrentProject]);
const handleAddProject = useCallback(() => {
setSetupWizardOpen(true);
}, []);
const handlePauseProject = useCallback(async (project: ProjectInfo) => {
try {
await updateProject(project.id, { status: "paused" });
addToast(`Project ${project.name} paused`, "success");
} catch {
addToast(`Failed to pause project ${project.name}`, "error");
}
}, [updateProject, addToast]);
const handleResumeProject = useCallback(async (project: ProjectInfo) => {
try {
await updateProject(project.id, { status: "active" });
addToast(`Project ${project.name} resumed`, "success");
} catch {
addToast(`Failed to resume project ${project.name}`, "error");
}
}, [updateProject, addToast]);
const handleRemoveProject = useCallback(async (project: ProjectInfo) => {
try {
await unregisterProject(project.id);
addToast(`Project ${project.name} removed`, "success");
// If we removed the current project, go back to overview
if (currentProject?.id === project.id) {
clearCurrentProject();
setViewMode("overview");
}
} catch {
addToast(`Failed to remove project ${project.name}`, "error");
}
}, [unregisterProject, currentProject, clearCurrentProject, addToast]);
const handleNewTaskOpen = useCallback(() => setNewTaskModalOpen(true), []);
const handleNewTaskClose = useCallback(() => setNewTaskModalOpen(false), []);
@@ -337,16 +223,17 @@ function AppInner() {
setTerminalOpen((prev) => !prev);
}, []);
const handleTerminalClose = useCallback(() => {
setTerminalOpen(false);
}, []);
const handleOpenFiles = useCallback(() => {
setFilesOpen(true);
}, []);
const handleOpenChangedFiles = useCallback((taskId: string, worktree: string | undefined, column: string) => {
setChangedFilesState({ taskId, worktree, column });
}, []);
const handleCloseChangedFiles = useCallback(() => {
setChangedFilesState(null);
const handleOpenFilesForTask = useCallback((taskId: string) => {
setFileBrowserWorkspace(taskId);
setFilesOpen(true);
}, []);
const handleWorkspaceChange = useCallback((workspace: string) => {
@@ -365,54 +252,31 @@ function AppInner() {
const handleOpenAgents = useCallback(() => setAgentsOpen(true), []);
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
// Scripts handlers
const handleOpenScripts = useCallback(() => setScriptsOpen(true), []);
const handleCloseScripts = useCallback(() => setScriptsOpen(false), []);
const handleRunScript = useCallback((name: string, command: string) => {
setTerminalInitialCommand(command);
setScriptsOpen(false);
setTerminalOpen(true);
addToast(`Running script: ${name}`, "success");
}, [addToast]);
const handleTerminalClose = useCallback(() => {
setTerminalOpen(false);
setTerminalInitialCommand(undefined);
}, []);
// Setup wizard complete handler
const handleSetupComplete = useCallback((project: ProjectInfo) => {
setSetupWizardOpen(false);
setCurrentProject(project);
setViewMode("project");
addToast(`Project ${project.name} added successfully`, "success");
}, [setCurrentProject, addToast]);
// Determine which view to render
const renderMainContent = () => {
if (viewMode === "overview") {
return (
<ProjectOverview
projects={projects}
loading={projectsLoading}
onSelectProject={handleSelectProject}
onAddProject={handleAddProject}
onPauseProject={handlePauseProject}
onResumeProject={handleResumeProject}
onRemoveProject={handleRemoveProject}
onViewAllProjects={handleViewAllProjects}
/>
);
}
// Project task view
if (taskView === "agents") {
return <AgentsView addToast={addToast} />;
}
if (taskView === "board") {
return (
return (
<>
<Header
onOpenSettings={() => setSettingsOpen(true)}
onOpenGitHubImport={() => setGitHubImportOpen(true)}
onOpenPlanning={handlePlanningOpen}
onOpenUsage={handleOpenUsage}
onOpenActivityLog={handleOpenActivityLog}
onOpenSchedules={handleOpenSchedules}
onOpenGitManager={handleOpenGitManager}
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
onOpenAgents={handleOpenAgents}
onToggleTerminal={handleToggleTerminal}
onOpenFiles={handleOpenFiles}
filesOpen={filesOpen}
globalPaused={globalPaused}
enginePaused={enginePaused}
onToggleGlobalPause={handleToggleGlobalPause}
onToggleEnginePause={handleToggleEnginePause}
view={view}
onChangeView={handleChangeView}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
{view === "board" ? (
<Board
tasks={tasks}
maxConcurrent={maxConcurrent}
@@ -432,62 +296,23 @@ function AppInner() {
onArchiveAllDone={archiveAllDone}
searchQuery={searchQuery}
availableModels={availableModels}
onOpenFilesForTask={handleOpenChangedFiles}
projectId={currentProject?.id}
projectName={currentProject?.name}
onOpenFilesForTask={handleOpenFilesForTask}
/>
);
}
// List view
return (
<ListView
tasks={tasks}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={handleNewTaskOpen}
onQuickCreate={handleBoardQuickCreate}
onPlanningMode={handleNewTaskPlanningMode}
onSubtaskBreakdown={handleSubtaskBreakdown}
availableModels={availableModels}
projectId={currentProject?.id}
projectName={currentProject?.name}
/>
);
};
return (
<>
<Header
onOpenSettings={() => setSettingsOpen(true)}
onOpenGitHubImport={() => setGitHubImportOpen(true)}
onOpenPlanning={handlePlanningOpen}
onOpenUsage={handleOpenUsage}
onOpenActivityLog={handleOpenActivityLog}
onOpenSchedules={handleOpenSchedules}
onOpenGitManager={handleOpenGitManager}
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
onOpenAgents={handleOpenAgents}
onOpenScripts={handleOpenScripts}
onToggleTerminal={handleToggleTerminal}
onOpenFiles={handleOpenFiles}
filesOpen={filesOpen}
globalPaused={globalPaused}
enginePaused={enginePaused}
onToggleGlobalPause={handleToggleGlobalPause}
onToggleEnginePause={handleToggleEnginePause}
view={taskView}
onChangeView={setTaskView}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
projects={projects}
currentProject={currentProject}
onSelectProject={handleSelectProject}
onViewAllProjects={handleViewAllProjects}
/>
{renderMainContent()}
) : (
// List view now uses the same modal-based create flow as board view.
<ListView
tasks={tasks}
onMoveTask={moveTask}
onOpenDetail={handleDetailOpen}
addToast={addToast}
globalPaused={globalPaused}
onNewTask={handleNewTaskOpen}
onQuickCreate={handleBoardQuickCreate}
onPlanningMode={handleNewTaskPlanningMode}
onSubtaskBreakdown={handleSubtaskBreakdown}
availableModels={availableModels}
/>
)}
{detailTask && (
<TaskDetailModal
task={detailTask}
@@ -539,13 +364,6 @@ function AppInner() {
<TerminalModal
isOpen={terminalOpen}
onClose={handleTerminalClose}
initialCommand={terminalInitialCommand}
/>
<ScriptsModal
isOpen={scriptsOpen}
onClose={handleCloseScripts}
addToast={addToast}
onRunScript={handleRunScript}
/>
{filesOpen && (
<FileBrowserModal
@@ -555,15 +373,6 @@ function AppInner() {
onWorkspaceChange={handleWorkspaceChange}
/>
)}
{changedFilesState && (
<ChangedFilesModal
taskId={changedFilesState.taskId}
worktree={changedFilesState.worktree}
column={changedFilesState.column}
isOpen={true}
onClose={handleCloseChangedFiles}
/>
)}
<UsageIndicator
isOpen={usageOpen}
onClose={handleCloseUsage}
@@ -610,12 +419,6 @@ function AppInner() {
onClose={handleCloseAgents}
addToast={addToast}
/>
<SetupWizardModal
isOpen={setupWizardOpen}
onClose={() => setSetupWizardOpen(false)}
onComplete={handleSetupComplete}
onRegisterProject={register}
/>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);

View File

@@ -23,22 +23,6 @@ import {
startPlanningStreaming,
fetchTasks,
summarizeTitle,
fetchProjects,
registerProject,
unregisterProject,
fetchProjectHealth,
fetchActivityFeed,
pauseProject,
resumeProject,
fetchFirstRunStatus,
fetchGlobalConcurrency,
fetchProjectTasks,
fetchProjectConfig,
type ProjectInfo,
type ProjectHealth,
type ActivityFeedEntry,
type FirstRunStatus,
type GlobalConcurrencyState,
} from "./api";
import type { Task, TaskDetail, BatchStatusResponse } from "@fusion/core";
@@ -52,7 +36,7 @@ const FAKE_DETAIL: TaskDetail = {
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# FN-001",
prompt: "# KB-001",
};
function mockFetchResponse(
@@ -141,7 +125,7 @@ describe("updateTask", () => {
const result = await updateTask("FN-001", { dependencies: ["FN-002"] });
expect(result.dependencies).toEqual(["FN-002"]);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ dependencies: ["FN-002"] }),
@@ -182,7 +166,7 @@ describe("task comments api", () => {
const result = await fetchTaskComments("FN-001");
expect(result).toEqual(comments);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments", {
headers: { "Content-Type": "application/json" },
});
});
@@ -193,7 +177,7 @@ describe("task comments api", () => {
const result = await addTaskComment("FN-001", "Hello", "user");
expect(result).toEqual(FAKE_TASK);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ text: "Hello", author: "user" }),
@@ -205,7 +189,7 @@ describe("task comments api", () => {
await updateTaskComment("FN-001", "c1", "Updated");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments/c1", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments/c1", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ text: "Updated" }),
@@ -217,7 +201,7 @@ describe("task comments api", () => {
await deleteTaskComment("FN-001", "c1");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments/c1", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments/c1", {
headers: { "Content-Type": "application/json" },
method: "DELETE",
});
@@ -529,7 +513,7 @@ describe("addSteeringComment", () => {
expect(result.id).toBe("FN-001");
expect(result.steeringComments).toHaveLength(1);
expect(result.steeringComments![0].text).toBe("Please handle the edge case");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/steer", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/steer", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ text: "Please handle the edge case" }),
@@ -794,7 +778,7 @@ describe("approvePlan", () => {
expect(result.column).toBe("todo");
expect(result.status).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/approve-plan", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/approve-plan", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
@@ -828,7 +812,7 @@ describe("rejectPlan", () => {
expect(result.column).toBe("triage");
expect(result.status).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/reject-plan", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/reject-plan", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
@@ -854,7 +838,7 @@ describe("refineTask", () => {
const FAKE_REFINED_TASK: Task = {
id: "FN-002",
description: "Refinement of FN-001",
description: "Refinement of KB-001",
column: "triage",
dependencies: ["FN-001"],
steps: [],
@@ -872,7 +856,7 @@ describe("refineTask", () => {
expect(result.id).toBe("FN-002");
expect(result.column).toBe("triage");
expect(result.dependencies).toContain("FN-001");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/refine", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/refine", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ feedback: "Need to add more tests and improve error handling" }),
@@ -1171,7 +1155,7 @@ describe("Git Management API", () => {
const response = await archiveTask("FN-001");
expect(response.column).toBe("archived");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/archive", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/archive", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
@@ -1192,7 +1176,7 @@ describe("Git Management API", () => {
const response = await unarchiveTask("FN-001");
expect(response.column).toBe("done");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/unarchive", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/unarchive", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
@@ -1228,7 +1212,7 @@ describe("Git Management API", () => {
const response = await fetchWorkspaceFileList("FN-001", "src");
expect(response).toEqual(payload);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files?workspace=FN-001&path=src", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files?workspace=KB-001&path=src", {
headers: { "Content-Type": "application/json" },
});
});
@@ -1252,7 +1236,7 @@ describe("Git Management API", () => {
const response = await saveWorkspaceFileContent("FN-001", "src/index.ts", "hello");
expect(response).toEqual(payload);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files/src%2Findex.ts?workspace=FN-001", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files/src%2Findex.ts?workspace=KB-001", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ content: "hello" }),
@@ -1816,342 +1800,3 @@ describe("summarizeTitle", () => {
});
});
// ── Project Management API Tests ───────────────────────────────────────────
const FAKE_PROJECT: ProjectInfo = {
id: "proj_abc123",
name: "Test Project",
path: "/path/to/project",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lastActivityAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_PROJECT_HEALTH: ProjectHealth = {
projectId: "proj_abc123",
status: "active",
activeTaskCount: 5,
inFlightAgentCount: 2,
lastActivityAt: "2026-01-01T00:00:00.000Z",
totalTasksCompleted: 100,
totalTasksFailed: 5,
averageTaskDurationMs: 600000,
updatedAt: "2026-01-01T00:00:00.000Z",
};
const FAKE_ACTIVITY_ENTRY: ActivityFeedEntry = {
id: "act_123",
timestamp: "2026-01-01T00:00:00.000Z",
type: "task:created",
projectId: "proj_abc123",
projectName: "Test Project",
taskId: "KB-001",
taskTitle: "Test Task",
details: "Task created",
};
describe("fetchProjects", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns list of projects", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [FAKE_PROJECT]));
const result = await fetchProjects();
expect(result).toHaveLength(1);
expect(result[0].id).toBe("proj_abc123");
expect(result[0].name).toBe("Test Project");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects",
expect.objectContaining({ headers: { "Content-Type": "application/json" } })
);
});
it("throws on error response", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Database error" }));
await expect(fetchProjects()).rejects.toThrow("Database error");
});
});
describe("registerProject", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("registers a new project", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_PROJECT));
const result = await registerProject({
name: "Test Project",
path: "/path/to/project",
isolationMode: "in-process",
});
expect(result.id).toBe("proj_abc123");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
name: "Test Project",
path: "/path/to/project",
isolationMode: "in-process",
}),
})
);
});
it("uses default isolation mode when not specified", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_PROJECT));
await registerProject({
name: "Test Project",
path: "/path/to/project",
});
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: JSON.stringify({
name: "Test Project",
path: "/path/to/project",
isolationMode: undefined,
}),
})
);
});
});
describe("unregisterProject", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("unregisters a project", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
await unregisterProject("proj_abc123");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj_abc123",
expect.objectContaining({ method: "DELETE" })
);
});
it("url-encodes project id", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
await unregisterProject("proj/with+special");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj%2Fwith%2Bspecial",
expect.any(Object)
);
});
});
describe("fetchProjectHealth", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns health metrics for a project", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_PROJECT_HEALTH));
const result = await fetchProjectHealth("proj_abc123");
expect(result.projectId).toBe("proj_abc123");
expect(result.activeTaskCount).toBe(5);
expect(result.inFlightAgentCount).toBe(2);
expect(result.totalTasksCompleted).toBe(100);
});
});
describe("fetchActivityFeed", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns activity feed without options", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [FAKE_ACTIVITY_ENTRY]));
const result = await fetchActivityFeed();
expect(result).toHaveLength(1);
expect(result[0].type).toBe("task:created");
expect(result[0].projectName).toBe("Test Project");
});
it("passes query parameters", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
await fetchActivityFeed({
limit: 50,
since: "2026-01-01T00:00:00.000Z",
projectId: "proj_abc123",
type: "task:created",
});
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0]).toContain("limit=50");
expect(call[0]).toContain("since=2026-01-01T00%3A00%3A00.000Z");
expect(call[0]).toContain("projectId=proj_abc123");
expect(call[0]).toContain("type=task%3Acreated");
});
});
describe("pauseProject", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("pauses a project", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_PROJECT, status: "paused" }));
const result = await pauseProject("proj_abc123");
expect(result.status).toBe("paused");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj_abc123/pause",
expect.objectContaining({ method: "POST" })
);
});
});
describe("resumeProject", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("resumes a paused project", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_PROJECT, status: "active" }));
const result = await resumeProject("proj_abc123");
expect(result.status).toBe("active");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/projects/proj_abc123/resume",
expect.objectContaining({ method: "POST" })
);
});
});
describe("fetchFirstRunStatus", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns first run status with existing projects", async () => {
const mockStatus: FirstRunStatus = { hasProjects: true, singleProjectPath: "/existing/project" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
const result = await fetchFirstRunStatus();
expect(result.hasProjects).toBe(true);
expect(result.singleProjectPath).toBe("/existing/project");
});
it("returns first run status with no projects", async () => {
const mockStatus: FirstRunStatus = { hasProjects: false, singleProjectPath: null };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockStatus));
const result = await fetchFirstRunStatus();
expect(result.hasProjects).toBe(false);
expect(result.singleProjectPath).toBeNull();
});
});
describe("fetchGlobalConcurrency", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns global concurrency state", async () => {
const mockState: GlobalConcurrencyState = {
globalMaxConcurrent: 4,
currentlyActive: 2,
queuedCount: 1,
projectsActive: { "proj_abc123": 2 },
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockState));
const result = await fetchGlobalConcurrency();
expect(result.globalMaxConcurrent).toBe(4);
expect(result.currentlyActive).toBe(2);
expect(result.projectsActive["proj_abc123"]).toBe(2);
});
});
describe("fetchProjectTasks", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("fetches tasks for a specific project", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [{ id: "KB-001", description: "Test", column: "todo", dependencies: [], steps: [], currentStep: 0, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }]));
const result = await fetchProjectTasks("proj_abc123");
expect(result).toHaveLength(1);
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("/tasks?"),
expect.any(Object)
);
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0]).toContain("projectId=proj_abc123");
});
it("passes pagination parameters", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
await fetchProjectTasks("proj_abc123", 50, 100);
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0]).toContain("limit=50");
expect(call[0]).toContain("offset=100");
});
});
describe("fetchProjectConfig", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns project config", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { maxConcurrent: 4, rootDir: "/path/to/project" }));
const result = await fetchProjectConfig("proj_abc123");
expect(result.maxConcurrent).toBe(4);
expect(result.rootDir).toBe("/path/to/project");
});
});

View File

@@ -283,17 +283,6 @@ export function fetchSessionFiles(taskId: string): Promise<string[]> {
return api<string[]>(`/tasks/${taskId}/session-files`);
}
export interface TaskFileDiff {
path: string;
status: "added" | "modified" | "deleted" | "renamed";
diff: string;
oldPath?: string;
}
export function fetchTaskFileDiffs(taskId: string): Promise<TaskFileDiff[]> {
return api<TaskFileDiff[]>(`/tasks/${taskId}/file-diffs`);
}
export function fetchTaskComments(id: string): Promise<TaskComment[]> {
return api<TaskComment[]>(`/tasks/${id}/comments`);
}
@@ -318,7 +307,7 @@ export function deleteTaskComment(id: string, commentId: string): Promise<Task>
});
}
export function addComment(id: string, text: string): Promise<Task> {
export function addSteeringComment(id: string, text: string): Promise<Task> {
return api<Task>(`/tasks/${id}/steer`, {
method: "POST",
body: JSON.stringify({ text }),
@@ -1733,227 +1722,3 @@ export async function summarizeTitle(
return data.title;
}
// ── Project Management API (Multi-Project Support) ───────────────────────
/** Project information returned by project endpoints */
export interface ProjectInfo {
id: string;
name: string;
path: string;
status: "active" | "paused" | "errored" | "initializing";
isolationMode: "in-process" | "child-process";
createdAt: string;
updatedAt: string;
lastActivityAt?: string;
}
/** Project health metrics */
export interface ProjectHealth {
projectId: string;
status: "active" | "paused" | "errored" | "initializing";
activeTaskCount: number;
inFlightAgentCount: number;
lastActivityAt?: string;
lastErrorAt?: string;
lastErrorMessage?: string;
totalTasksCompleted: number;
totalTasksFailed: number;
averageTaskDurationMs?: number;
updatedAt: string;
}
/** Unified activity feed entry */
export interface ActivityFeedEntry {
id: string;
timestamp: string;
type: "task:created" | "task:moved" | "task:updated" | "task:deleted" | "task:merged" | "task:failed" | "settings:updated";
projectId: string;
projectName: string;
taskId?: string;
taskTitle?: string;
details: string;
metadata?: Record<string, unknown>;
}
/** Input for creating a new project */
export interface ProjectCreateInput {
name: string;
path: string;
isolationMode?: "in-process" | "child-process";
}
/** Options for fetching activity feed */
export interface FeedOptions {
limit?: number;
since?: string;
projectId?: string;
type?: ActivityFeedEntry["type"];
}
/** Global concurrency state across all projects */
export interface GlobalConcurrencyState {
globalMaxConcurrent: number;
currentlyActive: number;
queuedCount: number;
projectsActive: Record<string, number>;
}
/** First run status response */
export interface FirstRunStatus {
hasProjects: boolean;
singleProjectPath: string | null;
}
/** Fetch all registered projects */
export function fetchProjects(): Promise<ProjectInfo[]> {
return api<ProjectInfo[]>("/projects");
}
/** Register a new project */
export function registerProject(input: ProjectCreateInput): Promise<ProjectInfo> {
return api<ProjectInfo>("/projects", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Unregister a project */
export function unregisterProject(id: string): Promise<void> {
return api<void>(`/projects/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
/** Fetch health metrics for a specific project */
export function fetchProjectHealth(id: string): Promise<ProjectHealth> {
return api<ProjectHealth>(`/projects/${encodeURIComponent(id)}/health`);
}
/** Fetch unified activity feed */
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.since) params.set("since", options.since);
if (options?.projectId) params.set("projectId", options.projectId);
if (options?.type) params.set("type", options.type);
const query = params.size > 0 ? `?${params.toString()}` : "";
return api<ActivityFeedEntry[]>(`/activity-feed${query}`);
}
/** Pause a project */
export function pauseProject(id: string): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}/pause`, {
method: "POST",
});
}
/** Resume a paused project */
export function resumeProject(id: string): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}/resume`, {
method: "POST",
});
}
/** Fetch a specific project by ID */
export function fetchProject(id: string): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`);
}
/** Update a project */
export function updateProject(
id: string,
updates: { name?: string; isolationMode?: "in-process" | "child-process"; status?: "active" | "paused" | "errored" | "initializing" }
): Promise<ProjectInfo> {
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Detected project from auto-scan */
export interface DetectedProject {
path: string;
suggestedName: string;
existing: boolean;
}
/** Auto-detect kb projects in a given base path */
export function detectProjects(basePath?: string): Promise<{ projects: DetectedProject[] }> {
return api<{ projects: DetectedProject[] }>("/projects/detect", {
method: "POST",
body: JSON.stringify({ basePath }),
});
}
/** Fetch first run status to detect if user needs setup wizard */
export function fetchFirstRunStatus(): Promise<FirstRunStatus> {
return api<FirstRunStatus>("/first-run-status");
}
/** Fetch global concurrency state */
export function fetchGlobalConcurrency(): Promise<GlobalConcurrencyState> {
return api<GlobalConcurrencyState>("/global-concurrency");
}
/** Fetch tasks for a specific project */
export function fetchProjectTasks(projectId: string, limit?: number, offset?: number): Promise<Task[]> {
const params = new URLSearchParams();
params.set("projectId", projectId);
if (limit !== undefined) params.set("limit", String(limit));
if (offset !== undefined) params.set("offset", String(offset));
return api<Task[]>(`/tasks?${params.toString()}`);
}
/** Fetch project-specific config */
export function fetchProjectConfig(projectId: string): Promise<{ maxConcurrent: number; rootDir: string }> {
return api<{ maxConcurrent: number; rootDir: string }>(`/projects/${encodeURIComponent(projectId)}/config`);
}
/** Diff information for a task */
export interface TaskDiff {
files: string[];
diffs: Record<string, { stat: string; patch: string }>;
}
/** Fetch diff information for a task */
export function fetchTaskDiff(taskId: string): Promise<TaskDiff> {
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff`);
}
// ── Scripts API ───────────────────────────────────────────────────────────
/** Script execution result */
export interface ScriptRunResult {
output: string;
exitCode: number;
}
/** Fetch all project-defined scripts */
export function fetchScripts(): Promise<Record<string, string>> {
return api<Record<string, string>>("/scripts");
}
/** Add or update a script */
export function addScript(name: string, command: string): Promise<Record<string, string>> {
return api<Record<string, string>>("/scripts", {
method: "POST",
body: JSON.stringify({ name, command }),
});
}
/** Remove a script by name */
export function removeScript(name: string): Promise<Record<string, string>> {
return api<Record<string, string>>(`/scripts/${encodeURIComponent(name)}`, {
method: "DELETE",
});
}
/** Execute a script with optional arguments */
export function runScript(name: string, args?: string[]): Promise<ScriptRunResult> {
return api<ScriptRunResult>(`/scripts/${encodeURIComponent(name)}/run`, {
method: "POST",
body: JSON.stringify({ args }),
});
}

View File

@@ -1,20 +1,13 @@
import { useState, useEffect, useCallback } from "react";
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2, Folder } from "lucide-react";
import { clearActivityLog, type ActivityLogEntry, type ActivityEventType, type ActivityFeedEntry } from "../api";
import { useActivityLog } from "../hooks/useActivityLog";
import type { Task, ProjectInfo } from "@fusion/core";
import { useState, useEffect, useCallback, useRef } from "react";
import { X, History, Trash2, Filter, RefreshCw, CheckCircle, XCircle, ArrowRight, Plus, Settings, AlertCircle, Loader2 } from "lucide-react";
import { fetchActivityLog, clearActivityLog, type ActivityLogEntry, type ActivityEventType } from "../api";
import type { Task } from "@fusion/core";
interface ActivityLogModalProps {
isOpen: boolean;
onClose: () => void;
tasks: Task[];
onOpenTaskDetail?: (taskId: string) => void;
/** When provided, shows only activity for this project */
projectId?: string;
/** List of all projects for filter dropdown */
projects?: ProjectInfo[];
/** Called when project filter changes */
onProjectFilterChange?: (projectId: string | undefined) => void;
}
const EVENT_TYPE_LABELS: Record<ActivityEventType, string> = {
@@ -53,71 +46,78 @@ function formatTimestamp(timestamp: string): string {
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
/**
* ActivityLogModal - Activity log with project attribution and filtering
*
* Features:
* - Project name badge for each activity entry
* - Project filter dropdown (when projects list provided)
* - Event type filter
* - Real-time updates via useActivityLog hook
*/
export function ActivityLogModal({
isOpen,
onClose,
tasks,
onOpenTaskDetail,
projectId,
projects = [],
onProjectFilterChange,
}: ActivityLogModalProps) {
export function ActivityLogModal({ isOpen, onClose, tasks, onOpenTaskDetail }: ActivityLogModalProps) {
const [entries, setEntries] = useState<ActivityLogEntry[]>([]);
const [filteredType, setFilteredType] = useState<ActivityEventType | "all">("all");
const [filteredProjectId, setFilteredProjectId] = useState<string | "all">(projectId || "all");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirmClear, setShowConfirmClear] = useState(false);
// Sync with external projectId prop
useEffect(() => {
setFilteredProjectId(projectId || "all");
}, [projectId]);
// Convert filters to the format expected by useActivityLog
const activityType = filteredType === "all" ? undefined : filteredType;
const activeProjectId = filteredProjectId === "all" ? undefined : filteredProjectId;
// Use the hook for data fetching
const {
entries,
loading: isLoading,
error,
refresh,
hasMore
} = useActivityLog({
projectId: activeProjectId,
type: activityType,
limit: 100,
autoRefresh: isOpen,
});
const [hasMore, setHasMore] = useState(false);
const pollingRef = useRef<NodeJS.Timeout | null>(null);
// Convert entries to ActivityLogEntry format for compatibility
const convertedEntries: ActivityLogEntry[] = entries.map((entry: ActivityFeedEntry) => ({
id: entry.id,
timestamp: entry.timestamp,
type: entry.type,
taskId: entry.taskId,
taskTitle: entry.taskTitle,
details: entry.details,
metadata: entry.metadata,
projectId: entry.projectId,
projectName: entry.projectName,
}));
const loadActivityLog = useCallback(async (since?: string) => {
try {
setIsLoading(true);
setError(null);
const options: { limit: number; since?: string; type?: ActivityEventType } = {
limit: 100,
since,
};
if (filteredType !== "all") {
options.type = filteredType;
}
const data = await fetchActivityLog(options);
if (since) {
// Append older entries
setEntries((prev) => [...prev, ...data]);
} else {
// Replace with fresh entries
setEntries(data);
}
setHasMore(data.length === 100);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load activity log");
} finally {
setIsLoading(false);
}
}, [filteredType]);
// Initial load and filter change
useEffect(() => {
if (isOpen) {
loadActivityLog();
}
}, [isOpen, loadActivityLog]);
// Auto-refresh every 30 seconds when modal is open
useEffect(() => {
if (isOpen) {
pollingRef.current = setInterval(() => {
loadActivityLog();
}, 30000);
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [isOpen, loadActivityLog]);
const handleLoadMore = () => {
if (entries.length > 0) {
const lastEntry = entries[entries.length - 1];
loadActivityLog(lastEntry.timestamp);
}
};
const handleClearLog = async () => {
try {
await clearActivityLog();
refresh();
setEntries([]);
setShowConfirmClear(false);
} catch (err) {
// Error handled by hook
setError(err instanceof Error ? err.message : "Failed to clear activity log");
}
};
@@ -127,11 +127,6 @@ export function ActivityLogModal({
}
};
const handleProjectFilterChange = (value: string) => {
setFilteredProjectId(value);
onProjectFilterChange?.(value === "all" ? undefined : value);
};
// Handle escape key to close
useEffect(() => {
if (!isOpen) return;
@@ -148,9 +143,6 @@ export function ActivityLogModal({
return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, onClose, showConfirmClear]);
// Determine if any filter is active
const isFilterActive = filteredType !== "all" || filteredProjectId !== "all";
if (!isOpen) return null;
return (
@@ -169,27 +161,7 @@ export function ActivityLogModal({
<span>Activity Log</span>
</div>
<div className="activity-log-actions">
{/* Project filter dropdown (when projects provided) */}
{projects.length > 0 && (
<div className="activity-log-filter activity-log-filter--project">
<Folder size={14} />
<select
value={filteredProjectId}
onChange={(e) => handleProjectFilterChange(e.target.value)}
className="activity-log-filter-select"
data-testid="activity-project-filter"
>
<option value="all">All Projects</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</div>
)}
{/* Event type filter dropdown */}
{/* Filter dropdown */}
<div className="activity-log-filter">
<Filter size={14} />
<select
@@ -210,7 +182,7 @@ export function ActivityLogModal({
{/* Refresh button */}
<button
className="activity-log-refresh"
onClick={() => refresh()}
onClick={() => loadActivityLog()}
disabled={isLoading}
title="Refresh"
data-testid="activity-refresh"
@@ -219,7 +191,7 @@ export function ActivityLogModal({
</button>
{/* Clear button */}
{convertedEntries.length > 0 && (
{entries.length > 0 && (
<button
className="activity-log-clear"
onClick={() => setShowConfirmClear(true)}
@@ -242,33 +214,6 @@ export function ActivityLogModal({
</div>
</div>
{/* Active filters display */}
{isFilterActive && (
<div className="activity-log-active-filters">
<span className="activity-log-filter-label">Active filters:</span>
{filteredProjectId !== "all" && (
<span className="activity-log-filter-badge">
Project: {projects.find(p => p.id === filteredProjectId)?.name || filteredProjectId}
</span>
)}
{filteredType !== "all" && (
<span className="activity-log-filter-badge">
Type: {EVENT_TYPE_LABELS[filteredType]}
</span>
)}
<button
className="activity-log-clear-filters"
onClick={() => {
setFilteredType("all");
setFilteredProjectId("all");
onProjectFilterChange?.(undefined);
}}
>
Clear all
</button>
</div>
)}
{/* Content */}
<div className="activity-log-content" data-testid="activity-log-content">
{error && (
@@ -278,31 +223,15 @@ export function ActivityLogModal({
</div>
)}
{convertedEntries.length === 0 && !isLoading && !error && (
{entries.length === 0 && !isLoading && !error && (
<div className="activity-log-empty" data-testid="activity-empty">
<History size={48} className="activity-log-empty-icon" />
<p>
{isFilterActive
? "No activity matches the current filters"
: "No activity recorded yet"}
</p>
{isFilterActive && (
<button
className="btn btn-secondary"
onClick={() => {
setFilteredType("all");
setFilteredProjectId("all");
onProjectFilterChange?.(undefined);
}}
>
Clear Filters
</button>
)}
<p>No activity recorded yet</p>
</div>
)}
<div className="activity-log-list">
{convertedEntries.map((entry) => (
{entries.map((entry) => (
<div
key={entry.id}
className="activity-log-entry"
@@ -316,13 +245,6 @@ export function ActivityLogModal({
<span className="activity-log-entry-type">
{EVENT_TYPE_LABELS[entry.type]}
</span>
{/* Project name badge */}
{(entry as ActivityFeedEntry).projectName && (
<span className="activity-log-entry-project">
<Folder size={10} />
{(entry as ActivityFeedEntry).projectName}
</span>
)}
<span className="activity-log-entry-time">
{formatTimestamp(entry.timestamp)}
</span>
@@ -364,14 +286,14 @@ export function ActivityLogModal({
{hasMore && !isLoading && (
<button
className="activity-log-load-more"
onClick={() => {}}
onClick={handleLoadMore}
data-testid="activity-load-more"
>
Load More
</button>
)}
{isLoading && convertedEntries.length > 0 && (
{isLoading && entries.length > 0 && (
<div className="activity-log-loading">
<Loader2 size={20} className="spin" />
</div>

View File

@@ -4,7 +4,6 @@ import { Column } from "./Column";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch";
import { Folder } from "lucide-react";
import type { ModelInfo } from "../api";
interface BoardProps {
@@ -35,10 +34,7 @@ interface BoardProps {
* Called when the user clicks the "Subtask" button in the inline create card.
*/
onSubtaskBreakdown?: (description: string) => void;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
/** Project context for multi-project mode */
projectId?: string;
projectName?: string;
onOpenFilesForTask?: (taskId: string) => void;
}
function sortTasksForColumn(tasks: Task[]): Task[] {
@@ -57,7 +53,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
return previous.every((task, index) => task === next[index]);
}
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask, projectId, projectName }: BoardProps) {
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const { fetchBatch } = useBatchBadgeFetch();
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -146,42 +142,30 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
}
};
}, [taskIdsWithBadges, fetchBatch]);
return (
<>
{/* Project context badge */}
{projectId && projectName && (
<div className="board-project-context">
<span className="board-project-badge">
<Folder size={14} />
{projectName}
</span>
</div>
)}
<main className="board" id="board">
{COLUMNS.map((col) => (
<Column
key={col}
column={col}
tasks={tasksByColumn[col]}
maxConcurrent={maxConcurrent}
onMoveTask={onMoveTask}
onOpenDetail={onOpenDetail}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
allTasks={filteredTasks}
availableModels={availableModels}
onOpenFilesForTask={onOpenFilesForTask}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})}
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
/>
))}
</main>
</>
<main className="board" id="board">
{COLUMNS.map((col) => (
<Column
key={col}
column={col}
tasks={tasksByColumn[col]}
maxConcurrent={maxConcurrent}
onMoveTask={onMoveTask}
onOpenDetail={onOpenDetail}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
allTasks={filteredTasks}
availableModels={availableModels}
onOpenFilesForTask={onOpenFilesForTask}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})}
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
/>
))}
</main>
);
}

View File

@@ -45,7 +45,7 @@ interface ColumnProps {
* Called when the user clicks the "Subtask" button in the inline create card.
*/
onSubtaskBreakdown?: (description: string) => void;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
onOpenFilesForTask?: (taskId: string) => void;
}
function ColumnComponent({ column, tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenFilesForTask }: ColumnProps) {

View File

@@ -1,7 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft } from "lucide-react";
import type { ProjectInfo } from "@fusion/core";
import { ProjectSelector } from "./ProjectSelector";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot } from "lucide-react";
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
function GitHubLogo({ size = 16 }: { size?: number }) {
@@ -18,7 +16,7 @@ function GitHubLogo({ size = 16 }: { size?: number }) {
);
}
export interface HeaderProps {
interface HeaderProps {
onOpenSettings?: () => void;
onOpenGitHubImport?: () => void;
onOpenPlanning?: () => void;
@@ -28,7 +26,6 @@ export interface HeaderProps {
onOpenGitManager?: () => void;
onOpenWorkflowSteps?: () => void;
onOpenAgents?: () => void;
onOpenScripts?: () => void;
onToggleTerminal?: () => void;
/** Opens the top-level workspace-aware file browser modal. */
onOpenFiles?: () => void;
@@ -37,15 +34,10 @@ export interface HeaderProps {
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void;
view?: "board" | "list" | "agents";
onChangeView?: (view: "board" | "list" | "agents") => void;
view?: "board" | "list";
onChangeView?: (view: "board" | "list") => void;
searchQuery?: string;
onSearchChange?: (query: string) => void;
/** Multi-project props */
projects?: ProjectInfo[];
currentProject?: ProjectInfo | null;
onSelectProject?: (project: ProjectInfo) => void;
onViewAllProjects?: () => void;
}
function useIsMobile() {
@@ -75,7 +67,6 @@ export function Header({
onOpenGitManager,
onOpenWorkflowSteps,
onOpenAgents,
onOpenScripts,
onToggleTerminal,
onOpenFiles,
filesOpen,
@@ -87,10 +78,6 @@ export function Header({
onChangeView,
searchQuery = "",
onSearchChange,
projects = [],
currentProject,
onSelectProject,
onViewAllProjects,
}: HeaderProps) {
const isMobile = useIsMobile();
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
@@ -166,35 +153,7 @@ export function Header({
<img src="/logo.svg" alt="Fusion logo" className="header-logo" width={24} height={24} />
<h1 className="logo">Fusion</h1>
<span className="logo-sub">tasks</span>
{/* Back to All Projects button when viewing a specific project */}
{currentProject && onViewAllProjects && (
<button
className="header-back-button"
onClick={onViewAllProjects}
title="Back to All Projects"
data-testid="back-to-projects-btn"
>
<ChevronLeft size={14} />
<span>All Projects</span>
</button>
)}
</div>
{/* Project Selector - shown when 2+ projects */}
{projects.length > 1 && (
<div className="header-project-selector">
<ProjectSelector
projects={projects}
currentProject={currentProject || null}
onSelect={(project) => {
onSelectProject?.(project);
}}
onViewAll={onViewAllProjects || (() => {})}
/>
</div>
)}
<div className="header-actions">
{/* Desktop Search - only show in board view */}
{onSearchChange && view === "board" && !isMobile && (
@@ -279,15 +238,6 @@ export function Header({
>
<List size={16} />
</button>
<button
className={`view-toggle-btn${view === "agents" ? " active" : ""}`}
onClick={() => onChangeView("agents")}
title="Agents view"
aria-label="Agents view"
aria-pressed={view === "agents"}
>
<Bot size={16} />
</button>
</div>
)}
@@ -395,18 +345,6 @@ export function Header({
</button>
)}
{/* Scripts - desktop only */}
{!isMobile && onOpenScripts && (
<button
className="btn-icon"
onClick={onOpenScripts}
title="Scripts"
data-testid="scripts-btn"
>
<Terminal size={16} />
</button>
)}
{/* Settings - always inline on desktop */}
{!isMobile && (
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
@@ -538,17 +476,6 @@ export function Header({
<span>Manage Agents</span>
</button>
)}
{onOpenScripts && (
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenScripts)}
role="menuitem"
data-testid="overflow-scripts-btn"
>
<Terminal size={16} />
<span>Scripts</span>
</button>
)}
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenSettings)}

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp } from "lucide-react";
import { Brain, Link, Lightbulb, ListTree, Zap } from "lucide-react";
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
import { fetchModels, uploadAttachment, fetchSettings } from "../api";
@@ -87,8 +87,6 @@ export function InlineCreateCard({
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
const [submitting, setSubmitting] = useState(false);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const justResetRef = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
@@ -190,21 +188,40 @@ export function InlineCreateCard({
[loadedModels],
);
// Track focus-out for justResetRef cleanup only (no auto-cancel on blur)
// Cancel when focus leaves the card entirely and there's no content
useEffect(() => {
const card = cardRef.current;
if (!card) return;
const handleFocusOut = (e: FocusEvent) => {
// relatedTarget is the element receiving focus — if it's inside the card, ignore
if (e.relatedTarget instanceof Node && card.contains(e.relatedTarget)) return;
// Clear justResetRef flag when focus actually leaves the card
if (justResetRef.current) {
justResetRef.current = false;
// Only cancel if empty and dropdowns are not open
if (
description.trim() === "" &&
pendingImages.length === 0 &&
dependencies.length === 0 &&
!hasExecutorOverride &&
!hasValidatorOverride &&
!showDeps &&
!showModels &&
!showPresets
) {
onCancel();
}
};
card.addEventListener("focusout", handleFocusOut);
return () => card.removeEventListener("focusout", handleFocusOut);
}, []);
}, [
description,
pendingImages,
dependencies,
hasExecutorOverride,
hasValidatorOverride,
showDeps,
showModels,
showPresets,
onCancel,
]);
// Clean up object URLs on unmount to prevent memory leaks
useEffect(() => {
@@ -290,19 +307,9 @@ export function InlineCreateCard({
setPendingImages([]);
setSelectedPresetId(undefined);
setExecutorProvider(undefined);
setExecutorModelId(undefined);
setValidatorProvider(undefined);
setValidatorModelId(undefined);
setDependencies([]);
setShowDeps(false);
setShowModels(false);
setShowPresets(false);
addToast(`Created ${task.id}`, "success");
// Collapse and clear localStorage after successful task creation
setIsExpanded(false);
justResetRef.current = true;
// Clear localStorage after successful task creation
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
@@ -330,27 +337,10 @@ export function InlineCreateCard({
async (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
// Close dropdowns first if open
if (showDeps || showModels || showPresets) {
setShowDeps(false);
setShowModels(false);
setShowPresets(false);
return;
// Clear localStorage when user explicitly cancels
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
// Clear non-empty input on Escape and clear localStorage
if (description.trim()) {
setDescription("");
// Reset height
if (inputRef.current) {
inputRef.current.style.height = "auto";
}
// Clear localStorage when user explicitly clears input
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
}
// Collapse and cancel on escape
setIsExpanded(false);
onCancel();
return;
}
@@ -359,7 +349,7 @@ export function InlineCreateCard({
handleSubmit();
}
},
[handleSubmit, onCancel, description, showDeps, showModels, showPresets],
[handleSubmit, onCancel],
);
const toggleDep = useCallback((id: string) => {
@@ -450,47 +440,24 @@ export function InlineCreateCard({
const truncate = (s: string, len: number) =>
s.length > len ? s.slice(0, len) + "…" : s;
const toggleExpanded = useCallback(() => {
// Skip if we just reset the form (prevents re-expanding after successful creation)
if (justResetRef.current) {
justResetRef.current = false;
return;
}
setIsExpanded((prev) => !prev);
}, []);
return (
<div className={`inline-create-card ${isExpanded ? "inline-create-card--expanded" : "inline-create-card--collapsed"}`} ref={cardRef}>
<div className="inline-create-main-row">
<textarea
ref={inputRef}
rows={1}
className="inline-create-input"
placeholder="What needs to be done?"
value={description}
onChange={(e) => {
setDescription(e.target.value);
const el = e.target;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
disabled={submitting}
aria-controls="inline-create-controls"
/>
<button
type="button"
className="btn btn-sm inline-create-toggle"
onClick={toggleExpanded}
aria-expanded={isExpanded}
aria-controls="inline-create-controls"
data-testid="inline-create-toggle"
title={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
<div className="inline-create-card" ref={cardRef}>
<textarea
ref={inputRef}
rows={1}
className="inline-create-input"
placeholder="What needs to be done?"
value={description}
onChange={(e) => {
setDescription(e.target.value);
const el = e.target;
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
disabled={submitting}
/>
{pendingImages.length > 0 && (
<div className="inline-create-previews">
{pendingImages.map((img, i) => (
@@ -509,30 +476,29 @@ export function InlineCreateCard({
))}
</div>
)}
{isExpanded && (
<div id="inline-create-controls" className="inline-create-footer">
<div className="inline-create-controls">
<div className="dep-trigger-wrap">
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={toggleDepsDropdown}
>
<Link size={12} style={{ verticalAlign: "middle" }} />
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
</button>
{showDeps && (() => {
const term = depSearch.toLowerCase();
const filtered = (term
? tasks.filter((t) =>
t.id.toLowerCase().includes(term) ||
(t.title && t.title.toLowerCase().includes(term)) ||
(t.description && t.description.toLowerCase().includes(term))
)
: [...tasks]
).sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
<div className="inline-create-footer">
<div className="inline-create-controls">
<div className="dep-trigger-wrap">
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={toggleDepsDropdown}
>
<Link size={12} style={{ verticalAlign: "middle" }} />
{dependencies.length > 0 ? ` ${dependencies.length} deps` : " Deps"}
</button>
{showDeps && (() => {
const term = depSearch.toLowerCase();
const filtered = (term
? tasks.filter((t) =>
t.id.toLowerCase().includes(term) ||
(t.title && t.title.toLowerCase().includes(term)) ||
(t.description && t.description.toLowerCase().includes(term))
)
: [...tasks]
).sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return bNum - aNum;
@@ -750,7 +716,6 @@ export function InlineCreateCard({
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight, Folder } from "lucide-react";
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@fusion/core";
import { COLUMN_LABELS, COLUMNS } from "@fusion/core";
import { fetchTaskDetail, batchUpdateTaskModels } from "../api";
@@ -48,9 +48,6 @@ interface ListViewProps {
* Allows parent to refresh task list or handle optimistically.
*/
onTasksUpdated?: (updatedTasks: Task[]) => void;
/** Project context for multi-project mode */
projectId?: string;
projectName?: string;
}
function getStepProgress(steps: TaskStep[]): string {
@@ -119,7 +116,7 @@ export function ListView({
// Invalid localStorage data - fall through to default
}
}
return true; // Default: hide done tasks
return false; // Default: show done tasks
});
// Collapsed sections state - initialize from localStorage
@@ -567,15 +564,6 @@ export function ListView({
return (
<div className="list-view">
{/* Project context badge */}
{projectId && projectName && (
<div className="list-project-context">
<span className="list-project-badge">
<Folder size={14} />
{projectName}
</span>
</div>
)}
<div className="list-toolbar">
<div className="list-filter">
<Search size={14} className="filter-icon" />
@@ -736,7 +724,6 @@ export function ListView({
availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
autoExpand={false}
/>
</div>
{filteredCount === 0 ? (

View File

@@ -1,4 +1,4 @@
import type { Task } from "@fusion/core";
import type { Task } from "@kb/core";
interface MergeDetailsProps {
task: Task;

View File

@@ -163,36 +163,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isOpen, view]);
const handleCancel = useCallback(async () => {
// Show confirmation if user has made progress
if (hasProgress) {
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
return;
}
}
// Always close the stream connection
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
if (view.type === "question" || view.type === "summary") {
try {
await cancelPlanning(view.session.sessionId);
} catch {
// Ignore errors on cancel
}
}
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setHasProgress(false);
currentSessionIdRef.current = null;
onClose();
}, [hasProgress, view, onClose]);
// Handle escape key to close
useEffect(() => {
if (!isOpen) return;
@@ -244,6 +214,36 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
[view]
);
const handleCancel = useCallback(async () => {
// Show confirmation if user has made progress
if (hasProgress) {
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
return;
}
}
// Always close the stream connection
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
if (view.type === "question" || view.type === "summary") {
try {
await cancelPlanning(view.session.sessionId);
} catch {
// Ignore errors on cancel
}
}
setInitialPlan("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setHasProgress(false);
currentSessionIdRef.current = null;
onClose();
}, [hasProgress, view, onClose]);
const handleCreateTask = useCallback(async () => {
if (view.type !== "summary") return;

View File

@@ -3,12 +3,11 @@ import type { ToastType } from "../hooks/useToast";
import type { Task, TaskCreateInput } from "@fusion/core";
import type { ModelInfo, RefinementType } from "../api";
import { fetchModels, refineText, getRefineErrorMessage } from "../api";
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp } from "lucide-react";
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ModelSelectionModal } from "./ModelSelectionModal";
const STORAGE_KEY = "kb-quick-entry-text";
const DISCLOSURE_STORAGE_KEY = "kb-quick-entry-expanded";
interface QuickEntryBoxProps {
onCreate?: (input: TaskCreateInput) => Promise<void>;
@@ -23,11 +22,6 @@ interface QuickEntryBoxProps {
* Called when the user clicks the "Subtask" button to trigger subtask breakdown.
*/
onSubtaskBreakdown?: (description: string) => void;
/**
* When false, the component will not auto-expand on focus.
* Defaults to true for backward compatibility.
*/
autoExpand?: boolean;
}
function getModelSelectionValue(provider?: string, modelId?: string): string {
@@ -50,7 +44,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
};
}
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, autoExpand = true }: QuickEntryBoxProps) {
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown }: QuickEntryBoxProps) {
const [description, setDescription] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem(STORAGE_KEY) || "";
@@ -58,18 +52,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
return "";
});
const [isSubmitting, setIsSubmitting] = useState(false);
// isExpanded controls textarea height styling (auto-resize)
const [isExpanded, setIsExpanded] = useState(false);
// isDisclosureExpanded controls visibility of the controls panel (Deps, Models, etc.)
const [isDisclosureExpanded, setIsDisclosureExpanded] = useState(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(DISCLOSURE_STORAGE_KEY);
// Default to true (expanded) for backward compatibility - only collapse if explicitly set to "false"
return saved !== "false";
}
return true;
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const justResetRef = useRef(false);
// Rich creation state (mirrors InlineCreateCard)
@@ -150,17 +135,12 @@ 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
// Cleanup timeout on unmount
useEffect(() => {
return () => {
// No blur timeout to clean up
if (blurTimeoutRef.current) {
clearTimeout(blurTimeoutRef.current);
}
};
}, []);
@@ -224,8 +204,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setIsModelModalOpen(false);
setIsRefineMenuOpen(false);
setIsRefining(false);
setIsExpanded(false); // Collapse textarea height on reset
// Note: isDisclosureExpanded is NOT reset - user preference persists
setIsExpanded(false);
justResetRef.current = true;
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
@@ -311,21 +290,17 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
localStorage.removeItem(STORAGE_KEY);
}
}
// Collapse textarea and disclosure on escape
setIsExpanded(false);
setIsDisclosureExpanded(false);
// Collapse on escape
resetForm();
// Clear any pending blur timeout
if (blurTimeoutRef.current) {
clearTimeout(blurTimeoutRef.current);
blurTimeoutRef.current = null;
}
textareaRef.current?.blur();
}
},
[
handleSubmit,
description,
isExpanded,
showDeps,
isModelModalOpen,
isRefineMenuOpen,
setIsDisclosureExpanded,
],
[handleSubmit, description, isExpanded, showDeps, isModelModalOpen, isRefineMenuOpen, resetForm],
);
const handleFocus = useCallback(() => {
@@ -334,16 +309,28 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
justResetRef.current = false;
return;
}
// No auto-expand on focus — manual toggle only
setIsExpanded(true);
}, []);
const handleBlur = useCallback(() => {
// No auto-collapse on blur — state persists until manually toggled or task is submitted/cancelled
// Only clear the justResetRef flag if needed
if (justResetRef.current) {
justResetRef.current = false;
// Clear any existing timeout
if (blurTimeoutRef.current) {
clearTimeout(blurTimeoutRef.current);
}
}, []);
// Collapse after a short delay to allow click events on dropdowns
// Collapse regardless of content - only check if dropdowns are open
blurTimeoutRef.current = setTimeout(() => {
if (!showDeps && !isModelModalOpen && !isRefineMenuOpen) {
setIsExpanded(false);
// Reset height when collapsing
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
}
}
blurTimeoutRef.current = null;
}, 200);
}, [showDeps, isModelModalOpen, isRefineMenuOpen]);
const toggleDep = useCallback((id: string) => {
setDependencies((prev) =>
@@ -448,45 +435,26 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
}, [availableModels]);
// Show expanded controls based on disclosure state (user preference), not textarea focus
const showExpandedControls = isDisclosureExpanded;
const toggleExpanded = useCallback(() => {
setIsDisclosureExpanded((prev) => !prev);
setIsExpanded((prev) => !prev);
}, []);
// Show expanded controls only when focused/interacted (isExpanded)
const showExpandedControls = isExpanded;
return (
<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}
className={`quick-entry-input ${isExpanded ? "quick-entry-input--expanded" : ""}`}
placeholder={isSubmitting ? "Creating..." : "Add a task..."}
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isSubmitting || isDisabled}
data-testid="quick-entry-input"
rows={1}
aria-controls="quick-entry-controls"
/>
<button
type="button"
className="btn btn-sm quick-entry-toggle"
onClick={toggleExpanded}
aria-expanded={isDisclosureExpanded}
aria-controls="quick-entry-controls"
data-testid="quick-entry-toggle"
title={isDisclosureExpanded ? "Collapse" : "Expand"}
>
{isDisclosureExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
<div className="quick-entry-box" data-testid="quick-entry-box">
<textarea
ref={textareaRef}
className={`quick-entry-input ${isExpanded ? "quick-entry-input--expanded" : ""}`}
placeholder={isSubmitting ? "Creating..." : "Add a task..."}
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={isSubmitting || isDisabled}
data-testid="quick-entry-input"
rows={1}
/>
{showExpandedControls && (
<div id="quick-entry-controls" className="quick-entry-controls">
<div className="quick-entry-controls">
<div className="quick-entry-controls-left">
<div className="dep-trigger-wrap">
<button

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from "react";
import type { TaskDetail } from "@fusion/core";
import { addComment } from "../api";
import { addSteeringComment } 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.comments || []);
const [comments, setComments] = useState(task.steeringComments || []);
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 addComment(task.id, newComment.trim());
setComments(updated.comments || []);
const updated = await addSteeringComment(task.id, newComment.trim());
setComments(updated.steeringComments || []);
setNewComment("");
addToast("Comment added", "success");
addToast("Steering 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>Comments</h4>
<h4>Steering Comments</h4>
<p style={{ fontSize: "13px", opacity: 0.7, marginBottom: "12px" }}>
Add comments to guide the AI during task execution. These are injected into the execution context.
</p>
@@ -113,7 +113,7 @@ export function SteeringTab({ task, addToast }: SteeringTabProps) {
))}
</div>
) : (
<div style={{ opacity: 0.5, marginBottom: "16px" }}>(no comments yet)</div>
<div style={{ opacity: 0.5, marginBottom: "16px" }}>(no steering 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 comment... (Ctrl+Enter to submit)"
placeholder="Add a steering 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 Comment"}
{isSubmitting ? "Adding…" : "Add Steering Comment"}
</button>
</div>
</div>

View File

@@ -43,7 +43,7 @@ interface TaskCardProps {
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
onOpenFilesForTask?: (taskId: string) => void;
}
function areTaskBadgeInfosEqual(
@@ -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.comments ?? []) === JSON.stringify(nextTask.comments ?? []) &&
JSON.stringify(previousTask.steeringComments ?? []) === JSON.stringify(nextTask.steeringComments ?? []) &&
areTaskDependenciesEqual(previousTask.dependencies, nextTask.dependencies) &&
areTaskStepsEqual(previousTask.steps, nextTask.steps) &&
areTaskBadgeInfosEqual(previousTask.prInfo, nextTask.prInfo) &&
@@ -694,7 +694,7 @@ function TaskCardComponent({
className="card-session-files"
onClick={(e) => {
e.stopPropagation();
onOpenFilesForTask?.(task.id, task.worktree, task.column);
onOpenFilesForTask?.(task.id);
}}
disabled={!onOpenFilesForTask}
>

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import type { Task, TaskComment } from "@fusion/core";
import type { Task, TaskComment } from "@kb/core";
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
import type { ToastType } from "../hooks/useToast";

View File

@@ -13,7 +13,6 @@ import { ModelSelectorTab } from "./ModelSelectorTab";
import { PrSection } from "./PrSection";
import { TaskComments } from "./TaskComments";
import { MergeDetails } from "./MergeDetails";
import { TaskChangesTab } from "./TaskChangesTab";
interface ModelSelection {
provider?: string;
@@ -106,7 +105,7 @@ export function TaskDetailModal({
addToast,
githubTokenConfigured,
}: TaskDetailModalProps) {
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "changes" | "steering" | "comments" | "model">("definition");
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "comments" | "model">("definition");
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false);
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
@@ -672,14 +671,6 @@ export function TaskDetailModal({
>
Agent Log
</button>
{(task.column === "in-progress" || task.column === "in-review" || task.column === "done") && (
<button
className={`detail-tab${activeTab === "changes" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("changes")}
>
Changes
</button>
)}
<button
className={`detail-tab${activeTab === "steering" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("steering")}
@@ -712,8 +703,6 @@ export function TaskDetailModal({
validatorModel={getValidatorSelection(task)}
/>
</div>
) : activeTab === "changes" ? (
<TaskChangesTab taskId={task.id} worktree={task.worktree} />
) : activeTab === "steering" ? (
<SteeringTab task={task} addToast={addToast} />
) : activeTab === "comments" ? (

View File

@@ -241,21 +241,6 @@ export function UsageIndicator({ isOpen, onClose }: UsageIndicatorProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [viewMode, setViewMode] = useState<'used' | 'remaining'>('used');
const contentRef = useRef<HTMLDivElement>(null);
const wasOpenRef = useRef(isOpen);
// Trigger refresh when modal opens (isOpen transitions from false to true)
useEffect(() => {
// Only refresh when transitioning from closed to open
if (!wasOpenRef.current && isOpen) {
// Skip if data is fresh (within 5 seconds) to avoid duplicate requests
if (!lastUpdated || Date.now() - lastUpdated.getTime() > 5000) {
refresh();
}
}
// Update ref for next render
wasOpenRef.current = isOpen;
}, [isOpen, lastUpdated, refresh]);
// Load view mode preference from localStorage on mount
useEffect(() => {

View File

@@ -15,7 +15,7 @@ interface WorktreeGroupProps {
id: string,
updates: { title?: string; description?: string; dependencies?: string[] }
) => Promise<Task>;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
onOpenFilesForTask?: (taskId: string) => void;
}
function WorktreeGroupComponent({

View File

@@ -6,11 +6,11 @@ import type { ActivityLogEntry } from "@fusion/core";
// Mock the API module
vi.mock("../../api", () => ({
fetchActivityFeed: vi.fn(),
fetchActivityLog: vi.fn(),
clearActivityLog: vi.fn(),
}));
const mockFetchActivityFeed = vi.mocked(apiModule.fetchActivityFeed);
const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog);
const mockClearActivityLog = vi.mocked(apiModule.clearActivityLog);
describe("ActivityLogModal", () => {
@@ -53,7 +53,7 @@ describe("ActivityLogModal", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchActivityFeed.mockResolvedValue(mockActivityEntries);
mockFetchActivityLog.mockResolvedValue(mockActivityEntries);
mockClearActivityLog.mockResolvedValue({ success: true });
});
@@ -128,7 +128,7 @@ describe("ActivityLogModal", () => {
);
await waitFor(() => {
expect(mockFetchActivityFeed).toHaveBeenCalled();
expect(mockFetchActivityLog).toHaveBeenCalled();
});
});
@@ -146,7 +146,7 @@ describe("ActivityLogModal", () => {
fireEvent.change(filterSelect, { target: { value: "task:created" } });
await waitFor(() => {
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
expect(mockFetchActivityLog).toHaveBeenCalledWith(
expect.objectContaining({ type: "task:created" })
);
});
@@ -164,19 +164,19 @@ describe("ActivityLogModal", () => {
// Wait for initial load
await waitFor(() => {
expect(mockFetchActivityFeed).toHaveBeenCalledTimes(1);
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
});
const refreshButton = screen.getByTestId("activity-refresh");
fireEvent.click(refreshButton);
await waitFor(() => {
expect(mockFetchActivityFeed).toHaveBeenCalledTimes(2);
expect(mockFetchActivityLog).toHaveBeenCalledTimes(2);
});
});
it("shows empty state when no entries", async () => {
mockFetchActivityFeed.mockResolvedValue([]);
mockFetchActivityLog.mockResolvedValue([]);
render(
<ActivityLogModal
@@ -193,7 +193,7 @@ describe("ActivityLogModal", () => {
});
it("shows error state when API fails", async () => {
mockFetchActivityFeed.mockRejectedValue(new Error("API Error"));
mockFetchActivityLog.mockRejectedValue(new Error("API Error"));
render(
<ActivityLogModal
@@ -250,100 +250,4 @@ describe("ActivityLogModal", () => {
// Check that confirmation dialog appears
expect(screen.getByText(/Clear Activity Log/i)).toBeTruthy();
});
// ── Project Filter Tests ─────────────────────────────────────────
it("shows project filter when projects provided", async () => {
const mockProjects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
tasks={mockTasks}
projects={mockProjects}
/>
);
const projectFilter = await screen.findByTestId("activity-project-filter");
expect(projectFilter).toBeTruthy();
// Should have "All Projects" option
expect(screen.getByText("All Projects")).toBeDefined();
// Should have project options
expect(screen.getByText("Project One")).toBeDefined();
expect(screen.getByText("Project Two")).toBeDefined();
});
it("does not show project filter when no projects provided", async () => {
render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
tasks={mockTasks}
/>
);
await waitFor(() => {
expect(screen.getByTestId("activity-filter")).toBeTruthy();
});
// Project filter should not exist
expect(screen.queryByTestId("activity-project-filter")).toBeNull();
});
it("calls onProjectFilterChange when project filter changed", async () => {
const mockProjects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
const onProjectFilterChange = vi.fn();
render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
tasks={mockTasks}
projects={mockProjects}
onProjectFilterChange={onProjectFilterChange}
/>
);
const projectFilter = await screen.findByTestId("activity-project-filter");
fireEvent.change(projectFilter, { target: { value: "proj_1" } });
expect(onProjectFilterChange).toHaveBeenCalledWith("proj_1");
});
it("shows empty state message mentioning filters when filter is active", async () => {
mockFetchActivityFeed.mockResolvedValue([]);
const mockProjects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
tasks={mockTasks}
projects={mockProjects}
/>
);
// Wait for initial load
await waitFor(() => {
expect(screen.getByTestId("activity-empty")).toBeTruthy();
});
// Change the filter to trigger filtered empty state
const projectFilter = screen.getByTestId("activity-project-filter");
fireEvent.change(projectFilter, { target: { value: "proj_1" } });
// Should show filter-specific message
await waitFor(() => {
expect(screen.getByText(/No activity matches the current filters/)).toBeTruthy();
});
});
});

View File

@@ -256,7 +256,7 @@ describe("Board", () => {
const todoTasks = JSON.parse(screen.getByTestId("column-todo").getAttribute("data-tasks") || "[]");
expect(todoTasks[0].title).toBe("Updated");
expect(columnRenderCounts.todo).toBeGreaterThan(initialTodoRenders);
expect(columnRenderCounts.done).toBeGreaterThanOrEqual(initialDoneRenders);
expect(columnRenderCounts.done).toBe(initialDoneRenders);
});
it("filtered tasks are sorted correctly (columnMovedAt, createdAt)", () => {
@@ -292,7 +292,7 @@ describe("Board", () => {
expect(todoTasks).toHaveLength(3);
// Tasks with columnMovedAt should come first, sorted by columnMovedAt descending (newest first)
// So FN-002 (12:00) should be first, FN-001 (10:00) second
// So KB-002 (12:00) should be first, KB-001 (10:00) second
// Legacy tasks (no columnMovedAt) come last, sorted by createdAt ascending
expect(todoTasks[0].id).toBe("FN-002");
expect(todoTasks[1].id).toBe("FN-001");
@@ -302,7 +302,7 @@ describe("Board", () => {
it("matches tasks across multiple fields simultaneously", () => {
const tasks: Task[] = [
createTask({ id: "SEARCH-123", title: "Searchable title", description: "Normal description", column: "todo" }),
createTask({ id: "FN-999", title: "Other task", description: "This has searchable content", column: "todo" }),
createTask({ id: "KB-999", title: "Other task", description: "This has searchable content", column: "todo" }),
createTask({ id: "FN-888", title: "Unrelated", description: "No match here", column: "todo" }),
];
@@ -313,7 +313,7 @@ describe("Board", () => {
// Should match both tasks with "search" in ID, title, or description
expect(todoTasks).toHaveLength(2);
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["FN-999", "SEARCH-123"]);
expect(todoTasks.map((t: Task) => t.id).sort()).toEqual(["KB-999", "SEARCH-123"]);
});
it("trims whitespace from search query", () => {

View File

@@ -991,9 +991,8 @@ describe("GitManagerModal", () => {
await user.clear(nameInput);
await user.type(nameInput, "upstream");
const saveButton = nameInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
expect(saveButton).toBeTruthy();
await user.click(saveButton as HTMLButtonElement);
const saveButton = screen.getByRole("button", { name: "" }); // Check button
await user.click(saveButton);
await waitFor(() => {
expect(renameGitRemote).toHaveBeenCalledWith("origin", "upstream");
@@ -1024,9 +1023,8 @@ describe("GitManagerModal", () => {
await user.clear(urlInput);
await user.type(urlInput, "https://new-url.com/repo.git");
const saveButton = urlInput.closest(".gm-remote-edit")?.querySelector(".btn.btn-sm.btn-primary");
expect(saveButton).toBeTruthy();
await user.click(saveButton as HTMLButtonElement);
const saveButton = screen.getByRole("button", { name: "" }); // Check button
await user.click(saveButton);
await waitFor(() => {
expect(updateGitRemoteUrl).toHaveBeenCalledWith("origin", "https://new-url.com/repo.git");

View File

@@ -210,47 +210,6 @@ describe("Header", () => {
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Agents View Toggle ──────────────────────────────────────────
it("renders agents view button in view toggle when onChangeView is provided", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const agentsBtn = screen.getByTitle("Agents view");
expect(agentsBtn).toBeDefined();
});
it("calls onChangeView with 'agents' when agents view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const agentsBtn = screen.getByTitle("Agents view");
fireEvent.click(agentsBtn);
expect(onChangeView).toHaveBeenCalledWith("agents");
});
it("marks agents view button as active when view is 'agents'", () => {
const onChangeView = vi.fn();
render(<Header view="agents" onChangeView={onChangeView} />);
const agentsBtn = screen.getByTitle("Agents view");
expect(agentsBtn.className).toContain("active");
expect(agentsBtn.getAttribute("aria-pressed")).toBe("true");
});
it("does not mark agents view button as active when view is 'board'", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const agentsBtn = screen.getByTitle("Agents view");
expect(agentsBtn.className).not.toContain("active");
expect(agentsBtn.getAttribute("aria-pressed")).toBe("false");
});
it("does not mark board view button as active when view is 'agents'", () => {
const onChangeView = vi.fn();
render(<Header view="agents" onChangeView={onChangeView} />);
const boardBtn = screen.getByTitle("Board view");
expect(boardBtn.className).not.toContain("active");
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Terminal Button ─────────────────────────────────────────────
it("renders terminal button with correct title", () => {
@@ -546,107 +505,4 @@ describe("Header", () => {
const btn = screen.getByTestId("agents-btn");
expect(btn).toBeDefined();
});
// ── Multi-Project Selector ────────────────────────────────────
it("shows ProjectSelector when 2+ projects provided", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(<Header projects={projects} />);
expect(screen.getByTestId("project-selector-trigger")).toBeDefined();
});
it("does not show ProjectSelector with single project", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
const { container } = render(<Header projects={projects} />);
expect(container.querySelector(".project-selector")).toBeNull();
});
it("does not show ProjectSelector when no projects", () => {
const { container } = render(<Header projects={[]} />);
expect(container.querySelector(".project-selector")).toBeNull();
});
it("shows 'Back to All Projects' button when currentProject is set", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onViewAllProjects={vi.fn()}
/>
);
expect(screen.getByTestId("back-to-projects-btn")).toBeDefined();
});
it("calls onViewAllProjects when 'Back to All Projects' clicked", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
const onViewAllProjects = vi.fn();
render(
<Header
projects={projects}
currentProject={projects[0]}
onViewAllProjects={onViewAllProjects}
/>
);
fireEvent.click(screen.getByTestId("back-to-projects-btn"));
expect(onViewAllProjects).toHaveBeenCalled();
});
it("does not show 'Back to All Projects' when no currentProject", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(<Header projects={projects} currentProject={null} />);
expect(screen.queryByTestId("back-to-projects-btn")).toBeNull();
});
it("calls onSelectProject when project selected from selector", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
const onSelectProject = vi.fn();
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={onSelectProject}
onViewAllProjects={vi.fn()}
/>
);
// Open selector
fireEvent.click(screen.getByTestId("project-selector-trigger"));
// Click on a project in the dropdown
fireEvent.click(screen.getByText("Project Two"));
expect(onSelectProject).toHaveBeenCalledWith(projects[1]);
});
it("shows current project name in selector trigger", () => {
const projects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
onViewAllProjects={vi.fn()}
/>
);
expect(screen.getByText("Project One")).toBeDefined();
});
});

View File

@@ -17,8 +17,6 @@ vi.mock("lucide-react", () => ({
Lightbulb: () => null,
ListTree: () => null,
Zap: () => null,
ChevronDown: () => null,
ChevronUp: () => null,
}));
// Mock the api module
@@ -90,12 +88,6 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
fireEvent.click(screen.getByText(optionText));
}
// Helper to expand the InlineCreateCard by clicking the toggle button
function expandInlineCreate() {
const toggleButton = screen.getByTestId("inline-create-toggle");
fireEvent.click(toggleButton);
}
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
@@ -107,84 +99,47 @@ beforeEach(() => {
});
});
describe("InlineCreateCard toggle button", () => {
it("toggle button expands the view", () => {
renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Initially, footer controls are not visible
expect(document.querySelector(".inline-create-footer")).toBeNull();
// Click toggle to expand
expandInlineCreate();
// Now footer controls should be visible
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
});
it("toggle button collapses the view when expanded", () => {
renderCard();
// Expand first
expandInlineCreate();
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
// Click toggle again to collapse
expandInlineCreate();
// Footer should be hidden
expect(document.querySelector(".inline-create-footer")).toBeNull();
});
it("does NOT expand on focus", () => {
renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Focus should not expand the card
textarea.focus();
expect(document.querySelector(".inline-create-footer")).toBeNull();
});
it("does NOT collapse on blur", () => {
renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Expand first
expandInlineCreate();
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
// Blur should not collapse
fireEvent.focusOut(textarea, { relatedTarget: null });
// Should still be expanded
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
});
});
describe("InlineCreateCard Escape key behavior", () => {
it("calls onCancel when Escape is pressed", () => {
describe("InlineCreateCard blur-to-cancel", () => {
it("calls onCancel when focus leaves the card with empty input", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.keyDown(textarea, { key: "Escape" });
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
it("closes dropdowns on first Escape, cancels on second", () => {
it("does NOT call onCancel when focus leaves with non-empty input", () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Open a dropdown
fireEvent.click(screen.getByText(/Deps/));
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
fireEvent.change(textarea, { target: { value: "Some task description" } });
fireEvent.focusOut(textarea, { relatedTarget: null });
// First Escape closes dropdown
fireEvent.keyDown(textarea, { key: "Escape" });
expect(document.querySelector(".dep-dropdown")).toBeNull();
expect(props.onCancel).not.toHaveBeenCalled();
});
it("does NOT call onCancel when focus moves to another element inside the card", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
const depsButton = screen.getByText(/Deps/);
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: depsButton });
expect(props.onCancel).not.toHaveBeenCalled();
});
it("calls onCancel when blur with only whitespace input", () => {
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: " " } });
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
});
describe("InlineCreateCard dep-dropdown focus retention", () => {
@@ -194,7 +149,6 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
it("dep-dropdown-item mouseDown calls preventDefault to retain focus", () => {
renderCard(testTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
expect(item).toBeTruthy();
@@ -202,12 +156,26 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
const prevented = !fireEvent.mouseDown(item);
expect(prevented).toBe(true);
});
it("does NOT call onCancel when focus leaves card with selected dependencies but empty description", () => {
const { props } = renderCard(testTasks);
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.click(screen.getByText(/Deps/));
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
expect(item).toBeTruthy();
fireEvent.click(item);
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(props.onCancel).not.toHaveBeenCalled();
});
});
describe("InlineCreateCard model selector", () => {
it("opens and closes the model disclosure dropdown", () => {
renderCard();
expandInlineCreate();
openModelPanel();
expect(screen.getByText("Executor Model")).toBeTruthy();
@@ -219,7 +187,6 @@ describe("InlineCreateCard model selector", () => {
it("updates executor selection and shows the selected model badge", () => {
renderCard();
expandInlineCreate();
openModelPanel();
chooseModel("Executor Model", "Claude Sonnet 4.5");
@@ -229,7 +196,6 @@ describe("InlineCreateCard model selector", () => {
it("updates validator selection and shows the selected model badge", () => {
renderCard();
expandInlineCreate();
openModelPanel();
chooseModel("Validator Model", "GPT-4o");
@@ -239,7 +205,6 @@ describe("InlineCreateCard model selector", () => {
it("clears the model selection when Use default is chosen", () => {
renderCard();
expandInlineCreate();
openModelPanel();
chooseModel("Executor Model", "Claude Sonnet 4.5");
@@ -255,7 +220,6 @@ describe("InlineCreateCard model selector", () => {
it("omits model fields from the submit payload after clearing back to default", async () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Task using defaults again" } });
@@ -282,7 +246,6 @@ describe("InlineCreateCard model selector", () => {
it("includes selected models in the submit payload", async () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Task with model overrides" } });
@@ -306,7 +269,6 @@ describe("InlineCreateCard model selector", () => {
it("does NOT call onCancel when focus leaves while the model dropdown is open", () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
openModelPanel();
@@ -323,7 +285,6 @@ describe("InlineCreateCard model selector", () => {
defaultPresetBySize: {},
});
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
@@ -333,21 +294,19 @@ describe("InlineCreateCard model selector", () => {
expect(props.onCancel).not.toHaveBeenCalled();
});
it.skip("includes selected preset id in the submit payload", async () => {
it("includes selected preset id in the submit payload", async () => {
vi.mocked(fetchSettings).mockResolvedValueOnce({
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
autoSelectModelPreset: false,
defaultPresetBySize: {},
});
const { props } = renderCard([], { availableModels: undefined });
expandInlineCreate();
const { props } = renderCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Task with preset" } });
fireEvent.click(await screen.findByRole("button", { name: /Preset/i }));
fireEvent.click(await screen.findByRole("button", { name: "Budget" }));
fireEvent.click(await screen.findByRole("button", { name: /Save/i }));
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
fireEvent.click(screen.getByRole("button", { name: "Budget" }));
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
await waitFor(() => {
expect(props.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
@@ -363,7 +322,6 @@ describe("InlineCreateCard model selector", () => {
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
openModelPanel();
@@ -378,7 +336,6 @@ describe("InlineCreateCard model selector", () => {
it("prevents default on model option mouseDown to retain focus while selecting", () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
textarea.focus();
@@ -412,7 +369,6 @@ describe("InlineCreateCard model selector", () => {
.mockResolvedValueOnce(MOCK_MODELS);
renderCard([], { availableModels: undefined });
expandInlineCreate();
openModelPanel();
await waitFor(() => {
@@ -437,7 +393,6 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
it("renders dependency dropdown items sorted newest-first by createdAt", () => {
renderCard(scrambledTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
@@ -447,7 +402,6 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
it("preserves newest-first sort order when a search filter is applied", () => {
renderCard(scrambledTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "FN-00" } });
@@ -467,7 +421,6 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
it("renders tasks with identical createdAt sorted newest-ID-first (descending numeric ID)", () => {
renderCard(sameTimeTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
@@ -477,7 +430,6 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
it("preserves newest-ID-first order when search filter is applied with identical timestamps", () => {
renderCard(sameTimeTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "FN-00" } });
@@ -497,7 +449,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
it("shows search input when dropdown is opened", () => {
renderCard(testTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
expect(input).toBeTruthy();
@@ -506,7 +457,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
it("filters tasks by search term", () => {
renderCard(testTasks);
expandInlineCreate();
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "dark" } });
@@ -520,7 +470,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
describe("InlineCreateCard Plan and Subtask buttons", () => {
it("renders Plan and Subtask buttons disabled when description is empty", () => {
renderCard();
expandInlineCreate();
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
expect(planButton.disabled).toBe(true);
@@ -529,7 +478,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
it("enables Plan and Subtask buttons when description is entered", () => {
renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Test task" } });
@@ -542,7 +490,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
it("calls onPlanningMode with description and clears input when Plan clicked", () => {
const onPlanningMode = vi.fn();
renderCard([], { onPlanningMode });
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Plan this task" } });
@@ -555,7 +502,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
it("calls onSubtaskBreakdown with description and clears input when Subtask clicked", () => {
const onSubtaskBreakdown = vi.fn();
renderCard([], { onSubtaskBreakdown });
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "Break this down" } });
@@ -569,7 +515,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
const addToast = vi.fn();
const onPlanningMode = vi.fn();
renderCard([], { addToast, onPlanningMode });
expandInlineCreate();
// When no description, button is disabled - verify that behavior
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
@@ -583,7 +528,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
const addToast = vi.fn();
const onSubtaskBreakdown = vi.fn();
renderCard([], { addToast, onSubtaskBreakdown });
expandInlineCreate();
// When no description, button is disabled - verify that behavior
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
@@ -629,7 +573,6 @@ describe("InlineCreateCard localStorage persistence", () => {
it("clears localStorage after successful task creation", async () => {
const { props } = renderCard();
expandInlineCreate();
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Type something to set localStorage

View File

@@ -46,7 +46,6 @@ const renderListView = (props: Partial<React.ComponentProps<typeof ListView>> =
describe("ListView", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("renders without crashing", () => {
@@ -227,10 +226,6 @@ describe("ListView", () => {
renderListView({ tasks });
// Click "Show Done" to reveal done tasks
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
const columnHeader = screen.getByText("Column");
fireEvent.click(columnHeader);
@@ -315,15 +310,11 @@ describe("ListView", () => {
const columns = ["triage", "todo", "in-progress", "in-review", "done"] as const;
const tasks = columns.map((col, i) =>
createMockTask({ id: `FN-00${i + 1}`, column: col })
createMockTask({ id: `KB-00${i + 1}`, column: col })
);
renderListView({ tasks });
// Click "Show Done" to reveal done tasks in the table
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Check that all column badges are rendered in the table
// Use getAllByText and check length since column names appear in both drop zones and badges
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
@@ -569,10 +560,6 @@ describe("ListView", () => {
renderListView({ tasks });
// Click "Show Done" to show all column sections including Done and Archived
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Check that section headers are rendered with column names
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
@@ -590,10 +577,6 @@ describe("ListView", () => {
renderListView({ tasks });
// Click "Show Done" to show all column sections
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Find section headers by their structure
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(6); // One for each column
@@ -668,7 +651,6 @@ describe("ListView", () => {
describe("ListView Column Filtering", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("filters tasks by column when drop zone is clicked", () => {
@@ -718,10 +700,6 @@ describe("ListView Column Filtering", () => {
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.getByText("FN-002")).toBeDefined();
// Click "Show Done" to reveal all column sections
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// All 6 section headers should be visible (one for each column)
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(6);
@@ -1041,26 +1019,13 @@ describe("ListView Hide Done Tasks", () => {
localStorage.clear();
});
it("renders hide done tasks toggle button with 'Show Done' when done tasks are hidden by default", () => {
it("renders hide done tasks toggle button", () => {
renderListView();
const hideDoneButton = screen.getByRole("button", { name: /show done/i });
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
expect(hideDoneButton).toBeDefined();
});
it("hides done tasks by default when no localStorage value exists", () => {
const tasks = [
createMockTask({ id: "FN-001", column: "done" }),
createMockTask({ id: "FN-002", column: "triage" }),
];
renderListView({ tasks });
// Done task should be hidden by default
expect(screen.queryByText("FN-001")).toBeNull();
expect(screen.getByText("FN-002")).toBeDefined();
});
it("hides done tasks when toggle is activated", () => {
const tasks = [
createMockTask({ id: "FN-001", column: "done" }),
@@ -1069,15 +1034,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Click "Show Done" to show done tasks first
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Both tasks should be visible now
// Both tasks should be visible initially
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.getByText("FN-002")).toBeDefined();
// Click "Hide Done" to hide done tasks
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
@@ -1094,15 +1055,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Click "Show Done" to show archived tasks first
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Both tasks should be visible now
// Both tasks should be visible initially
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.getByText("FN-002")).toBeDefined();
// Click "Hide Done" to hide archived tasks
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
@@ -1120,16 +1077,12 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Click "Show Done" to show all completed tasks first
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// All tasks should be visible now
// All tasks should be visible initially
expect(screen.getByText("FN-001")).toBeDefined();
expect(screen.getByText("FN-002")).toBeDefined();
expect(screen.getByText("FN-003")).toBeDefined();
// Click "Hide Done" to hide completed tasks
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
@@ -1148,13 +1101,16 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Completed tasks should be hidden by default
// Click hide done button to hide completed tasks
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Completed tasks should be hidden
expect(screen.queryByText("FN-001")).toBeNull();
expect(screen.queryByText("FN-002")).toBeNull();
// Click "Show Done" to show all tasks
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Click again to show all tasks
fireEvent.click(hideDoneButton);
// All tasks should be visible again
expect(screen.getByText("FN-001")).toBeDefined();
@@ -1166,11 +1122,7 @@ describe("ListView Hide Done Tasks", () => {
const tasks = [createMockTask({ id: "FN-001", column: "done" })];
renderListView({ tasks });
// Click "Show Done" first (since default is now hidden)
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
// Click "Hide Done" to hide done tasks
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
@@ -1207,7 +1159,14 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Stats should show filtered count with hidden indicator (default is now hidden)
// Initial stats should show all tasks
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Stats should show filtered count with hidden indicator
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
expect(screen.getByText(/2 hidden/)).toBeDefined();
});
@@ -1221,7 +1180,15 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Done and Archived sections should be hidden by default
// All section headers should be visible initially
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeadersBefore.length).toBe(6); // All 6 columns
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Done and Archived sections should be hidden
const doneSection = screen.getAllByRole("row").find(r =>
r.className.includes("list-section-header") && r.textContent?.includes("Done")
);
@@ -1247,7 +1214,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Done drop zone should be visible with "X of Y" format (hide done is active by default)
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Done drop zone should still be visible with "X of Y" format
const doneZone = document.querySelector('[data-column="done"].list-drop-zone');
expect(doneZone).toBeDefined();
expect(doneZone?.textContent).toContain("0 of 2");
@@ -1261,7 +1232,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Archived drop zone should be visible with "X of Y" format (hide done is active by default)
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Archived drop zone should still be visible with "X of Y" format
const archivedZone = document.querySelector('[data-column="archived"].list-drop-zone');
expect(archivedZone).toBeDefined();
expect(archivedZone?.textContent).toContain("0 of 2");
@@ -1276,11 +1251,15 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Hide done tasks
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Apply filter
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Gamma" } });
// Completed tasks should remain hidden (hide done is active by default)
// Completed tasks should remain hidden
expect(screen.queryByText("FN-001")).toBeNull();
expect(screen.queryByText("FN-002")).toBeNull();
// Filtered task should be visible
@@ -1295,7 +1274,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Done task should be hidden by default
// Enable hide done
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Done task should be hidden
expect(screen.queryByText("FN-001")).toBeNull();
// Click on the done drop zone to select that column
@@ -1315,7 +1298,11 @@ describe("ListView Hide Done Tasks", () => {
renderListView({ tasks });
// Archived task should be hidden by default
// Enable hide done
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Archived task should be hidden
expect(screen.queryByText("FN-001")).toBeNull();
// Click on the archived drop zone to select that column
@@ -1331,7 +1318,6 @@ describe("ListView Hide Done Tasks", () => {
describe("ListView Quick Entry", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("renders QuickEntryBox when onQuickCreate is provided", () => {
@@ -1769,7 +1755,7 @@ describe("ListView - Bulk Selection", () => {
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const checkboxes = screen.getAllByLabelText(/Select FN-/);
const checkboxes = screen.getAllByLabelText(/Select KB-/);
expect(checkboxes).toHaveLength(2);
});
@@ -1779,11 +1765,7 @@ describe("ListView - Bulk Selection", () => {
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
// Click "Show Done" to make archived tasks visible
const showDoneButton = screen.getByRole("button", { name: /show done/i });
fireEvent.click(showDoneButton);
const checkbox = screen.getByLabelText("Select FN-001");
const checkbox = screen.getByLabelText("Select KB-001");
expect(checkbox).toBeDisabled();
});
@@ -1794,7 +1776,7 @@ describe("ListView - Bulk Selection", () => {
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const checkbox = screen.getByLabelText("Select FN-001");
const checkbox = screen.getByLabelText("Select KB-001");
fireEvent.click(checkbox);
expect(screen.getByText("1 selected")).toBeDefined();
@@ -1806,7 +1788,7 @@ describe("ListView - Bulk Selection", () => {
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const checkbox = screen.getByLabelText("Select FN-001");
const checkbox = screen.getByLabelText("Select KB-001");
fireEvent.click(checkbox);
expect(screen.getByText("1 selected")).toBeDefined();
@@ -1845,7 +1827,7 @@ describe("ListView - Bulk Selection", () => {
/>
);
const checkbox = screen.getByLabelText("Select FN-001");
const checkbox = screen.getByLabelText("Select KB-001");
fireEvent.click(checkbox);
expect(screen.getByText("Bulk Edit Models:")).toBeDefined();
@@ -1867,7 +1849,7 @@ describe("ListView - Bulk Selection", () => {
/>
);
const checkbox = screen.getByLabelText("Select FN-001");
const checkbox = screen.getByLabelText("Select KB-001");
fireEvent.click(checkbox);
const applyButton = screen.getByText("Apply");
@@ -1878,7 +1860,7 @@ describe("ListView - Bulk Selection", () => {
const tasks = [createMockTask({ id: "FN-001" })];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const checkbox = screen.getByLabelText("Select FN-001");
const checkbox = screen.getByLabelText("Select KB-001");
fireEvent.click(checkbox);
expect(localStorage.getItem("kb-dashboard-selected-tasks")).toBe('["FN-001"]');
@@ -1891,7 +1873,7 @@ describe("ListView - Bulk Selection", () => {
];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
const checkboxes = screen.getAllByLabelText(/Select FN-/);
const checkboxes = screen.getAllByLabelText(/Select KB-/);
// Select only first task
fireEvent.click(checkboxes[0]);
@@ -1919,7 +1901,7 @@ describe("ListView - Bulk Selection", () => {
);
// Select the task
const checkbox = screen.getByLabelText("Select FN-001");
const checkbox = screen.getByLabelText("Select KB-001");
fireEvent.click(checkbox);
// Initially disabled

View File

@@ -128,7 +128,7 @@ describe("NewTaskModal", () => {
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.addToast).toHaveBeenCalledWith("Created FN-042", "success");
expect(props.addToast).toHaveBeenCalledWith("Created KB-042", "success");
});
});

View File

@@ -78,8 +78,6 @@ vi.mock("lucide-react", () => ({
Sparkles: () => null,
Save: () => null,
X: () => null,
ChevronDown: () => null,
ChevronUp: () => null,
}));
// Mock ModelSelectionModal
@@ -138,11 +136,7 @@ vi.mock("../ModelSelectionModal", () => ({
},
}));
function renderQuickEntryBox(props = {}, { startCollapsed = false } = {}) {
// Set disclosure state if needed
if (startCollapsed) {
localStorage.setItem("kb-quick-entry-expanded", "false");
}
function renderQuickEntryBox(props = {}) {
const defaultProps = {
onCreate: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(),
@@ -153,82 +147,38 @@ function renderQuickEntryBox(props = {}, { startCollapsed = false } = {}) {
return { ...result, props: { ...defaultProps, ...props } };
}
// Helper to expand the QuickEntryBox by clicking the toggle button
function expandQuickEntry() {
const toggleButton = screen.getByTestId("quick-entry-toggle");
fireEvent.click(toggleButton);
}
describe("QuickEntryBox", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
localStorage.clear();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
localStorage.clear();
});
it("renders textarea with placeholder", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
expect(textarea).toBeTruthy();
expect(textarea.tagName.toLowerCase()).toBe("textarea");
expect((textarea as HTMLTextAreaElement).placeholder).toBe("Add a task...");
});
it("does NOT expand on focus", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
it("expands on focus", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
// Should NOT auto-expand on focus (manual toggle only)
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
});
it("toggle button expands the view", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const textarea = screen.getByTestId("quick-entry-input");
// Initially not expanded
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
// Click toggle to expand
expandQuickEntry();
// Now expanded
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
});
it("toggle button collapses the view when expanded", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
it("collapses on blur when empty", async () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Expand first
expandQuickEntry();
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
// Click toggle again to collapse
expandQuickEntry();
// Now collapsed
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
});
it("does NOT collapse on blur when empty", async () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const textarea = screen.getByTestId("quick-entry-input");
// Expand manually
expandQuickEntry();
fireEvent.focus(textarea);
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
fireEvent.blur(textarea);
@@ -236,19 +186,16 @@ describe("QuickEntryBox", () => {
vi.advanceTimersByTime(250);
});
// Should NOT collapse on blur
await waitFor(() => {
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
});
});
it("does NOT collapse on blur when has content", async () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
it("collapses on blur even when has content", async () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Expand manually and add content
expandQuickEntry();
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Some task" } });
fireEvent.blur(textarea);
@@ -256,15 +203,14 @@ describe("QuickEntryBox", () => {
vi.advanceTimersByTime(250);
});
// Should NOT collapse on blur - expanded state persists
// Wait for React to re-render after state change
await waitFor(() => {
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
});
});
it("creates task on Enter key with TaskCreateInput", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "New task description" } });
@@ -281,11 +227,10 @@ describe("QuickEntryBox", () => {
});
it("allows Shift+Enter to insert newline when expanded", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Line 1" } });
// Shift+Enter should not prevent default (allow newline)
@@ -296,8 +241,7 @@ describe("QuickEntryBox", () => {
});
it("submits on Enter even when expanded (without Shift)", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
@@ -316,8 +260,7 @@ describe("QuickEntryBox", () => {
});
it("prevents default on Enter key (without Shift)", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task" } });
@@ -328,8 +271,7 @@ describe("QuickEntryBox", () => {
});
it("shows loading state during creation", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const { props } = renderQuickEntryBox();
// Slow down the promise to see loading state
props.onCreate.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
@@ -347,8 +289,7 @@ describe("QuickEntryBox", () => {
});
it("clears input after successful creation", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task to create" } });
@@ -362,8 +303,7 @@ describe("QuickEntryBox", () => {
});
it("shows error toast on failure and keeps input content", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const { props } = renderQuickEntryBox();
props.onCreate.mockRejectedValue(new Error("Network error"));
const textarea = screen.getByTestId("quick-entry-input");
@@ -379,8 +319,7 @@ describe("QuickEntryBox", () => {
});
it("clears non-empty input on Escape key", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Some text" } });
@@ -391,11 +330,10 @@ describe("QuickEntryBox", () => {
});
it("collapses and blurs on Escape key", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
fireEvent.keyDown(textarea, { key: "Escape" });
@@ -404,8 +342,7 @@ describe("QuickEntryBox", () => {
});
it("does not clear empty input on Escape key", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.keyDown(textarea, { key: "Escape" });
@@ -413,8 +350,7 @@ describe("QuickEntryBox", () => {
});
it("does not submit on Enter if input is empty", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.keyDown(textarea, { key: "Enter" });
@@ -426,8 +362,7 @@ describe("QuickEntryBox", () => {
});
it("does not submit on Enter if input is only whitespace", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: " " } });
@@ -439,8 +374,7 @@ describe("QuickEntryBox", () => {
});
it("updates textarea value on change", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Updated text" } });
@@ -448,8 +382,7 @@ describe("QuickEntryBox", () => {
});
it("trims whitespace when creating task", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: " Task with spaces " } });
@@ -465,8 +398,7 @@ describe("QuickEntryBox", () => {
});
it("maintains focus after successful creation", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task to create" } });
@@ -481,46 +413,46 @@ describe("QuickEntryBox", () => {
});
describe("Rich creation features", () => {
it("shows dependency button when expanded", () => {
renderQuickEntryBox({}, { startCollapsed: true });
it("shows dependency button when focused", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Initially, no controls are visible
// Initially, no controls are visible before focus
expect(screen.queryByTestId("quick-entry-deps-button")).toBeNull();
// Expand and type something
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
// Focus and type something
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with deps" } });
// Now the dependency button should be visible
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
});
it("shows model selector button when expanded", () => {
renderQuickEntryBox({}, { startCollapsed: true });
it("shows model selector button when focused", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Initially, no controls are visible
expect(screen.queryByTestId("quick-entry-models-button")).toBeNull();
// Expand and type something
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
// Focus and type something
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with models" } });
// Now the model selector button should be visible
expect(screen.getByTestId("quick-entry-models-button")).toBeTruthy();
});
it("shows Plan and Subtask buttons when expanded", () => {
renderQuickEntryBox({}, { startCollapsed: true });
it("shows Plan and Subtask buttons when focused", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Initially, no controls are visible
expect(screen.queryByTestId("plan-button")).toBeNull();
expect(screen.queryByTestId("subtask-button")).toBeNull();
// Expand and type something
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
// Focus and type something
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to plan" } });
// Now the Plan and Subtask buttons should be visible
@@ -529,10 +461,10 @@ describe("QuickEntryBox", () => {
});
it("opens dependency dropdown when clicking deps button", () => {
renderQuickEntryBox({}, { startCollapsed: true });
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with deps" } });
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
@@ -542,11 +474,10 @@ describe("QuickEntryBox", () => {
});
it("opens model modal when clicking models button", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with models" } });
// Modal should not be visible initially
@@ -560,11 +491,10 @@ describe("QuickEntryBox", () => {
});
it("modal receives correct props (models, loading state, etc.)", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with models" } });
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
@@ -580,11 +510,10 @@ describe("QuickEntryBox", () => {
});
it("selects dependencies and includes them in submit payload", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with deps" } });
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
@@ -611,6 +540,7 @@ describe("QuickEntryBox", () => {
const { props } = renderQuickEntryBox({ onPlanningMode });
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Plan this task" } });
fireEvent.click(screen.getByTestId("plan-button"));
@@ -627,6 +557,7 @@ describe("QuickEntryBox", () => {
const { props } = renderQuickEntryBox({ onSubtaskBreakdown });
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Break this down" } });
fireEvent.click(screen.getByTestId("subtask-button"));
@@ -639,12 +570,11 @@ describe("QuickEntryBox", () => {
});
it("disables Plan and Subtask buttons when description is empty", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Type something first to make buttons appear
// Focus and type something first to make buttons appear
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Some task" } });
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
@@ -666,11 +596,11 @@ describe("QuickEntryBox", () => {
});
it("Plan button prevents textarea blur on mousedown", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Focus and expand
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to plan" } });
// Get plan button and trigger mousedown (prevents blur)
@@ -685,11 +615,11 @@ describe("QuickEntryBox", () => {
});
it("Subtask button prevents textarea blur on mousedown", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Focus and expand
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to break down" } });
// Get subtask button and trigger mousedown (prevents blur)
@@ -706,10 +636,10 @@ describe("QuickEntryBox", () => {
it("shows toast when Plan clicked with empty description", () => {
const addToast = vi.fn();
renderQuickEntryBox({ addToast });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
// Type something first to make buttons appear
// Focus and type something first to make buttons appear
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Some task" } });
// Clear input
@@ -724,11 +654,10 @@ describe("QuickEntryBox", () => {
});
it("includes selected models in submit payload", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with model" } });
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
@@ -756,11 +685,10 @@ describe("QuickEntryBox", () => {
});
it("closes modal on Escape when open", async () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with modal" } });
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
@@ -778,11 +706,10 @@ describe("QuickEntryBox", () => {
});
it("clears all state on second Escape after dropdowns are closed", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to clear" } });
// First Escape closes any dropdowns
@@ -796,12 +723,11 @@ describe("QuickEntryBox", () => {
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
});
it("resets all state after successful creation (preserves disclosure preference)", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
it("resets all state after successful creation", async () => {
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to reset" } });
fireEvent.keyDown(textarea, { key: "Enter" });
@@ -810,12 +736,11 @@ describe("QuickEntryBox", () => {
expect(props.onCreate).toHaveBeenCalled();
});
// After creation, input should be cleared
// After creation, controls should be collapsed
expect((textarea as HTMLTextAreaElement).value).toBe("");
// Disclosure preference persists - controls remain visible since we expanded earlier
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
expect(screen.getByTestId("plan-button")).toBeTruthy();
expect(screen.getByTestId("subtask-button")).toBeTruthy();
expect(screen.queryByTestId("quick-entry-deps-button")).toBeNull();
expect(screen.queryByTestId("plan-button")).toBeNull();
expect(screen.queryByTestId("subtask-button")).toBeNull();
});
});
@@ -829,84 +754,11 @@ describe("QuickEntryBox", () => {
localStorage.clear();
});
it("restores disclosure state from localStorage on mount", () => {
// Pre-populate localStorage with expanded state
localStorage.setItem("kb-quick-entry-expanded", "true");
renderQuickEntryBox();
const toggleButton = screen.getByTestId("quick-entry-toggle");
// Should restore the saved disclosure state (expanded)
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
// Controls should be visible
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
});
it("defaults to expanded when localStorage is empty", () => {
renderQuickEntryBox();
const toggleButton = screen.getByTestId("quick-entry-toggle");
// Should default to expanded (true) for backward compatibility
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
// Controls should be visible
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
});
it("updates localStorage when toggling disclosure", async () => {
renderQuickEntryBox({}, { startCollapsed: true });
const toggleButton = screen.getByTestId("quick-entry-toggle");
// Wait for initial state to be persisted (useEffect runs after mount)
await waitFor(() => {
expect(localStorage.getItem("kb-quick-entry-expanded")).toBe("false");
});
// Initially collapsed
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
// Click to expand
fireEvent.click(toggleButton);
// Should be expanded
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
// localStorage should be updated
await waitFor(() => {
expect(localStorage.getItem("kb-quick-entry-expanded")).toBe("true");
});
// Click to collapse
fireEvent.click(toggleButton);
// Should be collapsed
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
// localStorage should be updated
await waitFor(() => {
expect(localStorage.getItem("kb-quick-entry-expanded")).toBe("false");
});
});
it("aria-expanded attribute updates correctly when toggling", () => {
renderQuickEntryBox({}, { startCollapsed: true });
const toggleButton = screen.getByTestId("quick-entry-toggle");
// Initially collapsed
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
// Click to expand
fireEvent.click(toggleButton);
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
// Click to collapse
fireEvent.click(toggleButton);
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
});
it("restores description from localStorage on mount", () => {
// Pre-populate localStorage
localStorage.setItem("kb-quick-entry-text", "Saved task description");
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Should restore the saved description
@@ -914,8 +766,7 @@ describe("QuickEntryBox", () => {
});
it("updates localStorage when typing", async () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Typing this task" } });
@@ -927,8 +778,7 @@ describe("QuickEntryBox", () => {
});
it("clears localStorage after successful task creation", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Type something to set localStorage
@@ -949,12 +799,11 @@ describe("QuickEntryBox", () => {
});
it("clears localStorage when Escape clears non-empty input", async () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Type something to set localStorage
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to clear" } });
await waitFor(() => {
expect(localStorage.getItem("kb-quick-entry-text")).toBe("Task to clear");
@@ -969,12 +818,11 @@ describe("QuickEntryBox", () => {
});
it("does not clear localStorage on first Escape when closing dropdowns", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Type something and open dropdown
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task with dropdown" } });
fireEvent.click(screen.getByTestId("quick-entry-deps-button"));
@@ -991,16 +839,15 @@ describe("QuickEntryBox", () => {
});
describe("AI Refine feature", () => {
it("shows refine button when expanded and text is entered", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
it("shows refine button when text is entered", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Initially, refine button is not visible
expect(screen.queryByTestId("refine-button")).toBeNull();
// Expand and type something
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
// Focus and type something
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to refine" } });
// Now the refine button should be visible
@@ -1008,12 +855,11 @@ describe("QuickEntryBox", () => {
});
it("refine button is hidden when textarea is empty", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Type something
// Focus and type something
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Some text" } });
expect(screen.getByTestId("refine-button")).toBeTruthy();
@@ -1028,11 +874,10 @@ describe("QuickEntryBox", () => {
});
it("opens refine menu on button click", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to refine" } });
fireEvent.click(screen.getByTestId("refine-button"));
@@ -1044,11 +889,10 @@ describe("QuickEntryBox", () => {
});
it("closes refine menu on Escape key", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to refine" } });
fireEvent.click(screen.getByTestId("refine-button"));
@@ -1067,11 +911,10 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Original text" } });
fireEvent.click(screen.getByTestId("refine-button"));
@@ -1091,11 +934,10 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined description");
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Original text" } });
fireEvent.click(screen.getByTestId("refine-button"));
fireEvent.click(screen.getByTestId("refine-clarify"));
@@ -1121,11 +963,10 @@ describe("QuickEntryBox", () => {
const { getRefineErrorMessage } = await import("../../api");
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Original text" } });
fireEvent.click(screen.getByTestId("refine-button"));
fireEvent.click(screen.getByTestId("refine-clarify"));
@@ -1143,11 +984,10 @@ describe("QuickEntryBox", () => {
// Slow down the promise to see loading state
vi.mocked(refineText).mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Original text" } });
fireEvent.click(screen.getByTestId("refine-button"));
fireEvent.click(screen.getByTestId("refine-clarify"));
@@ -1166,11 +1006,10 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined description with much more content here");
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Short" } });
fireEvent.click(screen.getByTestId("refine-button"));
fireEvent.click(screen.getByTestId("refine-expand"));
@@ -1184,12 +1023,11 @@ describe("QuickEntryBox", () => {
const { refineText } = await import("../../api");
vi.mocked(refineText).mockResolvedValueOnce("Refined text");
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Open refine menu but don't select anything
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task" } });
fireEvent.click(screen.getByTestId("refine-button"));
@@ -1208,16 +1046,15 @@ describe("QuickEntryBox", () => {
});
describe("Save button", () => {
it("shows save button when expanded and text is entered", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
it("shows save button when text is entered", () => {
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Initially, save button is not visible
expect(screen.queryByTestId("save-button")).toBeNull();
// Expand and type something
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
// Focus and type something
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to save" } });
// Now the save button should be visible
@@ -1225,12 +1062,11 @@ describe("QuickEntryBox", () => {
});
it("save button is disabled when textarea is empty", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Type something
// Focus and type something
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Some text" } });
expect(screen.getByTestId("save-button")).toBeTruthy();
@@ -1245,14 +1081,13 @@ describe("QuickEntryBox", () => {
});
it("save button is disabled during submission", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
const { props } = renderQuickEntryBox();
// Slow down the promise to see loading state
props.onCreate.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "New task" } });
// Start submission with Enter key
@@ -1268,11 +1103,10 @@ describe("QuickEntryBox", () => {
});
it("clicking save button persists to localStorage", async () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Draft task description" } });
// Click the save button
@@ -1285,11 +1119,10 @@ describe("QuickEntryBox", () => {
});
it("clicking save button creates the task", async () => {
const { props } = renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
const { props } = renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to save" } });
// Click the save button
@@ -1307,11 +1140,10 @@ describe("QuickEntryBox", () => {
});
it("save button has correct test id", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to save" } });
// Button should have data-testid="save-button"
@@ -1320,11 +1152,10 @@ describe("QuickEntryBox", () => {
});
it("save button has correct title attribute", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to save" } });
const saveButton = screen.getByTestId("save-button");
@@ -1332,11 +1163,11 @@ describe("QuickEntryBox", () => {
});
it("save button prevents textarea blur on mousedown", () => {
renderQuickEntryBox({}, { startCollapsed: true });
// Component starts with disclosure expanded by default
expandQuickEntry();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
// Focus and expand
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value: "Task to save" } });
// Get save button and trigger mousedown (prevents blur)

View File

@@ -689,7 +689,7 @@ describe("SettingsModal", () => {
// Check that no elements in the settings content have inline styles
const elementsWithStyle = container.querySelectorAll("[style]");
expect(elementsWithStyle.length).toBe(1);
expect(elementsWithStyle.length).toBe(0);
});
it("shows Thinking Effort dropdown with correct options in Model section", async () => {
@@ -833,14 +833,14 @@ describe("SettingsModal", () => {
expect(layout!.querySelector(".settings-content")).toBeTruthy();
});
it("has .settings-sidebar with 12 .settings-nav-item buttons for all sections", async () => {
it("has .settings-sidebar with 11 .settings-nav-item buttons for all sections", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const sidebar = container.querySelector(".settings-sidebar");
expect(sidebar).toBeTruthy();
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
expect(navItems.length).toBe(12);
expect(navItems.length).toBe(11);
// Labels include scope emoji indicators (🌐 for global, 📁 for project)
const labels = Array.from(navItems).map((el) => el.textContent);
@@ -848,7 +848,6 @@ describe("SettingsModal", () => {
"📁General",
"🌐Model",
"📁Model Presets",
"📁AI Summarization",
"🌐Appearance",
"📁Scheduling",
"📁Worktrees",

View File

@@ -487,7 +487,7 @@ describe("TaskCard file-scope overlap badge logic", () => {
}
it("generates correct tooltip text", () => {
expect(computeScopeTooltip("FN-005")).toBe("Blocked by FN-005 (file overlap)");
expect(computeScopeTooltip("FN-005")).toBe("Blocked by KB-005 (file overlap)");
});
});
@@ -647,7 +647,7 @@ describe("TaskCard clickable dependencies", () => {
fireEvent.click(depBadge);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error");
expect(addToast).toHaveBeenCalledWith("Failed to load dependency KB-001", "error");
});
expect(onOpenDetail).not.toHaveBeenCalled();
});
@@ -2295,17 +2295,18 @@ describe("TaskCard GitHub badges", () => {
});
/**
* Tests for task detail opening behavior in TaskCard.
* The card body opens the modal directly; there is no separate expand button.
* Tests for expand button and modal open behavior in TaskCard.
* Ensures that clicking the expand button opens the modal,
* while clicking the card body does not.
*/
describe("TaskCard detail opening", () => {
describe("TaskCard expand button", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("opens modal when clicking the card body", async () => {
it("opens modal when clicking the expand button", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const mockDetail: TaskDetail = {
@@ -2329,8 +2330,11 @@ describe("TaskCard detail opening", () => {
const card = document.querySelector('[data-id="FN-099"]');
expect(card).toBeDefined();
const cardTitle = screen.getByText("Test task");
fireEvent.click(cardTitle);
const expandButton = screen.getByRole("button", { name: /Open task details/i });
expect(expandButton).toBeDefined();
expect(expandButton.classList.contains("card-expand-btn")).toBe(true);
fireEvent.click(expandButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("FN-099");
@@ -2338,24 +2342,10 @@ describe("TaskCard detail opening", () => {
});
});
it("does not render a separate expand button", () => {
const task = makeTask();
render(<TaskCard task={task} onOpenDetail={vi.fn()} addToast={noopToast} />);
expect(screen.queryByRole("button", { name: /Open task details/i })).toBeNull();
});
it("opens modal only once per card click", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const mockDetail: TaskDetail = {
...makeTask({ id: "FN-099" }),
prompt: "",
attachments: [],
};
mockFetch.mockResolvedValueOnce(mockDetail);
it("does NOT open modal when clicking the card body", async () => {
const onOpenDetail = vi.fn();
const task = makeTask();
const task = makeTask({ title: "Test Task Title" });
render(
<TaskCard
@@ -2365,13 +2355,35 @@ describe("TaskCard detail opening", () => {
/>
);
fireEvent.click(screen.getByText("Test task"));
const card = document.querySelector('[data-id="FN-099"]');
expect(card).toBeDefined();
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("FN-099");
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
expect(onOpenDetail).toHaveBeenCalledTimes(1);
});
// Click on the card title (part of card body)
const cardTitle = screen.getByText("Test Task Title");
fireEvent.click(cardTitle);
// Wait for any async operations
await new Promise((resolve) => setTimeout(resolve, 50));
// Modal should NOT have opened
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("expand button has correct accessibility attributes", () => {
const task = makeTask();
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const expandButton = screen.getByRole("button", { name: /Open task details/i });
expect(expandButton).toBeDefined();
expect(expandButton.getAttribute("aria-label")).toBe("Open task details");
expect(expandButton.getAttribute("title")).toBe("Open task details");
});
it("does NOT open modal during vertical scrolling", async () => {
@@ -2472,7 +2484,7 @@ describe("TaskCard detail opening", () => {
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("does not render an expand button in any column", () => {
it("expand button is present in all columns", () => {
const columns: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
for (const column of columns) {
@@ -2486,11 +2498,49 @@ describe("TaskCard detail opening", () => {
/>
);
expect(screen.queryByRole("button", { name: /Open task details/i })).toBeNull();
const expandButton = screen.getByRole("button", { name: /Open task details/i });
expect(expandButton).toBeDefined();
expect(expandButton.classList.contains("card-expand-btn")).toBe(true);
unmount();
}
});
it("expand button stops propagation to prevent double-triggering", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const mockDetail: TaskDetail = {
...makeTask({ id: "FN-099" }),
prompt: "",
attachments: [],
};
mockFetch.mockResolvedValueOnce(mockDetail);
const onOpenDetail = vi.fn();
const task = makeTask();
render(
<TaskCard
task={task}
onOpenDetail={onOpenDetail}
addToast={noopToast}
/>
);
const card = document.querySelector('[data-id="FN-099"]');
expect(card).toBeDefined();
const expandButton = screen.getByRole("button", { name: /Open task details/i });
// Click the expand button - should only trigger once
fireEvent.click(expandButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("FN-099");
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
expect(onOpenDetail).toHaveBeenCalledTimes(1);
});
});
});
/**

View File

@@ -1341,7 +1341,7 @@ describe("TaskDetailModal", () => {
renderWithSearch();
fireEvent.click(screen.getByText("Add Dependency"));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "fn-020" } });
fireEvent.change(input, { target: { value: "kb-020" } });
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(1);
@@ -1465,7 +1465,7 @@ describe("TaskDetailModal", () => {
fireEvent.click(depLink);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to load dependency FN-001", "error");
expect(addToast).toHaveBeenCalledWith("Failed to load dependency KB-001", "error");
});
expect(onOpenDetail).not.toHaveBeenCalled();
});
@@ -1805,7 +1805,7 @@ describe("TaskDetailModal", () => {
await waitFor(() => {
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001");
});
expect(addToast).toHaveBeenCalledWith("Plan approved — FN-001 moved to Todo", "success");
expect(addToast).toHaveBeenCalledWith("Plan approved — KB-001 moved to Todo", "success");
expect(onClose).toHaveBeenCalled();
});
@@ -1846,7 +1846,7 @@ describe("TaskDetailModal", () => {
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001");
});
expect(addToast).toHaveBeenCalledWith(
"Plan rejected — FN-001 returned to Triage for re-specification",
"Plan rejected — KB-001 returned to Triage for re-specification",
"info"
);
expect(onClose).toHaveBeenCalled();
@@ -2013,7 +2013,7 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByText("Duplicate"));
expect(window.confirm).toHaveBeenCalledWith(
"Duplicate FN-001? This will create a new task in Triage with the same description and prompt."
"Duplicate KB-001? This will create a new task in Triage with the same description and prompt."
);
window.confirm = originalConfirm;
@@ -2072,7 +2072,7 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByText("Duplicate"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Duplicated FN-001 → FN-002", "success");
expect(addToast).toHaveBeenCalledWith("Duplicated KB-001 → KB-002", "success");
});
window.confirm = originalConfirm;
@@ -2393,7 +2393,7 @@ describe("TaskDetailModal", () => {
await waitFor(() => {
expect(refineTask).toHaveBeenCalledWith("FN-001", "Need to add more tests");
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-002", "success");
expect(addToast).toHaveBeenCalledWith("Refinement task created: KB-002", "success");
expect(onClose).toHaveBeenCalled();
});
});
@@ -2682,7 +2682,7 @@ describe("TaskDetailModal", () => {
fireEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Updated FN-001", "success");
expect(addToast).toHaveBeenCalledWith("Updated KB-001", "success");
});
// Should exit edit mode

View File

@@ -74,7 +74,7 @@ describe("useAgentLogs", () => {
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001");
expect(MockEventSource.instances).toHaveLength(1);
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
expect(MockEventSource.instances[0].url).toBe("/api/tasks/KB-001/logs/stream");
});
it("appends live SSE entries to historical entries", async () => {

View File

@@ -110,8 +110,8 @@ describe("useMultiAgentLogs", () => {
await waitFor(() => {
// Filter to unique URLs (Strict Mode may create duplicates)
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
expect(urls).toContain("/api/tasks/FN-001/logs/stream");
expect(urls).toContain("/api/tasks/FN-002/logs/stream");
expect(urls).toContain("/api/tasks/KB-001/logs/stream");
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
});
});
@@ -214,7 +214,7 @@ describe("useMultiAgentLogs", () => {
await waitFor(() => {
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
expect(urls).toContain("/api/tasks/FN-002/logs/stream");
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
});
});
@@ -232,7 +232,7 @@ describe("useMultiAgentLogs", () => {
expect(result.current["FN-001"].entries).toHaveLength(2);
});
// Clear only FN-001
// Clear only KB-001
act(() => {
result.current["FN-001"].clear();
});

View File

@@ -809,140 +809,4 @@ describe("useTasks", () => {
expect(result.current.tasks[0].column).toBe("todo");
});
});
describe("visibility change", () => {
let originalVisibilityState: PropertyDescriptor | undefined;
beforeEach(() => {
// Store original descriptor to restore later
originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
});
afterEach(() => {
// Restore original visibilityState property
if (originalVisibilityState) {
Object.defineProperty(document, "visibilityState", originalVisibilityState);
} else {
// If no original descriptor, just delete our mock
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (document as any).visibilityState;
}
});
function setVisibilityState(state: "visible" | "hidden") {
Object.defineProperty(document, "visibilityState", {
value: state,
writable: true,
configurable: true,
});
}
function dispatchVisibilityChange() {
document.dispatchEvent(new Event("visibilitychange"));
}
it("refetches tasks when visibility changes from hidden to visible", async () => {
const initialTask = createMockTask({ id: "FN-001", column: "todo" as Column });
const refreshedTask = createMockTask({
id: "FN-001",
column: "in-progress" as Column,
updatedAt: "2026-01-02T00:00:00Z",
});
mockFetchTasks.mockResolvedValueOnce([initialTask]);
const { result } = renderHook(() => useTasks());
await waitFor(() => {
expect(result.current.tasks).toHaveLength(1);
});
// Reset mock to return refreshed data
mockFetchTasks.mockResolvedValueOnce([refreshedTask]);
// Simulate tab becoming visible
setVisibilityState("hidden");
setVisibilityState("visible");
dispatchVisibilityChange();
await waitFor(() => {
expect(result.current.tasks[0].column).toBe("in-progress");
});
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
});
it("does not refetch when visibility changes to hidden", async () => {
const initialTask = createMockTask({ id: "FN-001" });
mockFetchTasks.mockResolvedValueOnce([initialTask]);
renderHook(() => useTasks());
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
// Simulate tab becoming hidden
setVisibilityState("visible");
setVisibilityState("hidden");
dispatchVisibilityChange();
// Should not trigger another fetch
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
const initialTask = createMockTask({ id: "FN-001" });
mockFetchTasks.mockResolvedValueOnce([initialTask]);
const { result } = renderHook(() => useTasks());
await waitFor(() => {
expect(result.current.tasks).toHaveLength(1);
});
// Wait for 1 second to ensure debounce window has passed from initial fetch
await new Promise((resolve) => setTimeout(resolve, 1100));
// Reset mock to track new calls
mockFetchTasks.mockClear();
// First visibility change should trigger a fetch (1s has passed)
setVisibilityState("hidden");
setVisibilityState("visible");
dispatchVisibilityChange();
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
// Rapid visibility changes immediately after should be debounced
for (let i = 0; i < 5; i++) {
setVisibilityState("hidden");
setVisibilityState("visible");
dispatchVisibilityChange();
}
// Should still only be 1 call (debounced)
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
it("cleans up visibility change listener on unmount", async () => {
mockFetchTasks.mockResolvedValueOnce([]);
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
const { unmount } = renderHook(() => useTasks());
await waitFor(() => {
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
});
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
removeEventListenerSpy.mockRestore();
});
});
});

View File

@@ -24,70 +24,18 @@ function compareTimestamps(a: string | undefined, b: string | undefined): number
return a.localeCompare(b);
}
export interface UseTasksOptions {
/**
* When provided, fetches tasks only for this project.
* Note: SSE updates are not filtered by project in current implementation.
*/
projectId?: string;
}
export function useTasks(options?: UseTasksOptions) {
const projectId = options?.projectId;
export function useTasks() {
const [tasks, setTasks] = useState<Task[]>([]);
const [connectionNonce, setConnectionNonce] = useState(0);
const tasksRef = useRef(tasks);
tasksRef.current = tasks;
// Ref to track last visibility fetch time for debouncing (1 second minimum)
const lastVisibilityFetchRef = useRef<number>(0);
const VISIBILITY_FETCH_DEBOUNCE_MS = 1000;
// Determine which fetch function to use
const fetchTasksFn = useCallback(() => {
if (projectId) {
return api.fetchProjectTasks(projectId);
}
return api.fetchTasks();
}, [projectId]);
// Fetch initial tasks
useEffect(() => {
fetchTasksFn()
.then((tasks) => setTasks(tasks.map(normalizeTask)))
.catch(() => setTasks([]));
}, [fetchTasksFn]);
// Visibility change listener - refresh tasks when tab becomes visible
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
const now = Date.now();
const timeSinceLastFetch = now - lastVisibilityFetchRef.current;
// Debounce: only fetch if at least 1 second has passed since last visibility fetch
if (timeSinceLastFetch >= VISIBILITY_FETCH_DEBOUNCE_MS) {
lastVisibilityFetchRef.current = now;
fetchTasksFn()
.then((tasks) => setTasks(tasks.map(normalizeTask)))
.catch(() => {
// Silently ignore fetch errors on visibility change
});
}
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [fetchTasksFn]);
api.fetchTasks().then((tasks) => setTasks(tasks.map(normalizeTask))).catch(() => setTasks([]));
}, []);
// SSE live updates
// Note: In multi-project mode, SSE receives all task events.
// Tasks are filtered by ID match, so cross-project updates won't affect
// the local state since task IDs are unique and we only fetch from one project.
useEffect(() => {
let closedByCleanup = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -95,17 +43,12 @@ export function useTasks(options?: UseTasksOptions) {
const handleCreated = (e: MessageEvent) => {
const task = normalizeTask(JSON.parse(e.data) as Task);
// In project mode, only add if this task belongs to our project
// Since we can't determine project from event, we add and let subsequent
// fetches correct the state, or filter by checking if task exists in our set
setTasks((prev) => {
// Avoid duplicates
if (prev.some((t) => t.id === task.id)) return prev;
return [...prev, task];
});
setTasks((prev) => [...prev, task]);
};
const handleMoved = (e: MessageEvent) => {
// Payload: { task, from, to } - task object includes server-set columnMovedAt
// We use 'to' as the authoritative column and trust the server's columnMovedAt
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
const normalizedTask = normalizeTask(task);
setTasks((prev) =>
@@ -121,24 +64,35 @@ export function useTasks(options?: UseTasksOptions) {
prev.map((t) => {
if (t.id !== incoming.id) return t;
// First check overall freshness using updatedAt
const updatedAtCompare = compareTimestamps(incoming.updatedAt, t.updatedAt);
// If incoming is older overall, skip the update
if (updatedAtCompare < 0) {
return t;
}
// If columns are the same, no conflict - accept the incoming update
if (t.column === incoming.column) {
return incoming;
}
// Columns differ - need to check columnMovedAt to resolve conflict
const columnTimestampCompare = compareTimestamps(t.columnMovedAt, incoming.columnMovedAt);
// Edge case: current has columnMovedAt but incoming doesn't (legacy data)
// Preserve the column information we have
if (t.columnMovedAt && !incoming.columnMovedAt) {
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
}
// If current state has a newer columnMovedAt, reject the column change
if (columnTimestampCompare > 0) {
// Current state is newer - preserve column, merge other fields
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
}
// Incoming has newer or equal columnMovedAt, accept the update
return incoming;
})
);
@@ -150,10 +104,13 @@ export function useTasks(options?: UseTasksOptions) {
};
const handleMerged = (e: MessageEvent) => {
// Payload: { task, branch, merged, worktreeRemoved, branchDeleted, ... }
// The task object has already been moved to 'done' by the server
const { task }: { task: Task } = JSON.parse(e.data);
const normalizedTask = normalizeTask(task);
setTasks((prev) =>
prev.map((t) =>
// Ensure column is 'done' since that's where merged tasks always go
t.id === normalizedTask.id ? { ...normalizedTask, column: "done" as Column } : t
)
);
@@ -224,6 +181,7 @@ export function useTasks(options?: UseTasksOptions) {
id: string,
updates: { title?: string; description?: string; dependencies?: string[] }
): Promise<Task> => {
// Optimistic update: apply changes immediately
const previousTask = tasksRef.current.find((t) => t.id === id);
const optimisticTask = previousTask
? { ...previousTask, ...updates, updatedAt: new Date().toISOString() }
@@ -237,11 +195,13 @@ export function useTasks(options?: UseTasksOptions) {
try {
const updatedTask = normalizeTask(await api.updateTask(id, updates));
// Replace with server response
setTasks((prev) =>
prev.map((t) => (t.id === id ? updatedTask : t))
);
return updatedTask;
} catch (err) {
// Rollback on error: restore previous state
if (previousTask) {
setTasks((prev) =>
prev.map((t) => (t.id === id ? previousTask : t))
@@ -270,6 +230,7 @@ export function useTasks(options?: UseTasksOptions) {
const archiveAllDone = useCallback(async (): Promise<Task[]> => {
const archived = await api.archiveAllDone();
const normalized = archived.map(normalizeTask);
// Update local state by mapping over tasks and updating archived ones
setTasks((prev) =>
prev.map((t) => {
const updated = normalized.find((archived) => archived.id === t.id);

View File

@@ -11,7 +11,7 @@
try {
var mode = localStorage.getItem('kb-dashboard-theme-mode') || 'dark';
var colorTheme = localStorage.getItem('kb-dashboard-color-theme') || 'default';
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'high-contrast', 'industrial', 'monochrome', 'solarized', 'factory', 'ayu', 'one-dark'];
var validThemes = ['default', 'ocean', 'forest', 'sunset', 'zen', 'berry', 'monochrome', 'high-contrast', 'solarized', 'factory', 'ayu', 'one-dark'];
if (!validThemes.includes(colorTheme)) {
colorTheme = 'default';
}

File diff suppressed because it is too large Load Diff

View File

@@ -25,8 +25,6 @@ import type {
FeatureCreateInput,
MissionStatus,
MilestoneStatus,
SliceStatus,
FeatureStatus,
InterviewState,
} from "@fusion/core";
import {
@@ -37,43 +35,26 @@ import {
INTERVIEW_STATES,
} from "@fusion/core";
// ── Param Utilities ────────────────────────────────────────────────────────
/** Extract a route param as string (Express 5 params can be string | string[]) */
function param(req: Request, name: string): string {
const val = req.params[name];
return Array.isArray(val) ? val[0] : val;
}
// ── Validation Utilities ────────────────────────────────────────────────────
function validateUuid(id: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
}
function validateMissionId(id: string | string[]): boolean {
const str = Array.isArray(id) ? id[0] : id;
return /^M-\d+$/.test(str);
function validateMissionId(id: string): boolean {
return /^M-\d+$/.test(id);
}
function validateMilestoneId(id: string | string[]): boolean {
const str = Array.isArray(id) ? id[0] : id;
return /^MS-\d+$/.test(str);
function validateMilestoneId(id: string): boolean {
return /^MS-\d+$/.test(id);
}
function validateSliceId(id: string | string[]): boolean {
const str = Array.isArray(id) ? id[0] : id;
return /^SL-\d+$/.test(str);
function validateSliceId(id: string): boolean {
return /^SL-\d+$/.test(id);
}
function validateFeatureId(id: string | string[]): boolean {
const str = Array.isArray(id) ? id[0] : id;
return /^F-\d+$/.test(str);
}
/** Helper to extract string from Express param (handles string | string[]) */
function paramString(value: string | string[]): string {
return Array.isArray(value) ? value[0] : value;
function validateFeatureId(id: string): boolean {
return /^F-\d+$/.test(id);
}
function validateTitle(title: unknown): string {
@@ -136,11 +117,9 @@ function validateOrderedIds(body: unknown): string[] {
// ── Async Handler Wrapper ───────────────────────────────────────────────────
type TypedRequest = Request<Record<string, string>>;
function asyncHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise<void>) {
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req as TypedRequest, res, next)).catch(next);
Promise.resolve(fn(req, res, next)).catch(next);
};
}
@@ -195,7 +174,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId",
asyncHandler(async (req, res) => {
const missionId = param(req, "missionId");
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -219,7 +198,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/:missionId",
asyncHandler(async (req, res) => {
const missionId = param(req, "missionId");
const { missionId } = req.params;
const { title, description, status } = req.body;
if (!validateMissionId(missionId)) {
@@ -264,7 +243,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/:missionId",
asyncHandler(async (req, res) => {
const missionId = param(req, "missionId");
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -289,7 +268,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId/status",
asyncHandler(async (req, res) => {
const missionId = param(req, "missionId");
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -316,7 +295,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId/interview-state",
asyncHandler(async (req, res) => {
const missionId = param(req, "missionId");
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -340,7 +319,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/:missionId/interview-state",
asyncHandler(async (req, res) => {
const missionId = param(req, "missionId");
const { missionId } = req.params;
const { state } = req.body;
if (!validateMissionId(missionId)) {
@@ -372,7 +351,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/:missionId/milestones",
asyncHandler(async (req, res) => {
const missionId = param(req, "missionId");
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -399,7 +378,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/:missionId/milestones",
asyncHandler(async (req, res) => {
const missionId = param(req, "missionId");
const { missionId } = req.params;
const { title, description, dependencies } = req.body;
if (!validateMissionId(missionId)) {
@@ -435,7 +414,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/:missionId/milestones/reorder",
asyncHandler(async (req, res) => {
const missionId = param(req, "missionId");
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -477,7 +456,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/milestones/:milestoneId",
asyncHandler(async (req, res) => {
const milestoneId = param(req, "milestoneId");
const { milestoneId } = req.params;
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -501,7 +480,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/milestones/:milestoneId",
asyncHandler(async (req, res) => {
const milestoneId = param(req, "milestoneId");
const { milestoneId } = req.params;
const { title, description, status, dependencies } = req.body;
if (!validateMilestoneId(milestoneId)) {
@@ -549,7 +528,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/milestones/:milestoneId",
asyncHandler(async (req, res) => {
const milestoneId = param(req, "milestoneId");
const { milestoneId } = req.params;
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -576,7 +555,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/milestones/:milestoneId/interview-state",
asyncHandler(async (req, res) => {
const milestoneId = param(req, "milestoneId");
const { milestoneId } = req.params;
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -600,7 +579,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/milestones/:milestoneId/interview-state",
asyncHandler(async (req, res) => {
const milestoneId = param(req, "milestoneId");
const { milestoneId } = req.params;
const { state } = req.body;
if (!validateMilestoneId(milestoneId)) {
@@ -632,7 +611,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/milestones/:milestoneId/slices",
asyncHandler(async (req, res) => {
const milestoneId = param(req, "milestoneId");
const { milestoneId } = req.params;
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -659,7 +638,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/milestones/:milestoneId/slices",
asyncHandler(async (req, res) => {
const milestoneId = param(req, "milestoneId");
const { milestoneId } = req.params;
const { title, description } = req.body;
if (!validateMilestoneId(milestoneId)) {
@@ -693,7 +672,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/milestones/:milestoneId/slices/reorder",
asyncHandler(async (req, res) => {
const milestoneId = param(req, "milestoneId");
const { milestoneId } = req.params;
if (!validateMilestoneId(milestoneId)) {
res.status(400).json({ error: "Invalid milestone ID format" });
@@ -735,7 +714,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/slices/:sliceId",
asyncHandler(async (req, res) => {
const sliceId = param(req, "sliceId");
const { sliceId } = req.params;
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -759,7 +738,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/slices/:sliceId",
asyncHandler(async (req, res) => {
const sliceId = param(req, "sliceId");
const { sliceId } = req.params;
const { title, description, status } = req.body;
if (!validateSliceId(sliceId)) {
@@ -804,7 +783,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/slices/:sliceId",
asyncHandler(async (req, res) => {
const sliceId = param(req, "sliceId");
const { sliceId } = req.params;
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -829,7 +808,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/slices/:sliceId/activate",
asyncHandler(async (req, res) => {
const sliceId = param(req, "sliceId");
const { sliceId } = req.params;
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -858,7 +837,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/slices/:sliceId/features",
asyncHandler(async (req, res) => {
const sliceId = param(req, "sliceId");
const { sliceId } = req.params;
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
@@ -883,7 +862,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/slices/:sliceId/features",
asyncHandler(async (req, res) => {
const sliceId = param(req, "sliceId");
const { sliceId } = req.params;
const { title, description, acceptanceCriteria } = req.body;
if (!validateSliceId(sliceId)) {
@@ -919,7 +898,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.get(
"/features/:featureId",
asyncHandler(async (req, res) => {
const featureId = param(req, "featureId");
const { featureId } = req.params;
if (!validateFeatureId(featureId)) {
res.status(400).json({ error: "Invalid feature ID format" });
@@ -943,7 +922,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.patch(
"/features/:featureId",
asyncHandler(async (req, res) => {
const featureId = param(req, "featureId");
const { featureId } = req.params;
const { title, description, acceptanceCriteria, status } = req.body;
if (!validateFeatureId(featureId)) {
@@ -991,7 +970,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.delete(
"/features/:featureId",
asyncHandler(async (req, res) => {
const featureId = param(req, "featureId");
const { featureId } = req.params;
if (!validateFeatureId(featureId)) {
res.status(400).json({ error: "Invalid feature ID format" });
@@ -1016,7 +995,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/features/:featureId/link-task",
asyncHandler(async (req, res) => {
const featureId = param(req, "featureId");
const { featureId } = req.params;
const { taskId } = req.body;
if (!validateFeatureId(featureId)) {
@@ -1055,7 +1034,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/features/:featureId/unlink-task",
asyncHandler(async (req, res) => {
const featureId = param(req, "featureId");
const { featureId } = req.params;
if (!validateFeatureId(featureId)) {
res.status(400).json({ error: "Invalid feature ID format" });

View File

@@ -1,13 +1,6 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import http from "node:http";
import { EventEmitter } from "node:events";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { createApiRoutes } from "./routes.js";
import { GitHubClient } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -17,7 +10,6 @@ import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { __resetPlanningState } from "./planning.js";
import { __resetSubtaskBreakdownState } from "./subtask-breakdown.js";
import * as terminalServiceModule from "./terminal-service.js";
import { get as performGet, request as performRequest } from "./test-request.js";
// Mock @fusion/core for gh CLI auth checks
vi.mock("@fusion/core", async () => {
@@ -71,7 +63,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addComment: vi.fn(),
addSteeringComment: vi.fn(),
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
@@ -101,11 +93,28 @@ const FAKE_TASK_DETAIL: TaskDetail = {
prompt: "# KB-001\n\nTest task",
};
/** Helper: send GET and return { status, body } */
async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> {
const res = await performGet(app, path);
return { status: res.status, body: res.body };
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const addr = server.address() as { port: number };
http.get(`http://127.0.0.1:${addr.port}${path}`, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
server.close();
try {
resolve({ status: res.statusCode!, body: JSON.parse(data) });
} catch {
resolve({ status: res.statusCode!, body: data });
}
});
}).on("error", (err) => { server.close(); reject(err); });
});
});
}
/** Helper: send a request with method/body and return { status, body } */
async function REQUEST(
app: express.Express,
method: string,
@@ -113,8 +122,30 @@ async function REQUEST(
body?: Buffer | string,
headers?: Record<string, string>,
): Promise<{ status: number; body: any }> {
const res = await performRequest(app, method, path, body, headers);
return { status: res.status, body: res.body };
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const addr = server.address() as { port: number };
const url = new URL(`http://127.0.0.1:${addr.port}${path}`);
const req = http.request(
{ hostname: url.hostname, port: url.port, path: url.pathname, method, headers },
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
server.close();
try {
resolve({ status: res.statusCode!, body: JSON.parse(data) });
} catch {
resolve({ status: res.statusCode!, body: data });
}
});
},
);
req.on("error", (err) => { server.close(); reject(err); });
if (body) req.write(body);
req.end();
});
});
}
/** Build a minimal multipart/form-data body */
@@ -238,19 +269,13 @@ describe("POST /tasks", () => {
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: undefined,
description: "Big initiative",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: true,
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
expect(store.createTask).toHaveBeenCalledWith({
title: undefined,
description: "Big initiative",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: true,
});
});
it("forwards model overrides when both provider and id are supplied", async () => {
@@ -279,23 +304,17 @@ describe("POST /tasks", () => {
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: undefined,
description: "Use explicit models",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
expect(store.createTask).toHaveBeenCalledWith({
title: undefined,
description: "Use explicit models",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
});
it("normalizes partial model overrides back to defaults", async () => {
@@ -318,23 +337,17 @@ describe("POST /tasks", () => {
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: undefined,
description: "Ignore partial model selection",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: undefined,
modelId: undefined,
validatorModelProvider: undefined,
validatorModelId: undefined,
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
expect(store.createTask).toHaveBeenCalledWith({
title: undefined,
description: "Ignore partial model selection",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: undefined,
modelId: undefined,
validatorModelProvider: undefined,
validatorModelId: undefined,
});
});
it("returns 400 when model fields are not strings", async () => {
@@ -595,8 +608,8 @@ describe("POST /tasks/:id/retry", () => {
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
});
it("returns 400 when task is not in failed state", async () => {
@@ -623,8 +636,8 @@ describe("POST /tasks/:id/retry", () => {
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
});
});
@@ -655,7 +668,7 @@ describe("POST /tasks/:id/duplicate", () => {
expect(res.status).toBe(201);
expect(res.body.id).toBe("FN-002");
expect(res.body.column).toBe("triage");
expect(store.duplicateTask).toHaveBeenCalledWith("KB-001");
expect(store.duplicateTask).toHaveBeenCalledWith("FN-001");
});
it("returns 404 when source task not found", async () => {
@@ -712,8 +725,8 @@ describe("POST /tasks/:id/refine", () => {
expect(res.status).toBe(201);
expect(res.body.id).toBe("FN-002");
expect(res.body.column).toBe("triage");
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Need improvements");
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Refinement requested", "Need improvements");
expect(store.refineTask).toHaveBeenCalledWith("FN-001", "Need improvements");
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Refinement requested", "Need improvements");
});
it("creates refinement task from in-review task and returns 201", async () => {
@@ -727,7 +740,7 @@ describe("POST /tasks/:id/refine", () => {
expect(res.status).toBe(201);
expect(res.body.column).toBe("triage");
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Fix edge cases");
expect(store.refineTask).toHaveBeenCalledWith("FN-001", "Fix edge cases");
});
it("returns 400 when task is not in done or in-review column", async () => {
@@ -834,7 +847,7 @@ describe("POST /tasks/:id/archive", () => {
expect(res.status).toBe(200);
expect(res.body.column).toBe("archived");
expect(store.archiveTask).toHaveBeenCalledWith("KB-001");
expect(store.archiveTask).toHaveBeenCalledWith("FN-001");
});
it("returns 400 when task is not in done column", async () => {
@@ -886,7 +899,7 @@ describe("POST /tasks/:id/unarchive", () => {
expect(res.status).toBe(200);
expect(res.body.column).toBe("done");
expect(store.unarchiveTask).toHaveBeenCalledWith("KB-001");
expect(store.unarchiveTask).toHaveBeenCalledWith("FN-001");
});
it("returns 400 when task is not in archived column", async () => {
@@ -1260,7 +1273,7 @@ describe("PATCH /tasks/:id", () => {
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: undefined,
description: undefined,
prompt: undefined,
@@ -1281,7 +1294,7 @@ describe("PATCH /tasks/:id", () => {
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: "New",
description: undefined,
prompt: undefined,
@@ -1312,7 +1325,7 @@ describe("PATCH /tasks/:id", () => {
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: undefined,
description: undefined,
prompt: undefined,
@@ -1361,7 +1374,7 @@ describe("PATCH /tasks/:id", () => {
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: undefined,
description: undefined,
prompt: undefined,
@@ -1411,7 +1424,7 @@ describe("Attachment routes", () => {
expect(res.status).toBe(201);
expect(res.body.filename).toBe("1234-screenshot.png");
expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"KB-001",
"FN-001",
"screenshot.png",
expect.any(Buffer),
"image/png",
@@ -1454,7 +1467,7 @@ describe("Attachment routes", () => {
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001/attachments/1234-screenshot.png");
expect(res.status).toBe(200);
expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("KB-001", "1234-screenshot.png");
expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("FN-001", "1234-screenshot.png");
});
it("DELETE /tasks/:id/attachments/:filename — returns 404 for missing", async () => {
@@ -1478,7 +1491,7 @@ describe("Attachment routes", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual(fakeLogs);
expect(store.getAgentLogs).toHaveBeenCalledWith("KB-001");
expect(store.getAgentLogs).toHaveBeenCalledWith("FN-001");
});
it("GET /tasks/:id/logs — returns empty array when no logs", async () => {
@@ -1763,14 +1776,14 @@ describe("Pause/Unpause endpoints", () => {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/pause");
expect(res.status).toBe(200);
expect(res.body).toEqual({ id: "FN-001", paused: true });
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", true);
expect(store.pauseTask).toHaveBeenCalledWith("FN-001", true);
});
it("POST /tasks/:id/unpause — unpauses a task", async () => {
(store.pauseTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001" });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unpause");
expect(res.status).toBe(200);
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", false);
expect(store.pauseTask).toHaveBeenCalledWith("FN-001", false);
});
it("POST /tasks/:id/pause — returns 500 on error", async () => {
@@ -1807,7 +1820,7 @@ describe("Pause/Unpause endpoints", () => {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "user");
expect(store.addTaskComment).toHaveBeenCalledWith("FN-001", "Hello", "user");
});
it("PATCH /tasks/:id/comments/:commentId — updates a task comment", async () => {
@@ -1821,7 +1834,7 @@ describe("Pause/Unpause endpoints", () => {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTaskComment).toHaveBeenCalledWith("KB-001", "c1", "Updated");
expect(store.updateTaskComment).toHaveBeenCalledWith("FN-001", "c1", "Updated");
});
it("DELETE /tasks/:id/comments/:commentId — deletes a task comment", async () => {
@@ -1833,7 +1846,7 @@ describe("Pause/Unpause endpoints", () => {
const res = await REQUEST(app, "DELETE", "/api/tasks/KB-001/comments/c1");
expect(res.status).toBe(200);
expect(store.deleteTaskComment).toHaveBeenCalledWith("KB-001", "c1");
expect(store.deleteTaskComment).toHaveBeenCalledWith("FN-001", "c1");
});
});
@@ -1850,7 +1863,7 @@ describe("Pause/Unpause endpoints", () => {
},
],
};
(store.addComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
const res = await REQUEST(
buildApp(),
@@ -1862,8 +1875,8 @@ describe("Pause/Unpause endpoints", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual(mockComment);
expect(store.addComment).toHaveBeenCalledWith(
"KB-001",
expect(store.addSteeringComment).toHaveBeenCalledWith(
"FN-001",
"Please handle the edge case",
"user"
);
@@ -1909,7 +1922,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.addComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
const res = await REQUEST(
buildApp(),
@@ -1923,7 +1936,7 @@ describe("Pause/Unpause endpoints", () => {
});
it("returns 500 on unexpected errors", async () => {
(store.addComment as ReturnType<typeof vi.fn>).mockRejectedValue(
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Database error")
);
@@ -3160,10 +3173,19 @@ describe("POST /github/issues/batch-import", () => {
});
it("handles rate limit (429) with retry and eventual success", async () => {
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({
success: true,
data: mockGitHubIssue(1, "Issue After Rate Limit"),
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
fetchSpy
.mockResolvedValueOnce({
ok: false,
status: 429,
statusText: "Too Many Requests",
headers: new Headers({ "Retry-After": "1" }),
json: () => Promise.resolve({ message: "Rate limited" }),
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue(1, "Issue After Rate Limit")),
} as Response);
const res = await REQUEST(
buildApp(),
@@ -3177,7 +3199,7 @@ describe("POST /github/issues/batch-import", () => {
expect(res.body.results).toHaveLength(1);
expect(res.body.results[0].success).toBe(true);
expect(res.body.results[0].taskId).toBeDefined();
expect(throttledSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledTimes(2); // Initial 429 + 1 retry
}, 10000); // Increase timeout for retry delay
it("returns error after max retries exceeded on 429", async () => {
@@ -3203,7 +3225,8 @@ describe("POST /github/issues/batch-import", () => {
expect(res.body.results[0].success).toBe(false);
expect(res.body.results[0].error).toContain("rate limit");
expect(res.body.results[0].retryAfter).toBe(1);
expect(fetchSpy.mock.calls.length).toBeGreaterThanOrEqual(4);
// Initial attempt + 3 retries = 4 calls
expect(fetchSpy).toHaveBeenCalledTimes(4);
}, 15000); // Increase timeout for multiple retries
it("processes issues sequentially (not parallel)", async () => {
@@ -3651,126 +3674,16 @@ describe("POST /tasks/:id/reject-plan", () => {
// --- Git Management route tests ---
// These are integration tests that run against the actual git repository
describe("GET /tasks/:id/file-diffs", () => {
let store: TaskStore;
let worktreeDir: string;
let testRoot: string;
beforeEach(() => {
testRoot = mkdtempSync(join(tmpdir(), "kb-dashboard-file-diffs-"));
worktreeDir = join(testRoot, "repo");
mkdirSync(worktreeDir, { recursive: true });
execFileSync("git", ["init", "-b", "main", worktreeDir]);
execFileSync("git", ["-C", worktreeDir, "config", "user.email", "kb-tests@example.com"]);
execFileSync("git", ["-C", worktreeDir, "config", "user.name", "KB Tests"]);
writeFileSync(join(worktreeDir, "README.md"), "base\n");
writeFileSync(join(worktreeDir, "keep.txt"), "keep\n");
execFileSync("git", ["-C", worktreeDir, "add", "."]);
execFileSync("git", ["-C", worktreeDir, "commit", "-m", "base"]);
store = createMockStore({
getTask: vi.fn().mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-651",
worktree: worktreeDir,
baseBranch: "main",
}),
});
});
afterEach(() => {
rmSync(testRoot, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns changed files with statuses and diffs", async () => {
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged\n");
writeFileSync(join(worktreeDir, "added.txt"), "new file\n");
execFileSync("git", ["-C", worktreeDir, "mv", "keep.txt", "renamed.txt"]);
execFileSync("git", ["-C", worktreeDir, "rm", "renamed.txt"]);
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "README.md", status: "modified", diff: expect.stringContaining("+changed") }),
expect.objectContaining({ path: "added.txt", status: "added", diff: expect.stringContaining("+++ b/added.txt") }),
expect.objectContaining({ path: "keep.txt", status: "deleted", diff: expect.stringContaining("--- a/keep.txt") }),
]),
);
});
it("returns renamed files with oldPath", async () => {
execFileSync("git", ["-C", worktreeDir, "mv", "keep.txt", "renamed.txt"]);
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual([
expect.objectContaining({ path: "renamed.txt", oldPath: "keep.txt", status: "renamed" }),
]);
});
it("returns empty array when worktree is missing", async () => {
store = createMockStore({
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "KB-651", worktree: join(testRoot, "missing"), baseBranch: "main" }),
});
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it("returns empty array when there are no changes", async () => {
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
describe("Git Management endpoints", () => {
let store: TaskStore;
let gitRepoDir: string;
let gitTestRoot: string;
beforeAll(() => {
gitTestRoot = mkdtempSync(join(tmpdir(), "kb-dashboard-git-"));
const remoteDir = join(gitTestRoot, "remote.git");
gitRepoDir = join(gitTestRoot, "repo");
mkdirSync(gitRepoDir, { recursive: true });
execFileSync("git", ["init", "--bare", remoteDir]);
execFileSync("git", ["init", gitRepoDir]);
execFileSync("git", ["-C", gitRepoDir, "config", "user.email", "kb-tests@example.com"]);
execFileSync("git", ["-C", gitRepoDir, "config", "user.name", "KB Tests"]);
writeFileSync(join(gitRepoDir, "README.md"), "# Test Repo\n");
execFileSync("git", ["-C", gitRepoDir, "add", "README.md"]);
execFileSync("git", ["-C", gitRepoDir, "commit", "-m", "Initial commit"]);
execFileSync("git", ["-C", gitRepoDir, "remote", "add", "origin", remoteDir]);
execFileSync("git", ["-C", gitRepoDir, "push", "-u", "origin", "HEAD"]);
});
beforeEach(() => {
// Use the actual project root so git commands work
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(gitRepoDir),
getRootDir: vi.fn().mockReturnValue(process.cwd()),
});
});
afterAll(() => {
if (gitTestRoot) {
rmSync(gitTestRoot, { recursive: true, force: true });
}
});
function buildApp() {
const app = express();
app.use(express.json());
@@ -4817,13 +4730,9 @@ describe("Terminal session routes", () => {
});
describe("POST /api/terminal/sessions", () => {
it("returns 503 when max sessions reached", async () => {
it("returns 503 when max sessions reached (session is null)", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: false,
code: "max_sessions",
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
}),
createSession: vi.fn().mockResolvedValue(null),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
@@ -4836,102 +4745,7 @@ describe("Terminal session routes", () => {
);
expect(res.status).toBe(503);
expect(res.body.error).toBe("Maximum terminal sessions reached. Please close an existing terminal and try again.");
vi.restoreAllMocks();
});
it("returns 400 when shell is not allowed", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: false,
code: "invalid_shell",
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toBe("Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).");
vi.restoreAllMocks();
});
it("returns 503 when PTY module fails to load", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: false,
code: "pty_load_failed",
error: "Terminal service unavailable. The PTY module could not be loaded.",
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(503);
expect(res.body.error).toBe("Terminal service unavailable. The PTY module could not be loaded.");
vi.restoreAllMocks();
});
it("returns 500 when PTY spawn fails", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: false,
code: "pty_spawn_failed",
error: "Failed to start terminal shell process.",
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(500);
expect(res.body.error).toBe("Failed to start terminal shell process.");
vi.restoreAllMocks();
});
it("returns 201 when session creation succeeds", async () => {
const mockService = {
createSession: vi.fn().mockResolvedValue({
success: true,
session: { id: "term-123", shell: "/bin/zsh", cwd: "/test" },
}),
};
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
const res = await REQUEST(
buildApp(),
"POST",
"/api/terminal/sessions",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
expect(res.body).toEqual({ sessionId: "term-123", shell: "/bin/zsh", cwd: "/test" });
expect(res.body.error).toContain("Max sessions");
vi.restoreAllMocks();
});
@@ -4973,25 +4787,39 @@ describe("Terminal WebSocket close handler", () => {
const server = http.createServer(app);
setupTerminalWebSocket(app, server);
class FakeWebSocket extends EventEmitter {
send = vi.fn();
close = vi.fn(() => this.emit("close"));
terminate = vi.fn();
}
const ws = new FakeWebSocket();
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer;
expect(wss).toBeTruthy();
await new Promise<void>((resolve, reject) => {
server.listen(0, () => {
const addr = server.address() as { port: number };
const { WebSocket: WsClient } = require("ws");
const ws = new WsClient(`ws://127.0.0.1:${addr.port}/api/terminal/ws?sessionId=term-ws-test`);
wss!.emit("connection", ws, {
url: "/api/terminal/ws?sessionId=term-ws-test",
headers: { host: "127.0.0.1" },
ws.on("open", () => {
// Close the WebSocket - this should trigger killSession
ws.close();
});
ws.on("close", () => {
// Give the close handler time to execute
setTimeout(() => {
try {
expect(killSessionMock).toHaveBeenCalledWith("term-ws-test");
server.close();
resolve();
} catch (err) {
server.close();
reject(err);
}
}, 50);
});
ws.on("error", (err: Error) => {
server.close();
reject(err);
});
});
});
ws.close();
expect(killSessionMock).toHaveBeenCalledWith("term-ws-test");
vi.restoreAllMocks();
});
@@ -5024,25 +4852,32 @@ describe("Terminal WebSocket close handler", () => {
const server = http.createServer(app);
setupTerminalWebSocket(app, server);
class FakeWebSocket extends EventEmitter {
send = vi.fn();
close = vi.fn(() => this.emit("close"));
terminate = vi.fn();
}
const ws = new FakeWebSocket();
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer;
expect(wss).toBeTruthy();
await new Promise<void>((resolve, reject) => {
server.listen(0, () => {
const addr = server.address() as { port: number };
const { WebSocket: WsClient } = require("ws");
const ws = new WsClient(`ws://127.0.0.1:${addr.port}/api/terminal/ws?sessionId=term-ws-err`);
wss!.emit("connection", ws, {
url: "/api/terminal/ws?sessionId=term-ws-err",
headers: { host: "127.0.0.1" },
ws.on("open", () => {
// Force-terminate the connection to trigger error/close
ws.terminate();
});
// After termination, give the handler time to run
setTimeout(() => {
try {
expect(killSessionMock).toHaveBeenCalledWith("term-ws-err");
server.close();
resolve();
} catch (err) {
server.close();
reject(err);
}
}, 200);
});
});
ws.emit("error", new Error("synthetic websocket failure"));
expect(killSessionMock).toHaveBeenCalledWith("term-ws-err");
vi.restoreAllMocks();
});
});

View File

@@ -1062,13 +1062,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
console.debug("[planning:routes:registered]", planningRoutes);
}
const sessionFilesCache = new Map<string, { files: string[]; expiresAt: number }>();
const taskFileDiffsCache = new Map<
string,
{
files: Array<{ path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string }>;
expiresAt: number;
}
>();
// Get GitHub token from options or env
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
@@ -1848,190 +1841,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
router.get("/tasks/:id/file-diffs", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
if (!task.worktree || !existsSync(task.worktree)) {
res.json([]);
return;
}
const cached = taskFileDiffsCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) {
res.json(cached.files);
return;
}
const baseBranch = task.baseBranch ?? "main";
type TaskFileDiff = { path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string };
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];
if (statusCode === "R") {
const oldPath = parts[1];
const path = parts[2];
return {
path,
oldPath,
status: "renamed" as const,
diff: "",
};
}
const path = parts[1];
return {
path,
status:
statusCode === "A"
? ("added" as const)
: statusCode === "D"
? ("deleted" as const)
: ("modified" as const),
diff: "",
};
})
.filter((entry): entry is TaskFileDiff => Boolean(entry.path));
};
try {
const output = execSync(`git diff --name-status ${baseBranch}...HEAD`, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
files = output ? parseNameStatus(output) : [];
} catch {
const fallback = execSync("git diff --name-status HEAD", {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
files = fallback ? parseNameStatus(fallback) : [];
}
if (files.length === 0) {
taskFileDiffsCache.set(task.id, {
files: [],
expiresAt: Date.now() + 10000,
});
res.json([]);
return;
}
const filesWithDiffs = files.map((file) => {
try {
const diff = execSync(`git diff ${baseBranch}...HEAD -- "${file.path.replace(/"/g, '\\"')}"`, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 10000,
});
return { ...file, diff };
} catch {
return file;
}
});
taskFileDiffsCache.set(task.id, {
files: filesWithDiffs,
expiresAt: Date.now() + 10000,
});
res.json(filesWithDiffs);
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* GET /api/tasks/:id/diff
* Get detailed diff information for files modified during task execution.
* Returns: { files: string[]; diffs: Record<string, { stat: string; patch: string }> }
*/
router.get("/tasks/:id/diff", async (req, res) => {
try {
const task = await store.getTask(req.params.id);
// Only tasks with worktrees can have diffs
if (!task.worktree || !existsSync(task.worktree)) {
res.json({ files: [], diffs: {} });
return;
}
// Use stored modifiedFiles if available, otherwise compute on-the-fly
let files = task.modifiedFiles;
if (!files || files.length === 0) {
// Fallback: compute files using git diff
try {
const baseRef = task.baseCommitSha ?? "HEAD~1";
const output = execSync(`git diff --name-only ${baseRef}..HEAD`, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
files = output ? output.split("\n").filter(Boolean) : [];
} catch {
files = [];
}
}
if (files.length === 0) {
res.json({ files: [], diffs: {} });
return;
}
// Compute diffs for each file
const diffs: Record<string, { stat: string; patch: string }> = {};
const baseRef = task.baseCommitSha ?? "HEAD~1";
for (const file of files) {
try {
// Get stat for this file
const stat = execSync(`git diff --stat ${baseRef}..HEAD -- "${file}"`, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
// Get patch for this file
const patch = execSync(`git diff ${baseRef}..HEAD -- "${file}"`, {
cwd: task.worktree,
encoding: "utf-8",
timeout: 10000,
});
diffs[file] = { stat, patch };
} catch (err: any) {
// Log error but continue with other files
console.warn(`Failed to get diff for ${file}:`, err.message);
diffs[file] = { stat: "", patch: "" };
}
}
res.json({ files, diffs });
} catch (err: any) {
if (err.code === "ENOENT") {
res.status(404).json({ error: `Task ${req.params.id} not found` });
} else {
res.status(500).json({ error: err.message || "Internal server error" });
}
}
});
/**
* GET /api/tasks/:id/workflow-results
* Get workflow step execution results for a task.
@@ -2229,7 +2038,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.addComment(req.params.id, text, "user");
const task = await store.addSteeringComment(req.params.id, text, "user");
res.json(task);
} catch (err: any) {
const status = err.code === "ENOENT" ? 404 : 500;
@@ -4336,28 +4145,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const { cwd, cols, rows } = req.body;
const terminalService = getTerminalService(store.getRootDir());
const result = await terminalService.createSession({
const session = await terminalService.createSession({
cwd,
cols: typeof cols === "number" ? cols : undefined,
rows: typeof rows === "number" ? rows : undefined,
});
if (!result.success) {
const statusByCode = {
max_sessions: 503,
invalid_shell: 400,
pty_load_failed: 503,
pty_spawn_failed: 500,
} as const;
res.status(statusByCode[result.code]).json({ error: result.error });
if (!session) {
res.status(503).json({ error: "Failed to create session. Max sessions may be reached." });
return;
}
res.status(201).json({
sessionId: result.session.id,
shell: result.session.shell,
cwd: result.session.cwd,
sessionId: session.id,
shell: session.shell,
cwd: session.cwd,
});
} catch (err: any) {
res.status(500).json({ error: err.message || "Failed to create terminal session" });
@@ -5806,187 +5608,6 @@ Output ONLY the prompt text (no markdown, no explanations).`;
}
});
// ── Scripts Routes ─────────────────────────────────────────────────────────
/**
* GET /api/scripts
* Returns all project-defined scripts from settings.
* Response: Record<string, string>
*/
router.get("/scripts", async (_req, res) => {
try {
const settings = await store.getSettings();
res.json(settings.scripts || {});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/scripts
* Add or update a script.
* Body: { name: string, command: string }
* Validates name (alphanumeric, hyphens, underscores only, no spaces).
* Returns: Record<string, string> (updated scripts)
*/
router.post("/scripts", async (req, res) => {
try {
const { name, command } = req.body;
// Validate name
if (!name || typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "name is required" });
return;
}
if (!command || typeof command !== "string" || !command.trim()) {
res.status(400).json({ error: "command is required" });
return;
}
const trimmedName = name.trim();
const trimmedCommand = command.trim();
// Validate script name format (alphanumeric, hyphens, underscores only)
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedName)) {
res.status(400).json({
error: "Script name must be alphanumeric with hyphens and underscores only (no spaces)",
});
return;
}
// Check for reserved/conflicting names
const reservedNames = ["run", "list", "add", "remove", "delete", "help"];
if (reservedNames.includes(trimmedName.toLowerCase())) {
res.status(400).json({ error: `Script name '${trimmedName}' is reserved` });
return;
}
const settings = await store.getSettings();
const currentScripts = settings.scripts || {};
// Check if script already exists (for conflict detection)
const exists = trimmedName in currentScripts;
// Update scripts
const updatedScripts = {
...currentScripts,
[trimmedName]: trimmedCommand,
};
await store.updateSettings({ scripts: updatedScripts });
res.status(exists ? 200 : 201).json(updatedScripts);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* DELETE /api/scripts/:name
* Remove a script by name.
* Returns: Record<string, string> (updated scripts)
*/
router.delete("/scripts/:name", async (req, res) => {
try {
const { name } = req.params;
if (!name || !name.trim()) {
res.status(400).json({ error: "Script name is required" });
return;
}
const settings = await store.getSettings();
const currentScripts = settings.scripts || {};
if (!(name in currentScripts)) {
res.status(404).json({ error: `Script '${name}' not found` });
return;
}
// Remove the script
const { [name]: _removed, ...remainingScripts } = currentScripts;
await store.updateSettings({ scripts: remainingScripts });
res.json(remainingScripts);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/scripts/:name/run
* Execute a script with optional args.
* Body: { args?: string[] }
* Returns: { output: string; exitCode: number }
*/
router.post("/scripts/:name/run", async (req, res) => {
try {
const { name } = req.params;
const { args } = req.body;
if (!name || !name.trim()) {
res.status(400).json({ error: "Script name is required" });
return;
}
// Validate args if provided
if (args !== undefined && !Array.isArray(args)) {
res.status(400).json({ error: "args must be an array of strings" });
return;
}
if (args && args.some((arg: unknown) => typeof arg !== "string")) {
res.status(400).json({ error: "args must be an array of strings" });
return;
}
const settings = await store.getSettings();
const scripts = settings.scripts || {};
const command = scripts[name];
if (!command) {
res.status(404).json({ error: `Script '${name}' not found` });
return;
}
// Build the full command with args
const sanitizedArgs = (args || [])
.map((arg: string) => arg.replace(/["\\]/g, "\\$&"))
.join(" ");
const fullCommand = sanitizedArgs ? `${command} ${sanitizedArgs}` : command;
// Execute the command using terminal service or execSync
const rootDir = store.getRootDir();
let output: string;
let exitCode: number;
try {
// Use execSync for synchronous execution
output = execSync(fullCommand, {
encoding: "utf-8",
timeout: 300000, // 5 minute timeout
cwd: rootDir,
stdio: ["pipe", "pipe", "pipe"],
});
exitCode = 0;
} catch (execErr: any) {
// Command failed or timed out
output = execErr.stdout || "";
if (execErr.stderr) {
output += (output ? "\n" : "") + execErr.stderr;
}
if (execErr.message && !execErr.stderr) {
output += (output ? "\n" : "") + execErr.message;
}
exitCode = execErr.status || 1;
}
res.json({ output: output.trim(), exitCode });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Agent Routes ───────────────────────────────────────────────────────────
/**
@@ -6185,396 +5806,6 @@ Output ONLY the prompt text (no markdown, no explanations).`;
// Mount mission routes at /api/missions
router.use("/missions", createMissionRouter(store));
// ── Project Management Routes (Multi-Project Support) ───────────────────────
// These routes require CentralCore which is imported dynamically to avoid
// circular dependencies and ensure the central database is initialized.
/**
* GET /api/projects
* List all registered projects with their basic info.
* Returns: ProjectInfo[]
* Gracefully returns empty array if CentralCore not available.
*/
router.get("/projects", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const projects = await central.listProjects();
await central.close();
res.json(projects);
} catch {
// Graceful fallback: return empty array if CentralCore unavailable
res.json([]);
}
});
/**
* GET /api/projects/:id
* Get a specific project by ID.
* Returns: ProjectInfo
*/
router.get("/projects/:id", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.getProject(req.params.id);
await central.close();
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
res.json(project);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* PATCH /api/projects/:id
* Update a project's metadata.
* Body: { name?: string, isolationMode?: "in-process" | "child-process", status?: "active" | "paused" }
* Returns: Updated ProjectInfo
*/
router.patch("/projects/:id", async (req, res) => {
try {
const { name, isolationMode, status } = req.body;
// Validate isolationMode if provided
if (isolationMode !== undefined && !["in-process", "child-process"].includes(isolationMode)) {
res.status(400).json({ error: "isolationMode must be 'in-process' or 'child-process'" });
return;
}
// Validate status if provided
if (status !== undefined && !["active", "paused", "errored", "initializing"].includes(status)) {
res.status(400).json({ error: "status must be 'active', 'paused', 'errored', or 'initializing'" });
return;
}
const updates: { name?: string; isolationMode?: "in-process" | "child-process"; status?: "active" | "paused" | "errored" | "initializing" } = {};
if (name !== undefined) updates.name = name;
if (isolationMode !== undefined) updates.isolationMode = isolationMode;
if (status !== undefined) updates.status = status;
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.updateProject(req.params.id, updates);
await central.close();
res.json(project);
} catch (err: any) {
const status = err.message?.includes("not found") ? 404 : 500;
res.status(status).json({ error: err.message });
}
});
/**
* POST /api/projects/detect
* Auto-detect kb projects in a given base path.
* Body: { basePath?: string } (defaults to home directory)
* Returns: Array of detected projects with path and suggested name
*/
router.post("/projects/detect", async (req, res) => {
try {
const { basePath } = req.body;
const { existsSync } = await import("node:fs");
const { join, basename } = await import("node:path");
const { readdir, stat } = await import("node:fs/promises");
const { homedir } = await import("node:os");
const searchPath = basePath || homedir();
if (!existsSync(searchPath)) {
res.status(400).json({ error: "Base path does not exist" });
return;
}
const detectedProjects: Array<{ path: string; suggestedName: string; existing: boolean }> = [];
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
// Get list of already registered paths to avoid duplicates
const registeredProjects = await central.listProjects();
const registeredPaths = new Set(registeredProjects.map(p => p.path));
await central.close();
// Scan immediate subdirectories for .fusion/kb.db
try {
const entries = await readdir(searchPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const projectPath = join(searchPath, entry.name);
const fusionDir = join(projectPath, ".fusion");
const dbPath = join(fusionDir, "kb.db");
// Check if this directory has a .fusion/kb.db file
if (existsSync(dbPath)) {
detectedProjects.push({
path: projectPath,
suggestedName: entry.name,
existing: registeredPaths.has(projectPath),
});
}
}
} catch {
// Ignore errors reading directories
}
res.json({ projects: detectedProjects });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/projects
* Register a new project.
* Body: { name: string, path: string, isolationMode?: "in-process" | "child-process" }
* Returns: RegisteredProject
*/
router.post("/projects", async (req, res) => {
try {
const { name, path, isolationMode = "in-process" } = req.body;
if (!name || typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "name is required and must be a non-empty string" });
return;
}
if (!path || typeof path !== "string" || !path.trim()) {
res.status(400).json({ error: "path is required and must be a non-empty string" });
return;
}
if (!["in-process", "child-process"].includes(isolationMode)) {
res.status(400).json({ error: "isolationMode must be 'in-process' or 'child-process'" });
return;
}
// Check if path exists and has .fusion/ directory
const { existsSync } = await import("node:fs");
const { join } = await import("node:path");
if (!existsSync(path)) {
res.status(400).json({ error: "Project path does not exist" });
return;
}
const hasFusionDir = existsSync(join(path, ".fusion"));
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.registerProject({
name: name.trim(),
path: path.trim(),
isolationMode,
});
await central.close();
res.status(201).json({ ...project, _meta: { hasFusionDir: hasFusionDir ? undefined : false } });
} catch (err: any) {
const status = err.message?.includes("already registered") ? 409
: err.message?.includes("Duplicate path") ? 409
: 500;
res.status(status).json({ error: err.message });
}
});
/**
* DELETE /api/projects/:id
* Unregister a project.
*/
router.delete("/projects/:id", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
await central.unregisterProject(req.params.id);
await central.close();
res.json({ success: true });
} catch (err: any) {
const status = err.message?.includes("not found") ? 404 : 500;
res.status(status).json({ error: err.message });
}
});
/**
* GET /api/projects/:id/health
* Get health metrics for a specific project.
* Returns: ProjectHealth
*/
router.get("/projects/:id/health", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const health = await central.getProjectHealth(req.params.id);
await central.close();
if (!health) {
res.status(404).json({ error: "Project not found" });
return;
}
res.json(health);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/projects/:id/config
* Get project-specific configuration.
* Returns: { maxConcurrent: number, rootDir: string }
*/
router.get("/projects/:id/config", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.getProject(req.params.id);
await central.close();
if (!project) {
res.status(404).json({ error: "Project not found" });
return;
}
res.json({
maxConcurrent: 2,
rootDir: project.path,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/projects/:id/pause
* Pause a project.
*/
router.post("/projects/:id/pause", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.updateProject(req.params.id, { status: "paused" });
await central.updateProjectHealth(req.params.id, { status: "paused" });
await central.close();
res.json(project);
} catch (err: any) {
const status = err.message?.includes("not found") ? 404 : 500;
res.status(status).json({ error: err.message });
}
});
/**
* POST /api/projects/:id/resume
* Resume a paused project.
*/
router.post("/projects/:id/resume", async (req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const project = await central.updateProject(req.params.id, { status: "active" });
await central.updateProjectHealth(req.params.id, { status: "active" });
await central.close();
res.json(project);
} catch (err: any) {
const status = err.message?.includes("not found") ? 404 : 500;
res.status(status).json({ error: err.message });
}
});
/**
* GET /api/activity-feed
* Get unified activity feed across all projects.
* Query: limit, projectId, types
* Returns: ActivityFeedEntry[]
*/
router.get("/activity-feed", async (req, res) => {
try {
const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50;
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined;
const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined;
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const entries = await central.getRecentActivity({ limit, projectId, types });
await central.close();
res.json(entries);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/global-concurrency
* Get global concurrency state across all projects.
* Returns: GlobalConcurrencyState
*/
router.get("/global-concurrency", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const state = await central.getGlobalConcurrencyState();
await central.close();
res.json(state);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/first-run-status
* Check if user has projects or needs setup wizard.
* Returns: { hasProjects: boolean, singleProjectPath: string | null }
*/
router.get("/first-run-status", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
const projects = await central.listProjects();
await central.close();
const hasProjects = projects.length > 0;
const singleProjectPath = projects.length === 1 ? projects[0].path : null;
res.json({ hasProjects, singleProjectPath });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
return router;
}

View File

@@ -1,9 +1,8 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import http from "node:http";
import { createServer } from "./server.js";
import type { TaskStore } from "@fusion/core";
import { get as performGet, request as performRequest } from "./test-request.js";
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
@@ -24,43 +23,63 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
async function GET(app: ReturnType<typeof createServer>, path: string): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
const res = await performGet(app, path);
return res;
/** Helper: send GET and return { status, body, headers } */
async function GET(app: express.Express, path: string): Promise<{ status: number; body: unknown; headers: http.IncomingHttpHeaders }> {
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const addr = server.address() as { port: number };
http.get(`http://127.0.0.1:${addr.port}${path}`, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
server.close();
try {
resolve({ status: res.statusCode!, body: JSON.parse(data), headers: res.headers });
} catch {
resolve({ status: res.statusCode!, body: data, headers: res.headers });
}
});
}).on("error", (err) => { server.close(); reject(err); });
});
});
}
async function REQUEST(
app: ReturnType<typeof createServer>,
app: express.Express,
method: string,
path: string,
body?: string,
headers?: Record<string, string>,
): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
return performRequest(app, method, path, body, headers);
): Promise<{ status: number; body: unknown; headers: http.IncomingHttpHeaders }> {
return new Promise((resolve, reject) => {
const server = app.listen(0, () => {
const addr = server.address() as { port: number };
const req = http.request(
{ hostname: "127.0.0.1", port: addr.port, path, method, headers },
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
server.close();
try {
resolve({ status: res.statusCode!, body: JSON.parse(data), headers: res.headers });
} catch {
resolve({ status: res.statusCode!, body: data, headers: res.headers });
}
});
},
);
req.on("error", (err) => { server.close(); reject(err); });
if (body) req.write(body);
req.end();
});
});
}
describe("API Error Handling Middleware", () => {

View File

@@ -47,51 +47,38 @@ describe("TerminalService", () => {
describe("createSession", () => {
it("creates session with detected shell", async () => {
const result = await service.createSession();
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("Expected terminal session creation to succeed");
}
expect(result.session.id).toMatch(/^term-\d+-/);
expect(result.session.cwd).toBe(projectRoot);
const session = await service.createSession();
expect(session).toBeTruthy();
expect(session?.id).toMatch(/^term-\d+-/);
expect(session?.cwd).toBe(projectRoot);
});
it("returns max_sessions error when session limit reached", async () => {
it("returns null when session limit reached", async () => {
const limitedService = new TerminalService(projectRoot, 1);
const result1 = await limitedService.createSession();
expect(result1.success).toBe(true);
const result2 = await limitedService.createSession();
expect(result2).toEqual({
success: false,
code: "max_sessions",
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
});
const session1 = await limitedService.createSession();
expect(session1).toBeTruthy();
const session2 = await limitedService.createSession();
expect(session2).toBeNull();
limitedService.cleanup();
});
it("rejects shells not in allowlist", async () => {
const result = await service.createSession({ shell: "/tmp/evil-shell" });
expect(result).toEqual({
success: false,
code: "invalid_shell",
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
});
const session = await service.createSession({ shell: "/tmp/evil-shell" });
expect(session).toBeNull();
});
});
describe("write", () => {
it("sends data to PTY", async () => {
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
const result = service.write(session.id, "ls -la\n");
const session = await service.createSession();
expect(session).toBeTruthy();
const result = service.write(session!.id, "ls -la\n");
expect(result).toBe(true);
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n");
});
@@ -102,25 +89,21 @@ describe("TerminalService", () => {
});
it("rejects data with null bytes", async () => {
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
const result = service.write(session.id, "test\0malicious");
const session = await service.createSession();
expect(session).toBeTruthy();
const result = service.write(session!.id, "test\0malicious");
expect(result).toBe(false);
});
});
describe("resize", () => {
it("updates PTY dimensions", async () => {
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
const result = service.resize(session.id, 120, 40);
const session = await service.createSession();
expect(session).toBeTruthy();
const result = service.resize(session!.id, 120, 40);
expect(result).toBe(true);
expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40);
});
@@ -133,13 +116,11 @@ describe("TerminalService", () => {
describe("killSession", () => {
it("terminates session", async () => {
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
const result = service.killSession(session.id);
const session = await service.createSession();
expect(session).toBeTruthy();
const result = service.killSession(session!.id);
expect(result).toBe(true);
expect(mockPtyProcess.kill).toHaveBeenCalledWith("SIGTERM");
});
@@ -153,62 +134,51 @@ describe("TerminalService", () => {
describe("session management", () => {
it("enforces session limit", async () => {
const limitedService = new TerminalService(projectRoot, 2);
const session1 = await limitedService.createSession();
const session2 = await limitedService.createSession();
const session3 = await limitedService.createSession();
expect(session1.success).toBe(true);
expect(session2.success).toBe(true);
expect(session3).toEqual({
success: false,
code: "max_sessions",
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
});
expect(session1).toBeTruthy();
expect(session2).toBeTruthy();
expect(session3).toBeNull();
limitedService.cleanup();
});
it("lists active sessions", async () => {
const result1 = await service.createSession();
const result2 = await service.createSession();
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
if (!result1.success || !result2.success) throw new Error("Expected terminal session creation to succeed");
const session1 = await service.createSession();
const session2 = await service.createSession();
const sessions = service.getAllSessions();
expect(sessions).toHaveLength(2);
expect(sessions.some((s: { id: string }) => s.id === result1.session.id)).toBe(true);
expect(sessions.some((s: { id: string }) => s.id === result2.session.id)).toBe(true);
expect(sessions.some((s: { id: string }) => s.id === session1?.id)).toBe(true);
expect(sessions.some((s: { id: string }) => s.id === session2?.id)).toBe(true);
});
it("cleans up all sessions", async () => {
const result1 = await service.createSession();
const result2 = await service.createSession();
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
await service.createSession();
await service.createSession();
expect(service.getSessionCount()).toBe(2);
service.cleanup();
expect(service.getSessionCount()).toBe(0);
});
});
describe("scrollback buffer", () => {
it("maintains scrollback buffer", async () => {
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
const session = await service.createSession();
expect(session).toBeTruthy();
mockPtyProcess._onDataCallback?.("output line 1\n");
mockPtyProcess._onDataCallback?.("output line 2\n");
const scrollback = service.getScrollback(session.id);
const scrollback = service.getScrollback(session!.id);
expect(scrollback).toContain("output line 1");
expect(scrollback).toContain("output line 2");
});
@@ -224,42 +194,38 @@ describe("TerminalService", () => {
const dataMock = vi.fn();
service.onData(dataMock);
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
const session = await service.createSession();
expect(session).toBeTruthy();
mockPtyProcess._onDataCallback?.("test data");
await new Promise((resolve) => setTimeout(resolve, 25));
expect(dataMock).toHaveBeenCalledWith(session.id, "test data");
expect(dataMock).toHaveBeenCalledWith(session!.id, "test data");
});
it("emits exit events", async () => {
const exitMock = vi.fn();
service.onExit(exitMock);
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
const session = await service.createSession();
expect(session).toBeTruthy();
mockPtyProcess._onExitCallback?.({ exitCode: 0 });
expect(exitMock).toHaveBeenCalledWith(session.id, 0);
expect(exitMock).toHaveBeenCalledWith(session!.id, 0);
});
it("allows unsubscribing from events", async () => {
const dataMock = vi.fn();
const unsub = service.onData(dataMock);
unsub();
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
const session = await service.createSession();
expect(session).toBeTruthy();
mockPtyProcess._onDataCallback?.("test");
expect(dataMock).not.toHaveBeenCalled();
});
});
@@ -305,35 +271,31 @@ describe("TerminalService", () => {
describe("activity tracking", () => {
it("sets lastActivityAt on session creation", async () => {
const before = new Date();
const createResult = await service.createSession();
const session = await service.createSession();
const after = new Date();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
expect(createResult.session.lastActivityAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(createResult.session.lastActivityAt.getTime()).toBeLessThanOrEqual(after.getTime());
expect(session).toBeTruthy();
expect(session!.lastActivityAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(session!.lastActivityAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
it("updates lastActivityAt on write", async () => {
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
const session = await service.createSession();
expect(session).toBeTruthy();
const initialActivity = session.lastActivityAt.getTime();
const initialActivity = session!.lastActivityAt.getTime();
// Small delay to ensure time difference
await new Promise((resolve) => setTimeout(resolve, 10));
service.write(session.id, "hello");
service.write(session!.id, "hello");
const updatedSession = service.getSession(session.id);
const updatedSession = service.getSession(session!.id);
expect(updatedSession!.lastActivityAt.getTime()).toBeGreaterThan(initialActivity);
});
it("includes lastActivityAt in getAllSessions", async () => {
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
await service.createSession();
const sessions = service.getAllSessions();
expect(sessions).toHaveLength(1);
@@ -343,41 +305,37 @@ describe("TerminalService", () => {
describe("stale session detection", () => {
it("returns empty array when no sessions are stale", async () => {
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
await service.createSession();
const stale = service.getStaleSessions(300_000);
expect(stale).toHaveLength(0);
});
it("returns sessions older than threshold", async () => {
const createResult = await service.createSession();
expect(createResult.success).toBe(true);
if (!createResult.success) throw new Error("Expected terminal session creation to succeed");
const session = createResult.session;
const session = await service.createSession();
expect(session).toBeTruthy();
// Manually backdate the lastActivityAt
session.lastActivityAt = new Date(Date.now() - 600_000); // 10 min ago
session!.lastActivityAt = new Date(Date.now() - 600_000); // 10 min ago
const stale = service.getStaleSessions(300_000); // 5 min threshold
expect(stale).toHaveLength(1);
expect(stale[0].id).toBe(session.id);
expect(stale[0].id).toBe(session!.id);
});
it("sorts stale sessions oldest first", async () => {
const result1 = await service.createSession();
const result2 = await service.createSession();
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
if (!result1.success || !result2.success) throw new Error("Expected terminal session creation to succeed");
const session1 = await service.createSession();
const session2 = await service.createSession();
expect(session1).toBeTruthy();
expect(session2).toBeTruthy();
// session1 is older (more stale)
result1.session.lastActivityAt = new Date(Date.now() - 700_000);
result2.session.lastActivityAt = new Date(Date.now() - 600_000);
session1!.lastActivityAt = new Date(Date.now() - 700_000);
session2!.lastActivityAt = new Date(Date.now() - 600_000);
const stale = service.getStaleSessions(300_000);
expect(stale).toHaveLength(2);
expect(stale[0].id).toBe(result1.session.id);
expect(stale[1].id).toBe(result2.session.id);
expect(stale[0].id).toBe(session1!.id);
expect(stale[1].id).toBe(session2!.id);
});
});
@@ -392,10 +350,7 @@ describe("TerminalService", () => {
const sessions = [];
for (let i = 0; i < 5; i++) {
const result = await svc.createSession();
expect(result.success).toBe(true);
if (!result.success) throw new Error("Expected terminal session creation to succeed");
sessions.push(result.session);
sessions.push(await svc.createSession());
}
expect(svc.getSessionCount()).toBe(5);
@@ -422,10 +377,7 @@ describe("TerminalService", () => {
// Create 4 sessions (80% of 5)
const sessions = [];
for (let i = 0; i < 4; i++) {
const result = await svc.createSession();
expect(result.success).toBe(true);
if (!result.success) throw new Error("Expected terminal session creation to succeed");
sessions.push(result.session);
sessions.push(await svc.createSession());
}
expect(svc.getSessionCount()).toBe(4);
@@ -435,7 +387,7 @@ describe("TerminalService", () => {
// Creating a new session should trigger eviction first
const newSession = await svc.createSession();
expect(newSession.success).toBe(true);
expect(newSession).toBeTruthy();
// Should have evicted stale sessions, then created a new one
// After eviction, we target <= 4 (80%), evict oldest stale sessions
// Then create the new session
@@ -448,8 +400,7 @@ describe("TerminalService", () => {
const svc = new TerminalService(projectRoot, 5);
for (let i = 0; i < 5; i++) {
const result = await svc.createSession();
expect(result.success).toBe(true);
await svc.createSession();
}
// All sessions are fresh, no stale ones
const evicted = svc.evictStaleSessions(300_000);

View File

@@ -162,16 +162,6 @@ export interface TerminalOptions {
env?: Record<string, string>;
}
export type CreateSessionErrorCode =
| "max_sessions"
| "invalid_shell"
| "pty_load_failed"
| "pty_spawn_failed";
export type CreateSessionResult =
| { success: true; session: TerminalSession }
| { success: false; error: string; code: CreateSessionErrorCode };
type DataCallback = (sessionId: string, data: string) => void;
type ExitCallback = (sessionId: string, exitCode: number) => void;
@@ -398,7 +388,7 @@ export class TerminalService extends EventEmitter {
/**
* Create a new terminal session
*/
async createSession(options: TerminalOptions = {}): Promise<CreateSessionResult> {
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
// Auto-evict stale sessions when at 80% of limit
if (this.sessions.size >= Math.floor(this.maxSessions * 0.8)) {
this.evictStaleSessions();
@@ -407,11 +397,7 @@ export class TerminalService extends EventEmitter {
// Check session limit
if (this.sessions.size >= this.maxSessions) {
console.error(`Max sessions (${this.maxSessions}) reached, refusing new session`);
return {
success: false,
code: "max_sessions",
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
};
return null;
}
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
@@ -422,11 +408,7 @@ export class TerminalService extends EventEmitter {
// Validate shell is allowed
if (!this.isAllowedShell(shell)) {
console.error(`Shell not allowed: ${shell}`);
return {
success: false,
code: "invalid_shell",
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
};
return null;
}
// Validate and resolve working directory
@@ -457,12 +439,10 @@ export class TerminalService extends EventEmitter {
try {
pty = await loadPtyModule();
} catch (loadErr) {
// Native module couldn't be loaded (common in Bun binaries without proper setup)
// Return null for graceful degradation - routes will return 503
console.error(`[terminal] Failed to load PTY module: ${loadErr}`);
return {
success: false,
code: "pty_load_failed",
error: "Terminal service unavailable. The PTY module could not be loaded.",
};
return null;
}
// Build PTY spawn options
@@ -484,11 +464,7 @@ export class TerminalService extends EventEmitter {
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
} catch (spawnError) {
console.error(`[createSession] PTY spawn failed:`, spawnError);
return {
success: false,
code: "pty_spawn_failed",
error: "Failed to start terminal shell process.",
};
return null;
}
const session: TerminalSession = {
@@ -554,7 +530,7 @@ export class TerminalService extends EventEmitter {
});
console.info(`Session ${id} created successfully`);
return { success: true, session };
return session;
}
/**

View File

@@ -18,18 +18,11 @@ vi.mock("node:fs", () => ({
readFileSync: (...args: any[]) => mockReadFileSync(...args),
}));
// Mock child_process
const mockExecFileSync = vi.fn();
vi.mock("node:child_process", () => ({
execFileSync: (...args: any[]) => mockExecFileSync(...args),
}));
describe("usage", () => {
beforeEach(() => {
clearUsageCache();
mockRequest.mockClear();
mockReadFileSync.mockClear();
mockExecFileSync.mockClear();
vi.stubEnv("HOME", "/home/testuser");
});
@@ -95,9 +88,6 @@ describe("usage", () => {
mockReadFileSync.mockImplementation(() => {
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude");
@@ -107,141 +97,6 @@ describe("usage", () => {
expect(claude!.error).toContain("No Claude CLI credentials");
});
it("reads credentials from macOS keychain when file paths fail", async () => {
const mockResponse = {
five_hour: {
utilization: 30.0,
resets_at: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
},
seven_day: {
utilization: 15.0,
resets_at: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(),
},
};
// File paths fail
mockReadFileSync.mockImplementation(() => {
throw new Error("File not found");
});
// Keychain succeeds
mockExecFileSync.mockImplementation(() => {
return JSON.stringify({
claudeAiOauth: {
accessToken: "keychain-token",
scopes: ["user:profile"],
subscriptionType: "pro",
},
});
});
// Mock https request
const mockReq = {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
mockRequest.mockImplementation((options: any, callback: any) => {
const mockRes = {
statusCode: 200,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from(JSON.stringify(mockResponse)));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
expect(claude.plan).toBe("Pro");
expect(claude.windows).toHaveLength(2);
// Verify keychain command was called with correct arguments
expect(mockExecFileSync).toHaveBeenCalledWith(
"security",
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
{ encoding: "utf-8", timeout: 5000 }
);
});
it("falls back to no-auth when both file and keychain fail", async () => {
mockReadFileSync.mockImplementation(() => {
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("no-auth");
expect(claude.error).toContain("No Claude CLI credentials");
});
it("parses keychain credentials with rateLimitTier for plan detection", async () => {
const mockResponse = {
five_hour: {
utilization: 25.0,
resets_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
},
};
mockReadFileSync.mockImplementation(() => {
throw new Error("File not found");
});
// Keychain with rateLimitTier instead of subscriptionType
mockExecFileSync.mockImplementation(() => {
return JSON.stringify({
claudeAiOauth: {
accessToken: "keychain-token",
scopes: ["user:profile"],
rateLimitTier: "default_claude_max_20x",
},
});
});
const mockReq = {
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
mockRequest.mockImplementation((options: any, callback: any) => {
const mockRes = {
statusCode: 200,
headers: {},
on: vi.fn((event: string, handler: any) => {
if (event === "data") {
handler(Buffer.from(JSON.stringify(mockResponse)));
}
if (event === "end") {
handler();
}
}),
};
callback(mockRes);
return mockReq;
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude")!;
expect(claude.status).toBe("ok");
expect(claude.plan).toBe("Max"); // Should detect "max" from rateLimitTier
});
it("detects missing scope error", async () => {
mockReadFileSync.mockImplementation((path: string) => {
if (path.includes("claude")) {
@@ -252,9 +107,6 @@ describe("usage", () => {
}
throw new Error("File not found");
});
mockExecFileSync.mockImplementation(() => {
throw new Error("Keychain item not found");
});
const providers = await fetchAllProviderUsage();
const claude = providers.find((p) => p.name === "Claude");

View File

@@ -1,8 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as https from "node:https";
import * as child_process from "node:child_process";
import { promisify } from "node:util";
/**
* Pace information for weekly usage windows
@@ -211,23 +209,6 @@ function decodeJwtPayload(token: string): any {
// ── Claude fetcher ─────────────────────────────────────────────────────────
/**
* Read Claude credentials from macOS keychain.
* Returns the parsed credentials object or null if not found/error.
*/
function readClaudeKeychainCredentials(): any | null {
try {
const result = child_process.execFileSync(
"security",
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
{ encoding: "utf-8", timeout: 5000 }
);
return JSON.parse(result.trim());
} catch {
return null;
}
}
async function fetchClaudeUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Claude",
@@ -250,11 +231,6 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
} catch {}
}
// Fallback to macOS keychain if file credentials not found
if (!creds) {
creds = readClaudeKeychainCredentials();
}
const oauthCreds = creds?.claudeAiOauth || creds;
if (!oauthCreds?.accessToken) {
usage.error = "No Claude CLI credentials — run 'claude' to login";

View File

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

View File

@@ -3,36 +3,34 @@ import { vi } from "vitest";
// Mock localStorage
const localStorageMock: Record<string, string> = {};
if (typeof window !== "undefined") {
Object.defineProperty(window, "localStorage", {
value: {
getItem: (key: string) => localStorageMock[key] || null,
setItem: (key: string, value: string) => {
localStorageMock[key] = value;
},
removeItem: (key: string) => {
delete localStorageMock[key];
},
clear: () => {
Object.keys(localStorageMock).forEach((key) => delete localStorageMock[key]);
},
Object.defineProperty(window, "localStorage", {
value: {
getItem: (key: string) => localStorageMock[key] || null,
setItem: (key: string, value: string) => {
localStorageMock[key] = value;
},
writable: true,
});
removeItem: (key: string) => {
delete localStorageMock[key];
},
clear: () => {
Object.keys(localStorageMock).forEach((key) => delete localStorageMock[key]);
},
},
writable: true,
});
// Mock matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-color-scheme: dark)" ? true : false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
// Mock matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(prefers-color-scheme: dark)" ? true : false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Global MockEventSource for tests
class MockEventSource {

View File

@@ -18,8 +18,7 @@
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run --exclude src/executor.test.ts && pnpm run test:executor",
"test:executor": "vitest run src/executor.test.ts -t \"TaskExecutor with semaphore|TaskExecutor worktreeInitCommand|TaskExecutor worktree naming\" && vitest run src/executor.test.ts -t \"TaskExecutor worktree recovery\" && vitest run src/executor.test.ts -t \"TaskExecutor dependency-based worktree creation\" && vitest run src/executor.test.ts -t \"TaskExecutor worktree pool integration|WorktreePool capacity|Merger worktree pool integration\" && vitest run src/executor.test.ts -t \"buildExecutionPrompt|summarizeToolArgs|TaskExecutor pause behavior|TaskExecutor global pause behavior|TaskExecutor enginePaused soft pause\" && vitest run src/executor.test.ts -t \"Code review verdict|RETHINK verdict handling|Plan RETHINK verdict handling|task_add_dep tool|TaskExecutor usage limit detection|Per-task model overrides|Invalid transition error handling|TaskExecutor task_done with summary|Workflow Steps Execution|Real-time steering injection\""
"test": "vitest run"
},
"dependencies": {
"@fusion/core": "workspace:*",

View File

@@ -8,27 +8,6 @@ vi.mock("./pi.js", () => ({
vi.mock("./reviewer.js", () => ({
reviewStep: vi.fn(),
}));
vi.mock("./logger.js", () => {
const createMockLogger = () => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
});
return {
createLogger: vi.fn(() => createMockLogger()),
schedulerLog: createMockLogger(),
executorLog: createMockLogger(),
triageLog: createMockLogger(),
mergerLog: createMockLogger(),
worktreePoolLog: createMockLogger(),
reviewerLog: createMockLogger(),
prMonitorLog: createMockLogger(),
runtimeLog: createMockLogger(),
ipcLog: createMockLogger(),
projectManagerLog: createMockLogger(),
hybridExecutorLog: createMockLogger(),
};
});
vi.mock("./merger.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./merger.js")>();
return {
@@ -473,7 +452,7 @@ describe("TaskExecutor worktree naming", () => {
// Should use task ID (lowercase) as worktree name
expect(store.updateTask).toHaveBeenCalledWith("FN-042", {
worktree: "/tmp/test/.worktrees/fn-042",
worktree: "/tmp/test/.worktrees/kb-042",
});
// Should NOT call generateWorktreeName when using task-id
expect(mockedGenerateWorktreeName).not.toHaveBeenCalled();
@@ -644,7 +623,8 @@ describe("TaskExecutor worktree recovery", () => {
// Should have logged worktree creation
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Worktree created at"),
expect.stringContaining("Worktree created"),
expect.stringContaining(".worktrees/"),
);
// execSync should be called for worktree creation
expect(mockedExecSync).toHaveBeenCalledWith(
@@ -678,8 +658,8 @@ describe("TaskExecutor worktree recovery", () => {
// Should have logged cleanup and retry
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Cleaned up conflicting worktree, retrying"),
"/tmp/test/.worktrees/swift-falcon",
expect.stringContaining("Cleaned up conflicting worktree"),
"/tmp/test/.worktrees/green-sage",
);
// Should eventually succeed
expect(store.updateTask).toHaveBeenCalledWith(
@@ -907,7 +887,8 @@ describe("TaskExecutor worktree recovery", () => {
);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Removed stale branch reference, retrying"),
expect.stringContaining("Removed stale branch"),
"fusion/fn-050",
);
});
@@ -941,6 +922,7 @@ describe("TaskExecutor worktree recovery", () => {
expect(store.logEntry).toHaveBeenCalledWith(
"FN-050",
expect.stringContaining("Removing existing directory (not a registered worktree)"),
expect.any(String),
);
});
@@ -1005,7 +987,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
await executor.execute(makeTask({
id: "FN-060",
baseBranch: "kb/fn-059",
baseBranch: "fusion/fn-059",
}));
// The git worktree add command should include the startPoint
@@ -1013,7 +995,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
);
expect(worktreeAddCalls.length).toBeGreaterThan(0);
expect(worktreeAddCalls[0][0]).toContain("kb/fn-059");
expect(worktreeAddCalls[0][0]).toContain("fusion/fn-059");
});
it("creates worktree from HEAD when baseBranch is not set", async () => {
@@ -1043,12 +1025,12 @@ describe("TaskExecutor dependency-based worktree creation", () => {
await executor.execute(makeTask({
id: "FN-062",
baseBranch: "kb/fn-061",
baseBranch: "fusion/fn-061",
}));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-062",
expect.stringContaining("based on kb/fn-061"),
expect.stringContaining("based on fusion/fn-061"),
);
});
@@ -1075,13 +1057,13 @@ describe("TaskExecutor dependency-based worktree creation", () => {
let firstAttempt = true;
mockedExecSync.mockImplementation((cmd: any) => {
if (typeof cmd === "string" && cmd.includes("git worktree add") && cmd.includes("-b") && firstAttempt) {
if (cmd === 'git worktree add -b "fusion/fn-064" "/tmp/test/.worktrees/swift-falcon"' && firstAttempt) {
firstAttempt = false;
const err: any = new Error(
`fatal: 'kb/fn-064' is already used by worktree at '${conflictingPath}'`,
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
);
err.stderr = Buffer.from(
`fatal: 'kb/fn-064' is already used by worktree at '${conflictingPath}'`,
`fatal: 'fusion/fn-064' is already used by worktree at '${conflictingPath}'`,
);
throw err;
}
@@ -1095,12 +1077,12 @@ describe("TaskExecutor dependency-based worktree creation", () => {
expect.objectContaining({ cwd: "/tmp/test", stdio: "pipe" }),
);
expect(mockedExecSync).toHaveBeenCalledWith(
'git branch -D "kb/fn-064"',
'git branch -D "fusion/fn-064"',
expect.objectContaining({ cwd: "/tmp/test", stdio: "pipe" }),
);
const worktreeCreateCalls = mockedExecSync.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes('git worktree add') && call[0].includes("-b"),
(call) => call[0] === 'git worktree add -b "fusion/fn-064" "/tmp/test/.worktrees/swift-falcon"',
);
expect(worktreeCreateCalls).toHaveLength(2);
expect(store.logEntry).toHaveBeenCalledWith(
@@ -1115,12 +1097,12 @@ describe("TaskExecutor dependency-based worktree creation", () => {
const conflictingPath = "/tmp/test/.worktrees/sharp-stone";
mockedExecSync.mockImplementation((cmd: any) => {
if (typeof cmd === "string" && cmd.includes("git worktree add") && cmd.includes("-b")) {
if (cmd === 'git worktree add -b "kb/fn-065" "/tmp/test/.worktrees/swift-falcon"') {
const err: any = new Error(
`fatal: 'kb/fn-065' is already used by worktree at '${conflictingPath}'`
`fatal: 'kb/fn-065' is already used by worktree at '${conflictingPath}'`,
);
err.stderr = Buffer.from(
`fatal: 'kb/fn-065' is already used by worktree at '${conflictingPath}'`
`fatal: 'kb/fn-065' is already used by worktree at '${conflictingPath}'`,
);
throw err;
}
@@ -1139,6 +1121,10 @@ describe("TaskExecutor dependency-based worktree creation", () => {
await executor.execute(makeTask({ id: "FN-065" }));
// After 3 retry attempts, should fail with combined error message
expect(store.updateTask).toHaveBeenCalledWith("FN-065", {
status: "failed",
error: expect.stringContaining("Worktree conflict"),
});
expect(store.updateTask).toHaveBeenCalledWith("FN-065", {
status: "failed",
error: expect.stringContaining("automatic cleanup failed"),
@@ -1168,13 +1154,13 @@ describe("TaskExecutor dependency-based worktree creation", () => {
await executor.execute(makeTask({
id: "FN-064",
baseBranch: "kb/fn-063",
baseBranch: "fusion/fn-063",
}));
expect(prepareSpy).toHaveBeenCalledWith(
"/tmp/test/.worktrees/idle-wt",
"kb/fn-064",
"kb/fn-063",
"fusion/fn-064",
"fusion/fn-063",
);
});
@@ -1205,7 +1191,7 @@ describe("TaskExecutor dependency-based worktree creation", () => {
expect(prepareSpy).toHaveBeenCalledWith(
"/tmp/test/.worktrees/idle-wt",
"kb/fn-065",
"fusion/fn-065",
undefined,
);
});
@@ -1512,7 +1498,7 @@ describe("buildExecutionPrompt", () => {
expect(result).toContain("## Attachments");
expect(result).toContain("**screenshot.png** (screenshot)");
expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/abc123-screenshot.png");
expect(result).toContain("/home/user/project/.fusion/tasks/KB-001/attachments/abc123-screenshot.png");
});
it("includes attachment section with absolute paths for text attachments", () => {
@@ -1526,7 +1512,7 @@ describe("buildExecutionPrompt", () => {
expect(result).toContain("## Attachments");
expect(result).toContain("**error.log** (text/plain)");
expect(result).toContain("read for context");
expect(result).toContain("/home/user/project/.fusion/tasks/FN-001/attachments/def456-error.log");
expect(result).toContain("/home/user/project/.fusion/tasks/KB-001/attachments/def456-error.log");
});
it("includes both image and text attachments", () => {
@@ -1615,9 +1601,9 @@ describe("buildExecutionPrompt", () => {
expect(result).not.toContain("## Project Commands");
});
it("includes Comments section when steeringComments has entries", () => {
it("includes Steering Comments section when steeringComments has entries", () => {
const task = createMockTaskDetail({
comments: [
steeringComments: [
{
id: "1",
text: "Please handle the edge case",
@@ -1628,16 +1614,16 @@ describe("buildExecutionPrompt", () => {
});
const result = buildExecutionPrompt(task);
expect(result).toContain("## Comments");
expect(result).toContain("## Steering Comments");
expect(result).toContain("**user**");
expect(result).toContain("> Please handle the edge case");
expect(result).toContain("The following comments were added during execution");
expect(result).toContain("The following steering comments were added by the user");
});
it("formats multiple comments correctly", () => {
it("formats multiple steering comments correctly", () => {
const now = new Date();
const task = createMockTaskDetail({
comments: [
steeringComments: [
{
id: "1",
text: "First comment",
@@ -1660,29 +1646,29 @@ describe("buildExecutionPrompt", () => {
expect(result).toContain("> Second comment");
});
it("omits Comments section when steeringComments is empty", () => {
const task = createMockTaskDetail({ comments: [] });
it("omits Steering Comments section when steeringComments is empty", () => {
const task = createMockTaskDetail({ steeringComments: [] });
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Comments");
expect(result).not.toContain("## Steering Comments");
});
it("omits Comments section when steeringComments is undefined", () => {
it("omits Steering Comments section when steeringComments is undefined", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Comments");
expect(result).not.toContain("## Steering Comments");
});
it("includes only the 10 most recent comments", () => {
const comments = Array.from({ length: 15 }, (_, i) => ({
it("includes only the 10 most recent steering comments", () => {
const steeringComments = Array.from({ length: 15 }, (_, i) => ({
id: `${i}`,
text: `Comment ${i}`,
createdAt: new Date().toISOString(),
author: "user" as const,
}));
const task = createMockTaskDetail({ comments });
const task = createMockTaskDetail({ steeringComments });
const result = buildExecutionPrompt(task);
// Should include comments 5-14 (the 10 most recent), not 0-4
@@ -1692,12 +1678,12 @@ describe("buildExecutionPrompt", () => {
expect(result).not.toContain("> Comment 4");
});
it("end-to-end: comments are fully injected into execution prompt with correct format", () => {
it("end-to-end: steering comments are fully injected into execution prompt with correct format", () => {
const now = new Date();
const task = createMockTaskDetail({
id: "FN-123",
title: "Verify Steering Feature",
comments: [
steeringComments: [
{
id: "sc-001",
text: "Please ensure all edge cases are handled in the validation logic",
@@ -1722,10 +1708,10 @@ describe("buildExecutionPrompt", () => {
const result = buildExecutionPrompt(task, "/project", { testCommand: "pnpm test" } as any);
// Verify section header exists
expect(result).toContain("## Comments");
expect(result).toContain("## Steering Comments");
// Verify explanatory header text
expect(result).toContain("The following comments were added during execution");
expect(result).toContain("The following steering comments were added by the user during execution");
expect(result).toContain("Consider adjusting your approach or replanning remaining steps based on this feedback");
// Verify all three comments appear with correct author badges
@@ -1742,7 +1728,7 @@ describe("buildExecutionPrompt", () => {
expect(result).toMatch(/\*\*user\*\* — \d+m? ago/);
// Verify the section appears in the expected location (after progress section, before review level)
const steeringSectionIndex = result.indexOf("## Comments");
const steeringSectionIndex = result.indexOf("## Steering Comments");
const reviewLevelIndex = result.indexOf("## Review level");
expect(steeringSectionIndex).toBeGreaterThan(0);
expect(reviewLevelIndex).toBeGreaterThan(steeringSectionIndex);
@@ -3193,7 +3179,7 @@ describe("task_add_dep tool", () => {
await tools.task_add_dep("call1", { task_id: "FN-OTHER", confirm: true });
expect(store.logEntry).toHaveBeenCalledWith("FN-TEST", "Added dependency on FN-OTHER — stopping execution for re-specification");
expect(store.logEntry).toHaveBeenCalledWith("FN-TEST", "Added dependency on KB-OTHER — stopping execution for re-specification");
});
it("appends to existing dependencies without overwriting when confirm=true", async () => {
@@ -3334,7 +3320,7 @@ describe("task_add_dep tool", () => {
// Branch deletion should have been attempted
const branchDeleteCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && (c[0] as string).includes("branch -D") && (c[0] as string).includes("kb/fn-dep"),
(c) => typeof c[0] === "string" && (c[0] as string).includes("branch -D") && (c[0] as string).includes("fusion/fn-dep"),
);
expect(branchDeleteCalls.length).toBeGreaterThan(0);
@@ -4127,7 +4113,7 @@ describe("Workflow Steps Execution", () => {
});
});
describe("Real-time comment injection", () => {
describe("Real-time steering injection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -4165,7 +4151,7 @@ describe("Real-time comment injection", () => {
steps: [],
currentStep: 0,
log: [],
comments: [existingComment],
steeringComments: [existingComment],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
@@ -4177,7 +4163,7 @@ describe("Real-time comment injection", () => {
expect(steerFn).not.toHaveBeenCalled();
});
it("injects new comments via session.steer() on task:updated", async () => {
it("injects new steering comments via session.steer() on task:updated", async () => {
const store = createMockStore();
const steerFn = vi.fn().mockResolvedValue(undefined);
let promptResolve: () => void;
@@ -4213,7 +4199,7 @@ describe("Real-time comment injection", () => {
// Wait for agent to start
await new Promise(resolve => setTimeout(resolve, 20));
// Simulate adding a comment mid-execution
// Simulate adding a steering comment mid-execution
const newComment = {
id: "9876543210-def456",
text: "Please use a different approach",
@@ -4230,7 +4216,7 @@ describe("Real-time comment injection", () => {
steps: [],
currentStep: 0,
log: [],
comments: [newComment],
steeringComments: [newComment],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
@@ -4240,13 +4226,13 @@ describe("Real-time comment injection", () => {
// Verify steer was called with the formatted message
expect(steerFn).toHaveBeenCalledOnce();
expect(steerFn.mock.calls[0][0]).toContain("📣 **New feedback**");
expect(steerFn.mock.calls[0][0]).toContain("📣 **New steering feedback**");
expect(steerFn.mock.calls[0][0]).toContain("Please use a different approach");
// Verify log entry was created
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
expect.stringContaining("Comment received mid-execution"),
expect.stringContaining("Steering comment received mid-execution"),
"by user"
);
@@ -4255,7 +4241,7 @@ describe("Real-time comment injection", () => {
await executePromise;
});
it("does not re-inject already seen comments", async () => {
it("does not re-inject already seen steering comments", async () => {
const store = createMockStore();
const steerFn = vi.fn().mockResolvedValue(undefined);
@@ -4280,7 +4266,7 @@ describe("Real-time comment injection", () => {
steps: [],
currentStep: 0,
log: [],
comments: [{
steeringComments: [{
id: commentId,
text: "Original comment",
createdAt: new Date().toISOString(),
@@ -4303,7 +4289,7 @@ describe("Real-time comment injection", () => {
steps: [],
currentStep: 0,
log: [],
comments: [{
steeringComments: [{
id: commentId,
text: "Original comment",
createdAt: new Date().toISOString(),
@@ -4345,7 +4331,7 @@ describe("Real-time comment injection", () => {
steps: [],
currentStep: 0,
log: [],
comments: [],
steeringComments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
@@ -4363,7 +4349,7 @@ describe("Real-time comment injection", () => {
steps: [],
currentStep: 0,
log: [],
comments: [{
steeringComments: [{
id: commentId,
text: "Comment that fails",
createdAt: new Date().toISOString(),
@@ -4389,7 +4375,7 @@ describe("Real-time comment injection", () => {
steps: [],
currentStep: 0,
log: [],
comments: [{
steeringComments: [{
id: commentId,
text: "Comment that fails",
createdAt: new Date().toISOString(),
@@ -4408,7 +4394,7 @@ describe("Real-time comment injection", () => {
await executePromise;
});
it("does not inject comments for tasks not in activeSessions", async () => {
it("does not inject steering comments for tasks not in activeSessions", async () => {
const store = createMockStore();
const steerFn = vi.fn().mockResolvedValue(undefined);
@@ -4432,7 +4418,7 @@ describe("Real-time comment injection", () => {
steps: [],
currentStep: 0,
log: [],
comments: [{
steeringComments: [{
id: "3333333333-ccc333",
text: "Should not be injected",
createdAt: new Date().toISOString(),
@@ -4447,13 +4433,13 @@ describe("Real-time comment injection", () => {
expect(steerFn).not.toHaveBeenCalled();
});
it("handles multiple new comments in a single task:updated", async () => {
it("handles multiple new steering comments in a single task:updated", async () => {
const store = createMockStore();
const steerFn = vi.fn().mockResolvedValue(undefined);
let resolvePrompt: () => void;
const promptPromise = new Promise<void>(resolve => { resolvePrompt = resolve; });
// Set up getTask to return the task with existing comment
// Set up getTask to return the task with existing steering comment
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
@@ -4464,7 +4450,7 @@ describe("Real-time comment injection", () => {
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
comments: [{
steeringComments: [{
id: "existing-comment",
text: "Original",
createdAt: new Date().toISOString(),
@@ -4511,7 +4497,7 @@ describe("Real-time comment injection", () => {
steps: [],
currentStep: 0,
log: [],
comments: [
steeringComments: [
{
id: "existing-comment",
text: "Original",
@@ -4546,3 +4532,4 @@ describe("Real-time comment injection", () => {
await executePromise;
});
});

View File

@@ -221,12 +221,12 @@ export class TaskExecutor {
// Handle steering comments - inject new ones into the running session
// Only process if session is active (activeSessions check is sufficient
// since entries are only added when a task is in-progress)
if (this.activeSessions.has(task.id) && task.comments) {
if (this.activeSessions.has(task.id) && task.steeringComments) {
const activeSession = this.activeSessions.get(task.id)!;
const { session, seenSteeringIds } = activeSession;
// Find new steering comments that haven't been seen yet
const newComments = task.comments.filter(c => !seenSteeringIds.has(c.id));
const newComments = task.steeringComments.filter(c => !seenSteeringIds.has(c.id));
if (newComments.length > 0) {
for (const comment of newComments) {
@@ -237,21 +237,21 @@ export class TaskExecutor {
// Mark as seen BEFORE attempting injection to prevent retry loops on failure
seenSteeringIds.add(comment.id);
// Format and inject the comment
const commentMessage = formatCommentForInjection(comment);
// Format and inject the steering comment
const steeringMessage = formatSteeringCommentForInjection(comment);
try {
executorLog.log(`Injecting comment into ${task.id}: ${summary}`);
await session.steer(commentMessage);
executorLog.log(`Successfully injected comment into ${task.id}`);
executorLog.log(`Injecting steering comment into ${task.id}: ${summary}`);
await session.steer(steeringMessage);
executorLog.log(`Successfully injected steering comment into ${task.id}`);
// Log to the task that comment was received
// Log to the task that steering was received
await this.store.logEntry(
task.id,
`Comment received mid-execution: ${summary}`,
`Steering comment received mid-execution: ${summary}`,
`by ${comment.author}`
);
} catch (err) {
executorLog.error(`Failed to inject comment for ${task.id}:`, err);
executorLog.error(`Failed to inject steering comment for ${task.id}:`, err);
// Comment is already marked as seen - we won't retry to avoid spamming
// the agent with failed injections. The error is logged for debugging.
}
@@ -449,31 +449,12 @@ export class TaskExecutor {
}
}
}
} else if (task.worktree) {
// Task already had a worktree assigned and it exists on disk — reuse it
executorLog.log(`Reusing existing worktree: ${worktreePath}`);
} else {
// Directory exists at generated path but task has no worktree — create via normal flow
worktreePath = task.worktree || join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
isResume = existsSync(worktreePath);
worktreePath = await this.createWorktree(branchName, worktreePath, task.id);
}
// Capture the base commit SHA for diff computation
// This is done after worktree creation when we're on the new branch
if (!task.baseCommitSha) {
try {
const baseCommitSha = execSync("git rev-parse HEAD", {
cwd: worktreePath,
stdio: "pipe",
encoding: "utf-8",
}).trim();
await this.store.updateTask(task.id, { baseCommitSha });
executorLog.log(`${task.id}: captured baseCommitSha ${baseCommitSha.slice(0, 7)}`);
} catch (err: any) {
executorLog.log(`Failed to capture baseCommitSha for ${task.id}: ${err.message}`);
// Non-fatal: task can continue without baseCommitSha
}
}
this.activeWorktrees.set(task.id, worktreePath);
this.options.onStart?.(task, worktreePath);
@@ -551,10 +532,10 @@ export class TaskExecutor {
sessionRef.current = session;
// Register session so the pause listener can terminate it
// Initialize with empty set of seen comments
// Initialize with empty set of seen steering comments
const seenSteeringIds = new Set<string>();
if (detail.comments) {
for (const comment of detail.comments) {
if (detail.steeringComments) {
for (const comment of detail.steeringComments) {
seenSteeringIds.add(comment.id);
}
}
@@ -588,14 +569,6 @@ export class TaskExecutor {
}
if (taskDone) {
// Capture modified files before running workflow steps
const updatedTask = await this.store.getTask(task.id);
const modifiedFiles = this.captureModifiedFiles(worktreePath, updatedTask.baseCommitSha);
if (modifiedFiles.length > 0) {
await this.store.updateTask(task.id, { modifiedFiles });
executorLog.log(`${task.id}: captured ${modifiedFiles.length} modified files`);
}
// Run workflow steps before moving to in-review
const workflowSuccess = await this.runWorkflowSteps(task, worktreePath, settings);
if (!workflowSuccess) {
@@ -1092,61 +1065,6 @@ export class TaskExecutor {
await this.store.logEntry(taskId, "Execution stopped — work discarded, moved to triage for re-specification");
}
/**
* Capture the list of files modified during agent execution.
* Uses git diff against the stored baseCommitSha to determine what changed.
* Returns an empty array if no changes or if git commands fail.
*/
private captureModifiedFiles(worktreePath: string, baseCommitSha?: string): string[] {
try {
// Determine the base reference for diff
// If baseCommitSha is stored, use it; otherwise fall back to merge-base with HEAD
let baseRef = baseCommitSha;
if (!baseRef) {
// Try to find merge-base with main/master as fallback
try {
baseRef = execSync("git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main", {
cwd: worktreePath,
stdio: "pipe",
encoding: "utf-8",
}).trim();
} catch {
// If merge-base fails, use HEAD~1 as last resort
try {
baseRef = execSync("git rev-parse HEAD~1", {
cwd: worktreePath,
stdio: "pipe",
encoding: "utf-8",
}).trim();
} catch {
executorLog.log(`Could not determine base commit for diff in ${worktreePath}`);
return [];
}
}
}
if (!baseRef) {
return [];
}
// Get list of modified files using git diff --name-only
const output = execSync(`git diff --name-only ${baseRef}..HEAD`, {
cwd: worktreePath,
stdio: "pipe",
encoding: "utf-8",
}).trim();
if (!output) {
return [];
}
return output.split("\n").filter(Boolean);
} catch (err: any) {
executorLog.log(`Failed to capture modified files: ${err.message}`);
return [];
}
}
// ── Worktree management ────────────────────────────────────────────
/**
@@ -1822,15 +1740,15 @@ git log --oneline
commandsSection = "\n" + lines.join("\n") + "\n";
}
// Build comments section (last 10 comments only to avoid context bloat)
let commentsSection = "";
if (task.comments && task.comments.length > 0) {
const recentComments = [...task.comments].slice(-10);
// Build steering comments section (last 10 comments only to avoid context bloat)
let steeringSection = "";
if (task.steeringComments && task.steeringComments.length > 0) {
const recentComments = [...task.steeringComments].slice(-10);
const lines = [
"",
"## Comments",
"## Steering Comments",
"",
"The following comments were added during execution. Consider adjusting your approach or replanning remaining steps based on this feedback.",
"The following steering comments were added by the user during execution. Consider adjusting your approach or replanning remaining steps based on this feedback.",
"",
];
for (const comment of recentComments) {
@@ -1839,7 +1757,7 @@ git log --oneline
lines.push(`> ${comment.text}`);
lines.push("");
}
commentsSection = lines.join("\n");
steeringSection = lines.join("\n");
}
return `Execute this task.
@@ -1851,7 +1769,7 @@ ${task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}`
## PROMPT.md
${task.prompt}
${attachmentsSection}${commandsSection}${progressSection}${commentsSection}
${attachmentsSection}${commandsSection}${progressSection}${steeringSection}
## Review level: ${reviewLevel}
${reviewLevel === 0 ? "No reviews required. Implement directly." : ""}
@@ -1874,10 +1792,10 @@ When all steps are complete: call \`task_done()\``;
}
/**
* Format a comment for injection into a running agent session.
* Format a steering comment for injection into a running agent session.
* Used for real-time steering during task execution.
*/
function formatCommentForInjection(comment: import("@fusion/core").TaskComment): string {
function formatSteeringCommentForInjection(comment: import("@fusion/core").SteeringComment): string {
const timestamp = formatTimestamp(comment.createdAt);
return `📣 **New feedback** — ${timestamp} (${comment.author}):\n\n${comment.text}\n\nPlease adjust your approach based on this feedback.`;
return `📣 **New steering feedback** — ${timestamp} (${comment.author}):\n\n${comment.text}\n\nPlease adjust your approach based on this feedback.`;
}

View File

@@ -14,62 +14,3 @@ export { PrCommentHandler } from "./pr-comment-handler.js";
export { NtfyNotifier, type NtfyNotifierOptions } from "./notifier.js";
export { CronRunner, type CronRunnerOptions } from "./cron-runner.js";
export { StuckTaskDetector, type StuckTaskDetectorOptions, type DisposableSession } from "./stuck-task-detector.js";
// ── Project Runtime (Multi-Project Support) ────────────────────────────────
export {
type ProjectRuntime,
type ProjectRuntimeConfig,
type RuntimeStatus,
type RuntimeMetrics,
type ProjectRuntimeEvents,
type GlobalMetrics,
} from "./project-runtime.js";
export { InProcessRuntime } from "./runtimes/in-process-runtime.js";
export { ChildProcessRuntime } from "./runtimes/child-process-runtime.js";
export { ProjectManager, type ProjectManagerEvents } from "./project-manager.js";
// ── IPC Protocol ───────────────────────────────────────────────────────
export {
type IpcMessage,
type IpcCommandType,
type IpcResponseType,
type IpcEventType,
START_RUNTIME,
STOP_RUNTIME,
GET_STATUS,
GET_METRICS,
GET_TASK_STORE,
GET_SCHEDULER,
PING,
OK,
ERROR,
PONG,
TASK_CREATED,
TASK_MOVED,
TASK_UPDATED,
ERROR_EVENT,
HEALTH_CHANGED,
type StartRuntimePayload,
type StopRuntimePayload,
type OkPayload,
type ErrorPayload,
type PongPayload,
type TaskCreatedPayload,
type TaskMovedPayload,
type TaskUpdatedPayload,
type ErrorEventPayload,
type HealthChangedPayload,
isIpcCommand,
isIpcResponse,
isIpcEvent,
createCommand,
createResponse,
createEvent,
generateCorrelationId,
} from "./ipc/ipc-protocol.js";
export { IpcHost } from "./ipc/ipc-host.js";
export { IpcWorker } from "./ipc/ipc-worker.js";

View File

@@ -64,15 +64,3 @@ export const reviewerLog = createLogger("reviewer");
/** Logger for the PR monitor subsystem. */
export const prMonitorLog = createLogger("pr-monitor");
/** Logger for the project runtime subsystem. */
export const runtimeLog = createLogger("runtime");
/** Logger for the IPC subsystem. */
export const ipcLog = createLogger("ipc");
/** Logger for the project manager subsystem. */
export const projectManagerLog = createLogger("project-manager");
/** Logger for the hybrid executor subsystem. */
export const hybridExecutorLog = createLogger("hybrid-executor");

View File

@@ -339,7 +339,7 @@ describe("aiMergeTask — includeTaskIdInCommit setting", () => {
(call) => String(call[0]).includes("git commit"),
);
expect(commitCall).toBeDefined();
expect(String(commitCall![0])).toContain("feat(FN-050):");
expect(String(commitCall![0])).toContain("feat(KB-050):");
});
it("fallback commit omits task ID when includeTaskIdInCommit is false", async () => {

View File

@@ -3,7 +3,7 @@ import { PrCommentHandler } from "./pr-comment-handler.js";
import type { TaskStore, Task } from "@fusion/core";
const mockStore = {
addComment: vi.fn<(id: string, text: string, author?: "user" | "agent") => Promise<Task>>(),
addSteeringComment: vi.fn<(id: string, text: string, author?: "user" | "agent") => Promise<Task>>(),
createTask: vi.fn<(input: Parameters<TaskStore["createTask"]>[0]) => Promise<Task>>().mockResolvedValue({ id: "FN-123" } as Task),
} as unknown as TaskStore;
@@ -49,7 +49,7 @@ describe("PrCommentHandler", () => {
},
]);
expect(mockStore.addComment).not.toHaveBeenCalled();
expect(mockStore.addSteeringComment).not.toHaveBeenCalled();
});
});
@@ -77,7 +77,7 @@ describe("PrCommentHandler", () => {
},
]);
expect(mockStore.addComment).toHaveBeenCalled();
expect(mockStore.addSteeringComment).toHaveBeenCalled();
});
});
@@ -94,7 +94,7 @@ describe("PrCommentHandler", () => {
},
]);
expect(mockStore.addComment).toHaveBeenCalled();
expect(mockStore.addSteeringComment).toHaveBeenCalled();
});
it("creates steering comment for inline code suggestions", async () => {
@@ -109,7 +109,7 @@ describe("PrCommentHandler", () => {
},
]);
expect(mockStore.addComment).toHaveBeenCalled();
expect(mockStore.addSteeringComment).toHaveBeenCalled();
});
});
@@ -126,7 +126,7 @@ describe("PrCommentHandler", () => {
},
]);
const call = (mockStore.addComment as ReturnType<typeof vi.fn>).mock.calls[0];
const call = (mockStore.addSteeringComment as ReturnType<typeof vi.fn>).mock.calls[0];
const text = call[1] as string;
expect(text).toContain("PR Review Feedback");
@@ -151,7 +151,7 @@ describe("PrCommentHandler", () => {
},
]);
const call = (mockStore.addComment as ReturnType<typeof vi.fn>).mock.calls[0];
const call = (mockStore.addSteeringComment as ReturnType<typeof vi.fn>).mock.calls[0];
const text = call[1] as string;
expect(text.length).toBeLessThan(longBody.length);
@@ -170,7 +170,7 @@ describe("PrCommentHandler", () => {
},
]);
expect(mockStore.addComment).toHaveBeenCalledWith(
expect(mockStore.addSteeringComment).toHaveBeenCalledWith(
"FN-001",
expect.any(String),
"agent"
@@ -189,7 +189,7 @@ describe("PrCommentHandler", () => {
},
]);
const text = (mockStore.addComment as ReturnType<typeof vi.fn>).mock.calls[0][1] as string;
const text = (mockStore.addSteeringComment as ReturnType<typeof vi.fn>).mock.calls[0][1] as string;
expect(text).toContain("This PR is already merged");
expect(text).toContain("follow-up work");
});

View File

@@ -85,10 +85,10 @@ export class PrCommentHandler {
const text = this.buildSteeringText(prInfo, comment, hasCodeSuggestions);
try {
await this.store.addComment(taskId, text, "agent");
prMonitorLog.log(`Added comment for PR review #${comment.id}`);
await this.store.addSteeringComment(taskId, text, "agent");
prMonitorLog.log(`Added steering comment for PR review #${comment.id}`);
} catch (err) {
prMonitorLog.error(`Failed to add comment for ${taskId}:`, err);
prMonitorLog.error(`Failed to add steering comment for ${taskId}:`, err);
}
}

View File

@@ -27,9 +27,6 @@ vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
readdirSync: vi.fn().mockReturnValue([]),
}));
vi.mock("node:fs/promises", () => ({
readFile: vi.fn().mockResolvedValue("# Task prompt content"),
}));
import { TaskExecutor } from "./executor.js";
import { TriageProcessor } from "./triage.js";
@@ -76,7 +73,6 @@ function createMockStore(overrides: Record<string, any> = {}) {
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getRootDir: vi.fn().mockReturnValue("/tmp/root"),
updateStep: vi.fn().mockImplementation(async (id: string, step: number, status: StepStatus) => {
return makeTaskDetail(id, "in-progress");
}),
@@ -281,7 +277,7 @@ describe("In-review merge handling after restart", () => {
store.getTask.mockResolvedValue(makeTaskDetail("FN-050", "in-progress"));
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
"Cannot merge FN-050: task is in 'in-progress', must be in 'in-review'",
"Cannot merge KB-050: task is in 'in-progress', must be in 'in-review'",
);
// No git commands should have been executed
@@ -355,7 +351,7 @@ describe("In-review merge handling after restart", () => {
} as any);
await expect(aiMergeTask(store, "/tmp/root", "FN-055")).rejects.toThrow(
"AI merge failed for FN-055: all 3 attempts exhausted",
"AI merge failed for KB-055: all 3 attempts exhausted",
);
// Should have attempted git reset --merge cleanup

View File

@@ -455,197 +455,4 @@ describe("Scheduler", () => {
expect(moveTask).not.toHaveBeenCalledWith("FN-005", "triage");
});
});
describe("mission integration", () => {
// Helper to create mock MissionStore
function createMockMissionStore(overrides = {}) {
return {
getFeatureByTaskId: vi.fn(),
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
getSlice: vi.fn(),
getMilestone: vi.fn(),
computeSliceStatus: vi.fn(),
getMission: vi.fn(),
findNextPendingSlice: vi.fn(),
activateSlice: vi.fn(),
...overrides,
};
}
it("activateNextPendingSlice returns null when no missionStore", async () => {
const store = createMockStore();
const scheduler = new Scheduler(store);
const result = await scheduler.activateNextPendingSlice("M-001");
expect(result).toBeNull();
});
it("triggers feature in-progress update when task with sliceId moves to in-progress", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "triaged" }),
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "in-progress" }),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
// Trigger task:moved event by calling the registered handler
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
expect(movedHandler).toBeDefined();
// Simulate task moving to in-progress with sliceId
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "in-progress" });
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress");
});
it("does not update feature status when already past triaged", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "in-progress" }),
updateFeatureStatus: vi.fn(),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "in-progress" });
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
});
it("triggers feature done update when task with sliceId moves to done", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
computeSliceStatus: vi.fn().mockReturnValue("active"),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
// Trigger task:moved event by calling the registered handler
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
expect(movedHandler).toBeDefined();
// Simulate task moving to done with sliceId
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "done" });
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
});
it("auto-advances when slice completes and autoAdvance is enabled", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
computeSliceStatus: vi.fn().mockReturnValue("complete"),
getMission: vi.fn().mockReturnValue({ id: "M-001", autoAdvance: true }),
findNextPendingSlice: vi.fn().mockReturnValue({ id: "SL-002" }),
activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
// Trigger task:moved event
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "done" });
expect(mockMissionStore.computeSliceStatus).toHaveBeenCalledWith("SL-001");
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
});
it("does not auto-advance when autoAdvance is disabled", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
computeSliceStatus: vi.fn().mockReturnValue("complete"),
getMission: vi.fn().mockReturnValue({ id: "M-001", autoAdvance: false }),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
// Trigger task:moved event
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "done" });
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.findNextPendingSlice).not.toHaveBeenCalled();
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
});
it("handles task with sliceId but no linked feature gracefully", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue(undefined),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
// Trigger task:moved event
const onCalls = (store.on as any).mock.calls;
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "done" });
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
});
it("activateNextPendingSlice finds and activates correct slice", async () => {
const nextSlice = { id: "SL-002", status: "pending" };
const mockMissionStore = createMockMissionStore({
findNextPendingSlice: vi.fn().mockReturnValue(nextSlice),
activateSlice: vi.fn().mockReturnValue({ ...nextSlice, status: "active" }),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const result = await scheduler.activateNextPendingSlice("M-001");
expect(mockMissionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
expect(result).toEqual({ id: "SL-002", status: "active" });
});
it("activateNextPendingSlice returns null when no pending slices", async () => {
const mockMissionStore = createMockMissionStore({
findNextPendingSlice: vi.fn().mockReturnValue(undefined),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const result = await scheduler.activateNextPendingSlice("M-001");
expect(result).toBeNull();
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,4 @@
import { resolveDependencyOrder, type TaskStore, type Task, type MissionStore, type FeatureStatus } from "@fusion/core";
import { resolveDependencyOrder, type TaskStore, type Task } from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
@@ -60,8 +60,6 @@ export interface SchedulerOptions {
onBlocked?: (task: Task, blockedBy: string[]) => void;
/** Optional PR monitor for tracking in-review PRs */
prMonitor?: PrMonitor;
/** Optional MissionStore for slice activation and auto-advance */
missionStore?: MissionStore;
}
/**
@@ -124,36 +122,23 @@ export class Scheduler {
/**
* PR Monitoring: Start monitoring when a task moves to "in-review",
* stop monitoring when it moves out.
*
* Also handles mission auto-advance: when a linked task completes,
* update feature status and potentially activate next pending slice.
*/
this.store.on("task:moved", ({ task, to }) => {
// PR Monitoring
if (this.options.prMonitor) {
if (to === "in-review" && task.prInfo) {
// Start monitoring existing PR
const repo = getCurrentGitHubRepo(this.store.getRootDir());
if (repo) {
this.options.prMonitor.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
}
} else if (task.column === "in-review" && to !== "in-review") {
// Task moved out of in-review, stop monitoring
this.options.prMonitor.stopMonitoring(task.id);
if (!this.options.prMonitor) return;
// If task has a closed/merged PR, check for unaddressed feedback
if (task.prInfo && (task.prInfo.status === "closed" || task.prInfo.status === "merged")) {
// This would need the tracked PR data - handled by PrMonitor/PrCommentHandler
}
if (to === "in-review" && task.prInfo) {
// Start monitoring existing PR
const repo = getCurrentGitHubRepo(this.store.getRootDir());
if (repo) {
this.options.prMonitor.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
}
}
} else if (task.column === "in-review" && to !== "in-review") {
// Task moved out of in-review, stop monitoring
this.options.prMonitor.stopMonitoring(task.id);
// Mission progress tracking: when task with sliceId moves to "in-progress" or "done"
if (task.sliceId && this.options.missionStore) {
if (to === "in-progress") {
void this.handleMissionTaskStart(task.id, task.sliceId);
} else if (to === "done") {
void this.handleMissionTaskCompletion(task.id, task.sliceId);
// If task has a closed/merged PR, check for unaddressed feedback
if (task.prInfo && (task.prInfo.status === "closed" || task.prInfo.status === "merged")) {
// This would need the tracked PR data - handled by PrMonitor/PrCommentHandler
}
}
});
@@ -494,118 +479,4 @@ export class Scheduler {
this.scheduling = false;
}
}
/**
* Handle mission task start.
* When a task with a sliceId moves to "in-progress", update the linked
* feature status to "in-progress" to reflect active work.
*/
private async handleMissionTaskStart(taskId: string, sliceId: string): Promise<void> {
if (!this.options.missionStore) return;
const missionStore = this.options.missionStore;
try {
// Find the feature linked to this task
const feature = missionStore.getFeatureByTaskId(taskId);
if (!feature) {
schedulerLog.log(`Task ${taskId} has sliceId ${sliceId} but no linked feature found`);
return;
}
// Only update if feature is still in "triaged" status
if (feature.status === "triaged") {
await missionStore.updateFeatureStatus(feature.id, "in-progress");
schedulerLog.log(`Feature ${feature.id} marked in-progress (task ${taskId} started)`);
}
} catch (err) {
schedulerLog.error(`Error handling mission task start for ${taskId}:`, err);
}
}
/**
* Handle mission task completion.
* When a task with a sliceId moves to "done", update the linked feature
* status and check if the slice is complete. If autoAdvance is enabled
* on the mission, activate the next pending slice.
*/
private async handleMissionTaskCompletion(taskId: string, sliceId: string): Promise<void> {
if (!this.options.missionStore) return;
const missionStore = this.options.missionStore;
try {
// Find the feature linked to this task
const feature = missionStore.getFeatureByTaskId(taskId);
if (!feature) {
schedulerLog.log(`Task ${taskId} has sliceId ${sliceId} but no linked feature found`);
return;
}
// Update feature status to done
await missionStore.updateFeatureStatus(feature.id, "done");
schedulerLog.log(`Feature ${feature.id} marked done (task ${taskId} completed)`);
// Get the slice to check its status
const slice = missionStore.getSlice(sliceId);
if (!slice) {
schedulerLog.warn(`Slice ${sliceId} not found for task ${taskId}`);
return;
}
// Get the milestone to find the mission
const milestone = missionStore.getMilestone(slice.milestoneId);
if (!milestone) {
schedulerLog.warn(`Milestone ${slice.milestoneId} not found for slice ${sliceId}`);
return;
}
// Recompute and check if slice is now complete
const newSliceStatus = missionStore.computeSliceStatus(sliceId);
if (newSliceStatus === "complete") {
schedulerLog.log(`Slice ${sliceId} completed (all features done)`);
// Check if mission has autoAdvance enabled
const mission = missionStore.getMission(milestone.missionId);
if (mission?.autoAdvance) {
// Activate next pending slice
const nextSlice = await this.activateNextPendingSlice(mission.id);
if (nextSlice) {
schedulerLog.log(`Auto-advanced: activated slice ${nextSlice.id} for mission ${mission.id}`);
}
}
}
} catch (err) {
schedulerLog.error(`Error handling mission task completion for ${taskId}:`, err);
}
}
/**
* Activate the next pending slice in a mission.
* Finds the first milestone with pending slices and activates
* the first pending slice in that milestone.
*
* @param missionId - Mission ID
* @returns The activated slice, or null if no pending slices
*/
async activateNextPendingSlice(missionId: string): Promise<import("@fusion/core").Slice | null> {
if (!this.options.missionStore) return null;
const missionStore = this.options.missionStore;
try {
const nextSlice = missionStore.findNextPendingSlice(missionId);
if (!nextSlice) {
schedulerLog.log(`Mission ${missionId}: no pending slices to activate`);
return null;
}
const activated = missionStore.activateSlice(nextSlice.id);
schedulerLog.log(`Activated slice ${activated.id} for mission ${missionId}`);
return activated;
} catch (err) {
schedulerLog.error(`Error activating next slice for mission ${missionId}:`, err);
return null;
}
}
}