feat(FN-5061): merge fusion/fn-5061

This commit is contained in:
gsxdsm
2026-05-18 13:16:35 -07:00
parent ef6c18942d
commit 57eaac42bf
7 changed files with 105 additions and 95 deletions

View File

@@ -1337,6 +1337,7 @@ function AppInner() {
workflowStepNameLookup={workflowStepNameLookup}
disableDrag={true}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMerge}
/>
),
addToast,

View File

@@ -275,8 +275,9 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
workflowStepNameLookup={workflowStepNameLookup}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
{...(col === "in-review" ? { onToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})}
{...(col === "archived" ? { collapsed: archivedCollapsed, onToggleCollapse: handleToggleArchivedCollapse } : {})}
/>

View File

@@ -505,6 +505,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
workflowStepNameLookup={workflowStepNameLookup}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
/>
))
)
@@ -533,6 +534,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
workflowStepNameLookup={workflowStepNameLookup}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
/>
))}
{shouldPaginate && hiddenTaskCount > 0 && (

View File

@@ -132,10 +132,6 @@
z-index: 1;
}
.card-meta-move {
margin-left: auto;
position: relative;
}
.card-retry-badge {
cursor: pointer;
@@ -562,20 +558,13 @@
margin-top: var(--space-xs);
}
.card-bottom-row {
display: flex;
justify-content: flex-end;
margin-top: var(--space-sm);
}
.card-bottom-right-row {
.card-action-row {
display: flex;
align-items: center;
min-width: 0;
}
.card-bottom-right-row:focus-within .card-send-back-btn {
opacity: 1;
gap: var(--space-xs);
margin-top: var(--space-xs);
flex-wrap: wrap;
row-gap: var(--space-xs);
}
.card-provider-icons {
@@ -1213,16 +1202,9 @@
overflow: hidden;
}
.card-bottom-right-row .card-send-back-menu {
top: auto;
left: auto;
bottom: calc(100% + var(--space-xs));
right: 0;
}
.card-meta-move .card-send-back-menu {
left: auto;
right: 0;
.card-action-row .card-send-back-menu {
left: 0;
right: auto;
}
.card-send-back-menu-item {
@@ -1351,9 +1333,6 @@
min-height: var(--card-chip-height-mobile);
}
.card-bottom-row {
margin-top: var(--space-xs);
}
/* Card: smaller status badges for 280px width */
.card-status-badge,

View File

@@ -297,6 +297,8 @@ interface TaskCardProps {
fanout?: BlockerFanoutEntry;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
prAuthAvailable?: boolean;
/** Whether project-level auto-merge is enabled (hides manual Create PR quick action when true). */
autoMergeEnabled?: boolean;
}
function areTaskBadgeInfosEqual(
@@ -425,6 +427,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.globalPaused === next.globalPaused &&
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
previous.prAuthAvailable === next.prAuthAvailable &&
previous.autoMergeEnabled === next.autoMergeEnabled &&
previous.onOpenDetail === next.onOpenDetail &&
previous.addToast === next.addToast &&
previous.onUpdateTask === next.onUpdateTask &&
@@ -526,6 +529,7 @@ function TaskCardComponent({
disableDrag,
fanout,
prAuthAvailable,
autoMergeEnabled = false,
}: TaskCardProps) {
const [dragging, setDragging] = useState(false);
const [fileDragOver, setFileDragOver] = useState(false);
@@ -1063,6 +1067,7 @@ function TaskCardComponent({
const showInReviewMoveControl = task.column === "in-review" && Boolean(onMoveTask);
const showCreatePrQuickAction =
task.column === "in-review"
&& autoMergeEnabled !== true
&& !livePrInfo
&& prAuthAvailable === true
&& !isPaused
@@ -1074,8 +1079,7 @@ function TaskCardComponent({
|| task.status === "queued"
|| Boolean(task.blockedBy)
|| Boolean(task.overlapBlockedBy)
|| Boolean(fanout && fanout.totalCount > 0)
|| showCreatePrQuickAction;
|| Boolean(fanout && fanout.totalCount > 0);
const enterEditMode = useCallback((e?: React.MouseEvent) => {
e?.stopPropagation();
@@ -1875,52 +1879,7 @@ function TaskCardComponent({
</span>
)}
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
{showCreatePrQuickAction && (
<button
type="button"
className="card-create-pr-action"
title="Create a PR for this task"
aria-label="Create pull request"
onClick={(event) => {
event.stopPropagation();
setIsPrCreateOpen(true);
}}
>
<GitPullRequest size={12} />
Create PR
</button>
)}
{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) && (
@@ -1946,9 +1905,24 @@ function TaskCardComponent({
)}
</div>
)}
{showInReviewMoveControl && !metaRowVisible && (
<div className="card-bottom-row">
<div className="card-bottom-right-row">
{(showCreatePrQuickAction || showInReviewMoveControl) && (
<div className="card-action-row">
{showCreatePrQuickAction && (
<button
type="button"
className="card-create-pr-action"
title="Create a PR for this task"
aria-label="Create pull request"
onClick={(event) => {
event.stopPropagation();
setIsPrCreateOpen(true);
}}
>
<GitPullRequest size={12} />
Create PR
</button>
)}
{showInReviewMoveControl && (
<div className="card-send-back" ref={sendBackRef}>
<button
className="card-send-back-btn"
@@ -1976,7 +1950,7 @@ function TaskCardComponent({
</div>
)}
</div>
</div>
)}
</div>
)}
<PluginSlot slotId="task-card-badge" projectId={projectId} />

View File

@@ -31,6 +31,8 @@ interface WorktreeGroupProps {
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
prAuthAvailable?: boolean;
/** Whether project-level auto-merge is enabled, which hides manual Create PR card actions. */
autoMergeEnabled?: boolean;
}
function WorktreeGroupComponent({
@@ -50,6 +52,7 @@ function WorktreeGroupComponent({
workflowStepNameLookup,
blockerFanoutMap,
prAuthAvailable,
autoMergeEnabled,
}: WorktreeGroupProps) {
return (
<div className="worktree-group">
@@ -60,7 +63,7 @@ function WorktreeGroupComponent({
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} />
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMergeEnabled} />
))}
{queuedTasks.map((task) => (
<TaskCard
@@ -80,6 +83,7 @@ function WorktreeGroupComponent({
workflowStepNameLookup={workflowStepNameLookup}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMergeEnabled}
/>
))}
</div>

View File

@@ -413,6 +413,34 @@ describe("TaskCard", () => {
expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull();
});
it("does not render Create PR quick action when autoMergeEnabled is true", () => {
render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={true}
autoMergeEnabled={true}
/>,
);
expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull();
});
it("renders Create PR quick action when autoMergeEnabled is false", () => {
render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
prAuthAvailable={true}
autoMergeEnabled={false}
/>,
);
expect(screen.getByRole("button", { name: "Create pull request" })).toBeDefined();
});
it("does not render Create PR quick action when task already has prInfo", () => {
render(
<TaskCard
@@ -1621,7 +1649,7 @@ describe("TaskCard", () => {
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
});
it("renders in-review Move control inline in card-meta when meta row is visible", () => {
it("renders in-review Move control inside card-action-row when meta row is visible", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-review", blockedBy: "FN-777" })}
@@ -1633,15 +1661,16 @@ describe("TaskCard", () => {
const moveButton = screen.getByRole("button", { name: "Move task" });
const metaRow = container.querySelector(".card-meta");
const bottomRow = container.querySelector(".card-bottom-row");
const actionRow = container.querySelector(".card-action-row");
expect(metaRow).not.toBeNull();
expect(metaRow?.contains(moveButton)).toBe(true);
expect(moveButton.closest(".card-meta")).not.toBeNull();
expect(bottomRow).toBeNull();
expect(metaRow?.contains(moveButton)).toBe(false);
expect(actionRow).not.toBeNull();
expect(actionRow?.contains(moveButton)).toBe(true);
expect(moveButton.closest(".card-action-row")).not.toBeNull();
});
it("keeps in-review Move control in card-bottom-row when meta row is not visible", () => {
it("keeps in-review Move control in card-action-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 })}
@@ -1652,17 +1681,37 @@ describe("TaskCard", () => {
);
const moveButton = screen.getByRole("button", { name: "Move task" });
const bottomRow = container.querySelector(".card-bottom-row");
const actionRow = container.querySelector(".card-action-row");
expect(moveButton.closest(".card-bottom-right-row")).not.toBeNull();
expect(bottomRow).not.toBeNull();
expect(bottomRow?.contains(moveButton)).toBe(true);
expect(actionRow).not.toBeNull();
expect(actionRow?.contains(moveButton)).toBe(true);
expect(moveButton.closest(".card-meta")).toBeNull();
});
it("renders Create PR before Move inside card-action-row", () => {
render(
<TaskCard
task={makeTask({ column: "in-review", paused: false, userPaused: false, prInfo: undefined as any })}
onOpenDetail={noop}
addToast={noop}
onMoveTask={vi.fn()}
prAuthAvailable={true}
autoMergeEnabled={false}
/>,
);
const createPrButton = screen.getByRole("button", { name: "Create pull request" });
const moveButton = screen.getByRole("button", { name: "Move task" });
const actionRow = createPrButton.closest(".card-action-row");
expect(actionRow).not.toBeNull();
expect(moveButton.closest(".card-action-row")).toBe(actionRow);
expect(createPrButton.compareDocumentPosition(moveButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
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 }) },
{ name: "meta-row-visible variant", task: makeTask({ column: "in-review", blockedBy: "FN-777" }) },
{ name: "no-meta 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(