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:
Fusion
2026-04-29 08:14:46 -07:00
committed by gsxdsm
parent 5661188a61
commit d250f866d6
13 changed files with 84 additions and 147 deletions

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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"

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -1500,6 +1500,3 @@
color: var(--text-muted);
}
.settings-node-status__prefix {
color: var(--text-muted);
}

View File

@@ -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>
);

View File

@@ -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>
);
})()}

View File

@@ -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" })];

View File

@@ -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();

View File

@@ -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;