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