feat(FN-2724): add interactive task routing controls
- Replace the static routing panel with a dedicated Routing tab that shows effective node, source, and policy details - Add per-task node override selection with optimistic save/rollback, clear override action, and in-progress lock messaging - Tighten PATCH /tasks/:id nodeId validation (empty/non-string rejection) while preserving node override conflict enforcement - Update dashboard route and modal tests, including new RoutingTab coverage and tab-order assertions
This commit is contained in:
@@ -1,9 +1,117 @@
|
||||
.routing-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.routing-tab h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.routing-tab h5 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.routing-tab__intro {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.routing-tab__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.routing-summary-grid {
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.routing-summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.routing-summary-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.routing-summary-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.routing-summary-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.routing-summary-warning {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.routing-tab__info-banner,
|
||||
.routing-tab__warning-banner,
|
||||
.routing-tab__error {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.routing-tab__info-banner,
|
||||
.routing-tab__warning-banner {
|
||||
background: color-mix(in srgb, var(--color-warning) 14%, transparent);
|
||||
color: var(--color-warning);
|
||||
border: 1px solid color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
}
|
||||
|
||||
.routing-tab__error {
|
||||
background: var(--status-error-bg);
|
||||
color: var(--color-error);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||
}
|
||||
|
||||
.routing-tab__selector-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.routing-tab__selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.routing-tab__override-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.routing-tab__override-text {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.routing-tab {
|
||||
min-width: 0;
|
||||
.routing-summary-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.routing-tab__override-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,209 @@
|
||||
import "./RoutingTab.css";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Settings, Task, TaskDetail } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import { fetchNodes, updateTask } from "../api";
|
||||
import type { NodeInfo } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface RoutingTabProps {
|
||||
task: Task | TaskDetail;
|
||||
settings?: Settings;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
}
|
||||
|
||||
export function RoutingTab({ task, settings, projectId }: RoutingTabProps) {
|
||||
void projectId;
|
||||
const STATUS_DOT: Record<NodeInfo["status"], string> = {
|
||||
online: "🟢",
|
||||
offline: "🔴",
|
||||
connecting: "🟡",
|
||||
error: "🔴",
|
||||
};
|
||||
|
||||
type RoutingSettings = Settings & {
|
||||
defaultNodeId?: string;
|
||||
unavailableNodePolicy?: "block" | "fallback-local";
|
||||
};
|
||||
|
||||
function getRoutingPolicyLabel(policy: RoutingSettings["unavailableNodePolicy"] | undefined): string {
|
||||
if (policy === "block") return "Block execution";
|
||||
if (policy === "fallback-local") return "Fall back to local";
|
||||
return "Not configured";
|
||||
}
|
||||
|
||||
function isUnhealthy(status: NodeInfo["status"] | undefined): boolean {
|
||||
return status !== undefined && status !== "online";
|
||||
}
|
||||
|
||||
export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingTabProps) {
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [loadingNodes, setLoadingNodes] = useState(false);
|
||||
const [nodesError, setNodesError] = useState<string | null>(null);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string>(task.nodeId ?? "");
|
||||
const [savingNode, setSavingNode] = useState(false);
|
||||
|
||||
const activeTaskIdRef = useRef(task.id);
|
||||
|
||||
useEffect(() => {
|
||||
activeTaskIdRef.current = task.id;
|
||||
setSelectedNodeId(task.nodeId ?? "");
|
||||
setSavingNode(false);
|
||||
}, [task.id, task.nodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingNodes(true);
|
||||
setNodesError(null);
|
||||
|
||||
fetchNodes()
|
||||
.then((result) => {
|
||||
setNodes(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
setNodesError(getErrorMessage(err) || "Failed to load nodes");
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingNodes(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const nodesById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
|
||||
const sortedNodes = useMemo(
|
||||
() => [...nodes].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[nodes],
|
||||
);
|
||||
|
||||
const routingSettings = settings as RoutingSettings | undefined;
|
||||
const effectiveNodeId = task.nodeId ?? routingSettings?.defaultNodeId ?? null;
|
||||
const routingSource = task.nodeId
|
||||
? "Per-task override"
|
||||
: routingSettings?.defaultNodeId
|
||||
? "Project default"
|
||||
: "No routing";
|
||||
|
||||
const effectiveNode = effectiveNodeId ? nodesById.get(effectiveNodeId) : undefined;
|
||||
const effectiveNodeName = effectiveNode
|
||||
? `${STATUS_DOT[effectiveNode.status]} ${effectiveNode.name} (${effectiveNode.type})`
|
||||
: effectiveNodeId
|
||||
? `${effectiveNodeId} (node unavailable or unknown)`
|
||||
: "Local (no routing configured)";
|
||||
|
||||
const taskInProgress = task.column === "in-progress";
|
||||
const selectorDisabled = taskInProgress || savingNode || loadingNodes;
|
||||
|
||||
const handleNodeSelect = useCallback(
|
||||
async (nextValue: string) => {
|
||||
if (nextValue === selectedNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestTaskId = task.id;
|
||||
const previousValue = selectedNodeId;
|
||||
setSelectedNodeId(nextValue);
|
||||
setSavingNode(true);
|
||||
|
||||
try {
|
||||
const updatedTask = await updateTask(requestTaskId, { nodeId: nextValue || null });
|
||||
if (activeTaskIdRef.current !== requestTaskId) return;
|
||||
|
||||
setSelectedNodeId(updatedTask.nodeId ?? "");
|
||||
onTaskUpdated?.(updatedTask);
|
||||
addToast(nextValue ? "Node override updated" : "Node override cleared", "success");
|
||||
} catch (err) {
|
||||
if (activeTaskIdRef.current !== requestTaskId) return;
|
||||
setSelectedNodeId(previousValue);
|
||||
addToast(getErrorMessage(err) || "Failed to update node override", "error");
|
||||
} finally {
|
||||
if (activeTaskIdRef.current === requestTaskId) {
|
||||
setSavingNode(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[addToast, onTaskUpdated, selectedNodeId, task.id],
|
||||
);
|
||||
|
||||
const clearOverride = useCallback(() => {
|
||||
void handleNodeSelect("");
|
||||
}, [handleNodeSelect]);
|
||||
|
||||
return (
|
||||
<div className="detail-section routing-tab">
|
||||
<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 className="routing-tab">
|
||||
<h4>Task Routing</h4>
|
||||
<p className="routing-tab__intro">View the effective execution node and control per-task node override.</p>
|
||||
|
||||
<section className="routing-tab__section">
|
||||
<h5>Routing Summary</h5>
|
||||
<div className="routing-summary-grid" role="list">
|
||||
<div className="routing-summary-row" role="listitem">
|
||||
<span className="routing-summary-label">Effective node</span>
|
||||
<span className="routing-summary-value">
|
||||
{effectiveNodeName}
|
||||
{isUnhealthy(effectiveNode?.status) ? (
|
||||
<span className="routing-summary-warning">Unhealthy</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="routing-summary-row" role="listitem">
|
||||
<span className="routing-summary-label">Routing source</span>
|
||||
<span className="routing-summary-value">{routingSource}</span>
|
||||
</div>
|
||||
<div className="routing-summary-row" role="listitem">
|
||||
<span className="routing-summary-label">Unavailable-node policy</span>
|
||||
<span className="routing-summary-value">{getRoutingPolicyLabel(routingSettings?.unavailableNodePolicy)}</span>
|
||||
</div>
|
||||
</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>
|
||||
{taskInProgress && effectiveNodeId ? (
|
||||
<div className="routing-tab__info-banner">
|
||||
Routing is locked while this task is active. Node override cannot be changed until the task leaves in-progress.
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="routing-tab__section">
|
||||
<h5>Node Override</h5>
|
||||
{taskInProgress ? (
|
||||
<div className="routing-tab__warning-banner">
|
||||
Node override cannot be changed while the task is in progress.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="routing-tab__selector-label" htmlFor={`routing-node-${task.id}`}>
|
||||
Select execution node
|
||||
</label>
|
||||
<select
|
||||
id={`routing-node-${task.id}`}
|
||||
className="select routing-tab__selector"
|
||||
value={selectedNodeId}
|
||||
disabled={selectorDisabled}
|
||||
onChange={(event) => {
|
||||
void handleNodeSelect(event.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">Use project default</option>
|
||||
{sortedNodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{STATUS_DOT[node.status]} {node.name} ({node.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{nodesError ? <div className="routing-tab__error">{nodesError}</div> : null}
|
||||
|
||||
{task.nodeId ? (
|
||||
<div className="routing-tab__override-row">
|
||||
<span className="routing-tab__override-text">
|
||||
Override set to: {nodesById.get(task.nodeId)?.name ?? task.nodeId}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={taskInProgress || savingNode}
|
||||
onClick={clearOverride}
|
||||
>
|
||||
Clear override
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
type TabId = "definition" | "logs" | "changes" | "comments" | "model" | "routing" | "workflow" | "documents" | "stats" | `plugin-${string}`;
|
||||
type TabId = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | `plugin-${string}`;
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: Task | TaskDetail;
|
||||
@@ -1482,12 +1482,6 @@ export function TaskDetailModal({
|
||||
>
|
||||
Model
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "routing" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("routing")}
|
||||
>
|
||||
Routing
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "workflow" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("workflow")}
|
||||
@@ -1500,6 +1494,12 @@ export function TaskDetailModal({
|
||||
>
|
||||
Stats
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "routing" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("routing")}
|
||||
>
|
||||
Routing
|
||||
</button>
|
||||
{/* Plugin tabs */}
|
||||
{pluginTabSlots.map((entry, index) => {
|
||||
const pluginTabId = `plugin-${index}` as TabId;
|
||||
@@ -1531,8 +1531,6 @@ export function TaskDetailModal({
|
||||
<div className="detail-section">
|
||||
<ModelSelectorTab task={task} addToast={addToast} onTaskUpdated={onTaskUpdated} settings={settings} />
|
||||
</div>
|
||||
) : activeTab === "routing" ? (
|
||||
<RoutingTab task={task} settings={settings} projectId={projectId} />
|
||||
) : activeTab === "logs" ? (
|
||||
<div className={`detail-section${logSubview === "agent-log" ? " detail-section--agent-log" : ""}`}>
|
||||
<div className="log-subview-toggle">
|
||||
@@ -1615,6 +1613,15 @@ export function TaskDetailModal({
|
||||
task={workingTask}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "routing" ? (
|
||||
<div className="detail-section">
|
||||
<RoutingTab
|
||||
task={task}
|
||||
settings={settings}
|
||||
addToast={addToast}
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary section - only for done tasks with summary */}
|
||||
|
||||
169
packages/dashboard/app/components/__tests__/RoutingTab.test.tsx
Normal file
169
packages/dashboard/app/components/__tests__/RoutingTab.test.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Settings, Task } from "@fusion/core";
|
||||
import { RoutingTab } from "../RoutingTab";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("lucide-react", () => ({}));
|
||||
|
||||
vi.mock("../../api", async () => {
|
||||
const actual = await vi.importActual<typeof api>("../../api");
|
||||
return {
|
||||
...actual,
|
||||
fetchNodes: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetchNodes = api.fetchNodes as ReturnType<typeof vi.fn>;
|
||||
const mockUpdateTask = api.updateTask as ReturnType<typeof vi.fn>;
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-001",
|
||||
description: "Routing test task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type RoutingSettings = Settings & {
|
||||
defaultNodeId?: string;
|
||||
unavailableNodePolicy?: "block" | "fallback-local";
|
||||
};
|
||||
|
||||
function makeSettings(overrides: Partial<RoutingSettings> = {}): RoutingSettings {
|
||||
return {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 2,
|
||||
pollIntervalMs: 10000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RoutingTab", () => {
|
||||
const addToast = vi.fn();
|
||||
const onTaskUpdated = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchNodes.mockResolvedValue([
|
||||
{ id: "node-a", name: "Alpha", type: "local", status: "online" },
|
||||
{ id: "node-b", name: "Beta", type: "remote", status: "offline" },
|
||||
]);
|
||||
mockUpdateTask.mockImplementation(async (_id: string, updates: { nodeId?: string | null }) => {
|
||||
return makeTask({ nodeId: updates.nodeId ?? undefined });
|
||||
});
|
||||
});
|
||||
|
||||
it("renders routing summary with per-task override", async () => {
|
||||
render(
|
||||
<RoutingTab
|
||||
task={makeTask({ nodeId: "node-a" })}
|
||||
settings={makeSettings({ defaultNodeId: "node-b" })}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Per-task override")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders routing summary with project default", async () => {
|
||||
render(
|
||||
<RoutingTab
|
||||
task={makeTask()}
|
||||
settings={makeSettings({ defaultNodeId: "node-a" })}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Project default")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no-routing summary when no override or project default exists", async () => {
|
||||
render(<RoutingTab task={makeTask()} settings={makeSettings()} addToast={addToast} />);
|
||||
|
||||
expect(await screen.findByText("Local (no routing configured)")).toBeInTheDocument();
|
||||
expect(screen.getByText("No routing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["block", "Block execution"],
|
||||
["fallback-local", "Fall back to local"],
|
||||
] as const)("displays unavailable-node policy: %s", async (policy, label) => {
|
||||
render(
|
||||
<RoutingTab
|
||||
task={makeTask()}
|
||||
settings={makeSettings({ unavailableNodePolicy: policy })}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText(label);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables node selector for in-progress tasks", async () => {
|
||||
render(<RoutingTab task={makeTask({ column: "in-progress" })} settings={makeSettings()} addToast={addToast} />);
|
||||
|
||||
const selector = await screen.findByLabelText("Select execution node");
|
||||
expect(selector).toBeDisabled();
|
||||
expect(screen.getByText("Node override cannot be changed while the task is in progress.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables node selector for non-in-progress tasks", async () => {
|
||||
render(<RoutingTab task={makeTask({ column: "todo" })} settings={makeSettings()} addToast={addToast} />);
|
||||
|
||||
const selector = await screen.findByLabelText("Select execution node");
|
||||
expect(selector).toBeEnabled();
|
||||
});
|
||||
|
||||
it("calls updateTask when node selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<RoutingTab
|
||||
task={makeTask({ column: "todo" })}
|
||||
settings={makeSettings()}
|
||||
addToast={addToast}
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selector = await screen.findByLabelText("Select execution node");
|
||||
await user.selectOptions(selector, "node-a");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { nodeId: "node-a" });
|
||||
});
|
||||
});
|
||||
|
||||
it("shows clear override button and clears node override", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<RoutingTab
|
||||
task={makeTask({ nodeId: "node-a" })}
|
||||
settings={makeSettings()}
|
||||
addToast={addToast}
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clearButton = await screen.findByRole("button", { name: "Clear override" });
|
||||
await user.click(clearButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { nodeId: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1660,8 +1660,8 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
// For an in-progress task (no workflow steps, no merge commit), the
|
||||
// top-level tabs are: Definition, Logs, Changes, Comments, Documents,
|
||||
// Model, Routing, Workflow, Stats.
|
||||
const tabTexts = ["Definition", "Logs", "Changes", "Comments", "Documents", "Model", "Routing", "Workflow", "Stats"];
|
||||
// Model, Workflow, Stats, Routing.
|
||||
const tabTexts = ["Definition", "Logs", "Changes", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"];
|
||||
const tabs = screen.getAllByRole("button").filter((b) =>
|
||||
tabTexts.includes(b.textContent || "")
|
||||
);
|
||||
@@ -1672,9 +1672,9 @@ describe("TaskDetailModal", () => {
|
||||
expect(tabs[3].textContent).toBe("Comments");
|
||||
expect(tabs[4].textContent).toBe("Documents");
|
||||
expect(tabs[5].textContent).toBe("Model");
|
||||
expect(tabs[6].textContent).toBe("Routing");
|
||||
expect(tabs[7].textContent).toBe("Workflow");
|
||||
expect(tabs[8].textContent).toBe("Stats");
|
||||
expect(tabs[6].textContent).toBe("Workflow");
|
||||
expect(tabs[7].textContent).toBe("Stats");
|
||||
expect(tabs[8].textContent).toBe("Routing");
|
||||
|
||||
// Activity and Agent Log are NOT top-level tabs (they are subviews inside Logs)
|
||||
expect(container.querySelectorAll(".detail-tab").length).toBe(9);
|
||||
@@ -3168,7 +3168,7 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
// In-progress tasks show exactly 9 tabs:
|
||||
// Definition, Logs, Changes, Comments, Documents, Model, Routing, Workflow, Stats
|
||||
// Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats, Routing
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(tabs.length).toBe(9);
|
||||
expect(tabs[0].textContent).toBe("Definition");
|
||||
@@ -3177,9 +3177,9 @@ describe("TaskDetailModal", () => {
|
||||
expect(tabs[3].textContent).toBe("Comments");
|
||||
expect(tabs[4].textContent).toBe("Documents");
|
||||
expect(tabs[5].textContent).toBe("Model");
|
||||
expect(tabs[6].textContent).toBe("Routing");
|
||||
expect(tabs[7].textContent).toBe("Workflow");
|
||||
expect(tabs[8].textContent).toBe("Stats");
|
||||
expect(tabs[6].textContent).toBe("Workflow");
|
||||
expect(tabs[7].textContent).toBe("Stats");
|
||||
expect(tabs[8].textContent).toBe("Routing");
|
||||
// Commits tab should NOT be present for non-done tasks
|
||||
expect(screen.queryByText("Commits")).toBeNull();
|
||||
});
|
||||
@@ -3197,7 +3197,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// In-progress task with workflow steps: 9 tabs (Routing between Model and Workflow, Stats last)
|
||||
// In-progress task with workflow steps: 9 tabs (Workflow after Model, Stats then Routing)
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(tabs.length).toBe(9);
|
||||
expect(tabs[0].textContent).toBe("Definition");
|
||||
@@ -3206,9 +3206,9 @@ describe("TaskDetailModal", () => {
|
||||
expect(tabs[3].textContent).toBe("Comments");
|
||||
expect(tabs[4].textContent).toBe("Documents");
|
||||
expect(tabs[5].textContent).toBe("Model");
|
||||
expect(tabs[6].textContent).toBe("Routing");
|
||||
expect(tabs[7].textContent).toBe("Workflow");
|
||||
expect(tabs[8].textContent).toBe("Stats");
|
||||
expect(tabs[6].textContent).toBe("Workflow");
|
||||
expect(tabs[7].textContent).toBe("Stats");
|
||||
expect(tabs[8].textContent).toBe("Routing");
|
||||
});
|
||||
|
||||
it("does NOT show Commits tab for done task with mergeDetails.commitSha (changes merged into Changes tab)", () => {
|
||||
@@ -3227,7 +3227,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Done task with commit SHA: Definition, Logs, Changes, Comments, Documents, Model, Routing, Workflow, Stats (9 tabs, no Commits)
|
||||
// Done task with commit SHA: Definition, Logs, Changes, Comments, Documents, Model, Workflow, Stats, Routing (9 tabs, no Commits)
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(tabs.length).toBe(9);
|
||||
expect(tabs[0].textContent).toBe("Definition");
|
||||
@@ -3236,9 +3236,9 @@ describe("TaskDetailModal", () => {
|
||||
expect(tabs[3].textContent).toBe("Comments");
|
||||
expect(tabs[4].textContent).toBe("Documents");
|
||||
expect(tabs[5].textContent).toBe("Model");
|
||||
expect(tabs[6].textContent).toBe("Routing");
|
||||
expect(tabs[7].textContent).toBe("Workflow");
|
||||
expect(tabs[8].textContent).toBe("Stats");
|
||||
expect(tabs[6].textContent).toBe("Workflow");
|
||||
expect(tabs[7].textContent).toBe("Stats");
|
||||
expect(tabs[8].textContent).toBe("Routing");
|
||||
// Commits tab should NOT be present
|
||||
expect(screen.queryByText("Commits")).toBeNull();
|
||||
});
|
||||
@@ -3269,9 +3269,9 @@ describe("TaskDetailModal", () => {
|
||||
expect(tabs[3].textContent).toBe("Comments");
|
||||
expect(tabs[4].textContent).toBe("Documents");
|
||||
expect(tabs[5].textContent).toBe("Model");
|
||||
expect(tabs[6].textContent).toBe("Routing");
|
||||
expect(tabs[7].textContent).toBe("Workflow");
|
||||
expect(tabs[8].textContent).toBe("Stats");
|
||||
expect(tabs[6].textContent).toBe("Workflow");
|
||||
expect(tabs[7].textContent).toBe("Stats");
|
||||
expect(tabs[8].textContent).toBe("Routing");
|
||||
// Commits tab should NOT be present
|
||||
expect(screen.queryByText("Commits")).toBeNull();
|
||||
});
|
||||
@@ -3290,9 +3290,9 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
const triageTabs = triageContainer.querySelectorAll(".detail-tab");
|
||||
expect(triageTabs.length).toBe(8); // Definition, Logs, Comments, Documents, Model, Routing, Workflow, Stats
|
||||
expect(triageTabs.length).toBe(8); // Definition, Logs, Comments, Documents, Model, Workflow, Stats, Routing
|
||||
expect(Array.from(triageTabs).map(t => t.textContent)).toEqual([
|
||||
"Definition", "Logs", "Comments", "Documents", "Model", "Routing", "Workflow", "Stats",
|
||||
"Definition", "Logs", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing",
|
||||
]);
|
||||
|
||||
const { container: todoContainer } = render(
|
||||
@@ -3308,9 +3308,9 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
const todoTabs = todoContainer.querySelectorAll(".detail-tab");
|
||||
expect(todoTabs.length).toBe(8); // Definition, Logs, Comments, Documents, Model, Routing, Workflow, Stats
|
||||
expect(todoTabs.length).toBe(8); // Definition, Logs, Comments, Documents, Model, Workflow, Stats, Routing
|
||||
expect(Array.from(todoTabs).map(t => t.textContent)).toEqual([
|
||||
"Definition", "Logs", "Comments", "Documents", "Model", "Routing", "Workflow", "Stats",
|
||||
"Definition", "Logs", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user