feat(FN-2726): add bulk node override support in dashboard workflows
- Extend batch task update APIs and route handlers to accept nodeId overrides alongside model settings - Update ListView bulk edit UX to load nodes and apply node overrides through batch updates - Add node routing visibility improvements in task detail/settings UI and align related labels/styles - Expand dashboard/API test coverage for node override batch updates and bulk edit behavior
This commit is contained in:
@@ -823,6 +823,56 @@ describe("batchUpdateTaskModels", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes nodeId when provided", async () => {
|
||||
const mockResponse = { updated: [{ id: "FN-001", nodeId: "node-abc" }], count: 1 };
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockFetchResponse(true, mockResponse)
|
||||
);
|
||||
|
||||
const { batchUpdateTaskModels } = await import("../api");
|
||||
await batchUpdateTaskModels(
|
||||
["FN-001"],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"node-abc",
|
||||
"proj-123"
|
||||
);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/tasks/batch-update-models?projectId=proj-123",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
taskIds: ["FN-001"],
|
||||
nodeId: "node-abc",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes null nodeId when clearing override", async () => {
|
||||
const mockResponse = { updated: [{ id: "FN-001" }], count: 1 };
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockFetchResponse(true, mockResponse)
|
||||
);
|
||||
|
||||
const { batchUpdateTaskModels } = await import("../api");
|
||||
await batchUpdateTaskModels(["FN-001"], undefined, undefined, undefined, undefined, undefined, undefined, null);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/tasks/batch-update-models",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
taskIds: ["FN-001"],
|
||||
nodeId: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on 400 validation error", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
mockFetchResponse(false, { error: "taskIds must be an array" }, 400)
|
||||
|
||||
@@ -325,6 +325,7 @@ export function batchUpdateTaskModels(
|
||||
validatorModelId?: string | null,
|
||||
planningModelProvider?: string | null,
|
||||
planningModelId?: string | null,
|
||||
nodeId?: string | null,
|
||||
projectId?: string,
|
||||
): Promise<{ updated: Task[]; count: number }> {
|
||||
return api<{ updated: Task[]; count: number }>(withProjectId("/tasks/batch-update-models", projectId), {
|
||||
@@ -337,6 +338,7 @@ export function batchUpdateTaskModels(
|
||||
validatorModelId,
|
||||
planningModelProvider,
|
||||
planningModelId,
|
||||
nodeId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,45 +79,22 @@
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.bulk-edit-node-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.list-view-node-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.list-view-node-status__dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.list-view-node-status--online .list-view-node-status__dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.list-view-node-status--offline .list-view-node-status__dot,
|
||||
.list-view-node-status--error .list-view-node-status__dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.list-view-node-status--connecting .list-view-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.bulk-edit-dropdown .model-combobox-trigger {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.bulk-node-select {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.bulk-edit-apply-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -918,6 +895,10 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bulk-node-select {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
/* List column dropdown items: touch target on mobile */
|
||||
.list-column-dropdown-item {
|
||||
min-height: 36px;
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "rea
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, TaskCreateInput } from "@fusion/core";
|
||||
import { COLUMN_LABELS, COLUMNS, getErrorMessage } from "@fusion/core";
|
||||
import { batchUpdateTaskModels, updateTask } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { batchUpdateTaskModels, fetchNodes } from "../api";
|
||||
import type { ModelInfo, NodeInfo } from "../api";
|
||||
import { QuickEntryBox } from "./QuickEntryBox";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { isTaskStuck } from "../utils/taskStuck";
|
||||
@@ -12,7 +12,6 @@ import type { ToastType } from "../hooks/useToast";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
triage: "var(--triage)",
|
||||
@@ -25,24 +24,6 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
|
||||
|
||||
function isTaskActivelyExecuting(task: Task): boolean {
|
||||
return task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string);
|
||||
}
|
||||
|
||||
function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error"): string {
|
||||
if (status === "online") return "Online";
|
||||
if (status === "connecting") return "Connecting";
|
||||
if (status === "error") return "Error";
|
||||
return "Offline";
|
||||
}
|
||||
|
||||
function getNodeStatusClass(status: "online" | "offline" | "connecting" | "error"): string {
|
||||
if (status === "online") return "list-view-node-status--online";
|
||||
if (status === "connecting") return "list-view-node-status--connecting";
|
||||
if (status === "error") return "list-view-node-status--error";
|
||||
return "list-view-node-status--offline";
|
||||
}
|
||||
|
||||
type SortField = "id" | "title" | "status" | "column";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
@@ -50,6 +31,13 @@ type SortDirection = "asc" | "desc";
|
||||
const ALL_LIST_COLUMNS = ["id", "title", "status", "column", "dependencies", "progress"] as const;
|
||||
type ListColumn = typeof ALL_LIST_COLUMNS[number];
|
||||
|
||||
function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||
if (status === "online") return "Online";
|
||||
if (status === "connecting") return "Connecting";
|
||||
if (status === "error") return "Error";
|
||||
return "Offline";
|
||||
}
|
||||
|
||||
function readVisibleColumns(projectId?: string): Set<ListColumn> {
|
||||
try {
|
||||
const saved = getScopedItem("kb-dashboard-list-columns", projectId);
|
||||
@@ -466,9 +454,40 @@ export function ListView({
|
||||
// Bulk edit state and handlers (must be after groupedTasks and clearSelection definition)
|
||||
const [executorModel, setExecutorModel] = useState<string>("__no_change__");
|
||||
const [validatorModel, setValidatorModel] = useState<string>("__no_change__");
|
||||
const [bulkNodeId, setBulkNodeId] = useState<string>("__no_change__");
|
||||
const [nodeOverride, setNodeOverride] = useState<string>("__no_change__");
|
||||
const [availableNodes, setAvailableNodes] = useState<NodeInfo[]>([]);
|
||||
const [isLoadingNodes, setIsLoadingNodes] = useState(false);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const { nodes } = useNodes();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTaskIds.size === 0) return;
|
||||
let isCancelled = false;
|
||||
|
||||
const loadNodes = async () => {
|
||||
setIsLoadingNodes(true);
|
||||
try {
|
||||
const nodes = await fetchNodes();
|
||||
if (!isCancelled) {
|
||||
setAvailableNodes(nodes);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch nodes for bulk edit", err);
|
||||
if (!isCancelled) {
|
||||
setAvailableNodes([]);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoadingNodes(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadNodes();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [selectedTaskIds.size]);
|
||||
|
||||
// Handle apply bulk model update
|
||||
const handleApplyBulkUpdate = useCallback(async () => {
|
||||
@@ -484,10 +503,6 @@ export function ListView({
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedTasks = taskIds
|
||||
.map((id) => tasks.find((task) => task.id === id))
|
||||
.filter((task): task is Task => Boolean(task));
|
||||
|
||||
// Build payload - only include fields that changed from "__no_change__"
|
||||
const payload: {
|
||||
taskIds: string[];
|
||||
@@ -495,6 +510,7 @@ export function ListView({
|
||||
modelId?: string | null;
|
||||
validatorModelProvider?: string | null;
|
||||
validatorModelId?: string | null;
|
||||
nodeId?: string | null;
|
||||
} = { taskIds };
|
||||
|
||||
if (executorModel !== "__no_change__") {
|
||||
@@ -525,77 +541,51 @@ export function ListView({
|
||||
}
|
||||
}
|
||||
|
||||
const hasNodeChange = bulkNodeId !== "__no_change__";
|
||||
const activeTasks = hasNodeChange ? selectedTasks.filter((task) => isTaskActivelyExecuting(task)) : [];
|
||||
const nodeEligibleTaskIds = hasNodeChange
|
||||
? selectedTasks.filter((task) => !isTaskActivelyExecuting(task)).map((task) => task.id)
|
||||
: [];
|
||||
|
||||
// Check if any changes were made
|
||||
if (Object.keys(payload).length === 1 && !hasNodeChange) {
|
||||
addToast("No changes to apply", "info");
|
||||
return;
|
||||
if (nodeOverride !== "__no_change__") {
|
||||
if (nodeOverride === "") {
|
||||
payload.nodeId = null;
|
||||
} else {
|
||||
payload.nodeId = nodeOverride;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNodeChange && nodeEligibleTaskIds.length === 0 && Object.keys(payload).length === 1) {
|
||||
addToast("Node override cannot be changed for active tasks. Stop the tasks and try again.", "error");
|
||||
// Check if any changes were made
|
||||
if (Object.keys(payload).length === 1) {
|
||||
addToast("No changes to apply", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsApplying(true);
|
||||
try {
|
||||
const updatedTasks: Task[] = [];
|
||||
if (Object.keys(payload).length > 1) {
|
||||
const result = await batchUpdateTaskModels(
|
||||
payload.taskIds,
|
||||
payload.modelProvider,
|
||||
payload.modelId,
|
||||
payload.validatorModelProvider,
|
||||
payload.validatorModelId,
|
||||
undefined,
|
||||
undefined,
|
||||
projectId,
|
||||
);
|
||||
updatedTasks.push(...result.updated);
|
||||
const result = await batchUpdateTaskModels(
|
||||
payload.taskIds,
|
||||
payload.modelProvider,
|
||||
payload.modelId,
|
||||
payload.validatorModelProvider,
|
||||
payload.validatorModelId,
|
||||
undefined,
|
||||
undefined,
|
||||
payload.nodeId,
|
||||
projectId,
|
||||
);
|
||||
|
||||
if (onTasksUpdated) {
|
||||
onTasksUpdated(result.updated);
|
||||
}
|
||||
|
||||
if (hasNodeChange) {
|
||||
for (const taskId of nodeEligibleTaskIds) {
|
||||
const updated = await updateTask(taskId, { nodeId: bulkNodeId === "" ? null : bulkNodeId } as never, projectId);
|
||||
updatedTasks.push(updated);
|
||||
}
|
||||
}
|
||||
|
||||
if (onTasksUpdated && updatedTasks.length > 0) {
|
||||
onTasksUpdated(updatedTasks);
|
||||
}
|
||||
|
||||
const updatedCount = Object.keys(payload).length > 1
|
||||
? taskIds.length
|
||||
: hasNodeChange
|
||||
? nodeEligibleTaskIds.length
|
||||
: taskIds.length;
|
||||
const skippedIds = activeTasks.map((task) => task.id);
|
||||
if (hasNodeChange && skippedIds.length > 0) {
|
||||
addToast(
|
||||
`Updated ${updatedCount} task${updatedCount === 1 ? "" : "s"}. Skipped active tasks: ${skippedIds.join(", ")}`,
|
||||
"warning",
|
||||
);
|
||||
} else {
|
||||
addToast(`Updated ${updatedCount} task${updatedCount === 1 ? "" : "s"}`, "success");
|
||||
}
|
||||
addToast(`Updated ${taskIds.length} task${taskIds.length === 1 ? "" : "s"}`, "success");
|
||||
|
||||
// Reset state
|
||||
clearSelection();
|
||||
setExecutorModel("__no_change__");
|
||||
setValidatorModel("__no_change__");
|
||||
setBulkNodeId("__no_change__");
|
||||
setNodeOverride("__no_change__");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to update models", "error");
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
}, [selectedTaskIds, tasks, executorModel, validatorModel, bulkNodeId, projectId, addToast, clearSelection, onTasksUpdated]);
|
||||
}, [selectedTaskIds, tasks, executorModel, validatorModel, nodeOverride, projectId, addToast, clearSelection, onTasksUpdated]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(task: Task) => {
|
||||
@@ -771,29 +761,25 @@ export function ListView({
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
<div className="bulk-edit-dropdown bulk-edit-node-wrap">
|
||||
<select className="select" value={bulkNodeId} onChange={(e) => setBulkNodeId(e.target.value)}>
|
||||
<option value="__no_change__">Node: No change</option>
|
||||
<option value="">Node: Clear override</option>
|
||||
{nodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>{node.name} ({getNodeStatusLabel(node.status)})</option>
|
||||
<div className="bulk-edit-dropdown">
|
||||
<select
|
||||
className="select bulk-node-select"
|
||||
value={nodeOverride}
|
||||
onChange={(e) => setNodeOverride(e.target.value)}
|
||||
aria-label="Node Override"
|
||||
disabled={isLoadingNodes}
|
||||
>
|
||||
<option value="__no_change__">No change</option>
|
||||
<option value="">Use project default</option>
|
||||
{availableNodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>{`${node.name || node.id} (${getNodeStatusLabel(node.status)})`}</option>
|
||||
))}
|
||||
</select>
|
||||
{(() => {
|
||||
const selectedNode = nodes.find((node) => node.id === bulkNodeId);
|
||||
if (!selectedNode) return null;
|
||||
return (
|
||||
<span className={`list-view-node-status ${getNodeStatusClass(selectedNode.status)}`}>
|
||||
<span className="list-view-node-status__dot" aria-hidden="true" />
|
||||
{getNodeStatusLabel(selectedNode.status)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm bulk-edit-apply-btn"
|
||||
onClick={handleApplyBulkUpdate}
|
||||
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && bulkNodeId === "__no_change__")}
|
||||
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && nodeOverride === "__no_change__")}
|
||||
>
|
||||
{isApplying ? "Applying..." : "Apply"}
|
||||
</button>
|
||||
|
||||
@@ -1493,3 +1493,32 @@
|
||||
.settings-node-status--connecting .settings-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.settings-node-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-node-status__dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.settings-node-status--online .settings-node-status__dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.settings-node-status--offline .settings-node-status__dot,
|
||||
.settings-node-status--error .settings-node-status__dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.settings-node-status--connecting .settings-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
@@ -1636,6 +1636,31 @@ export function TaskDetailModal({
|
||||
</div>
|
||||
)}
|
||||
<MergeDetails task={task} />
|
||||
<div className="detail-section">
|
||||
<h4>Node Routing</h4>
|
||||
<dl className="detail-source-grid">
|
||||
<div>
|
||||
<dt>Task Override</dt>
|
||||
<dd>{task.nodeId ?? <span className="detail-source-empty">(none)</span>}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Effective Node</dt>
|
||||
<dd>{(task as Task & { effectiveNodeId?: string }).effectiveNodeId ?? "local execution"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Routing Source</dt>
|
||||
<dd>{(task as Task & { effectiveNodeSource?: string }).effectiveNodeSource ?? "local"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unavailable Node Policy</dt>
|
||||
<dd>{(settings as Settings & { unavailableNodePolicy?: string } | undefined)?.unavailableNodePolicy ?? "block"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Blocking Reason</dt>
|
||||
<dd>{((task as Task & { blockedReason?: string; statusReason?: string }).blockedReason || (task as Task & { statusReason?: string }).statusReason) ?? <span className="detail-source-empty">(not blocked)</span>}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
{task.sourceIssue && (
|
||||
<div className="detail-section detail-source-section">
|
||||
<h4>Source Issue</h4>
|
||||
|
||||
@@ -20,9 +20,10 @@ vi.mock("../../api", () => ({
|
||||
}),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
batchUpdateTaskModels: vi.fn(),
|
||||
fetchNodes: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
import { fetchTaskDetail, batchUpdateTaskModels } from "../../api";
|
||||
import { fetchTaskDetail, batchUpdateTaskModels, fetchNodes } from "../../api";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
const TEST_PROJECT_ID = "proj-123";
|
||||
@@ -2184,6 +2185,69 @@ describe("ListView - Bulk Selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bulk node override", () => {
|
||||
const availableModels = [
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
];
|
||||
|
||||
it("shows node override selector with node status labels when tasks are selected", async () => {
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-1", name: "Node One", status: "online" } as never]);
|
||||
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
|
||||
fireEvent.click(screen.getByLabelText("Select FN-001"));
|
||||
|
||||
expect(await screen.findByLabelText("Node Override")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("option", { name: "Node One (Online)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies explicit node override through batchUpdateTaskModels", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-abc", name: "Node ABC", status: "online" } as never]);
|
||||
vi.mocked(batchUpdateTaskModels).mockResolvedValue({ updated: tasks, count: 1 });
|
||||
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
|
||||
await user.click(screen.getByLabelText("Select FN-001"));
|
||||
|
||||
const nodeSelect = await screen.findByLabelText("Node Override");
|
||||
await user.selectOptions(nodeSelect, "node-abc");
|
||||
await user.click(screen.getByRole("button", { name: "Apply" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const args = vi.mocked(batchUpdateTaskModels).mock.calls.at(-1);
|
||||
expect(args?.[7]).toBe("node-abc");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses null nodeId when selecting Use project default", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-abc", name: "Node ABC", status: "online" } as never]);
|
||||
vi.mocked(batchUpdateTaskModels).mockResolvedValue({ updated: tasks, count: 1 });
|
||||
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
|
||||
await user.click(screen.getByLabelText("Select FN-001"));
|
||||
await user.selectOptions(await screen.findByLabelText("Node Override"), "");
|
||||
await user.click(screen.getByRole("button", { name: "Apply" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const args = vi.mocked(batchUpdateTaskModels).mock.calls.at(-1);
|
||||
expect(args?.[7]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps apply disabled when all controls are no change", async () => {
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-abc", name: "Node ABC", status: "online" } as never]);
|
||||
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
|
||||
fireEvent.click(screen.getByLabelText("Select FN-001"));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Apply" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards favoriteProviders and favoriteModels to QuickEntryBox model menu (FN-770)", async () => {
|
||||
const availableModels = [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
|
||||
@@ -1589,8 +1589,11 @@ describe("NewAgentDialog", () => {
|
||||
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const portalAfterRollback = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement;
|
||||
expect(within(portalAfterRollback).getByRole("button", { name: "Remove anthropic from favorites" })).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
const portalAfterRollback = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement | null;
|
||||
expect(portalAfterRollback).toBeTruthy();
|
||||
expect(within(portalAfterRollback as HTMLElement).getByRole("button", { name: "Remove anthropic from favorites" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1523,30 +1523,6 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forces enabled=true for selected active provider when saving", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openRemoteSection();
|
||||
|
||||
const tailscaleToggle = screen.getByLabelText("Enable Tailscale provider config");
|
||||
expect(tailscaleToggle).not.toBeChecked();
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText("Active provider"), "tailscale");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save Remote Settings" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRemoteSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteTailscaleEnabled: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles Cloudflare quick tunnel and hides manual cloudflare fields", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
@@ -1881,4 +1857,55 @@ describe("SettingsModal", () => {
|
||||
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory dream trigger", () => {
|
||||
const openMemorySection = async () => {
|
||||
const [memorySectionButton] = await screen.findAllByRole("button", { name: /^Memory$/i });
|
||||
await userEvent.click(memorySectionButton);
|
||||
};
|
||||
|
||||
it("shows Dream Now button when dreams are enabled", async () => {
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
memoryEnabled: true,
|
||||
memoryDreamsEnabled: true,
|
||||
memoryDreamsSchedule: "0 4 * * *",
|
||||
});
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openMemorySection();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Dream Now" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("triggers dream processing from Dream Now button", async () => {
|
||||
const addToast = vi.fn();
|
||||
mockFetchSettings.mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
memoryEnabled: true,
|
||||
memoryDreamsEnabled: true,
|
||||
});
|
||||
mockTriggerMemoryDreams.mockResolvedValueOnce({ success: true, summary: "done" });
|
||||
|
||||
renderModal({ addToast });
|
||||
await waitForSettingsModalReady();
|
||||
await openMemorySection();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Dream Now" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTriggerMemoryDreams).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
expect(addToast).toHaveBeenCalledWith("Dream processing completed", "success");
|
||||
});
|
||||
|
||||
it("hides Dream Now button when dreams are disabled", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openMemorySection();
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Dream Now" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,11 +209,12 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
|
||||
it("renders form controls inside settings-content for 16px mobile targeting", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container, getByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
const { container, findAllByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
// Authentication is first by default, so click General to see form controls
|
||||
await user.click(getByText("General"));
|
||||
const generalTabs = await findAllByText("General");
|
||||
await user.click(generalTabs[0]);
|
||||
|
||||
const controls = container.querySelectorAll(".settings-content input, .settings-content select, .settings-content textarea");
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -2858,6 +2858,95 @@ describe("POST /tasks/batch-update-models", () => {
|
||||
expect(res.body.error).toContain("non-empty strings");
|
||||
});
|
||||
|
||||
it("bulk sets nodeId", async () => {
|
||||
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
|
||||
const updated1 = { ...task1, nodeId: "node-abc" };
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["FN-001"],
|
||||
nodeId: "node-abc",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(1);
|
||||
expect(res.body.updated[0].nodeId).toBe("node-abc");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ nodeId: "node-abc" }));
|
||||
});
|
||||
|
||||
it("bulk clears nodeId", async () => {
|
||||
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001", nodeId: "node-abc" };
|
||||
const updated1 = { ...task1, nodeId: undefined };
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["FN-001"],
|
||||
nodeId: null,
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.updated[0].nodeId).toBeUndefined();
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({ nodeId: null }));
|
||||
});
|
||||
|
||||
it("accepts nodeId without model fields", async () => {
|
||||
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
|
||||
const updated1 = { ...task1, nodeId: "node-abc" };
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["FN-001"],
|
||||
nodeId: "node-abc",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid nodeId type", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["FN-001"],
|
||||
nodeId: 123,
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("nodeId must be a string, null, or undefined");
|
||||
});
|
||||
|
||||
it("updates nodeId across multiple tasks", async () => {
|
||||
const task1 = { ...FAKE_TASK_DETAIL, id: "FN-001" };
|
||||
const task2 = { ...FAKE_TASK_DETAIL, id: "FN-002" };
|
||||
const updated1 = { ...task1, nodeId: "node-xyz" };
|
||||
const updated2 = { ...task2, nodeId: "node-xyz" };
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(task1).mockResolvedValueOnce(task2);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updated1).mockResolvedValueOnce(updated2);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["FN-001", "FN-002"],
|
||||
nodeId: "node-xyz",
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.count).toBe(2);
|
||||
expect(res.body.updated).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns 400 when no model fields provided", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/batch-update-models", JSON.stringify({
|
||||
taskIds: ["FN-001"],
|
||||
|
||||
@@ -415,7 +415,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
router.post("/tasks/batch-update-models", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { taskIds, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId } = req.body;
|
||||
const {
|
||||
taskIds,
|
||||
modelProvider,
|
||||
modelId,
|
||||
validatorModelProvider,
|
||||
validatorModelId,
|
||||
planningModelProvider,
|
||||
planningModelId,
|
||||
nodeId,
|
||||
} = req.body;
|
||||
|
||||
// Validate taskIds
|
||||
if (!Array.isArray(taskIds)) {
|
||||
@@ -428,12 +437,17 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
throw badRequest("taskIds must contain non-empty strings");
|
||||
}
|
||||
|
||||
// Validate that at least one model field is being updated
|
||||
// Validate that at least one model field or node override is being updated
|
||||
const hasExecutorModel = modelProvider !== undefined || modelId !== undefined;
|
||||
const hasValidatorModel = validatorModelProvider !== undefined || validatorModelId !== undefined;
|
||||
const hasPlanningModel = planningModelProvider !== undefined || planningModelId !== undefined;
|
||||
if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel) {
|
||||
throw badRequest("At least one model field must be provided");
|
||||
const hasNodeId = nodeId !== undefined;
|
||||
if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel && !hasNodeId) {
|
||||
throw badRequest("At least one model field or nodeId must be provided");
|
||||
}
|
||||
|
||||
if (nodeId !== undefined && nodeId !== null && typeof nodeId !== "string") {
|
||||
throw badRequest("nodeId must be a string, null, or undefined");
|
||||
}
|
||||
|
||||
// Validate model field pairs (both provider and modelId must be provided together or neither)
|
||||
@@ -486,7 +500,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
|
||||
// Build update payload (only include fields that were explicitly provided)
|
||||
const updates: { modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null } = {};
|
||||
const updates: {
|
||||
modelProvider?: string | null;
|
||||
modelId?: string | null;
|
||||
validatorModelProvider?: string | null;
|
||||
validatorModelId?: string | null;
|
||||
planningModelProvider?: string | null;
|
||||
planningModelId?: string | null;
|
||||
nodeId?: string | null;
|
||||
} = {};
|
||||
if (validatedExecutor.provider !== undefined) {
|
||||
updates.modelProvider = validatedExecutor.provider;
|
||||
}
|
||||
@@ -505,6 +527,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
if (validatedPlanning.modelId !== undefined) {
|
||||
updates.planningModelId = validatedPlanning.modelId;
|
||||
}
|
||||
if (nodeId !== undefined) {
|
||||
updates.nodeId = nodeId;
|
||||
}
|
||||
|
||||
// Update all tasks in parallel
|
||||
const updatePromises = taskIds.map(async (taskId) => {
|
||||
|
||||
Reference in New Issue
Block a user