feat(FN-2729): unify node status indicator with NodeHealthDot
- Resolve merge conflicts in InlineCreateCard and QuickEntryBox using the existing node picker UX - Replace ad-hoc node status rendering with NodeHealthDot in task creation surfaces - Keep advanced quick-create controls and portal-based dropdown behavior intact - Preserve related dashboard styling and tests included in the staged squash Fusion-Task-Id: FN-2729
This commit is contained in:
@@ -244,34 +244,6 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.inline-create-node-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.inline-create-node-status__dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.inline-create-node-status--online .inline-create-node-status__dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.inline-create-node-status--offline .inline-create-node-status__dot,
|
||||
.inline-create-node-status--error .inline-create-node-status__dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.inline-create-node-status--connecting .inline-create-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.inline-create-priority-select {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fet
|
||||
import type { ModelInfo, Agent, NodeInfo } from "../api";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { ModelSelectionModal } from "./ModelSelectionModal";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { applyPresetToSelection } from "../utils/modelPresets";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
@@ -49,13 +50,6 @@ function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||
return "Offline";
|
||||
}
|
||||
|
||||
function getNodeStatusClass(status: NodeInfo["status"]): string {
|
||||
if (status === "online") return "inline-create-node-status--online";
|
||||
if (status === "connecting") return "inline-create-node-status--connecting";
|
||||
if (status === "error") return "inline-create-node-status--error";
|
||||
return "inline-create-node-status--offline";
|
||||
}
|
||||
|
||||
function getModelSelectionValue(provider?: string, modelId?: string): string {
|
||||
return provider && modelId ? `${provider}/${modelId}` : "";
|
||||
}
|
||||
@@ -878,12 +872,7 @@ export function InlineCreateCard({
|
||||
>
|
||||
<Server size={12} style={{ verticalAlign: "middle" }} />
|
||||
{selectedNode ? ` ${selectedNode.name}` : " Node"}
|
||||
{selectedNode && (
|
||||
<span className={`inline-create-node-status ${getNodeStatusClass(selectedNode.status)}`}>
|
||||
<span className="inline-create-node-status__dot" aria-hidden="true" />
|
||||
{getNodeStatusLabel(selectedNode.status)}
|
||||
</span>
|
||||
)}
|
||||
{selectedNode && <NodeHealthDot status={selectedNode.status} showLabel />}
|
||||
</button>
|
||||
{showNodePicker && (
|
||||
<div className="dep-dropdown node-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
|
||||
@@ -908,9 +897,7 @@ export function InlineCreateCard({
|
||||
setShowNodePicker(false);
|
||||
}}
|
||||
>
|
||||
<span className={`inline-create-node-status ${getNodeStatusClass(node.status)}`}>
|
||||
<span className="inline-create-node-status__dot" aria-hidden="true" />
|
||||
</span>
|
||||
<NodeHealthDot status={node.status} />
|
||||
<span className="dep-dropdown-title">{node.name}</span>
|
||||
<span className="node-picker-status-label">{getNodeStatusLabel(node.status)}</span>
|
||||
</button>
|
||||
|
||||
@@ -85,34 +85,6 @@
|
||||
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;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { batchUpdateTaskModels, fetchNodes } from "../api";
|
||||
import type { ModelInfo, NodeInfo } from "../api";
|
||||
import { QuickEntryBox } from "./QuickEntryBox";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { isTaskStuck } from "../utils/taskStuck";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
@@ -464,6 +465,10 @@ export function ListView({
|
||||
const [nodeOverride, setNodeOverride] = useState<string>("__no_change__");
|
||||
const [availableNodes, setAvailableNodes] = useState<NodeInfo[]>([]);
|
||||
const [isLoadingNodes, setIsLoadingNodes] = useState(false);
|
||||
const selectedOverrideNode = useMemo(
|
||||
() => (nodeOverride && nodeOverride !== "__no_change__" ? availableNodes.find((node) => node.id === nodeOverride) : undefined),
|
||||
[availableNodes, nodeOverride],
|
||||
);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -768,7 +773,7 @@ export function ListView({
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
<div className="bulk-edit-dropdown">
|
||||
<div className="bulk-edit-dropdown bulk-edit-node-wrap">
|
||||
<select
|
||||
className="select bulk-node-select"
|
||||
value={nodeOverride}
|
||||
@@ -784,6 +789,7 @@ export function ListView({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedOverrideNode ? <NodeHealthDot status={selectedOverrideNode.status} showLabel /> : null}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm bulk-edit-apply-btn"
|
||||
|
||||
@@ -152,34 +152,6 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.quick-entry-node-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.quick-entry-node-status__dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.quick-entry-node-status--online .quick-entry-node-status__dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.quick-entry-node-status--offline .quick-entry-node-status__dot,
|
||||
.quick-entry-node-status--error .quick-entry-node-status__dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.quick-entry-node-status--connecting .quick-entry-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.quick-entry-subtasks-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.routing-summary-value .node-health-dot {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.routing-summary-warning {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getErrorMessage } from "@fusion/core";
|
||||
import { fetchNodes, updateTask } from "../api";
|
||||
import type { NodeInfo } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
|
||||
interface RoutingTabProps {
|
||||
task: Task | TaskDetail;
|
||||
@@ -24,6 +25,8 @@ function getRoutingPolicyLabel(policy: RoutingSettings["unavailableNodePolicy"]
|
||||
return "Not configured";
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
|
||||
|
||||
function isUnhealthy(status: NodeInfo["status"] | undefined): boolean {
|
||||
return status !== undefined && status !== "online";
|
||||
}
|
||||
@@ -75,13 +78,13 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
||||
|
||||
const effectiveNode = effectiveNodeId ? nodesById.get(effectiveNodeId) : undefined;
|
||||
const effectiveNodeName = effectiveNode
|
||||
? `${effectiveNode.name} (${effectiveNode.type}) — ${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 isTaskActive = task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string);
|
||||
const selectorDisabled = isTaskActive || savingNode || loadingNodes;
|
||||
|
||||
const handleNodeSelect = useCallback(
|
||||
async (nextValue: string) => {
|
||||
@@ -129,6 +132,7 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
||||
<div className="routing-summary-row" role="listitem">
|
||||
<span className="routing-summary-label">Effective node</span>
|
||||
<span className="routing-summary-value">
|
||||
{effectiveNode ? <NodeHealthDot status={effectiveNode.status} compact /> : null}
|
||||
{effectiveNodeName}
|
||||
{isUnhealthy(effectiveNode?.status) ? (
|
||||
<span className="routing-summary-warning">Unhealthy</span>
|
||||
@@ -144,18 +148,18 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
||||
<span className="routing-summary-value">{getRoutingPolicyLabel(routingSettings?.unavailableNodePolicy)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{taskInProgress && effectiveNodeId ? (
|
||||
{isTaskActive && 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.
|
||||
Routing is locked while this task is active. Node override cannot be changed until the task is no longer active.
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="routing-tab__section">
|
||||
<h5>Node Override</h5>
|
||||
{taskInProgress ? (
|
||||
{isTaskActive ? (
|
||||
<div className="routing-tab__warning-banner">
|
||||
Node override cannot be changed while the task is in progress.
|
||||
Node override cannot be changed while the task is active.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -189,7 +193,7 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={taskInProgress || savingNode}
|
||||
disabled={isTaskActive || savingNode}
|
||||
onClick={clearOverride}
|
||||
>
|
||||
Clear override
|
||||
|
||||
@@ -1500,6 +1500,3 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-node-status__prefix {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -2696,7 +2696,7 @@ export function SettingsModal({
|
||||
if (!selectedNode) return null;
|
||||
return (
|
||||
<div className="settings-node-status">
|
||||
<span className="settings-node-status__prefix">Selected node:</span>
|
||||
<span>Selected node:</span>
|
||||
<NodeHealthDot status={selectedNode.status} showLabel />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { Sparkles, ChevronUp, ChevronDown, X, Maximize2, Minimize2 } from "lucide-react";
|
||||
|
||||
function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||
@@ -13,13 +14,6 @@ function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||
return "Offline";
|
||||
}
|
||||
|
||||
function getNodeStatusClass(status: NodeInfo["status"]): string {
|
||||
if (status === "online") return "task-form-node-status--online";
|
||||
if (status === "connecting") return "task-form-node-status--connecting";
|
||||
if (status === "error") return "task-form-node-status--error";
|
||||
return "task-form-node-status--offline";
|
||||
}
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
|
||||
/** Renders a phase badge using shared .phase-badge classes for consistency */
|
||||
@@ -850,9 +844,8 @@ export function TaskForm({
|
||||
const selectedNode = (nodeOptions ?? []).find((node) => node.id === nodeId);
|
||||
if (!selectedNode) return null;
|
||||
return (
|
||||
<div className={`task-form-node-status ${getNodeStatusClass(selectedNode.status)}`}>
|
||||
<span className="task-form-node-status__dot" aria-hidden="true" />
|
||||
<span>{`Selected node: ${getNodeStatusLabel(selectedNode.status)}`}</span>
|
||||
<div className="task-form-node-status">
|
||||
<NodeHealthDot status={selectedNode.status} showLabel />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -2211,6 +2211,21 @@ describe("ListView - Bulk Selection", () => {
|
||||
expect(await screen.findByRole("option", { name: "○ Node Two (Offline)" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows NodeHealthDot for selected bulk node override", 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]);
|
||||
|
||||
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");
|
||||
|
||||
expect(document.querySelector(".status-dot--online")).toBeInTheDocument();
|
||||
expect(screen.getByText("Online")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies explicit node override through batchUpdateTaskModels", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
|
||||
@@ -76,7 +76,8 @@ describe("RoutingTab", () => {
|
||||
|
||||
expect(await screen.findByText("Per-task override")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Alpha (local) — online")[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Alpha (local)")[0]).toBeInTheDocument();
|
||||
expect(document.querySelector(".status-dot--online")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders selector options with status text", async () => {
|
||||
@@ -100,6 +101,32 @@ describe("RoutingTab", () => {
|
||||
expect(screen.getByText("Unhealthy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders NodeHealthDot in routing summary for effective node", async () => {
|
||||
render(
|
||||
<RoutingTab
|
||||
task={makeTask({ nodeId: "node-a" })}
|
||||
settings={makeSettings()}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText("Alpha (local)");
|
||||
expect(document.querySelector(".status-dot--online")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders NodeHealthDot and Unhealthy badge for offline effective node", async () => {
|
||||
render(
|
||||
<RoutingTab
|
||||
task={makeTask()}
|
||||
settings={makeSettings({ defaultNodeId: "node-b" })}
|
||||
addToast={addToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText("Unhealthy");
|
||||
expect(document.querySelector(".status-dot--offline")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no-routing summary when no override or project default exists", async () => {
|
||||
render(<RoutingTab task={makeTask()} settings={makeSettings()} addToast={addToast} />);
|
||||
|
||||
@@ -128,11 +155,19 @@ describe("RoutingTab", () => {
|
||||
|
||||
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();
|
||||
expect(screen.getByText("Node override cannot be changed while the task is active.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables node selector for non-in-progress tasks", async () => {
|
||||
render(<RoutingTab task={makeTask({ column: "todo" })} settings={makeSettings()} addToast={addToast} />);
|
||||
it("disables node selector for active task statuses", async () => {
|
||||
render(<RoutingTab task={makeTask({ column: "todo", status: "executing" })} 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 active.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables node selector for non-active tasks", async () => {
|
||||
render(<RoutingTab task={makeTask({ column: "todo", status: "pending" })} settings={makeSettings()} addToast={addToast} />);
|
||||
|
||||
const selector = await screen.findByLabelText("Select execution node");
|
||||
expect(selector).toBeEnabled();
|
||||
|
||||
@@ -1165,26 +1165,6 @@ body {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-form-node-status__dot {
|
||||
width: var(--space-sm);
|
||||
height: var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.task-form-node-status--online .task-form-node-status__dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.task-form-node-status--offline .task-form-node-status__dot,
|
||||
.task-form-node-status--error .task-form-node-status__dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.task-form-node-status--connecting .task-form-node-status__dot {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
/* === Heartbeat Multiplier === */
|
||||
.heartbeat-multiplier-group {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user