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} workflowStepNameLookup={workflowStepNameLookup}
disableDrag={true} disableDrag={true}
prAuthAvailable={prAuthAvailable} prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMerge}
/> />
), ),
addToast, addToast,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -413,6 +413,34 @@ describe("TaskCard", () => {
expect(screen.queryByRole("button", { name: "Create pull request" })).toBeNull(); 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", () => { it("does not render Create PR quick action when task already has prInfo", () => {
render( render(
<TaskCard <TaskCard
@@ -1621,7 +1649,7 @@ describe("TaskCard", () => {
expect(actionsContainer?.contains(archiveBtn)).toBe(true); 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( const { container } = render(
<TaskCard <TaskCard
task={makeTask({ column: "in-review", blockedBy: "FN-777" })} task={makeTask({ column: "in-review", blockedBy: "FN-777" })}
@@ -1633,15 +1661,16 @@ describe("TaskCard", () => {
const moveButton = screen.getByRole("button", { name: "Move task" }); const moveButton = screen.getByRole("button", { name: "Move task" });
const metaRow = container.querySelector(".card-meta"); 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).not.toBeNull();
expect(metaRow?.contains(moveButton)).toBe(true); expect(metaRow?.contains(moveButton)).toBe(false);
expect(moveButton.closest(".card-meta")).not.toBeNull(); expect(actionRow).not.toBeNull();
expect(bottomRow).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( const { container } = render(
<TaskCard <TaskCard
task={makeTask({ column: "in-review", dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined, status: undefined as any })} 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 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(actionRow).not.toBeNull();
expect(bottomRow).not.toBeNull(); expect(actionRow?.contains(moveButton)).toBe(true);
expect(bottomRow?.contains(moveButton)).toBe(true);
expect(moveButton.closest(".card-meta")).toBeNull(); 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([ it.each([
{ name: "inline meta-row variant", task: makeTask({ column: "in-review", blockedBy: "FN-777" }) }, { name: "meta-row-visible 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: "no-meta variant", task: makeTask({ column: "in-review", dependencies: [], blockedBy: undefined, overlapBlockedBy: undefined, status: undefined as any }) },
])("keeps Move dropdown behavior for $name", ({ task }) => { ])("keeps Move dropdown behavior for $name", ({ task }) => {
const onMoveTask = vi.fn(); const onMoveTask = vi.fn();
render( render(