feat(KB-651): add changed files diff viewer to dashboard

- Add backend API endpoint /api/tasks/:id/diff for file diffs
- Add useChangedFiles hook for fetching and managing diff state
- Create ChangedFilesModal component with file list and diff viewer
- Integrate modal with TaskCard via files changed badge click
- Add CSS styling for changed files layout and diff display
- Add keyboard navigation (Escape to close)
- Include unit tests for ChangedFilesModal component
This commit is contained in:
gsxdsm
2026-04-01 08:26:46 -07:00
parent e9808ada71
commit 1fd4dc372a
12 changed files with 314 additions and 24 deletions

View File

@@ -46,6 +46,7 @@ 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

@@ -10,6 +10,7 @@ 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";
@@ -45,6 +46,7 @@ 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);
@@ -339,9 +341,12 @@ function AppInner() {
setFilesOpen(true);
}, []);
const handleOpenFilesForTask = useCallback((taskId: string) => {
setFileBrowserWorkspace(taskId);
setFilesOpen(true);
const handleOpenChangedFiles = useCallback((taskId: string, worktree: string | undefined, column: string) => {
setChangedFilesState({ taskId, worktree, column });
}, []);
const handleCloseChangedFiles = useCallback(() => {
setChangedFilesState(null);
}, []);
const handleWorkspaceChange = useCallback((workspace: string) => {
@@ -427,7 +432,7 @@ function AppInner() {
onArchiveAllDone={archiveAllDone}
searchQuery={searchQuery}
availableModels={availableModels}
onOpenFilesForTask={handleOpenFilesForTask}
onOpenFilesForTask={handleOpenChangedFiles}
projectId={currentProject?.id}
projectName={currentProject?.name}
/>
@@ -550,6 +555,15 @@ function AppInner() {
onWorkspaceChange={handleWorkspaceChange}
/>
)}
{changedFilesState && (
<ChangedFilesModal
taskId={changedFilesState.taskId}
worktree={changedFilesState.worktree}
column={changedFilesState.column}
isOpen={true}
onClose={handleCloseChangedFiles}
/>
)}
<UsageIndicator
isOpen={usageOpen}
onClose={handleCloseUsage}

View File

@@ -283,6 +283,17 @@ 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`);
}

View File

@@ -35,7 +35,7 @@ interface BoardProps {
* Called when the user clicks the "Subtask" button in the inline create card.
*/
onSubtaskBreakdown?: (description: string) => void;
onOpenFilesForTask?: (taskId: string) => void;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
/** Project context for multi-project mode */
projectId?: string;
projectName?: string;

View File

@@ -74,14 +74,14 @@ export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }:
</button>
</div>
<div className="file-browser-body">
<aside className="file-browser-sidebar" style={{ flex: "0 0 30%" }}>
<div className="file-browser-body changed-files-layout">
<aside className="file-browser-sidebar changed-files-sidebar">
{loading ? (
<div className="gm-diff-loading">Loading changed files</div>
) : error ? (
<div className="gm-diff-error">{error}</div>
) : files.length === 0 ? (
<div className="file-browser-empty-state">No files changed</div>
<div className="file-browser-empty">No files changed</div>
) : (
<div className="file-browser-list" role="list" aria-label="Changed files list">
{files.map((file) => {
@@ -91,14 +91,13 @@ export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }:
key={`${file.oldPath ?? ""}:${file.path}`}
type="button"
role="listitem"
className={`file-browser-entry ${active ? "active" : ""}`}
aria-label={file.path}
className={`file-node file-node--file changed-files-entry ${active ? "active" : ""}`}
onClick={() => setSelectedFile(file)}
>
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
{getStatusIcon(file.status)}
<span>{file.path}</span>
</span>
<span className="badge">{getStatusLabel(file.status)}</span>
<span className="file-node-icon">{getStatusIcon(file.status)}</span>
<span className="file-node-name">{file.path}</span>
<span className="detail-column-badge changed-files-badge">{getStatusLabel(file.status)}</span>
</button>
);
})}
@@ -106,9 +105,9 @@ export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }:
)}
</aside>
<section className="file-browser-content" style={{ flex: "0 0 70%" }}>
<section className="file-browser-content changed-files-content">
{!loading && !error && files.length > 0 && !selectedFile ? (
<div className="file-browser-empty-state">Select a file to view changes</div>
<div className="file-browser-empty">Select a file to view changes</div>
) : null}
{selectedFile ? (
@@ -116,7 +115,7 @@ export function ChangedFilesModal({ taskId, worktree, column, isOpen, onClose }:
<div className="file-browser-toolbar">
<div className="file-browser-file-info">
<strong>{selectedFile.path}</strong>
<span className="badge">{getStatusLabel(selectedFile.status)}</span>
<span className="detail-column-badge changed-files-badge">{getStatusLabel(selectedFile.status)}</span>
{selectedFile.oldPath ? <span>Renamed from {selectedFile.oldPath}</span> : null}
</div>
</div>

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) => void;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: 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

@@ -43,7 +43,7 @@ interface TaskCardProps {
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onOpenFilesForTask?: (taskId: string) => void;
onOpenFilesForTask?: (taskId: string, worktree: string | undefined, column: string) => void;
}
function areTaskBadgeInfosEqual(
@@ -694,7 +694,7 @@ function TaskCardComponent({
className="card-session-files"
onClick={(e) => {
e.stopPropagation();
onOpenFilesForTask?.(task.id);
onOpenFilesForTask?.(task.id, task.worktree, task.column);
}}
disabled={!onOpenFilesForTask}
>

View File

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

View File

@@ -41,7 +41,7 @@ describe("ChangedFilesModal", () => {
);
expect(screen.getByText("Changed Files — KB-651")).toBeInTheDocument();
expect(screen.getByText("src/a.ts")).toBeInTheDocument();
expect(screen.getByRole("listitem", { name: "src/a.ts" })).toBeInTheDocument();
expect(screen.getByLabelText("Diff for src/a.ts")).toBeInTheDocument();
expect(screen.getByText(/\+hello/)).toBeInTheDocument();
});
@@ -57,7 +57,7 @@ describe("ChangedFilesModal", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: /src\/b.ts/i }));
fireEvent.click(screen.getByRole("listitem", { name: /src\/b.ts/i }));
expect(mockSetSelectedFile).toHaveBeenCalledWith({ path: "src/b.ts", status: "added", diff: "diff --git a/src/b.ts b/src/b.ts" });
});

View File

@@ -11690,6 +11690,41 @@ html .column.drag-over * {
font-size: 13px;
}
.changed-files-layout {
display: flex;
flex: 1;
min-height: 0;
}
.changed-files-sidebar {
width: 30%;
min-width: 260px;
max-width: 420px;
}
.changed-files-content {
flex: 1;
min-width: 0;
}
.changed-files-entry {
width: 100%;
border: 0;
background: transparent;
text-align: left;
}
.changed-files-entry.active {
background: var(--card-hover);
}
.changed-files-badge {
margin-left: auto;
flex-shrink: 0;
background: rgba(88, 166, 255, 0.15);
color: var(--todo);
}
/* ── Commit Form ── */
.gm-commit-form {

View File

@@ -7,7 +7,7 @@ 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 { execFileSync, execSync } from "node:child_process";
import { createApiRoutes } from "./routes.js";
import { GitHubClient } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -3792,6 +3792,94 @@ 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",
}),
getRootDir: vi.fn().mockReturnValue(worktreeDir),
});
});
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, "rm", "keep.txt"]);
expect(execSync("git status --short", { cwd: worktreeDir, encoding: "utf-8" })).not.toBe("");
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"]);
expect(execSync("git status --short", { cwd: worktreeDir, encoding: "utf-8" })).not.toBe("");
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;

View File

@@ -1062,6 +1062,13 @@ 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;
@@ -1949,6 +1956,141 @@ 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 {
try {
const fallback = execSync("git diff --name-status HEAD", {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
files = fallback ? parseNameStatus(fallback) : [];
} catch {
const workingTreeFallback = execSync("git status --short", {
cwd: task.worktree,
encoding: "utf-8",
timeout: 5000,
}).trim();
files = workingTreeFallback
? workingTreeFallback
.split("\n")
.map((line) => line.trimEnd())
.filter(Boolean)
.map((line) => {
const indexStatus = line[0] ?? " ";
const worktreeStatus = line[1] ?? " ";
const statusCode = indexStatus !== " " ? indexStatus : worktreeStatus;
const remainder = line.slice(3).trim();
const normalized = statusCode === "R"
? `R\t${remainder.replace(/\s+->\s+/, "\t")}`
: `${statusCode || "M"}\t${remainder}`;
return normalized;
})
.map((line) => parseNameStatus(line)[0])
.filter((entry): entry is TaskFileDiff => Boolean(entry))
: [];
}
}
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.