feat(FN-3711): add inline fast mode toggle and wire shared state in peer ex

This merge introduces several dashboard and engine improvements: agent inbox wake controls (FN-3087, FN-3588) with immediate message triggers; separated plugin lifecycle from setup probe state (FN-3704); assigned-agent triage inheritance in the engine (FN-3703); inline fast mode toggle in the task d

Fusion-Task-Id: FN-3711
This commit is contained in:
Fusion
2026-05-07 11:51:42 -07:00
committed by gsxdsm
parent 24325cd3c1
commit cc02def488
7 changed files with 239 additions and 5 deletions

View File

@@ -202,6 +202,31 @@
text-transform: uppercase;
}
.detail-execution-mode-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-left: var(--space-xs);
min-height: calc(var(--space-2xl) + var(--space-xs));
}
.detail-execution-mode-toggle svg {
width: 1em;
height: 1em;
}
.detail-execution-mode-toggle--fast {
color: var(--color-warning);
}
.detail-execution-mode-toggle--saving {
opacity: 0.75;
}
.detail-execution-mode-toggle:disabled {
cursor: wait;
}
/* Error alert in task detail modal */
.detail-error-alert {
@@ -1442,6 +1467,11 @@
min-height: var(--detail-priority-control-min-height);
}
.detail-execution-mode-toggle {
margin-left: 0;
margin-top: var(--space-xs);
}
.detail-source-header {
align-items: flex-start;
}

View File

