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:
5
.changeset/fn-5968-changes-tab-mobile.md
Normal file
5
.changeset/fn-5968-changes-tab-mobile.md
Normal 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.
|
||||||
@@ -217,6 +217,75 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
|
|||||||
const canGoPrev = currentFileIndex !== null && currentFileIndex > 0;
|
const canGoPrev = currentFileIndex !== null && currentFileIndex > 0;
|
||||||
const canGoNext = currentFileIndex !== null && currentFileIndex < files.length - 1;
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
@@ -294,6 +363,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="detail-section task-changes-tab">
|
<div className="detail-section task-changes-tab">
|
||||||
|
{renderChangesHeader()}
|
||||||
<div className="task-changes-state task-changes-state--empty">
|
<div className="task-changes-state task-changes-state--empty">
|
||||||
<FileCode size={24} />
|
<FileCode size={24} />
|
||||||
<p>{t("taskChanges.noFilesModified", "No files modified.")}</p>
|
<p>{t("taskChanges.noFilesModified", "No files modified.")}</p>
|
||||||
@@ -335,72 +405,7 @@ export function TaskChangesTab({ taskId, worktree, projectId, column, mergeDetai
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="changes-header">
|
{renderChangesHeader()}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="changes-file-list task-changes-file-list--compact">
|
<div className="changes-file-list task-changes-file-list--compact">
|
||||||
{files.map((file) => {
|
{files.map((file) => {
|
||||||
|
|||||||
@@ -1166,6 +1166,45 @@
|
|||||||
margin-left: calc(var(--space-lg) + var(--space-sm)); /* align with text after icon + gap */
|
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
|
/* 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
|
* wraps the path in `<bdo dir="ltr">` to preserve readable left-to-right
|
||||||
* display while `direction: rtl` flips the ellipsis to the start. */
|
* display while `direction: rtl` flips the ellipsis to the start. */
|
||||||
|
|||||||
@@ -1053,7 +1053,7 @@ describe("TaskChangesTab — expand button", () => {
|
|||||||
expect(screen.getByTestId("changes-diff-modal")).toBeTruthy();
|
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({
|
mockFetchTaskDiff.mockResolvedValue({
|
||||||
files: [],
|
files: [],
|
||||||
stats: { filesChanged: 0, additions: 0, deletions: 0 },
|
stats: { filesChanged: 0, additions: 0, deletions: 0 },
|
||||||
@@ -1072,7 +1072,7 @@ describe("TaskChangesTab — expand button", () => {
|
|||||||
expect(screen.getByText("No files modified.")).toBeTruthy();
|
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", () => {
|
describe("TaskChangesTab — action button sizing", () => {
|
||||||
it("keeps Refresh and expand buttons on the same compact height", () => {
|
it("keeps Refresh and expand buttons on the same compact height", () => {
|
||||||
const css = loadAllAppCss();
|
const css = loadAllAppCss();
|
||||||
|
|||||||
Reference in New Issue
Block a user