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 9fd693880b
commit fb204dd68c
12 changed files with 314 additions and 24 deletions

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" });
});