feat(KB-160): thread engineStopped prop to suppress agent-active indicator

- Derive engineStopped from globalPaused/enginePaused in App and pass through Board → Column → WorktreeGroup → TaskCard
- Guard isAgentActive in TaskCard with !engineStopped so the pulse animation stops when the engine is paused
- Update Board test to supply the new engineStopped prop
- Add TaskCard tests covering engineStopped=true, false, and undefined (backward compat)
- Add engineStopped to all intermediate component interfaces
This commit is contained in:
Dustin Byrne
2026-03-28 08:46:18 -04:00
parent cac10af5e1
commit 81ab64bdf7
7 changed files with 50 additions and 9 deletions

View File

@@ -91,6 +91,8 @@ function AppInner() {
const handleDetailClose = useCallback(() => setDetailTask(null), []);
const engineStopped = globalPaused || enginePaused;
return (
<>
<Header
@@ -112,6 +114,7 @@ function AppInner() {
onNewTask={handleCreateOpen}
autoMerge={autoMerge}
onToggleAutoMerge={handleToggleAutoMerge}
engineStopped={engineStopped}
/>
{detailTask && (
<TaskDetailModal

View File

@@ -15,9 +15,10 @@ interface BoardProps {
onNewTask: () => void;
autoMerge: boolean;
onToggleAutoMerge: () => void;
engineStopped?: boolean;
}
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge }: BoardProps) {
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, engineStopped }: BoardProps) {
return (
<main className="board" id="board">
{COLUMNS.map((col) => (
@@ -41,6 +42,7 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
onMoveTask={onMoveTask}
onOpenDetail={onOpenDetail}
addToast={addToast}
engineStopped={engineStopped}
{...(col === "triage" ? { isCreating, onCancelCreate, onCreateTask, onNewTask } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
/>

View File

@@ -22,9 +22,10 @@ interface ColumnProps {
onNewTask?: () => void;
autoMerge?: boolean;
onToggleAutoMerge?: () => void;
engineStopped?: boolean;
}
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge }: ColumnProps) {
export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, isCreating, onCancelCreate, onCreateTask, onNewTask, autoMerge, onToggleAutoMerge, engineStopped }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const countFlashing = useFlashOnIncrease(tasks.length);
@@ -107,6 +108,7 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
queuedTasks={group.queuedTasks}
onOpenDetail={onOpenDetail}
addToast={addToast}
engineStopped={engineStopped}
/>
))
);
@@ -115,7 +117,7 @@ export function Column({ column, tasks, allTasks, maxConcurrent, onMoveTask, onO
<div className="empty-column">No tasks</div>
) : (
tasks.map((task) => (
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} />
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} engineStopped={engineStopped} />
))
)}
</div>

View File

@@ -27,9 +27,10 @@ interface TaskCardProps {
queued?: boolean;
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
engineStopped?: boolean;
}
export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps) {
export function TaskCard({ task, queued, onOpenDetail, addToast, engineStopped }: TaskCardProps) {
const [dragging, setDragging] = useState(false);
const [fileDragOver, setFileDragOver] = useState(false);
@@ -90,7 +91,7 @@ export function TaskCard({ task, queued, onOpenDetail, addToast }: TaskCardProps
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isAgentActive = !queued && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isAgentActive = !engineStopped && !queued && !isFailed && !isPaused && (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isDraggable = !queued && !isPaused;
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${fileDragOver ? " file-drop-target" : ""}`;

View File

@@ -9,6 +9,7 @@ interface WorktreeGroupProps {
queuedTasks: Task[];
onOpenDetail: (task: TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
engineStopped?: boolean;
}
export function WorktreeGroup({
@@ -17,6 +18,7 @@ export function WorktreeGroup({
queuedTasks,
onOpenDetail,
addToast,
engineStopped,
}: WorktreeGroupProps) {
return (
<div className="worktree-group">
@@ -27,7 +29,7 @@ export function WorktreeGroup({
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} />
<TaskCard key={task.id} task={task} onOpenDetail={onOpenDetail} addToast={addToast} engineStopped={engineStopped} />
))}
{queuedTasks.map((task) => (
<TaskCard
@@ -36,6 +38,7 @@ export function WorktreeGroup({
queued
onOpenDetail={onOpenDetail}
addToast={addToast}
engineStopped={engineStopped}
/>
))}
</div>

View File

@@ -27,6 +27,7 @@ function renderBoard() {
onNewTask={noop}
autoMerge={false}
onToggleAutoMerge={noop}
engineStopped={false}
/>,
);
}

View File

@@ -11,10 +11,10 @@ import type { Column } from "@kb/core";
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]);
/** Mirrors the cardClass computation from TaskCard.tsx */
function computeCardClass(opts: { dragging?: boolean; queued?: boolean; status?: string; column?: Column }): string {
const { dragging = false, queued = false, status, column = "todo" } = opts;
function computeCardClass(opts: { dragging?: boolean; queued?: boolean; status?: string; column?: Column; engineStopped?: boolean }): string {
const { dragging = false, queued = false, status, column = "todo", engineStopped } = opts;
const isFailed = status === "failed";
const isAgentActive = !queued && !isFailed && (column === "in-progress" || ACTIVE_STATUSES.has(status as string));
const isAgentActive = !engineStopped && !queued && !isFailed && (column === "in-progress" || ACTIVE_STATUSES.has(status as string));
return `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}`;
}
@@ -96,6 +96,35 @@ describe("TaskCard agent-active class", () => {
expect(cls).not.toContain("agent-active");
expect(cls).toContain("failed");
});
// engineStopped tests
it("does NOT apply agent-active when engineStopped is true with active status", () => {
for (const status of ["planning", "researching", "executing", "finalizing", "merging", "specifying"]) {
const cls = computeCardClass({ status, engineStopped: true });
expect(cls).not.toContain("agent-active");
}
});
it("does NOT apply agent-active when engineStopped is true for in-progress column", () => {
const cls = computeCardClass({ column: "in-progress", engineStopped: true });
expect(cls).not.toContain("agent-active");
});
it("does NOT apply agent-active when engineStopped is true with active status and in-progress column", () => {
const cls = computeCardClass({ column: "in-progress", status: "executing", engineStopped: true });
expect(cls).not.toContain("agent-active");
});
it("applies agent-active when engineStopped is false with active status", () => {
const cls = computeCardClass({ status: "executing", engineStopped: false });
expect(cls).toContain("agent-active");
});
it("applies agent-active when engineStopped is undefined (backward compat)", () => {
const cls = computeCardClass({ status: "executing", engineStopped: undefined });
expect(cls).toContain("agent-active");
});
});
describe("TaskCard failed status", () => {