**Batch branch is live: `batch-dashboard-app`.** Push conversions here as commits rather than opening per-file PRs — that is the CI-run bottleneck this model removes. **One-line ownership note for you to arbitrate:** you have addressed me as U11, U12 and U7 at different points, so the `u12 worker -> batch-dashboard-app` mapping is ambiguous from my side. I claimed it because `dashboard/app` is where I have done the most work this session (TaskContextMenu, Column, TaskCard, TaskDetailModal, columnRoles, taskActivity) and I know which of its guards are load-bearing fallbacks. **If another worker is the intended owner, say so and I will hand the branch over rather than both of us pushing to it** — two workers on one shared branch is exactly what silently discarded a reviewed fix in #2645 today. ## The work order (measured at branch point, tests excluded) **75 guards across 32 files.** Largest: `TaskContextMenu.tsx` 9 · `Column.tsx` 7 · `ListView.tsx` 6 · `TaskDetailModal.tsx` 4 · then a long tail of 3s, 2s and 1s. Full per-file list is in the committed work order so feeders can claim without re-measuring. ## Two rules this surface keeps tripping on **1. A literal after `??`, or in the `else` of a `flags ?` ternary, is a DEGRADED-MODE answer — not an unconverted guard.** Two real states reach it: the **pre-load window** (board renders before the workflows fetch resolves) and a card stranded on an id its workflow no longer declares. In both, `columnFlagsById` has no entry at all. Deleting the fallback does not remove a decision — it substitutes "no role" silently, and affordances vanish during first paint. Those sites reach 0 by **marking**, not deleting. Expect `TaskContextMenu.tsx` and the `utils` files to be **mostly marks**. A "9 → 0" that deleted 9 fallbacks is a regression wearing a green census. **2. A marker excuses ONLY the construct it is attached to** — the statement or function holding the literal, not a sibling declaration. This has cost three passes, two of them mine; my first attempt on `reliability-metrics.ts` scored **1 of 6**. **Verify by the count moving, not by the comment existing.** With the ratchet gate-blocking, a mis-marked batch either wedges the gate or locks the miss into a re-recorded baseline. ## Status Opening commit is the work order only — **0 of 75 converted so far.** I am near the end of my context, so I am establishing the branch and the shared list rather than starting conversions I cannot finish cleanly. Feeders can begin immediately; I will keep the branch rebased. My other PR **#2762** (`live-agent-count.ts` 6 → 0) is green and unconflicted — per your rule it should land rather than fold into a batch, and it is `packages/core` so it belongs to batch-core anyway. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Task UI now resolves workflow “column roles” per task to drive diffs/merge details, routing/steering, progress/runtime visibility, and review badges. * Right-dock/overflow views and dev-server now use per-task column traits for “executing” behavior and dependency-based “Up Next” eligibility. * **Bug Fixes** * Fixed bulk action selection/delete/archive eligibility and prevented cross-workflow role leakage. * Made in-review/stale-paused-review, stuck, and effective executor/validator model logic role-aware. * **Tests** * Added regression coverage for degraded-flag behavior and ensured resolved-flag props aren’t ignored. * Added a static check to fail builds on inert optional flag seams. * **Documentation** * Updated batch work-order and mega-batch branch guidance. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --- ## Late addition: the seam gate was masking a real offender `scripts/check-inert-flag-seams.mjs` matched call sites by NAME, so two same-named functions in different modules were conflated. I had documented that as a known false-positive source and moved on — reports mentioning `sortTasksForDisplayColumn` are noise, read past them. That annotation was the damage. Core's `sortTasksForDisplayColumn` genuinely never receives its `columnFlags` argument outside its own tests. The dashboard's separate function of the same name (`app/components/taskSorting.ts`), called with up to five arguments from `Lane`/`Board`/`ListView`, was raising the arg-count max and clearing core's seam. The offender was behind a row everyone had been told to skip. The gate now records the module each callee is imported from and matches it against the seam's declaring module. **Measured, by reverting the change:** the scan prints `17 seams, all supplied` and emits **no row** for the function. With the change, it is reported. Both directions watched. Reported on #2783 rather than fixed from outside — core owns it, and "wire the flags" vs "drop the parameter and let the literal stay counted" is their judgment call. TEMPORARY allow-list entry carries it meanwhile; the existing staleness check fails the moment the site becomes supplied, so the entry cannot outlive the fix. Two known limits remain, both inherent to name matching and both documented in the script: the one-supplier floor, and the `__tests__` exclusion (hence the two permanent `ALLOWED` entries). ## And the one-supplier floor, closed the same way I wrote in the section above that the floor "hasn't cost anything yet." That is verbatim the reasoning that kept the imported-shadow bug alive, so I closed it instead of leaving the note. `best < arity` asked only whether SOME caller supplied the argument. One correct call site cleared the seam while every sibling took the legacy fallback — the `isTaskStuck` defect class, where two of three sites omitted the flags and the gate stayed green because the third was right. Review caught that one. A partially-supplied seam is the harder of the two: wholly-unsupplied is uniformly wrong, this works on the board you tested and degrades on the column you did not. **Measured:** dropping the flags argument at `Column.tsx`'s supplied call site produces `supplied by 5/6 call sites; omitted at packages/dashboard/app/components/Column.tsx:1 (of 2)`; restoring returns `all supplied at every call site`. Red and green both watched. Two real omissions found, both on `isNearDuplicateCanonicalInactive`: - **`TaskDetailModal.tsx`** — deliberate, and it **corrects a note I left at that site**. The old note said hoisting the flags state was "the actual fix." It is not, for this call: the flags in scope describe the *modal's* task, and the canonical is a **different task** on a column this component never resolves. Passing them would type-check, read as a conversion, and answer about the wrong task — exactly what `column-role-degraded-flags.test.ts` exists to catch. Supplying it correctly needs a fetch, which is a data change and out of scope. - **`core/task-store/branch-group-ops.ts`** — genuinely wireable (the impl is async and already holds `store` and `canonicalId`). Reported on #2783, not edited from outside. Exemptions for this class are keyed by **call site** (`<file>::<function>`), not by function name. A name-level entry would waive every site of a partially-supplied seam, which is backwards — its other sites are correct and are the reason the omission is worth reporting. Both entries carry the same staleness check as the name-level list and cannot outlive their fix. Remaining known limit, now the only one: the `__tests__` exclusion, which makes a test-only export read as having no callers. That is what the two permanent `ALLOWED` entries are. ## The `__tests__` exclusion, and two allow-list entries built on false reasons Named as the "last remaining limit" above, so it got closed too. The scan now reads test files for call sites — but counts them **separately**, and a test never clears a seam. That direction is the dangerous one: counting test callers as suppliers would have re-hidden core's `sortTasksForDisplayColumn`, whose only suppliers are its own tests. Measured by lifting its exemption: still reported. Both permanent allow-list entries claimed the scanner couldn't see their callers. **Both reasons were false**, and reading tests is what proved it: - **`evaluateMergeBlockerGuard`** — zero callers in tests either. Its only reference in the repo is its own declaration; never registered as a trait hook; the `evaluateDefaultWorkflowGuards` reader its file header credits does not exist. The `lifecycleColumns` conversion went onto dead code, and its note describes a crossing the guard cannot make. Reported on #2783, including the two things I am explicitly *not* concluding (no `"guard"` hook is registered in production; whether that is residue or a dropped registration needs core's intent). - **`isRecoverableMissingWorktreeReviewFailure`** — 5 test call sites. It wraps `...WithProgress`/`...NoProgress`, the live pair called from `self-healing.ts`, both supplying `reviewColumns`. Entry kept, true reason recorded. ### A wrong turn, recorded because it is the failure mode this PR is about I first classified no-production-caller seams as *informational* when they weren't re-exported from a package index, reasoning that a public export might be called externally. That silently downgraded `sortTasksForDisplayColumn` — a confirmed real offender — from failing to a footnote. Publication status has nothing to do with whether there is production behaviour to be wrong. Reverted to the simple rule: no production caller means inert, and it fails. It is worth stating plainly because it is the exact shape of everything else in this PR: a change that made the gate read *cleaner* while making it catch *less*, and it type-checked, passed every test, and would have reviewed fine. ### Where that leaves the check Every blind spot named in this PR has now been closed, and **each one produced a real defect within minutes of closing it** — imported shadows, the one-supplier floor, the `__tests__` exclusion. Four verified findings went to core, one to engine. I would not read the remaining ~240 guards' green gates as evidence that they are clean; I would read them as untested. ## Two guards for one question, one of them worse Having hardened the script, I checked its older twin rather than assuming it was fine. `resolved-flags-seams-have-suppliers.test.ts` carried its own copy of the trailing-flags-parameter check — written before the script existed — with **all three** holes the script has since closed. **Measured on one reintroduced defect** (dropping the flags argument at `Column.tsx`'s supplied `isNearDuplicateCanonicalInactive` call): | | result | |---|---| | `scripts/check-inert-flag-seams.mjs` | `supplied by 5/6 call sites; omitted at .../Column.tsx:1 (of 2)` | | this test's arity half | **3 passed** | Deleted the arity half. Redundancy between a strong and a weak check isn't redundancy — it's a green result available to whoever runs the weak one, and there was no signal at the call site telling you which you were looking at. The **props-shape half stays**: it has no twin in the script, and I confirmed it still fires by reintroducing the original `PrPanel` defect (outer component stops destructuring `taskColumnFlags`) — it reports `PrPanel declares taskColumnFlags but never takes it`. Dashboard app suite: **113 files / 3921 tests** (was 3922 — the deleted case is the difference). ## The gate started catching defects as they landed Syncing with main brought in three fresh conversions from other workers. The hardened check flagged all three immediately — the first time these guards have fired on someone else's landed code rather than on my own. - **`TaskCard`** — `getRunningOptionalGateBadge(task)` omitted flags while *both* `ListView` sites supplied. Fixed, and `taskColumnFlags` added to the `useMemo` deps: no `exhaustive-deps` rule here, so a memo that reads flags without listing them keeps the first-paint `undefined` answer and reproduces the bug through staleness instead of omission. - **`TaskTokenStatsPanel`** — `getTotalAgentActiveMs` omitted while `TaskCard` supplied, so the same runtime number came from the real column on a card and from legacy ids in the detail modal. Now takes `columnFlags`, supplied from `detailColumnFlags` — correct here because the panel renders the modal's **own** task, unlike the near-duplicate canonical above. - **`ListView` ×2** — passed `columnFlagsById.get(task.column)`, the cross-workflow **union**. A task whose own workflow doesn't declare that column gets a *neighbour workflow's* traits. The landed comment justified it as "this list already owns `columnFlagsById`" — exactly the reasoning `column-role-degraded-flags.test.ts` exists to reject. It failed on merge and is how I found this. Also: the `getTotalAgentActiveMs` exemption I was carrying **self-retired**. Main wired the seam, the staleness check failed the entry, and I removed it. That mechanism has now paid for itself once. ### Pre-existing, NOT from this PR: `App.test.tsx` is red on main `app/components/__tests__/App.test.tsx` fails **10 of 141** identically with my changes, with my changes stashed, and with main's own `App.tsx` restored. Not mine, and not in the merge gate. **Bisected on clean `main` checkouts, so this is measured rather than inferred:** | commit | date | result | |---|---|---| | `main~400` (`41d60f0355`) | 2026-07-25 | **140 passed** (140 tests) | | `main~275` (`74d6513fae`) | 2026-07-27 | 3 failed / 141 | | `main~210` (`d2ce1ba8b5`) | 2026-07-29 | 10 failed / 141 | | `main` (`6fc98fd6c7`) | 2026-07-30 | 10 failed / 141 | So it is **not one regression** — it degraded in two stages across 2026-07-25 → 07-29, and the test file itself changed in that window (140 → 141 tests). Three commits touched it there: `73b2a32e2b`, `f26cbedf4f`, `f157bf7460`. That window overlaps the workflow-owned lifecycle migration, which is suggestive but not something I confirmed. The failures are render-level, not assertion-level — `Unable to find an element with the text: + New Task`, `Unable to find role="dialog"`, `Unable to find ... Back nav task`. The board appears to render nothing. That reads like a real regression or a harness mismatch after the lifecycle migration, not a flake, so I have deliberately **not** quarantined it — quarantine is for flakes, and using it here would hide the signal. Flagging for whoever owns `App.tsx`. My suites: `app/__tests__` **113 files / 3921 tests** green, `tsc` 0, lint 0, census `--strict` 0, seam gate 0. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
495 lines
20 KiB
TypeScript
495 lines
20 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import { isCompleteColumnRole, isReviewColumnRole, isWipColumnRole } from "../utils/columnRoles";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FileCode, ChevronDown, ChevronRight, ChevronLeft, AlertCircle, GitCommit, WrapText, Maximize2 } from "lucide-react";
|
|
import type { MergeDetails, ColumnId } from "@fusion/core";
|
|
import { LoadingSpinner } from "./LoadingSpinner";
|
|
import { getErrorMessage } from "@fusion/core";
|
|
import {
|
|
fetchTaskDiff,
|
|
type TaskDiff,
|
|
} from "../api";
|
|
import { highlightDiff } from "../utils/highlightDiff";
|
|
import { ChangesDiffModal } from "./ChangesDiffModal";
|
|
import "./TaskDiffShared.css";
|
|
import "./TaskChangesTab.css";
|
|
|
|
interface TaskChangesTabProps {
|
|
/** Resolved column flags for this task, from TaskDetailModal. */
|
|
columnFlags?: Parameters<typeof isCompleteColumnRole>[0];
|
|
taskId: string;
|
|
worktree?: string;
|
|
projectId?: string;
|
|
column?: ColumnId;
|
|
mergeDetails?: MergeDetails;
|
|
/**
|
|
* FNXC:Workspace 2026-06-25-09:40:
|
|
* True for a workspace (multi-repo) task. Such a task has no singular
|
|
* `worktree`/`branch` — its changes live in per-sub-repo worktrees, which the
|
|
* backend `/tasks/:id/diff` now aggregates (repo-prefixed paths). Used to skip
|
|
* the single-repo "No worktree available" empty state, which would otherwise
|
|
* fire on every workspace task because `worktree` is undefined.
|
|
*/
|
|
isWorkspace?: boolean;
|
|
/**
|
|
* Files modified by the task during execution, captured from the worktree.
|
|
* Used as a last-resort fallback when the live worktree diff is empty or the
|
|
* recorded `mergeDetails.commitSha` resolves to an empty git commit (which
|
|
* can happen when the merger stores a per-branch SHA that gets collapsed
|
|
* into a different squash on main).
|
|
*
|
|
* Done-task sources of truth:
|
|
* - Authoritative landed diff: `/api/tasks/:id/diff` lineage union.
|
|
* - Fallback views keep a consistent `N file(s) changed` headline.
|
|
* - Provenance is disclosed in `task-changes-state-hint` text.
|
|
*/
|
|
modifiedFiles?: string[];
|
|
}
|
|
|
|
function getStatusLabel(status: "added" | "modified" | "deleted" | "unknown"): string {
|
|
switch (status) {
|
|
case "added":
|
|
return "A";
|
|
case "deleted":
|
|
return "D";
|
|
case "modified":
|
|
return "M";
|
|
default:
|
|
return "?";
|
|
}
|
|
}
|
|
|
|
function renderModifiedFilesFallback(
|
|
fileList: string[],
|
|
isDone: boolean,
|
|
mergeDetails?: MergeDetails,
|
|
source: "landed" | "execution" = "execution",
|
|
t?: ReturnType<typeof useTranslation>["t"],
|
|
) {
|
|
const getT = (key: string, defaultValue: string, options?: Record<string, unknown>) =>
|
|
t ? t(key, defaultValue, options) : defaultValue;
|
|
return (
|
|
<div className="detail-section task-changes-tab">
|
|
{isDone && mergeDetails && (
|
|
<div className="commit-diff-meta">
|
|
{mergeDetails.commitSha && (
|
|
<div className="commit-diff-sha">
|
|
<GitCommit size={14} />
|
|
<code>{mergeDetails.commitSha.slice(0, 7)}</code>
|
|
</div>
|
|
)}
|
|
{mergeDetails.mergedAt && (
|
|
<div className="commit-diff-timestamp">
|
|
{getT("taskChanges.merged", "Merged {{date}}", { date: new Date(mergeDetails.mergedAt).toLocaleString() })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="task-changes-state task-changes-state--empty">
|
|
<FileCode size={24} />
|
|
<p>{getT(`taskChanges.fileCount`, "{{count}} file{{plural}} changed.", { count: fileList.length, plural: fileList.length === 1 ? "" : "s" })}</p>
|
|
<span className="task-changes-state-hint">
|
|
{isDone
|
|
// FN-4647: done-task fallback must explicitly describe executor-captured scope.
|
|
? source === "landed"
|
|
? getT("taskChanges.landedFilesHint", "These are files captured from the merged commit metadata. The lineage-backed diff is unavailable for this task.")
|
|
: getT("taskChanges.executionFilesHint", "These are files captured from the worktree during execution. They may differ from the files that actually landed on main. The lineage-backed diff is unavailable for this task.")
|
|
: getT("taskChanges.emptyWorktreeHint", "The live worktree diff is empty. Showing the last file paths captured during execution — patches unavailable.")}
|
|
</span>
|
|
</div>
|
|
<div className="changes-file-list task-changes-file-list--compact">
|
|
{fileList.map((path) => (
|
|
<div key={path} className="changes-file-item">
|
|
<div className="changes-file-header changes-file-header--static">
|
|
<span
|
|
className="changes-file-status changes-file-status--unknown"
|
|
title={getT("taskChanges.statusUnknown", "status unknown")}
|
|
>
|
|
{getStatusLabel("unknown")}
|
|
</span>
|
|
<span className="changes-file-path" title={path}>
|
|
<bdo dir="ltr">{path}</bdo>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Normalized file entry used by both worktree-backed and commit-backed paths */
|
|
interface NormalizedFile {
|
|
path: string;
|
|
status: "added" | "modified" | "deleted" | "unknown";
|
|
additions: number;
|
|
deletions: number;
|
|
patch: string;
|
|
}
|
|
|
|
/**
|
|
* TaskChangesTab displays file-level diffs for a task.
|
|
*
|
|
* For in-progress/in-review tasks it loads the diff from the live worktree.
|
|
* For done tasks it always attempts `/api/tasks/:id/diff` (lineage-backed when
|
|
* needed) so the detailed view stays aligned with TaskCard/useTaskDiffStats.
|
|
*
|
|
* When a done task has no recorded merge commit SHA and the server returns no
|
|
* diff rows (or diff loading fails), the tab falls back to the safe summary/
|
|
* modifiedFiles view instead of showing a hard error. This preserves the prior
|
|
* graceful behavior while allowing FN-4563/FN-4576 lineage-backed parity.
|
|
*/
|
|
export function TaskChangesTab({ columnFlags, taskId, worktree, projectId, column, mergeDetails, modifiedFiles, isWorkspace }: TaskChangesTabProps) {
|
|
const { t } = useTranslation("app");
|
|
const [files, setFiles] = useState<NormalizedFile[]>([]);
|
|
const [stats, setStats] = useState<{ filesChanged: number; additions: number; deletions: number }>({ filesChanged: 0, additions: 0, deletions: 0 });
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
|
const [currentFileIndex, setCurrentFileIndex] = useState<number | null>(null);
|
|
const [wordWrap, setWordWrap] = useState(true);
|
|
const [expandedViewOpen, setExpandedViewOpen] = useState(false);
|
|
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-30-17:00 (batch-dashboard-app):
|
|
Two role questions, both resolved; `columnFlags` omitted -> the legacy ids.
|
|
|
|
`isDone` picks the diff SOURCE: a finished card diffs against its merge commit, an in-flight one
|
|
against its worktree. `canLoad` decides whether a diff can be fetched at all — and it was the
|
|
narrower failure: on a renamed board NONE of the three ids matched, so the Changes tab loaded
|
|
nothing and reported no changes for every task, whatever state it was in.
|
|
*/
|
|
/* `column` is optional on these props; `column === "done"` was false for undefined, so the empty
|
|
string preserves that exactly — no role matches it. */
|
|
const isDone = isCompleteColumnRole(columnFlags, column ?? "");
|
|
const isDoneWithCommit = isDone && Boolean(mergeDetails?.commitSha);
|
|
|
|
const canLoad = isWipColumnRole(columnFlags, column ?? "") || isReviewColumnRole(columnFlags, column ?? "") || isDone;
|
|
|
|
const loadDiff = useCallback(async () => {
|
|
if (!canLoad && !isDone) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
if (!canLoad) {
|
|
setFiles([]);
|
|
setStats({ filesChanged: 0, additions: 0, deletions: 0 });
|
|
return;
|
|
}
|
|
|
|
const data: TaskDiff = await fetchTaskDiff(taskId, undefined, projectId);
|
|
const normalized: NormalizedFile[] = data.files.map((f) => ({
|
|
path: f.path,
|
|
status: f.status,
|
|
additions: f.additions,
|
|
deletions: f.deletions,
|
|
patch: f.patch,
|
|
}));
|
|
setFiles(normalized);
|
|
setStats(data.stats);
|
|
if (normalized.length > 0) {
|
|
setExpandedFiles(new Set([normalized[0].path]));
|
|
setCurrentFileIndex(0);
|
|
}
|
|
} catch (err) {
|
|
if (isDone && !mergeDetails?.commitSha) {
|
|
setFiles([]);
|
|
setStats({ filesChanged: 0, additions: 0, deletions: 0 });
|
|
setError(null);
|
|
} else {
|
|
setError(getErrorMessage(err) || t("taskChanges.loadError", "Failed to load task changes"));
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [taskId, projectId, canLoad, isDone, mergeDetails?.commitSha]);
|
|
|
|
useEffect(() => {
|
|
loadDiff();
|
|
}, [loadDiff]);
|
|
|
|
const toggleFile = (filePath: string) => {
|
|
setExpandedFiles((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(filePath)) {
|
|
next.delete(filePath);
|
|
} else {
|
|
next.add(filePath);
|
|
// Update currentFileIndex to the newly expanded file
|
|
const idx = files.findIndex((f) => f.path === filePath);
|
|
if (idx !== -1) {
|
|
setCurrentFileIndex(idx);
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const navigateToFile = (index: number) => {
|
|
if (index < 0 || index >= files.length) return;
|
|
const targetPath = files[index].path;
|
|
// Collapse all files and expand only the target
|
|
setExpandedFiles(new Set([targetPath]));
|
|
setCurrentFileIndex(index);
|
|
};
|
|
|
|
const canGoPrev = currentFileIndex !== null && currentFileIndex > 0;
|
|
const canGoNext = currentFileIndex !== null && currentFileIndex < files.length - 1;
|
|
|
|
const renderChangesHeader = () => (
|
|
<div className="changes-header">
|
|
<div className="task-changes-header-title">
|
|
<h4>
|
|
<FileCode size={16} />
|
|
{t("taskChanges.filesChangedHeading", "Files Changed ({{count}})", { count: stats.filesChanged })}
|
|
</h4>
|
|
<span className="task-changes-stats changes-stat-summary">
|
|
<span className="diff-add">+{stats.additions}</span>{" "}
|
|
<span className="diff-del">-{stats.deletions}</span>
|
|
</span>
|
|
</div>
|
|
<div className="changes-header-actions-wrapper">
|
|
<div className="changes-header-actions">
|
|
{files.length > 0 && (
|
|
<div className="changes-nav">
|
|
<button
|
|
className="btn btn-sm btn-icon"
|
|
onClick={() => canGoPrev && navigateToFile(currentFileIndex! - 1)}
|
|
disabled={!canGoPrev}
|
|
title={t("taskChanges.previousFile", "Previous file")}
|
|
aria-label={t("taskChanges.previousFile", "Previous file")}
|
|
>
|
|
<ChevronLeft />
|
|
</button>
|
|
<span className="changes-nav-indicator" aria-live="polite">
|
|
{currentFileIndex !== null ? `${currentFileIndex + 1}/${files.length}` : `—/${files.length}`}
|
|
</span>
|
|
<button
|
|
className="btn btn-sm btn-icon"
|
|
onClick={() => canGoNext && navigateToFile(currentFileIndex! + 1)}
|
|
disabled={!canGoNext}
|
|
title={t("taskChanges.nextFile", "Next file")}
|
|
aria-label={t("taskChanges.nextFile", "Next file")}
|
|
>
|
|
<ChevronRight />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<button
|
|
className={`btn btn-sm ${wordWrap ? "btn-primary" : ""}`}
|
|
onClick={() => setWordWrap((prev) => !prev)}
|
|
title={t(`taskChanges.${wordWrap ? "disableWordWrap" : "enableWordWrap"}`, wordWrap ? "Disable word wrap" : "Enable word wrap")}
|
|
aria-label={t("taskChanges.toggleWordWrap", "Toggle word wrap")}
|
|
>
|
|
<WrapText size={14} />
|
|
</button>
|
|
</div>
|
|
<div className="changes-header-actions-secondary">
|
|
<button
|
|
className="btn btn-sm"
|
|
onClick={loadDiff}
|
|
disabled={loading}
|
|
>
|
|
{t("common.refresh", "Refresh")}
|
|
</button>
|
|
<button
|
|
className="btn btn-sm btn-icon"
|
|
onClick={() => setExpandedViewOpen(true)}
|
|
title={t("taskChanges.expandDiff", "Expand to full-screen diff view")}
|
|
aria-label={t("taskChanges.expandDiffView", "Expand diff view")}
|
|
>
|
|
<Maximize2 />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="detail-section">
|
|
<div className="task-changes-state task-changes-state--loading">
|
|
<LoadingSpinner label={t("taskChanges.loading", "Loading changes...")} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="detail-section">
|
|
<div className="task-changes-state task-changes-state--error">
|
|
<AlertCircle size={16} />
|
|
<span>{t("taskChanges.error", "Error loading changes: {{error}}", { error })}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Non-done task without a worktree → only show fallback state when branch-fallback diff is empty.
|
|
// A workspace task legitimately has no singular `worktree` (its changes come from the per-sub-repo
|
|
// aggregation), so it must NOT hit this "No worktree available" branch — fall through to the
|
|
// standard empty/populated rendering below.
|
|
if (!isDone && !worktree && !isWorkspace && files.length === 0) {
|
|
if (modifiedFiles && modifiedFiles.length > 0) {
|
|
return renderModifiedFilesFallback(modifiedFiles, false, undefined, "execution", t);
|
|
}
|
|
|
|
return (
|
|
<div className="detail-section">
|
|
<div className="task-changes-state task-changes-state--empty">
|
|
<FileCode size={24} />
|
|
<p>{t("taskChanges.noWorktree", "No worktree available for this task.")}</p>
|
|
<span className="task-changes-state-hint">
|
|
{t("taskChanges.noWorktreeHint", "Changes will be shown once the task is in progress.")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
if (files.length === 0) {
|
|
if (isDone && !isDoneWithCommit) {
|
|
const doneFallbackFiles = mergeDetails?.landedFiles && mergeDetails.landedFiles.length > 0
|
|
? mergeDetails.landedFiles
|
|
: modifiedFiles;
|
|
if (doneFallbackFiles && doneFallbackFiles.length > 0) {
|
|
return renderModifiedFilesFallback(doneFallbackFiles, true, mergeDetails, mergeDetails?.landedFiles?.length ? "landed" : "execution", t);
|
|
}
|
|
|
|
const summaryFiles = mergeDetails?.filesChanged;
|
|
const summaryAdditions = mergeDetails?.insertions;
|
|
const summaryDeletions = mergeDetails?.deletions;
|
|
const hasSummary = summaryFiles != null || summaryAdditions != null || summaryDeletions != null;
|
|
|
|
return (
|
|
<div className="detail-section">
|
|
<div className="task-changes-state task-changes-state--empty">
|
|
<FileCode size={24} />
|
|
<p>{t("taskChanges.unavailable", "Detailed file changes unavailable.")}</p>
|
|
<span className="task-changes-state-hint">
|
|
{hasSummary
|
|
? t("taskChanges.summaryHint", "Final commit summary: {{files}} file{{plural}} changed, +{{additions}} additions, -{{deletions}} deletions. Counts only the recorded merge/squash commit, not the full task lineage.", { files: summaryFiles ?? 0, plural: (summaryFiles ?? 0) === 1 ? "" : "s", additions: summaryAdditions ?? 0, deletions: summaryDeletions ?? 0 })
|
|
: t("taskChanges.noMergeCommit", "No merge commit was recorded for this task.")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isDone && modifiedFiles && modifiedFiles.length > 0) {
|
|
return renderModifiedFilesFallback(modifiedFiles, isDone, mergeDetails, "execution", t);
|
|
}
|
|
|
|
return (
|
|
<div className="detail-section task-changes-tab">
|
|
{renderChangesHeader()}
|
|
<div className="task-changes-state task-changes-state--empty">
|
|
<FileCode size={24} />
|
|
<p>{t("taskChanges.noFilesModified", "No files modified.")}</p>
|
|
<span className="task-changes-state-hint">
|
|
{isDone
|
|
? t("taskChanges.noMergeFileChanges", "No file changes were recorded in the merge commit.")
|
|
: t("taskChanges.noExecutionModifications", "The agent did not modify any files during execution.")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="detail-section task-changes-tab">
|
|
{/* Commit metadata for done tasks */}
|
|
{isDone && mergeDetails && (
|
|
<div className="commit-diff-meta">
|
|
{mergeDetails.commitSha && (
|
|
<div className="commit-diff-sha">
|
|
<GitCommit size={14} />
|
|
<code>{mergeDetails.commitSha.slice(0, 7)}</code>
|
|
</div>
|
|
)}
|
|
{mergeDetails.mergeCommitMessage && (
|
|
<div className="commit-diff-message">{mergeDetails.mergeCommitMessage}</div>
|
|
)}
|
|
{mergeDetails.mergedAt && (
|
|
<div className="commit-diff-timestamp">
|
|
{t("taskChanges.mergedAt", "Merged {{date}}", { date: new Date(mergeDetails.mergedAt).toLocaleString() })}
|
|
</div>
|
|
)}
|
|
{mergeDetails.noOpVerifiedShortCircuit && (
|
|
<div className="text-muted">{t("taskChanges.noOpShortCircuit", "Verified short-circuit — work was already on main (rebase walked foreign commits).")}</div>
|
|
)}
|
|
{mergeDetails.landedFilesCaptureFallback === "attribution-failed" && (
|
|
<div className="text-muted">{t("taskChanges.attributionFailed", "Landed-files set may include foreign commits (attribution unavailable).")}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{renderChangesHeader()}
|
|
|
|
<div className="changes-file-list task-changes-file-list--compact">
|
|
{files.map((file) => {
|
|
const isExpanded = expandedFiles.has(file.path);
|
|
|
|
return (
|
|
<div
|
|
key={file.path}
|
|
className={`changes-file-item ${isExpanded ? "expanded" : ""}`}
|
|
>
|
|
<button
|
|
className="changes-file-header"
|
|
onClick={() => toggleFile(file.path)}
|
|
>
|
|
<span className="changes-file-toggle">
|
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
</span>
|
|
<span
|
|
className={`changes-file-status changes-file-status--${file.status}`}
|
|
title={file.status}
|
|
>
|
|
{getStatusLabel(file.status)}
|
|
</span>
|
|
<span className="changes-file-path" title={file.path}>
|
|
<bdo dir="ltr">{file.path}</bdo>
|
|
</span>
|
|
<span
|
|
className="changes-file-stat"
|
|
title={`+${file.additions} -${file.deletions}`}
|
|
>
|
|
+{file.additions} -{file.deletions}
|
|
</span>
|
|
</button>
|
|
|
|
{isExpanded && file.patch && (
|
|
<div className="changes-file-content">
|
|
<pre className={`changes-diff-patch ${wordWrap ? "changes-diff-patch--wrap" : "changes-diff-patch--nowrap"}`}>
|
|
<code>{highlightDiff(file.patch)}</code>
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<ChangesDiffModal
|
|
isOpen={expandedViewOpen}
|
|
taskId={taskId}
|
|
files={files}
|
|
stats={stats}
|
|
mergeDetails={mergeDetails}
|
|
column={column}
|
|
columnFlags={columnFlags}
|
|
onClose={() => setExpandedViewOpen(false)}
|
|
onRefresh={loadDiff}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|