@@ -1,6 +1,6 @@
import "./TaskDetailModal.css";
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft } from "lucide-react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -538,6 +538,8 @@ export function TaskDetailContent({
const [isSaving, setIsSaving] = useState(false);
const [inlinePriority, setInlinePriority] = useState<TaskPriority>(normalizeTaskPriorityValue(task.priority));
const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false);
const [inlineExecutionMode, setInlineExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
const [isSavingInlineExecutionMode, setIsSavingInlineExecutionMode] = useState(false);
const mountedRef = useRef(false);
// Split-menu dropdown state for footer actions
@@ -600,6 +602,10 @@ export function TaskDetailContent({
setInlinePriority(normalizeTaskPriorityValue(task.priority));
}, [task.id, task.priority]);
useEffect(() => {
setInlineExecutionMode(normalizeExecutionModeValue(task.executionMode));
}, [task.id, task.executionMode]);
// Load merged settings for effective model resolution
useEffect(() => {
let cancelled = false;
@@ -1019,6 +1025,30 @@ export function TaskDetailContent({
}
}, [task.id, task.priority, projectId, inlinePriority, onTaskUpdated, addToast]);
const handleInlineExecutionModeToggle = useCallback(async () => {
const currentMode = normalizeExecutionModeValue(task.executionMode);
const nextMode = currentMode === "fast" ? "standard" : "fast";
const previousMode = inlineExecutionMode;
setInlineExecutionMode(nextMode);
setIsSavingInlineExecutionMode(true);
try {
const updatedTask = await updateTask(task.id, { executionMode: nextMode === "fast" ? "fast" : null }, projectId);
const normalizedUpdatedMode = normalizeExecutionModeValue(updatedTask.executionMode);
setInlineExecutionMode(normalizedUpdatedMode);
onTaskUpdated?.(updatedTask);
addToast(`Execution mode updated to ${normalizedUpdatedMode}`, "success");
} catch (err) {
setInlineExecutionMode(previousMode);
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) {
setIsSavingInlineExecutionMode(false);
}
}
}, [task.id, task.executionMode, projectId, inlineExecutionMode, onTaskUpdated, addToast]);
// Handle keyboard shortcuts for edit mode
const handleEditKeyDown = useCallback((e: KeyboardEvent) => {
if (!isEditing) return;
@@ -1851,6 +1881,19 @@ export function TaskDetailContent({
))}
</select>
</label>
<button
type="button"
className={`btn btn-sm detail-execution-mode-toggle ${inlineExecutionMode === "fast" ? "detail-execution-mode-toggle--fast" : ""} ${isSavingInlineExecutionMode ? "detail-execution-mode-toggle--saving" : ""}`}
onClick={() => {
void handleInlineExecutionModeToggle();
}}
disabled={isSavingInlineExecutionMode}
aria-label={`Execution mode: ${inlineExecutionMode}`}
aria-pressed={inlineExecutionMode === "fast"}
>
<Zap aria-hidden="true" />
<span>{inlineExecutionMode === "fast" ? "Fast" : "Standard"}</span>
</button>
{provenanceDisplay && (
<div className="detail-provenance">
<GitBranch aria-hidden="true" />

View File

@@ -941,6 +941,124 @@ describe("TaskDetailModal", () => {
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Request failed", "error");
});
it("toggles inline execution mode from standard to fast", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const addToast = vi.fn();
const onTaskUpdated = vi.fn();
const updatedTask = makeTask({ id: "FN-001", column: "todo", executionMode: "fast" });
mockUpdate.mockResolvedValueOnce(updatedTask as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", executionMode: "standard" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={onTaskUpdated}
addToast={addToast}
/>,
);
const toggle = screen.getByRole("button", { name: "Execution mode: standard" });
fireEvent.click(toggle);
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
});
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
expect(addToast).toHaveBeenCalledWith("Execution mode updated to fast", "success");
await waitFor(() => {
expect(screen.getByRole("button", { name: "Execution mode: fast" })).toHaveAttribute("aria-pressed", "true");
});
});
it("toggles inline execution mode from fast to standard", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-001", column: "todo", executionMode: null }) as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", executionMode: "fast" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Execution mode: fast" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: null }, undefined);
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Execution mode: standard" })).toHaveAttribute("aria-pressed", "false");
});
});
it("reverts inline execution mode when save fails", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
const addToast = vi.fn();
mockUpdate.mockRejectedValueOnce(new Error("Request failed"));
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", executionMode: "standard" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Execution mode: standard" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Execution mode: standard" })).toHaveAttribute("aria-pressed", "false");
});
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Request failed", "error");
});
it("disables inline execution mode toggle while save is in-flight", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve(makeTask({ executionMode: "fast" }) as Task), 100)),
);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "todo", executionMode: "standard" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const toggle = screen.getByRole("button", { name: "Execution mode: standard" });
fireEvent.click(toggle);
expect(toggle).toBeDisabled();
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { executionMode: "fast" }, undefined);
});
});
it("pre-populates form with existing task values", () => {
const { container } = render(
<TaskDetailModal
@@ -1655,7 +1773,7 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Execution Timing")).toBeInTheDocument();
expect(screen.getByText("Execution Details")).toBeInTheDocument();
expect(screen.getByText("Loading token statistics…")).toBeDefined();
expect(screen.getByText("Fast")).toBeInTheDocument();
expect(screen.getAllByText("Fast").length).toBeGreaterThan(0);
expect(screen.getByText("executing")).toBeInTheDocument();
});
@@ -1743,7 +1861,7 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Workflow runtime")).toBeInTheDocument();
expect(screen.getByText("Execution mode")).toBeInTheDocument();
expect(screen.getByText("Runtime status")).toBeInTheDocument();
expect(screen.getByText("Fast")).toBeInTheDocument();
expect(screen.getAllByText("Fast").length).toBeGreaterThan(0);
expect(screen.getByText("executing")).toBeInTheDocument();
expect(screen.getByText((1200).toLocaleString())).toBeInTheDocument();
expect(screen.getByText((450).toLocaleString())).toBeInTheDocument();

View File

@@ -306,6 +306,44 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Comments")).toBeTruthy();
});
describe("inline execution mode toggle", () => {
it("renders standard mode as an unpressed toggle", () => {
render(
<TaskDetailModal
task={makeTask({ column: "triage", executionMode: "standard" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const toggle = screen.getByRole("button", { name: "Execution mode: standard" });
expect(toggle).toHaveAttribute("aria-pressed", "false");
expect(toggle).toHaveTextContent("Standard");
});
it("renders fast mode as a pressed toggle", () => {
render(
<TaskDetailModal
task={makeTask({ column: "todo", executionMode: "fast" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const toggle = screen.getByRole("button", { name: "Execution mode: fast" });
expect(toggle).toHaveAttribute("aria-pressed", "true");
expect(toggle).toHaveTextContent("Fast");
});
});
it("appends daemon token query to attachment href/src URLs for direct browser loads", () => {
localStorage.setItem("fn.authToken", "daemon-token");
@@ -1207,7 +1245,7 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Execution Timing")).toBeInTheDocument();
expect(screen.getByText("Execution Details")).toBeInTheDocument();
expect(screen.getByText("Loading token statistics…")).toBeDefined();
expect(screen.getByText("Fast")).toBeInTheDocument();
expect(screen.getAllByText("Fast").length).toBeGreaterThan(0);
expect(screen.getByText("executing")).toBeInTheDocument();
});
@@ -1295,7 +1333,7 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Workflow runtime")).toBeInTheDocument();
expect(screen.getByText("Execution mode")).toBeInTheDocument();
expect(screen.getByText("Runtime status")).toBeInTheDocument();
expect(screen.getByText("Fast")).toBeInTheDocument();
expect(screen.getAllByText("Fast").length).toBeGreaterThan(0);
expect(screen.getByText("executing")).toBeInTheDocument();
expect(screen.getByText((1200).toLocaleString())).toBeInTheDocument();
expect(screen.getByText((450).toLocaleString())).toBeInTheDocument();

View File

@@ -52,6 +52,7 @@ vi.mock("lucide-react", () => ({
ChevronDown: () => null,
ChevronRight: (props: any) => React.createElement("svg", { "data-testid": "chevron-right-icon", ...props }),
ArrowLeft: () => null,
Zap: () => null,
X: () => null,
Maximize2: () => null,
Minimize2: () => null,