feat(KB-617): add diff viewer tab to task detail modal
- Add modifiedFiles and baseCommitSha tracking during task execution - Create GET /tasks/:id/diff API endpoint for file list and patches - Build TaskChangesTab component with expandable file diffs - Integrate Changes tab into TaskDetailModal for in-progress, in-review, and done tasks - Add database columns and types for diff tracking - Update executor to capture modified files during agent sessions
This commit is contained in:
@@ -86,7 +86,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
});
|
||||
|
||||
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(3);
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
});
|
||||
|
||||
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 3 (includes both v1→v2 and v2→v3 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
// Verify version bumped to 4 (includes v1→v2, v2→v3, and v3→v4 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
|
||||
// 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(3);
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
|
||||
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 v2→v3 migration
|
||||
// Now run init() which should trigger migrations v2→v3→v4
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 3
|
||||
expect(db.getSchemaVersion()).toBe(3);
|
||||
// Verify version bumped to 4
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
|
||||
// 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(3);
|
||||
expect(db.getSchemaVersion()).toBe(4);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -58,7 +58,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 3;
|
||||
const SCHEMA_VERSION = 4;
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
-- Tasks table with JSON columns for nested data
|
||||
@@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
blockedBy TEXT,
|
||||
paused INTEGER DEFAULT 0,
|
||||
baseBranch TEXT,
|
||||
baseCommitSha TEXT,
|
||||
modelPresetId TEXT,
|
||||
modelProvider TEXT,
|
||||
modelId TEXT,
|
||||
@@ -99,7 +100,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
issueInfo TEXT,
|
||||
mergeDetails TEXT,
|
||||
breakIntoSubtasks INTEGER DEFAULT 0,
|
||||
enabledWorkflowSteps TEXT DEFAULT '[]'
|
||||
enabledWorkflowSteps TEXT DEFAULT '[]',
|
||||
modifiedFiles TEXT DEFAULT '[]'
|
||||
);
|
||||
|
||||
-- Config table (single row with project settings)
|
||||
@@ -322,6 +324,15 @@ 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");
|
||||
});
|
||||
}
|
||||
|
||||
// Future migrations go here:
|
||||
// if (version < 3) { this.applyMigration(3, () => { ... }); }
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ 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,
|
||||
@@ -157,6 +158,7 @@ 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; })(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,15 +169,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, modelPresetId, modelProvider,
|
||||
worktree, blockedBy, paused, baseBranch, baseCommitSha, modelPresetId, modelProvider,
|
||||
modelId, validatorModelProvider, validatorModelId, mergeRetries, error,
|
||||
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
|
||||
breakIntoSubtasks, enabledWorkflowSteps
|
||||
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`).run(
|
||||
task.id,
|
||||
@@ -190,6 +192,7 @@ 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,
|
||||
@@ -214,6 +217,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
toJsonNullable(task.mergeDetails),
|
||||
task.breakIntoSubtasks ? 1 : 0,
|
||||
toJson(task.enabledWorkflowSteps || []),
|
||||
toJson(task.modifiedFiles || []),
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
}
|
||||
@@ -875,7 +879,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; 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 },
|
||||
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 },
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
// Validate that task doesn't depend on itself
|
||||
@@ -925,6 +929,7 @@ 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;
|
||||
@@ -963,6 +968,11 @@ 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);
|
||||
@@ -1465,8 +1475,10 @@ 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
|
||||
@@ -2295,8 +2307,10 @@ 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
|
||||
@@ -2359,7 +2373,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
validatorModelProvider: entry.validatorModelProvider,
|
||||
validatorModelId: entry.validatorModelId,
|
||||
breakIntoSubtasks: entry.breakIntoSubtasks,
|
||||
// Intentionally NOT restoring: worktree, status, blockedBy, paused, baseBranch, error, steeringComments
|
||||
modifiedFiles: entry.modifiedFiles,
|
||||
// Intentionally NOT restoring: worktree, status, blockedBy, paused, baseBranch, baseCommitSha, error, steeringComments
|
||||
};
|
||||
|
||||
// Write task.json
|
||||
|
||||
@@ -387,6 +387,10 @@ 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[];
|
||||
@@ -428,6 +432,8 @@ 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[];
|
||||
/** 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,6 +529,9 @@ 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 for the current user. Used to automatically select
|
||||
* the default project when opening the dashboard without a specific project. */
|
||||
defaultProjectId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -682,6 +691,7 @@ export const DEFAULT_GLOBAL_SETTINGS: Required<Pick<GlobalSettings, "themeMode"
|
||||
defaultThinkingLevel: undefined,
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
defaultProjectId: undefined,
|
||||
};
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
@@ -740,6 +750,7 @@ export const GLOBAL_SETTINGS_KEYS: ReadonlyArray<keyof GlobalSettings> = [
|
||||
"defaultThinkingLevel",
|
||||
"ntfyEnabled",
|
||||
"ntfyTopic",
|
||||
"defaultProjectId",
|
||||
] as const;
|
||||
|
||||
/** Keys that belong to the project settings scope. */
|
||||
@@ -861,8 +872,11 @@ 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 */
|
||||
|
||||
Reference in New Issue
Block a user