FN-5968: streamline Changes-tab mobile controls

Tighten the Task Changes toolbar so mobile diff navigation and actions stay compact.

- reuse a shared Changes header renderer so empty and populated states keep the same controls
- add mobile-specific Task Changes toolbar layout rules for wrapped navigation and secondary actions
- cover the mobile header structure, empty-diff actions, and publish a patch changeset

Files changed:
 .changeset/fn-5968-changes-tab-mobile.md           |   5 +
 packages/dashboard/app/components/TaskChangesTab.tsx    | 137 +++++++++++----------
 packages/dashboard/app/components/TaskDetailModal.css   |  39 ++++++
 packages/dashboard/app/components/__tests__/TaskChangesTab.test.tsx   | 103 +++++++++++++++-
 4 files changed, 216 insertions(+), 68 deletions(-)

Fusion-Task-Id: FN-5968

Fusion-Task-Lineage: 70865b8f-7f77-4cdb-aa0f-0d497d05e4ac
This commit is contained in:
gsxdsm
2026-06-07 10:21:09 -07:00
parent fb6b441fbe
commit 08d25f041a
4 changed files with 216 additions and 68 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Streamline the Task Changes tab header controls on mobile so diff navigation and actions use a more compact layout.

View File

@@ -217,6 +217,75 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
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">
@@ -294,6 +363,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
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>
@@ -335,72 +405,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
</div>
)}
<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>
{renderChangesHeader()}
<div className="changes-file-list task-changes-file-list--compact">
{files.map((file) => {

View File

@@ -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 `<bdo dir="ltr">` to preserve readable left-to-right
* display while `direction: rtl` flips the ellipsis to the start. */

View File

@@ -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(
<TaskChangesTab
taskId="FN-001"
worktree={undefined}
column="done"
mergeDetails={MERGE_DETAILS}
/>,
);
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(
<TaskChangesTab
taskId="FN-001"
worktree="/path/to/worktree"
column="in-progress"
/>,
);
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(
<TaskChangesTab
taskId="FN-001"
worktree={undefined}
column="done"
mergeDetails={MERGE_DETAILS}
/>,
);
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();