@@ -294,6 +363,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
return (
+ {renderChangesHeader()}
{t("taskChanges.noFilesModified", "No files modified.")}
@@ -335,72 +405,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
)}
-
-
-
-
- {t("taskChanges.filesChangedHeading", "Files Changed ({{count}})", { count: stats.filesChanged })}
-
-
- +{stats.additions}{" "}
- -{stats.deletions}
-
-
-
-
- {files.length > 0 && (
-
-
-
- {currentFileIndex !== null ? `${currentFileIndex + 1}/${files.length}` : `—/${files.length}`}
-
-
-
- )}
-
-
-
-
-
-
-
-
+ {renderChangesHeader()}
{files.map((file) => {
diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css
index 66fb98f4f9..85dfe64467 100644
--- a/packages/dashboard/app/components/TaskDetailModal.css
+++ b/packages/dashboard/app/components/TaskDetailModal.css
@@ -1166,6 +1166,45 @@
margin-left: calc(var(--space-lg) + var(--space-sm)); /* align with text after icon + gap */
}
+@media (max-width: 768px) {
+ .task-changes-tab .changes-header {
+ align-items: stretch;
+ gap: var(--space-sm);
+ }
+
+ .task-changes-tab .changes-header-actions-wrapper {
+ width: 100%;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-start;
+ gap: var(--space-sm);
+ }
+
+ .task-changes-tab .changes-header-actions,
+ .task-changes-tab .changes-header-actions-secondary {
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+ }
+
+ .task-changes-tab .changes-header-actions {
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+
+ .task-changes-tab .changes-header-actions-secondary {
+ margin-left: auto;
+ }
+
+ .task-changes-tab .task-changes-header-title {
+ gap: var(--space-xs);
+ }
+
+ .task-changes-tab .task-changes-stats {
+ margin-left: calc(var(--space-lg) + var(--space-xs));
+ }
+}
+
/* Truncate parent dirs from the LEFT so the filename stays visible. JSX
* wraps the path in `` to preserve readable left-to-right
* display while `direction: rtl` flips the ellipsis to the start. */
diff --git a/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx
index 2bce2e9760..38baf06f45 100644
--- a/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx
@@ -1053,7 +1053,7 @@ describe("TaskChangesTab — expand button", () => {
expect(screen.getByTestId("changes-diff-modal")).toBeTruthy();
});
- it("does not render expand button when no files are loaded", async () => {
+ it("keeps the expand button available when no files are loaded", async () => {
mockFetchTaskDiff.mockResolvedValue({
files: [],
stats: { filesChanged: 0, additions: 0, deletions: 0 },
@@ -1072,7 +1072,7 @@ describe("TaskChangesTab — expand button", () => {
expect(screen.getByText("No files modified.")).toBeTruthy();
});
- expect(screen.queryByLabelText("Expand diff view")).toBeNull();
+ expect(screen.getByLabelText("Expand diff view")).toBeTruthy();
});
});
@@ -1224,6 +1224,105 @@ describe("TaskChangesTab — file path display", () => {
});
});
+describe("TaskChangesTab — header toolbar structure", () => {
+ it("keeps nav and controls grouped inside the header actions wrapper for populated diffs", async () => {
+ mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
+ const { container } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Files Changed (2)")).toBeTruthy();
+ });
+
+ const header = container.querySelector(".task-changes-tab .changes-header");
+ const actionsWrapper = header?.querySelector(".changes-header-actions-wrapper");
+ const primaryActions = actionsWrapper?.querySelector(".changes-header-actions");
+ const secondaryActions = actionsWrapper?.querySelector(".changes-header-actions-secondary");
+ const nav = primaryActions?.querySelector(".changes-nav");
+
+ expect(actionsWrapper).toBeTruthy();
+ expect(primaryActions).toBeTruthy();
+ expect(secondaryActions).toBeTruthy();
+ expect(nav).toBeTruthy();
+ expect(nav?.querySelector('[aria-label="Previous file"]')).toBeTruthy();
+ expect(nav?.querySelector('[aria-label="Next file"]')).toBeTruthy();
+ expect(nav?.querySelector(".changes-nav-indicator")?.textContent).toBe("1/2");
+ expect(screen.getByLabelText("Toggle word wrap").closest(".changes-header-actions")).toBe(primaryActions ?? null);
+ expect(screen.getByText("Refresh").closest(".changes-header-actions-secondary")).toBe(secondaryActions ?? null);
+ expect(screen.getByLabelText("Expand diff view").closest(".changes-header-actions-secondary")).toBe(secondaryActions ?? null);
+ });
+
+ it("renders the header controls without nav when the diff is empty", async () => {
+ mockFetchTaskDiff.mockResolvedValue({
+ files: [],
+ stats: { filesChanged: 0, additions: 0, deletions: 0 },
+ });
+
+ const { container } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("No files modified.")).toBeTruthy();
+ });
+
+ const header = container.querySelector(".task-changes-tab .changes-header");
+ const actionsWrapper = header?.querySelector(".changes-header-actions-wrapper");
+
+ expect(header).toBeTruthy();
+ expect(actionsWrapper).toBeTruthy();
+ expect(header?.querySelector(".changes-nav")).toBeNull();
+ expect(screen.getByLabelText("Toggle word wrap")).toBeTruthy();
+ expect(screen.getByText("Refresh")).toBeTruthy();
+ expect(screen.getByLabelText("Expand diff view")).toBeTruthy();
+ });
+
+ it("keeps commit metadata above the header while preserving the actions structure", async () => {
+ mockFetchTaskDiff.mockResolvedValue(DONE_TASK_DIFF);
+ const { container } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Files Changed (2)")).toBeTruthy();
+ });
+
+ const taskTab = container.querySelector(".task-changes-tab");
+ const header = taskTab?.querySelector(":scope > .changes-header");
+ const commitMeta = taskTab?.querySelector(":scope > .commit-diff-meta");
+
+ expect(commitMeta).toBeTruthy();
+ expect(header).toBeTruthy();
+ expect(commitMeta?.nextElementSibling).toBe(header ?? null);
+ expect(header?.querySelector(".changes-header-actions-wrapper .changes-header-actions-secondary")).toBeTruthy();
+ });
+
+ it("defines task-scoped mobile toolbar overrides without changing shared desktop rules", () => {
+ const css = loadAllAppCss();
+ const mobileToolbarRule = css.match(/@media\s*\(max-width:\s*768px\)\s*\{[\s\S]*?\.task-changes-tab\s+\.changes-header-actions-wrapper\s*\{([\s\S]*?)\}/);
+
+ expect(mobileToolbarRule).toBeTruthy();
+ expect(mobileToolbarRule![1]).toContain("flex-direction: row;");
+ expect(mobileToolbarRule![1]).toContain("flex-wrap: wrap;");
+ expect(mobileToolbarRule![1]).toContain("width: 100%;");
+ });
+});
+
describe("TaskChangesTab — action button sizing", () => {
it("keeps Refresh and expand buttons on the same compact height", () => {
const css = loadAllAppCss();