feat(FN-2885): upgrade routing controls and task timing UI

- Redesign the task Routing tab with effective-node summary rows, unhealthy node signaling, and direct per-task override selection
- Add robust override update handling (loading/saving states, stale-task guards, clear override action, and in-progress lock messaging)
- Extend TaskCard execution time indicator behavior to in-review cards and add focused regression coverage
- Refresh dashboard tests and styling for routing and node-status presentation, and remove an unused remote settings API import
This commit is contained in:
Fusion
2026-04-28 11:31:18 -07:00
committed by gsxdsm
parent 3d44398920
commit feb213c4ef
8 changed files with 505 additions and 213 deletions

View File

@@ -1,34 +1,117 @@
.routing-tab {
width: 100%;
}
.routing-tab-summary {
margin-bottom: var(--space-lg);
}
.routing-tab-override {
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-tab-blocked {
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
color: var(--color-warning);
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent);
border-radius: var(--radius-md);
padding: var(--space-md) var(--space-lg);
font-size: 0.85rem;
margin-top: 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-tab-actions {
margin-top: var(--space-sm);
.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-summary,
.routing-tab-override {
margin-bottom: var(--space-md);
.routing-summary-row {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-xs);
}
.routing-tab__override-row {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -1,136 +1,209 @@
import "./RoutingTab.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getErrorMessage, type Settings, type Task, type TaskDetail } from "@fusion/core";
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";
import { ProjectNodeSelector } from "./ProjectNodeSelector";
interface RoutingTabProps {
task: Task | TaskDetail;
settings?: Settings;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
onTaskUpdated?: (task: Task) => void;
}
function resolveUnavailablePolicy(policy?: string): string {
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";
}
export function RoutingTab({ task, settings, projectId, addToast, onTaskUpdated }: RoutingTabProps) {
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 isInProgress = task.column === "in-progress";
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(() => {
let mounted = true;
activeTaskIdRef.current = task.id;
setSelectedNodeId(task.nodeId ?? "");
setSavingNode(false);
}, [task.id, task.nodeId]);
useEffect(() => {
setLoadingNodes(true);
setNodesError(null);
fetchNodes()
.then((fetchedNodes) => {
if (mounted) setNodes(fetchedNodes);
.then((result) => {
setNodes(result);
})
.catch((error) => {
if (mounted) {
addToast(`Failed to load nodes: ${getErrorMessage(error)}`, "error");
}
.catch((err) => {
setNodesError(getErrorMessage(err) || "Failed to load nodes");
})
.finally(() => {
setLoadingNodes(false);
});
}, []);
return () => {
mounted = false;
};
}, [addToast]);
const effectiveNodeName = useMemo(() => {
if (task.nodeId) {
const taskNode = nodes.find((node) => node.id === task.nodeId);
return taskNode ? taskNode.name : `${task.nodeId} (unknown node)`;
}
if (settings?.defaultNodeId) {
const defaultNode = nodes.find((node) => node.id === settings.defaultNodeId);
return defaultNode ? `${defaultNode.name} (project default)` : `${settings.defaultNodeId} (unknown node)`;
}
return "Local (no routing configured)";
}, [nodes, settings?.defaultNodeId, task.nodeId]);
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"
: settings?.defaultNodeId
: routingSettings?.defaultNodeId
? "Project default"
: "No routing";
const blockingReason = (task as Task & { blockedReason?: string; statusReason?: string }).blockedReason
|| (task as Task & { statusReason?: string }).statusReason;
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 (selectedNodeId: string | null) => {
async (nextValue: string) => {
if (nextValue === selectedNodeId) {
return;
}
const requestTaskId = task.id;
const previousValue = selectedNodeId;
setSelectedNodeId(nextValue);
setSavingNode(true);
try {
const updated = await updateTask(task.id, { nodeId: selectedNodeId || null });
addToast("Node override updated", "success");
onTaskUpdated?.(updated);
} catch (error) {
addToast(`Failed to update node override: ${getErrorMessage(error)}`, "error");
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, task.id],
[addToast, onTaskUpdated, selectedNodeId, task.id],
);
const handleClearOverride = useCallback(async () => {
await handleNodeSelect(null);
const clearOverride = useCallback(() => {
void handleNodeSelect("");
}, [handleNodeSelect]);
return (
<div className="routing-tab">
<div className="routing-tab-summary">
<h4>Node Routing Summary</h4>
<dl className="detail-source-grid">
<div>
<dt>Effective Node</dt>
<dd>{effectiveNodeName}</dd>
</div>
<div>
<dt>Routing Source</dt>
<dd>{routingSource}</dd>
</div>
<div>
<dt>Unavailable Node Policy</dt>
<dd>{resolveUnavailablePolicy(settings?.unavailableNodePolicy)}</dd>
</div>
<div>
<dt>Blocking Reason</dt>
<dd>{blockingReason ?? <span className="detail-source-empty">(not blocked)</span>}</dd>
</div>
</dl>
</div>
<h4>Task Routing</h4>
<p className="routing-tab__intro">View the effective execution node and control per-task node override.</p>
<div className="routing-tab-override">
<h4>Node Override</h4>
<ProjectNodeSelector
projectId={projectId ?? ""}
nodes={nodes}
currentNodeId={task.nodeId ?? undefined}
onSelect={(nodeId) => {
void handleNodeSelect(nodeId);
}}
disabled={isInProgress}
/>
{isInProgress ? (
<div className="routing-tab-blocked">
Node override cannot be changed while the task is in progress. Wait for the task to complete or move it
back to todo first.
<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>
{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}
{task.nodeId && !isInProgress ? (
<div className="routing-tab-actions">
<button type="button" className="btn btn-sm" onClick={() => void handleClearOverride()}>
</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}
</div>
</section>
</div>
);
}

View File

@@ -1270,3 +1270,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);
}

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEve
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } from "lucide-react";
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -2704,6 +2704,7 @@ export function SettingsModal({
</div>
</>
);
case "worktrees":
return (
<>

View File

@@ -94,7 +94,11 @@ const COLUMN_PROGRESS_COLOR_MAP: Record<Column, string> = {
archived: "var(--text-muted)",
};
const TIME_INDICATOR_COLUMNS = new Set<Column>(["in-progress", "done"]);
const TIME_INDICATOR_COLUMNS = new Set<Column>([
"in-progress",
"in-review",
"done",
]);
const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
function parseTimestampToMs(value?: string): number | null {
@@ -677,15 +681,24 @@ function TaskCardComponent({
unifiedProgress.total > 0 && (task.status === "executing" || task.column === "in-progress");
useEffect(() => {
if (task.column !== "in-progress") {
if (task.column !== "in-progress" && task.column !== "in-review") {
return;
}
const hasInProgressStep = (task.workflowStepResults ?? []).some(
(step) => step.startedAt && !step.completedAt,
);
if (!hasInProgressStep) {
return;
if (task.column === "in-progress") {
const hasInProgressStep = (task.workflowStepResults ?? []).some(
(step) => step.startedAt && !step.completedAt,
);
if (!hasInProgressStep) {
return;
}
}
if (task.column === "in-review") {
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
if (instrumentedMs == null) {
return;
}
}
setTimeIndicatorNowMs(Date.now());
@@ -694,7 +707,7 @@ function TaskCardComponent({
}, LIVE_TIME_INDICATOR_POLL_MS);
return () => window.clearInterval(interval);
}, [task.column, task.workflowStepResults]);
}, [task.column, task.workflowStepResults, task.timedExecutionMs]);
const timeIndicator = useMemo(() => {
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
@@ -706,7 +719,7 @@ function TaskCardComponent({
return null;
}
if (task.column === "in-progress") {
if (task.column === "in-progress" || task.column === "in-review") {
const elapsedLabel = formatElapsedDuration(instrumentedMs);
if (!elapsedLabel) {
return null;

View File

@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
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 * as api from "../../api";
import { RoutingTab } from "../RoutingTab";
import * as api from "../../api";
vi.mock("lucide-react", () => ({}));
@@ -15,22 +16,13 @@ vi.mock("../../api", async () => {
};
});
vi.mock("../ProjectNodeSelector", () => ({
ProjectNodeSelector: (props: any) => (
<div data-testid="node-selector" data-disabled={String(Boolean(props.disabled))}>
<button type="button" onClick={() => props.onSelect("node-2")}>select-node-2</button>
<button type="button" onClick={() => props.onSelect(null)}>select-none</button>
</div>
),
}));
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-2845",
description: "routing test",
id: "FN-001",
description: "Routing test task",
column: "todo",
dependencies: [],
steps: [],
@@ -42,43 +34,66 @@ function makeTask(overrides: Partial<Task> = {}): Task {
};
}
function makeSettings(overrides: Partial<Settings> = {}): Settings {
type RoutingSettings = Settings & {
defaultNodeId?: string;
unavailableNodePolicy?: "block" | "fallback-local";
};
function makeSettings(overrides: Partial<RoutingSettings> = {}): RoutingSettings {
return {
maxConcurrent: 1,
maxWorktrees: 1,
pollIntervalMs: 1000,
autoMerge: false,
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-1", name: "Worker Alpha", type: "remote", status: "online", maxConcurrent: 5, createdAt: "", updatedAt: "" },
{ id: "node-2", name: "Worker Beta", type: "remote", status: "online", maxConcurrent: 5, createdAt: "", updatedAt: "" },
{ id: "node-a", name: "Alpha", type: "local", status: "online" },
{ id: "node-b", name: "Beta", type: "remote", status: "offline" },
]);
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => makeTask(updates as Partial<Task>));
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-1" })} addToast={addToast} settings={makeSettings()} />);
await screen.findByText("Worker Alpha");
expect(screen.getByText("Per-task override")).toBeInTheDocument();
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()} addToast={addToast} settings={makeSettings({ defaultNodeId: "node-2" })} />);
await screen.findByText("Worker Beta (project default)");
expect(screen.getByText("Project default")).toBeInTheDocument();
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 routing summary with no routing", async () => {
render(<RoutingTab task={makeTask()} addToast={addToast} settings={makeSettings()} />);
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();
});
@@ -86,43 +101,69 @@ describe("RoutingTab", () => {
it.each([
["block", "Block execution"],
["fallback-local", "Fall back to local"],
])("displays unavailable-node policy %s", async (policy, text) => {
render(<RoutingTab task={makeTask()} addToast={addToast} settings={makeSettings({ unavailableNodePolicy: policy as any })} />);
expect(await screen.findByText(text)).toBeInTheDocument();
] 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 selector and shows warning for in-progress tasks", async () => {
render(<RoutingTab task={makeTask({ column: "in-progress" })} addToast={addToast} settings={makeSettings()} />);
await waitFor(() => expect(screen.getByTestId("node-selector")).toHaveAttribute("data-disabled", "true"));
expect(screen.getByText(/Node override cannot be changed while the task is in progress/i)).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 selector for non-in-progress tasks", async () => {
render(<RoutingTab task={makeTask({ column: "todo" })} addToast={addToast} settings={makeSettings()} />);
await waitFor(() => expect(screen.getByTestId("node-selector")).toHaveAttribute("data-disabled", "false"));
expect(screen.queryByText(/Node override cannot be changed/i)).not.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 () => {
render(<RoutingTab task={makeTask()} addToast={addToast} settings={makeSettings()} />);
fireEvent.click(await screen.findByText("select-node-2"));
await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledWith("FN-2845", { nodeId: "node-2" }));
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 override", async () => {
render(<RoutingTab task={makeTask({ nodeId: "node-1", column: "todo" })} addToast={addToast} settings={makeSettings()} />);
const button = await screen.findByRole("button", { name: "Clear override" });
fireEvent.click(button);
await waitFor(() => expect(mockUpdateTask).toHaveBeenCalledWith("FN-2845", { nodeId: null }));
});
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}
/>,
);
it("hides clear override button for in-progress tasks", () => {
render(<RoutingTab task={makeTask({ nodeId: "node-1", column: "in-progress" })} addToast={addToast} settings={makeSettings()} />);
expect(screen.queryByRole("button", { name: "Clear override" })).not.toBeInTheDocument();
});
const clearButton = await screen.findByRole("button", { name: "Clear override" });
await user.click(clearButton);
it("shows unknown node IDs as raw id", async () => {
render(<RoutingTab task={makeTask({ nodeId: "ghost-node" })} addToast={addToast} settings={makeSettings()} />);
expect(await screen.findByText("ghost-node (unknown node)")).toBeInTheDocument();
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { nodeId: null });
});
});
});

View File

@@ -651,7 +651,58 @@ describe("TaskCard", () => {
expect(Array.from(footerRow?.children ?? [])).toEqual([filesChanged, timer]);
});
it.each(["triage", "todo", "in-review", "archived"] as const)(
it("shows timer chip for in-review cards", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
workflowStepResults: [
{
workflowStepId: "step-1",
workflowStepName: "Plan",
phase: "pre-merge" as const,
status: "passed" as const,
startedAt: "2026-04-25T12:00:00.000Z",
completedAt: "2026-04-25T12:08:00.000Z",
},
],
log: [
{
timestamp: "2026-04-25T12:09:00.000Z",
action: "[timing] llm_call in 240000ms",
outcome: "",
} as unknown as Task["log"][number],
],
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain("12m");
expect(timer?.getAttribute("title")).toContain("Execution time 12m");
});
it("does not render timer chip for in-review cards without instrumentation data", () => {
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
workflowStepResults: undefined,
log: [],
timedExecutionMs: undefined,
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-time-indicator")).toBeNull();
});
it.each(["triage", "todo", "archived"] as const)(
"does not render timer chip for %s cards",
(column) => {
const { container } = render(