feat(FN-2729): merge fusion/fn-2729

- fix(FN-2729): silence ansi strip lint warning
- test(FN-2729): complete Step 10 — cover node health indicators
- feat(FN-2729): complete Step 9 — colorize CLI node status output
- feat(FN-2729): complete Step 8 — use NodeHealthDot in settings routing status
- test(FN-2729): align ListView node override option expectations
- feat(FN-2729): complete Step 7 — add bulk node status indicators
- fix(engine): prevent auto-merge cooldown loop on unresolvable conflicts
- feat(FN-2911): improve chat attachment compose and preview UX
- feat(FN-2921): merge fusion/fn-2921
- feat(FN-2909): merge fusion/fn-2909
- feat(FN-2914): merge fusion/fn-2914
- feat(FN-2902): merge fusion/fn-2902
- chore(release): v0.8.3
- Update changeset
- fix(tui): swap 3/4 panel hotkeys so they match the help text
- fix(tui): always show auth token and report accurate macOS memory usage

Fusion-Task-Id: FN-2729
This commit is contained in:
Fusion
2026-04-28 23:21:27 -07:00
committed by gsxdsm
parent 15e4cba5e9
commit 19d787d74d
18 changed files with 253 additions and 221 deletions

View File

@@ -245,6 +245,7 @@ describe("node commands", () => {
expect(parsed).toHaveLength(1);
expect(parsed[0].name).toBe("json-node");
expect(parsed[0].apiKey).toBe("none");
expect(output).not.toMatch(/\x1b\[[0-9;]*m/);
});
it("runNodeList masks API keys in JSON output", async () => {
@@ -277,9 +278,18 @@ describe("node commands", () => {
await runNodeList();
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("● online");
expect(output).toContain("○ offline");
expect(output).toContain("✕ error");
expect(output).toContain("\x1b[32m●\x1b[0m \x1b[32monline\x1b[0m");
expect(output).toContain("\x1b[31m○\x1b[0m \x1b[31moffline\x1b[0m");
expect(output).toContain("\x1b[31m✕\x1b[0m \x1b[31merror\x1b[0m");
});
it("runNodeList colorizes connecting status", async () => {
mockListNodes.mockResolvedValue([makeNode({ name: "connecting-node", status: "connecting" })]);
await runNodeList();
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("\x1b[33m◐\x1b[0m \x1b[33mconnecting\x1b[0m");
});
// ── runNodeConnect Tests ─────────────────────────────────────────────────

View File

@@ -1,6 +1,12 @@
import { CentralCore, type NodeConfig } from "@fusion/core";
import { createInterface } from "node:readline/promises";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const YELLOW = "\x1b[33m";
const GRAY = "\x1b[90m";
const RESET = "\x1b[0m";
// ── Options Interfaces ───────────────────────────────────────────────────────
/** Options for node list command. */
@@ -133,19 +139,51 @@ export function formatLastActivity(timestamp?: string | null): string {
*/
function getStatusIndicator(status: string): string {
switch (status) {
case "online": return "●";
case "offline": return "○";
case "error": return "✕";
case "connecting": return "◐";
default: return "○";
case "online":
return `${GREEN}${RESET}`;
case "offline":
return `${RED}${RESET}`;
case "error":
return `${RED}${RESET}`;
case "connecting":
return `${YELLOW}${RESET}`;
default:
return `${GRAY}${RESET}`;
}
}
function colorizeStatusText(status: string): string {
switch (status) {
case "online":
return `${GREEN}${status}${RESET}`;
case "offline":
case "error":
return `${RED}${status}${RESET}`;
case "connecting":
return `${YELLOW}${status}${RESET}`;
default:
return `${GRAY}${status}${RESET}`;
}
}
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_PATTERN = new RegExp("\\u001b\\[[0-9;]*m", "g");
function stripAnsi(text: string): string {
return text.replace(ANSI_ESCAPE_PATTERN, "");
}
function visualPadEnd(text: string, minWidth: number): string {
const visibleLength = stripAnsi(text).length;
if (visibleLength >= minWidth) return text;
return `${text}${" ".repeat(minWidth - visibleLength)}`;
}
/**
* Get color-coded status string.
*/
function formatStatus(status: string): string {
return `${getStatusIndicator(status)} ${status}`;
return `${getStatusIndicator(status)} ${colorizeStatusText(status)}`;
}
// ── Core Command Functions ──────────────────────────────────────────────────
@@ -201,7 +239,7 @@ export async function runNodeList(options: NodeListOptions = {}): Promise<void>
for (const node of sorted) {
const name = node.name.padEnd(16);
const type = node.type.padEnd(8);
const statusStr = formatStatus(node.status).padEnd(12);
const statusStr = visualPadEnd(formatStatus(node.status), 12);
const max = String(node.maxConcurrent).padStart(3);
if (hasMetrics && node.systemMetrics) {
@@ -527,7 +565,7 @@ export async function runMeshStatus(options: MeshStatusOptions = {}): Promise<vo
for (const node of sorted) {
const name = node.name.padEnd(16);
const type = node.type.padEnd(8);
const statusStr = formatStatus(node.status).padEnd(12);
const statusStr = visualPadEnd(formatStatus(node.status), 12);
const url = node.type === "remote" ? (node.url ?? "-") : "(local)";
console.log(` ${name} ${type} ${statusStr} ${url}`);
}

View File

@@ -514,39 +514,6 @@
color: var(--todo);
}
.node-selector-option-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.node-selector-option-dot--local {
background: var(--color-success);
}
.node-selector-option-dot--online {
background: var(--color-success);
}
.node-selector-option-dot--offline {
background: var(--color-error);
}
.node-selector-option-dot--connecting {
background: var(--triage);
animation: node-connecting-pulse 1s ease-in-out infinite;
}
.node-selector-option-dot--error {
background: var(--color-error);
}
@keyframes node-connecting-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.node-selector-option-label {
flex: 1;
overflow: hidden;

View File

@@ -7,6 +7,7 @@ import type { ProjectInfo } from "../api";
import type { NodeConfig, ProjectStatus } from "@fusion/core";
import { fetchScripts } from "../api";
import { NodeStatusIndicator } from "./NodeStatusIndicator";
import { NodeHealthDot } from "./NodeHealthDot";
import { PluginSlot } from "./PluginSlot";
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
@@ -939,7 +940,7 @@ export function Header({
aria-selected={!isRemote}
data-testid="node-option-local"
>
<span className="node-selector-option-dot node-selector-option-dot--local" />
<NodeHealthDot status="online" compact />
<span className="node-selector-option-label">Local</span>
</button>
@@ -956,7 +957,7 @@ export function Header({
aria-selected={currentNode?.id === node.id}
data-testid={`node-option-${node.id}`}
>
<span className={`node-selector-option-dot node-selector-option-dot--${node.status}`} />
<NodeHealthDot status={node.status} compact />
<span className="node-selector-option-label">{node.name}</span>
<span className="node-selector-option-status">{node.status}</span>
</button>

View File

@@ -38,6 +38,13 @@ function getNodeStatusLabel(status: NodeInfo["status"]): string {
return "Offline";
}
function getNodeStatusSymbol(status: NodeInfo["status"]): string {
if (status === "online") return "●";
if (status === "connecting") return "◐";
if (status === "error") return "✕";
return "○";
}
function readVisibleColumns(projectId?: string): Set<ListColumn> {
try {
const saved = getScopedItem("kb-dashboard-list-columns", projectId);
@@ -772,7 +779,9 @@ export function ListView({
<option value="__no_change__">No change</option>
<option value="">Use project default</option>
{availableNodes.map((node) => (
<option key={node.id} value={node.id}>{`${node.name || node.id} (${getNodeStatusLabel(node.status)})`}</option>
<option key={node.id} value={node.id}>
{`${getNodeStatusSymbol(node.status)} ${node.name || node.id} (${getNodeStatusLabel(node.status)})`}
</option>
))}
</select>
</div>

View File

@@ -0,0 +1,19 @@
.node-health-dot {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.node-health-dot--compact {
gap: var(--space-xs);
}
.node-health-dot--compact .status-dot {
width: var(--space-xs);
height: var(--space-xs);
}
.node-health-dot__label {
color: var(--text-muted);
text-transform: capitalize;
}

View File

@@ -0,0 +1,28 @@
import type { NodeStatus } from "@fusion/core";
import "./NodeHealthDot.css";
export interface NodeHealthDotProps {
status: NodeStatus;
showLabel?: boolean;
className?: string;
compact?: boolean;
}
function toStatusLabel(status: NodeStatus): string {
return status.charAt(0).toUpperCase() + status.slice(1);
}
export function NodeHealthDot({ status, showLabel = false, className, compact = false }: NodeHealthDotProps) {
const label = toStatusLabel(status);
return (
<span
className={`node-health-dot${compact ? " node-health-dot--compact" : ""}${className ? ` ${className}` : ""}`}
title={label}
aria-label={`Node status: ${label}`}
>
<span className={`status-dot status-dot--${status}`} />
{showLabel ? <span className="node-health-dot__label">{label}</span> : null}
</span>
);
}

View File

@@ -10,13 +10,6 @@ interface ProjectNodeSelectorProps {
disabled?: boolean;
}
const STATUS_DOT: Record<NodeInfo["status"], string> = {
online: "🟢",
offline: "🔴",
connecting: "🟡",
error: "🔴",
};
export function ProjectNodeSelector({
projectId,
currentNodeId,
@@ -48,9 +41,10 @@ export function ProjectNodeSelector({
<option
key={node.id}
value={node.id}
title={`Status: ${node.status}`}
className={node.status === "offline" || node.status === "error" ? "project-node-selector__option--dim" : ""}
>
{STATUS_DOT[node.status]} {node.name} ({node.type})
{node.name} ({node.type}) {node.status}
</option>
))}
</select>

View File

@@ -11,6 +11,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { useNodes } from "../hooks/useNodes";
import type { NodeInfo } from "../api";
import { NodeHealthDot } from "./NodeHealthDot";
const STORAGE_KEY = "kb-quick-entry-text";
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
@@ -70,13 +71,6 @@ function getNodeStatusLabel(status: NodeInfo["status"]): string {
return "Offline";
}
function getNodeStatusClass(status: NodeInfo["status"]): string {
if (status === "online") return "quick-entry-node-status--online";
if (status === "connecting") return "quick-entry-node-status--connecting";
if (status === "error") return "quick-entry-node-status--error";
return "quick-entry-node-status--offline";
}
function getModelSelectionValue(provider?: string, modelId?: string): string {
return provider && modelId ? `${provider}/${modelId}` : "";
}
@@ -1400,9 +1394,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const selectedNode = nodes.find((node) => node.id === nodeId);
if (!selectedNode) return null;
return (
<span className={`quick-entry-node-status ${getNodeStatusClass(selectedNode.status)}`}>
<span className="quick-entry-node-status__dot" aria-hidden="true" />
{getNodeStatusLabel(selectedNode.status)}
<span className="quick-entry-node-status">
<NodeHealthDot status={selectedNode.status} showLabel />
</span>
);
})()}

View File

@@ -13,13 +13,6 @@ interface RoutingTabProps {
onTaskUpdated?: (task: Task) => void;
}
const STATUS_DOT: Record<NodeInfo["status"], string> = {
online: "🟢",
offline: "🔴",
connecting: "🟡",
error: "🔴",
};
type RoutingSettings = Settings & {
defaultNodeId?: string;
unavailableNodePolicy?: "block" | "fallback-local";
@@ -82,7 +75,7 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
const effectiveNode = effectiveNodeId ? nodesById.get(effectiveNodeId) : undefined;
const effectiveNodeName = effectiveNode
? `${STATUS_DOT[effectiveNode.status]} ${effectiveNode.name} (${effectiveNode.type})`
? `${effectiveNode.name} (${effectiveNode.type})${effectiveNode.status}`
: effectiveNodeId
? `${effectiveNodeId} (node unavailable or unknown)`
: "Local (no routing configured)";
@@ -180,8 +173,8 @@ export function RoutingTab({ task, settings, addToast, onTaskUpdated }: RoutingT
>
<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 key={node.id} value={node.id} title={`Status: ${node.status}`}>
{node.name} ({node.type}) {node.status}
</option>
))}
</select>

View File

@@ -1448,35 +1448,6 @@
}
}
.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);
}
/* Node Routing section */
.settings-node-routing-note {
background: var(--surface);
@@ -1498,109 +1469,6 @@
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);
}
.settings-node-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-sm);
font-size: 12px;
.settings-node-status__prefix {
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);
}
.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);
}
.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

@@ -36,6 +36,7 @@ import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPr
import { appendTokenQuery } from "../auth";
import { useConfirm } from "../hooks/useConfirm";
import { useNodes } from "../hooks/useNodes";
import { NodeHealthDot } from "./NodeHealthDot";
// ---------------------------------------------------------------------------
// GitHub star count — fetched once per session, cached in localStorage (1 h).
@@ -51,13 +52,6 @@ function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error
return "Offline";
}
function getNodeStatusClass(status: "online" | "offline" | "connecting" | "error"): string {
if (status === "online") return "settings-node-status--online";
if (status === "connecting") return "settings-node-status--connecting";
if (status === "error") return "settings-node-status--error";
return "settings-node-status--offline";
}
/**
* Has the user already clicked the "Star on GitHub" button at any point in
* the past? Used to permanently hide the button afterward — clicking opens
@@ -2701,9 +2695,9 @@ export function SettingsModal({
const selectedNode = nodes.find((node) => node.id === form.defaultNodeId);
if (!selectedNode) return null;
return (
<div className={`settings-node-status ${getNodeStatusClass(selectedNode.status)}`}>
<span className="settings-node-status__dot" aria-hidden="true" />
<span>{`Selected node: ${getNodeStatusLabel(selectedNode.status)}`}</span>
<div className="settings-node-status">
<span className="settings-node-status__prefix">Selected node:</span>
<NodeHealthDot status={selectedNode.status} showLabel />
</div>
);
})()}

View File

@@ -2198,7 +2198,17 @@ describe("ListView - Bulk Selection", () => {
fireEvent.click(screen.getByLabelText("Select FN-001"));
expect(await screen.findByLabelText("Node Override")).toBeInTheDocument();
expect(await screen.findByRole("option", { name: "Node One (Online)" })).toBeInTheDocument();
expect(await screen.findByRole("option", { name: "Node One (Online)" })).toBeInTheDocument();
});
it("renders non-online statuses with distinct symbols and labels", async () => {
const tasks = [createMockTask({ id: "FN-001" })];
vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-2", name: "Node Two", status: "offline" } as never]);
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} availableModels={availableModels} />);
fireEvent.click(screen.getByLabelText("Select FN-001"));
expect(await screen.findByRole("option", { name: "○ Node Two (Offline)" })).toBeInTheDocument();
});
it("applies explicit node override through batchUpdateTaskModels", async () => {

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { NodeHealthDot } from "../NodeHealthDot";
describe("NodeHealthDot", () => {
it.each(["online", "offline", "error", "connecting"] as const)("renders status dot for %s", (status) => {
render(<NodeHealthDot status={status} />);
expect(document.querySelector(`.status-dot--${status}`)).toBeInTheDocument();
});
it("shows label when requested", () => {
render(<NodeHealthDot status="online" showLabel />);
expect(screen.getByText("Online")).toBeInTheDocument();
});
it("hides label by default", () => {
render(<NodeHealthDot status="online" />);
expect(document.querySelector(".node-health-dot__label")).toBeNull();
});
it("applies compact class", () => {
render(<NodeHealthDot status="online" compact />);
expect(document.querySelector(".node-health-dot--compact")).toBeInTheDocument();
});
it("sets accessible label and title", () => {
render(<NodeHealthDot status="online" />);
const wrapper = screen.getByLabelText("Node status: Online");
expect(wrapper).toHaveAttribute("title", "Online");
});
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProjectNodeSelector } from "../ProjectNodeSelector";
import type { NodeInfo } from "../../api";
const nodes: NodeInfo[] = [
{ id: "node-1", name: "Alpha", type: "remote", status: "online" },
{ id: "node-2", name: "Beta", type: "remote", status: "offline" },
{ id: "node-3", name: "Gamma", type: "local", status: "error" },
];
describe("ProjectNodeSelector", () => {
it("renders select with auto option", () => {
render(<ProjectNodeSelector projectId="p1" nodes={nodes} onSelect={vi.fn()} />);
expect(screen.getByRole("option", { name: "Auto (no assignment)" })).toBeInTheDocument();
});
it("renders node option labels with type and status", () => {
render(<ProjectNodeSelector projectId="p1" nodes={nodes} onSelect={vi.fn()} />);
expect(screen.getByRole("option", { name: "Alpha (remote) — online" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Beta (remote) — offline" })).toBeInTheDocument();
});
it("dims offline and error options", () => {
render(<ProjectNodeSelector projectId="p1" nodes={nodes} onSelect={vi.fn()} />);
expect(screen.getByRole("option", { name: "Beta (remote) — offline" })).toHaveClass("project-node-selector__option--dim");
expect(screen.getByRole("option", { name: "Gamma (local) — error" })).toHaveClass("project-node-selector__option--dim");
});
it("does not dim online options", () => {
render(<ProjectNodeSelector projectId="p1" nodes={nodes} onSelect={vi.fn()} />);
expect(screen.getByRole("option", { name: "Alpha (remote) — online" })).not.toHaveClass("project-node-selector__option--dim");
});
});

View File

@@ -76,19 +76,28 @@ 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();
});
it("renders selector options with status text", async () => {
render(<RoutingTab task={makeTask()} settings={makeSettings()} addToast={addToast} />);
expect(await screen.findByRole("option", { name: "Alpha (local) — online" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Beta (remote) — offline" })).toBeInTheDocument();
});
it("renders routing summary with project default", async () => {
render(
<RoutingTab
task={makeTask()}
settings={makeSettings({ defaultNodeId: "node-a" })}
settings={makeSettings({ defaultNodeId: "node-b" })}
addToast={addToast}
/>,
);
expect(await screen.findByText("Project default")).toBeInTheDocument();
expect(screen.getByText(/Effective node/i)).toBeInTheDocument();
expect(screen.getByText("Unhealthy")).toBeInTheDocument();
});
it("renders no-routing summary when no override or project default exists", async () => {

View File

@@ -168,7 +168,8 @@ describe("SettingsModal Node Routing section", () => {
renderModal();
await openNodeRouting();
expect(screen.getByLabelText("Default Execution Node")).toHaveValue("node-remote-1");
expect(screen.getByText(/Selected node: Online/i)).toBeInTheDocument();
expect(screen.getByText(/Selected node:/i)).toBeInTheDocument();
expect(screen.getByLabelText("Node status: Online")).toBeInTheDocument();
});
it("lists all available nodes in selector", async () => {

View File

@@ -70,6 +70,39 @@ html {
justify-content: center;
}
.status-dot {
width: var(--space-sm);
height: var(--space-sm);
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.status-dot--online {
background: var(--color-success);
}
.status-dot--offline,
.status-dot--error {
background: var(--color-error);
}
.status-dot--connecting {
background: var(--color-warning);
animation: status-dot-pulse var(--transition-slow) ease-in-out infinite;
}
@keyframes status-dot-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* === Design Tokens (Theme-Agnostic Defaults) === */
:root {
/* Typography */