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

@@ -308,6 +308,7 @@ For related global/project configuration behavior, see [Settings reference](./se
Inspect task definition, logs, comments, documents, workflow outcomes, model overrides, and task routing from a single modal. Inspect task definition, logs, comments, documents, workflow outcomes, model overrides, and task routing from a single modal.
- The priority chip in task metadata is now an inline picker: you can change priority directly from the chip without entering full edit mode. - The priority chip in task metadata is now an inline picker: you can change priority directly from the chip without entering full edit mode.
- Execution mode now also has a read-mode inline control: a lightning-bolt toggle lets you switch Fast mode on/off from task metadata without opening the full edit form.
### Logs → Agent Log view ### Logs → Agent Log view

View File

@@ -185,6 +185,7 @@ Execution mode can be set during task creation or editing:
- **Via API**: Include `executionMode` field in task create/update payload - **Via API**: Include `executionMode` field in task create/update payload
- **Via dashboard**: Select execution mode in the task creation dialog or task detail modal - **Via dashboard**: Select execution mode in the task creation dialog or task detail modal
- **Task detail quick toggle**: In read mode, use the inline lightning-bolt control in task metadata to switch between **Standard** and **Fast** without entering full edit mode
- **Values**: `"standard"` (default) or `"fast"` - **Values**: `"standard"` (default) or `"fast"`
Example API payload: Example API payload:
@@ -215,6 +216,8 @@ Research document content appears in the existing **Documents** tab in Task Deta
The task detail modal exposes multiple tabs. The task detail modal exposes multiple tabs.
In read mode, task metadata includes lightweight inline controls: priority can be changed from the priority chip, and execution mode can be toggled with a one-click lightning-bolt fast-mode button (Fast ↔ Standard) without opening full edit mode.
Task settings edited from the modal now auto-save as you edit (change/blur with debounce for text-like fields). This includes title, description, dependencies, working/base branch (`branch`/`baseBranch`), workflow-step selection, model overrides in the edit form, and source issue metadata. The footer Save button remains available, but normal field edits no longer depend on a manual save click. Task settings edited from the modal now auto-save as you edit (change/blur with debounce for text-like fields). This includes title, description, dependencies, working/base branch (`branch`/`baseBranch`), workflow-step selection, model overrides in the edit form, and source issue metadata. The footer Save button remains available, but normal field edits no longer depend on a manual save click.
The edit footer shows inline autosave state (saving/saved/error), and successful saves propagate the returned task through `onTaskUpdated` so open detail/list state stays fresh. The edit footer shows inline autosave state (saving/saved/error), and successful saves propagate the returned task through `onTaskUpdated` so open detail/list state stays fresh.

View File

@@ -202,6 +202,31 @@
text-transform: uppercase; 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 */ /* Error alert in task detail modal */
.detail-error-alert { .detail-error-alert {
@@ -1442,6 +1467,11 @@
min-height: var(--detail-priority-control-min-height); min-height: var(--detail-priority-control-min-height);
} }
.detail-execution-mode-toggle {
margin-left: 0;
margin-top: var(--space-xs);
}
.detail-source-header { .detail-source-header {
align-items: flex-start; align-items: flex-start;
} }

View File

@@ -1,6 +1,6 @@
import "./TaskDetailModal.css"; import "./TaskDetailModal.css";
import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -538,6 +538,8 @@ export function TaskDetailContent({
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [inlinePriority, setInlinePriority] = useState<TaskPriority>(normalizeTaskPriorityValue(task.priority)); const [inlinePriority, setInlinePriority] = useState<TaskPriority>(normalizeTaskPriorityValue(task.priority));
const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false); const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false);
const [inlineExecutionMode, setInlineExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
const [isSavingInlineExecutionMode, setIsSavingInlineExecutionMode] = useState(false);
const mountedRef = useRef(false); const mountedRef = useRef(false);
// Split-menu dropdown state for footer actions // Split-menu dropdown state for footer actions
@@ -600,6 +602,10 @@ export function TaskDetailContent({
setInlinePriority(normalizeTaskPriorityValue(task.priority)); setInlinePriority(normalizeTaskPriorityValue(task.priority));
}, [task.id, task.priority]); }, [task.id, task.priority]);
useEffect(() => {
setInlineExecutionMode(normalizeExecutionModeValue(task.executionMode));
}, [task.id, task.executionMode]);
// Load merged settings for effective model resolution // Load merged settings for effective model resolution
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -1019,6 +1025,30 @@ export function TaskDetailContent({
} }
}, [task.id, task.priority, projectId, inlinePriority, onTaskUpdated, addToast]); }, [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 // Handle keyboard shortcuts for edit mode
const handleEditKeyDown = useCallback((e: KeyboardEvent) => { const handleEditKeyDown = useCallback((e: KeyboardEvent) => {
if (!isEditing) return; if (!isEditing) return;
@@ -1851,6 +1881,19 @@ export function TaskDetailContent({
))} ))}
</select> </select>
</label> </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 && ( {provenanceDisplay && (
<div className="detail-provenance"> <div className="detail-provenance">
<GitBranch aria-hidden="true" /> <GitBranch aria-hidden="true" />

View File

@@ -941,6 +941,124 @@ describe("TaskDetailModal", () => {
expect(addToast).toHaveBeenCalledWith("Failed to update FN-001: Request failed", "error"); 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", () => { it("pre-populates form with existing task values", () => {
const { container } = render( const { container } = render(
<TaskDetailModal <TaskDetailModal
@@ -1655,7 +1773,7 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Execution Timing")).toBeInTheDocument(); expect(screen.getByText("Execution Timing")).toBeInTheDocument();
expect(screen.getByText("Execution Details")).toBeInTheDocument(); expect(screen.getByText("Execution Details")).toBeInTheDocument();
expect(screen.getByText("Loading token statistics…")).toBeDefined(); expect(screen.getByText("Loading token statistics…")).toBeDefined();
expect(screen.getByText("Fast")).toBeInTheDocument(); expect(screen.getAllByText("Fast").length).toBeGreaterThan(0);
expect(screen.getByText("executing")).toBeInTheDocument(); expect(screen.getByText("executing")).toBeInTheDocument();
}); });
@@ -1743,7 +1861,7 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Workflow runtime")).toBeInTheDocument(); expect(screen.getByText("Workflow runtime")).toBeInTheDocument();
expect(screen.getByText("Execution mode")).toBeInTheDocument(); expect(screen.getByText("Execution mode")).toBeInTheDocument();
expect(screen.getByText("Runtime status")).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("executing")).toBeInTheDocument();
expect(screen.getByText((1200).toLocaleString())).toBeInTheDocument(); expect(screen.getByText((1200).toLocaleString())).toBeInTheDocument();
expect(screen.getByText((450).toLocaleString())).toBeInTheDocument(); expect(screen.getByText((450).toLocaleString())).toBeInTheDocument();

View File

@@ -306,6 +306,44 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Comments")).toBeTruthy(); 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", () => { it("appends daemon token query to attachment href/src URLs for direct browser loads", () => {
localStorage.setItem("fn.authToken", "daemon-token"); localStorage.setItem("fn.authToken", "daemon-token");
@@ -1207,7 +1245,7 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Execution Timing")).toBeInTheDocument(); expect(screen.getByText("Execution Timing")).toBeInTheDocument();
expect(screen.getByText("Execution Details")).toBeInTheDocument(); expect(screen.getByText("Execution Details")).toBeInTheDocument();
expect(screen.getByText("Loading token statistics…")).toBeDefined(); expect(screen.getByText("Loading token statistics…")).toBeDefined();
expect(screen.getByText("Fast")).toBeInTheDocument(); expect(screen.getAllByText("Fast").length).toBeGreaterThan(0);
expect(screen.getByText("executing")).toBeInTheDocument(); expect(screen.getByText("executing")).toBeInTheDocument();
}); });
@@ -1295,7 +1333,7 @@ describe("TaskDetailModal", () => {
expect(screen.getByText("Workflow runtime")).toBeInTheDocument(); expect(screen.getByText("Workflow runtime")).toBeInTheDocument();
expect(screen.getByText("Execution mode")).toBeInTheDocument(); expect(screen.getByText("Execution mode")).toBeInTheDocument();
expect(screen.getByText("Runtime status")).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("executing")).toBeInTheDocument();
expect(screen.getByText((1200).toLocaleString())).toBeInTheDocument(); expect(screen.getByText((1200).toLocaleString())).toBeInTheDocument();
expect(screen.getByText((450).toLocaleString())).toBeInTheDocument(); expect(screen.getByText((450).toLocaleString())).toBeInTheDocument();

View File

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