feat(FN-4618): add inline in-review move control with meta row

Adds an inline move-control button to the TaskCard meta row for in-review tasks, letting users move tasks directly from the board without opening the detail panel. Tests and a workflow-step allowlist entry were added alongside the CSS and component changes.

Fusion-Task-Id: FN-4618
This commit is contained in:
Fusion
2026-05-15 09:25:41 -07:00
committed by gsxdsm
parent a8bde08d26
commit 0c139a455f
5 changed files with 106 additions and 16 deletions

View File

@@ -122,7 +122,9 @@
.card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
row-gap: var(--space-xs);
margin-top: var(--space-sm);
font-size: 0.6875rem;
color: var(--text-dim);
@@ -130,6 +132,11 @@
z-index: 1;
}
.card-meta-move {
margin-left: auto;
position: relative;
}
.card-retry-badge {
cursor: pointer;
}
@@ -1178,6 +1185,11 @@
right: 0;
}
.card-meta-move .card-send-back-menu {
left: auto;
right: 0;
}
.card-send-back-menu-item {
display: block;
width: 100%;

View File

@@ -815,6 +815,13 @@ function TaskCardComponent({
&& !hasMatchingIssueInfoBadge
&& !hasMatchingSourceIssue;
const showInReviewMoveControl = task.column === "in-review" && Boolean(onMoveTask);
const metaRowVisible =
(task.dependencies?.length ?? 0) > 0
|| queued
|| task.status === "queued"
|| Boolean(task.blockedBy)
|| Boolean(task.overlapBlockedBy)
|| Boolean(fanout && fanout.totalCount > 0);
const branchMetadata = useMemo(() => getVisibleTaskCardBranches(task), [task.id, task.branch, task.baseBranch]);
const hasBranchMetadata = Boolean(branchMetadata.branch || branchMetadata.baseBranch);
const isAgentCreated = isAgentCreatedTask(task);
@@ -1804,7 +1811,7 @@ function TaskCardComponent({
)}
</div>
)}
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy || task.overlapBlockedBy || (fanout && fanout.totalCount > 0)) && (
{metaRowVisible && (
<div className="card-meta">
{task.dependencies && task.dependencies.length > 0 && (
<div className="card-dep-list">
@@ -1839,6 +1846,37 @@ function TaskCardComponent({
</span>
)}
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
{showInReviewMoveControl && (
<div className="card-meta-move">
<div className="card-send-back" ref={sendBackRef}>
<button
className="card-send-back-btn"
onClick={handleSendBackClick}
title="Move task"
aria-label="Move task"
aria-haspopup="menu"
aria-expanded={showSendBackMenu}
>
Move
<ChevronDown size={10} />
</button>
{showSendBackMenu && (
<div className="card-send-back-menu" role="menu">
{VALID_TRANSITIONS["in-review"].map((col) => (
<button
key={col}
className="card-send-back-menu-item"
role="menuitem"
onClick={(e) => handleSendBackOptionClick(e, col)}
>
{col === "done" ? "Done (no merge)" : COLUMN_LABELS[col]}
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
)}
{(task.assignedAgentId || taskProviders.length > 0) && (
@@ -1864,7 +1902,7 @@ function TaskCardComponent({
)}
</div>
)}
{showInReviewMoveControl && (
{showInReviewMoveControl && !metaRowVisible && (
<div className="card-bottom-row">
<div className="card-bottom-right-row">
<div className="card-send-back" ref={sendBackRef}>

View File

@@ -1417,30 +1417,63 @@ describe("TaskCard", () => {
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
});
it("FN-4540 renders in-review Move control in card-bottom-right-row and keeps menu behavior", () => {
const onMoveTask = vi.fn();
it("renders in-review Move control inline in card-meta when meta row is visible", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-review" })}
task={makeTask({ column: "in-review", blockedBy: "FN-777" })}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
const moveButton = screen.getByRole("button", { name: "Move task" });
const metaRow = container.querySelector(".card-meta");
const bottomRow = container.querySelector(".card-bottom-row");
expect(metaRow).not.toBeNull();
expect(metaRow?.contains(moveButton)).toBe(true);
expect(moveButton.closest(".card-meta")).not.toBeNull();
expect(bottomRow).toBeNull();
});
it("keeps in-review Move control in card-bottom-row when meta row is not visible", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-review", dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined, status: undefined as any })}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
/>,
);
const moveButton = screen.getByRole("button", { name: "Move task" });
const bottomRow = container.querySelector(".card-bottom-row");
expect(moveButton.closest(".card-bottom-right-row")).not.toBeNull();
expect(bottomRow).not.toBeNull();
expect(bottomRow?.contains(moveButton)).toBe(true);
expect(moveButton.closest(".card-meta")).toBeNull();
});
it.each([
{ name: "inline meta-row variant", task: makeTask({ column: "in-review", blockedBy: "FN-777" }) },
{ name: "fallback bottom-row variant", task: makeTask({ column: "in-review", dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined, status: undefined as any }) },
])("keeps Move dropdown behavior for $name", ({ task }) => {
const onMoveTask = vi.fn();
render(
<TaskCard
task={task}
onOpenDetail={noop}
addToast={noop}
onMoveTask={onMoveTask}
/>,
);
const moveButton = screen.getByRole("button", { name: "Move task" });
const actionsContainer = container.querySelector(".card-header-actions");
const bottomRow = container.querySelector(".card-bottom-row");
const bottomRight = moveButton.closest(".card-bottom-right-row");
fireEvent.click(screen.getByRole("button", { name: "Move task" }));
expect(actionsContainer).not.toBeNull();
expect(actionsContainer?.contains(moveButton)).toBe(false);
expect(bottomRow).not.toBeNull();
expect(bottomRight).not.toBeNull();
expect(bottomRow?.contains(bottomRight as HTMLElement)).toBe(true);
expect(getComputedStyle(bottomRow as HTMLElement).justifyContent).toBe("flex-end");
expect(screen.getAllByRole("menuitem").length).toBeGreaterThan(0);
fireEvent.click(moveButton);
fireEvent.click(screen.getByRole("menuitem", { name: "Done (no merge)" }));
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "done", undefined);

View File

@@ -14,6 +14,8 @@ describe("workflow-step readonly allowlist policy", () => {
"grep",
"find",
"ls",
"WebSearch",
"WebFetch",
"fn_web_fetch",
"fn_task_show",
"fn_task_list",