feat(KB-617): add Changes tab to task detail modal for viewing file diffs
- Add baseCommitSha field to tasks for diff computation baseline - Track modified files in executor and store in database - Add /api/tasks/:id/diff endpoint to serve unified diffs - Add getTaskDiff() API client function in dashboard - Create TaskChangesTab component with file list and diff viewer - Integrate Changes tab into TaskDetailModal with proper styling - Add changeset for the new diff viewer feature
This commit is contained in:
@@ -1868,3 +1868,14 @@ export function fetchProjectTasks(projectId: string, limit?: number, offset?: nu
|
||||
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`);
|
||||
}
|
||||
|
||||
199
packages/dashboard/app/components/TaskChangesTab.tsx
Normal file
199
packages/dashboard/app/components/TaskChangesTab.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { FileCode, ChevronDown, ChevronRight, AlertCircle } from "lucide-react";
|
||||
import { fetchTaskDiff, type TaskDiff } from "../api";
|
||||
|
||||
interface TaskChangesTabProps {
|
||||
taskId: string;
|
||||
worktree?: string;
|
||||
}
|
||||
|
||||
function getFileStatus(file: string, patch: string): "added" | "modified" | "deleted" | "unknown" {
|
||||
if (patch.includes("diff --git")) {
|
||||
if (patch.includes("new file mode")) return "added";
|
||||
if (patch.includes("deleted file mode")) return "deleted";
|
||||
return "modified";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function getStatusColor(status: "added" | "modified" | "deleted" | "unknown"): string {
|
||||
switch (status) {
|
||||
case "added":
|
||||
return "#3fb950"; // green
|
||||
case "deleted":
|
||||
return "#f85149"; // red
|
||||
case "modified":
|
||||
return "#58a6ff"; // blue
|
||||
default:
|
||||
return "#8b949e"; // gray
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
|
||||
const [diffData, setDiffData] = useState<TaskDiff | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadDiff = useCallback(async () => {
|
||||
if (!worktree) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchTaskDiff(taskId);
|
||||
setDiffData(data);
|
||||
// Auto-expand first file if there are files
|
||||
if (data.files.length > 0) {
|
||||
setExpandedFiles(new Set([data.files[0]]));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to load diff");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId, worktree]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDiff();
|
||||
}, [loadDiff]);
|
||||
|
||||
const toggleFile = (file: string) => {
|
||||
setExpandedFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(file)) {
|
||||
next.delete(file);
|
||||
} else {
|
||||
next.add(file);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<div className="detail-loading">
|
||||
<div className="loading-spinner" />
|
||||
<span>Loading changes...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<div className="detail-error">
|
||||
<AlertCircle size={16} />
|
||||
<span>Error loading changes: {error}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!worktree) {
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<div className="detail-empty-state">
|
||||
<FileCode size={24} />
|
||||
<p>No worktree available for this task.</p>
|
||||
<span className="detail-empty-hint">
|
||||
Changes will be shown once the task is in progress.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!diffData || diffData.files.length === 0) {
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<div className="detail-empty-state">
|
||||
<FileCode size={24} />
|
||||
<p>No files modified.</p>
|
||||
<span className="detail-empty-hint">
|
||||
The agent did not modify any files during execution.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detail-section task-changes-tab">
|
||||
<div className="changes-header">
|
||||
<h4>
|
||||
<FileCode size={16} />
|
||||
Modified Files ({diffData.files.length})
|
||||
</h4>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={loadDiff}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="changes-file-list">
|
||||
{diffData.files.map((file) => {
|
||||
const fileDiff = diffData.diffs[file];
|
||||
const status = getFileStatus(file, fileDiff?.patch || "");
|
||||
const isExpanded = expandedFiles.has(file);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file}
|
||||
className={`changes-file-item ${isExpanded ? "expanded" : ""}`}
|
||||
>
|
||||
<button
|
||||
className="changes-file-header"
|
||||
onClick={() => toggleFile(file)}
|
||||
>
|
||||
<span className="changes-file-toggle">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span
|
||||
className="changes-file-status"
|
||||
style={{ color: getStatusColor(status) }}
|
||||
title={status}
|
||||
>
|
||||
{status === "added" && "A"}
|
||||
{status === "modified" && "M"}
|
||||
{status === "deleted" && "D"}
|
||||
{status === "unknown" && "?"}
|
||||
</span>
|
||||
<span className="changes-file-path" title={file}>
|
||||
{file}
|
||||
</span>
|
||||
{fileDiff?.stat && (
|
||||
<span className="changes-file-stat" title={fileDiff.stat}>
|
||||
{fileDiff.stat}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && fileDiff?.patch && (
|
||||
<div className="changes-file-content">
|
||||
<pre className="changes-diff-patch">
|
||||
<code>{fileDiff.patch}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ModelSelectorTab } from "./ModelSelectorTab";
|
||||
import { PrSection } from "./PrSection";
|
||||
import { TaskComments } from "./TaskComments";
|
||||
import { MergeDetails } from "./MergeDetails";
|
||||
import { TaskChangesTab } from "./TaskChangesTab";
|
||||
|
||||
interface ModelSelection {
|
||||
provider?: string;
|
||||
@@ -105,7 +106,7 @@ export function TaskDetailModal({
|
||||
addToast,
|
||||
githubTokenConfigured,
|
||||
}: TaskDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "comments" | "model">("definition");
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "changes" | "steering" | "comments" | "model">("definition");
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
@@ -671,6 +672,14 @@ 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")}
|
||||
@@ -703,6 +712,8 @@ 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" ? (
|
||||
|
||||
@@ -11812,3 +11812,134 @@ html .column.drag-over * {
|
||||
[data-theme="light"] .gm-load-more:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* ── Task Changes Tab Styles ─────────────────────────────────────────────── */
|
||||
|
||||
.task-changes-tab {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.changes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.changes-header h4 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.changes-file-list {
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.changes-file-item {
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
}
|
||||
|
||||
.changes-file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.changes-file-item.expanded {
|
||||
background: var(--bg-secondary, #161b22);
|
||||
}
|
||||
|
||||
.changes-file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary, #c9d1d9);
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.changes-file-header:hover {
|
||||
background: var(--bg-hover, #1f242c);
|
||||
}
|
||||
|
||||
.changes-file-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.changes-file-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.changes-file-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.changes-file-stat {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.changes-file-content {
|
||||
border-top: 1px solid var(--border, #30363d);
|
||||
background: var(--bg-primary, #0d1117);
|
||||
}
|
||||
|
||||
.changes-diff-patch {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
color: var(--text-primary, #c9d1d9);
|
||||
}
|
||||
|
||||
.changes-diff-patch code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Syntax highlighting for diff */
|
||||
.changes-diff-patch .diff-add,
|
||||
.changes-diff-patch [data-prefix="+"] {
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.changes-diff-patch .diff-del,
|
||||
.changes-diff-patch [data-prefix="-"] {
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.changes-diff-patch .diff-hunk,
|
||||
.changes-diff-patch [data-prefix="@@"] {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user