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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user