feat(FN-1408): add node provider and remote node status integration

- Add NodeProvider and useNodeContext to App shell for centralized node state
- Add node selector dropdown and status indicator to Header component
- Wire remote node data and event hooks into task-facing views
- Update App.test.tsx mocks for new node context providers
- Add project memory learnings for node context wiring patterns
- Update dashboard guide documentation for node selection
This commit is contained in:
gsxdsm
2026-04-09 23:42:25 -07:00
parent 5063e24057
commit cba41e68d6
6 changed files with 714 additions and 6 deletions

View File

@@ -5,7 +5,6 @@
- `TaskExecutor` terminates active agent sessions (single and step) when tasks are moved away from `in-progress` via the `task:moved` event handler. This prevents zombie sessions when users manually send tasks back to todo/triage from the board UI.
- **Review Handoff (FN-1259)**: Agents can hand off tasks to users for human review via steering comments containing handoff phrases ("send it back to me", "hand off to user", etc.). When `reviewHandoffPolicy` is `"comment-triggered"`, the executor detects handoff intent in agent-authored steering comments and executes the handoff: sets `status: "awaiting-user-review"`, `assigneeUserId: "requesting-user"`, moves task to `in-review`, and disposes the agent session. The merger skips tasks with `"awaiting-user-review"` status (via `BLOCKING_TASK_STATUSES` in `task-merge.ts`). Users can accept review (clear status) or return to agent (move to todo). The `assigneeUserId` field stores the user ID who should review the task.
- Agent preset templates in `NewAgentDialog.tsx` are a UI-only concept (`AgentPreset` interface), separate from the engine's `AgentPromptTemplate` type. Presets populate agent creation fields (name, icon, role, soul, instructionsText) but don't map to engine types.
- Agent preset templates in `NewAgentDialog.tsx` are a UI-only concept (`AgentPreset` interface), separate from the engine's `AgentPromptTemplate` type. Presets populate agent creation fields (name, icon, role, soul, instructionsText) but don't map to engine types.
- `soul` and `instructionsText` are already supported in `AgentCreateInput` and `AgentUpdateInput` — no API changes needed when adding these to presets.
- `CronRunner` uses dependency injection for AI prompt execution: an `AiPromptExecutor` function is injected via options. This keeps it decoupled from `createKbAgent` and testable without real agent sessions.
- `createAiPromptExecutor(cwd)` is an async factory function that creates a new agent session per call, uses `onText` for text accumulation, and disposes sessions in a `finally` block.
@@ -287,3 +286,15 @@ The `@fusion/tui` package provides Ink-based React components for terminal UI.
- Kimi usage endpoint uses `/v1/coding_plan/usage` (underscore) as the primary endpoint — this is the Codexbar-validated working endpoint. The hyphen variant (`/v1/coding-plan/usage`) is a legacy fallback that may return 404 `url.not_found` for some accounts.
- When implementing endpoint fallbacks for API providers, test the fallback logic with extra fields in the error payload (e.g., `{"code":5,"error":"url.not_found","message":"没找到对象",...}`) to ensure the fallback trigger remains robust.
## FN-1516: Periodic Auto-Merge Sweep
- The `canAutoMergeTask()` function must be defined locally inside `runDashboard()` to work correctly with Vitest mocks. Module-level exports capture the real `getTaskMergeBlocker` at import time, before mocks are applied.
- When importing shared utilities from dashboard.ts in serve.ts, ensure both files define compatible predicates (same `mergeRetries` limit check).
- Periodic sweep tests using `vi.useFakeTimers()` must be isolated in their own test file or properly reset timers to avoid affecting subsequent tests.
## FN-1408: Node Provider and Remote Node Status
- When adding node context (`NodeProvider`, `useNodeContext`) to the App shell, update mocks in `App.test.tsx` for `useNodes`, `useRemoteNodeData`, `useRemoteNodeEvents`, and the `NodeContext` module.
- Clear `fusion-dashboard-current-node` from localStorage in test `beforeEach` to avoid cross-test leakage.
- Use `mockReturnValue` (not `mockReturnValueOnce`) for repeated mocks in tests with dynamic imports.
- Add `await act(async () => { await new Promise((resolve) => setTimeout(resolve, 300)); })` in tests that wait for App initial load to complete before interacting with Header components.

View File

@@ -165,3 +165,46 @@ Manage mission hierarchy and progression state.
Inspect logs, step progress, workflow outcomes, and model overrides.
![Task detail modal](./screenshots/task-detail.png)
## Node Dashboard
The Node Dashboard provides a mesh view of connected Fusion nodes. Each node can be a local instance or a remote headless node (`fn serve`).
### Local/Remote Node Switching
When remote nodes are available, the dashboard header displays a node status indicator:
- **Local mode** — Shows a green "Local" badge, indicating the dashboard is connected to the local Fusion instance
- **Remote mode** — Shows the remote node name with its connection status (online/offline/connecting)
Click the chevron next to the status indicator to open the node selector dropdown:
- **Local** — Switch back to viewing the local Fusion instance
- **Remote nodes** — Select a remote node to view its tasks, projects, and status
### How Node Switching Works
1. The node selector appears in the header when remote nodes are registered in the mesh
2. Selecting a remote node routes all API calls through the proxy endpoint (`/api/proxy/:nodeId/...`)
3. Task data (projects, tasks) is fetched from the remote node and displayed in the dashboard
4. SSE events from the remote node are streamed via the proxy and update the dashboard in real-time
5. Selecting "Local" returns to the local Fusion instance with full local data
### Benefits of Remote Node Viewing
- Monitor task progress across distributed teams
- View task status on remote headless nodes without direct SSH access
- Compare project health across multiple Fusion instances
- Stay informed about remote agent activity and task completion
### Node Status Indicators
| Status | Color | Meaning |
|--------|-------|---------|
| Online | Green | Node is connected and responsive |
| Offline | Red | Node is unreachable or shut down |
| Connecting | Yellow (pulsing) | Connection attempt in progress |
### Persistence
The selected node persists across browser sessions via localStorage. If the selected remote node is unregistered, the dashboard automatically falls back to local mode.

View File

@@ -30,6 +30,9 @@ import { useAuthOnboarding } from "./hooks/useAuthOnboarding";
import { useViewState, type TaskView } from "./hooks/useViewState";
import { useProjectActions } from "./hooks/useProjectActions";
import { useTaskHandlers } from "./hooks/useTaskHandlers";
import { useRemoteNodeData } from "./hooks/useRemoteNodeData";
import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
import { NodeProvider, useNodeContext } from "./context/NodeContext";
import type { AiSessionSummary } from "./api";
function AppInner() {
@@ -41,6 +44,28 @@ function AppInner() {
const { nodes } = useNodes();
const { currentProject, setCurrentProject, clearCurrentProject, loading: currentProjectLoading } = useCurrentProject(projects);
// Node context for local/remote node switching
const { currentNode, currentNodeId, isRemote, setCurrentNode, clearCurrentNode } = useNodeContext();
// Sync node context with useNodes() results - fall back to local if selected node is missing
useEffect(() => {
if (currentNodeId && nodes.length > 0) {
const nodeExists = nodes.some((n) => n.id === currentNodeId);
if (!nodeExists) {
// Selected node was deleted or unregistered - fall back to local
clearCurrentNode();
}
}
}, [currentNodeId, nodes, clearCurrentNode]);
// Remote node data and events when in remote mode
const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id });
const remoteEvents = useRemoteNodeEvents(currentNodeId);
// Use remote data when in remote mode, local data otherwise
const effectiveProjects = isRemote && remoteData.projects.length > 0 ? remoteData.projects : projects;
const effectiveTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : [];
// Search query state - must be defined before useTasks
const [searchQuery, setSearchQuery] = useState("");
@@ -309,7 +334,7 @@ function AppInner() {
return (
<PageErrorBoundary>
<Board
tasks={tasks}
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
projectId={currentProject?.id}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
@@ -344,7 +369,7 @@ function AppInner() {
return (
<PageErrorBoundary>
<ListView
tasks={tasks}
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
projectId={currentProject?.id}
onMoveTask={moveTask}
onOpenDetail={modalManager.openDetailTask}
@@ -405,12 +430,23 @@ function AppInner() {
onChangeView={viewMode === "project" && currentProject ? handleTaskViewChange : undefined}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
projects={projects}
projects={effectiveProjects}
currentProject={currentProject}
onSelectProject={handleSelectProject}
onViewAllProjects={handleViewAllProjects}
projectId={currentProject?.id}
mobileNavEnabled={isMobile}
// Node switching props
availableNodes={nodes}
currentNode={currentNode}
onSelectNode={(node) => {
if (node === null) {
clearCurrentNode();
} else {
setCurrentNode(node);
}
}}
isRemote={isRemote}
/>
{viewMode === "project" && currentProject && !nodesOpen && (
<SessionNotificationBanner
@@ -427,7 +463,7 @@ function AppInner() {
</div>
{viewMode === "project" && currentProject && !nodesOpen && (
<ExecutorStatusBar
tasks={tasks}
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
projectId={currentProject.id}
taskStuckTimeoutMs={taskStuckTimeoutMs}
backgroundSessions={bgSessions}
@@ -492,7 +528,9 @@ function AppInner() {
export function App() {
return (
<ToastProvider>
<AppInner />
<NodeProvider>
<AppInner />
</NodeProvider>
</ToastProvider>
);
}

View File

@@ -1,9 +1,11 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail } from "lucide-react";
import type { ProjectInfo } from "../api";
import type { NodeConfig } from "@fusion/core";
import { fetchScripts } from "../api";
import { ProjectSelector } from "./ProjectSelector";
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
import { NodeStatusIndicator } from "./NodeStatusIndicator";
import { useViewportMode, type ViewportMode } from "../hooks/useViewportMode";
export { useViewportMode };
@@ -64,6 +66,14 @@ export interface HeaderProps {
isElectron?: boolean;
/** When true, the mobile bottom nav bar handles primary navigation and header nav controls are hidden. */
mobileNavEnabled?: boolean;
/** Available nodes for the node selector */
availableNodes?: NodeConfig[];
/** Currently selected node (null for local) */
currentNode?: NodeConfig | null;
/** Callback when a node is selected */
onSelectNode?: (node: NodeConfig | null) => void;
/** Whether the current view is a remote node */
isRemote?: boolean;
}
export function Header({
@@ -100,6 +110,10 @@ export function Header({
projectId,
isElectron = false,
mobileNavEnabled,
availableNodes = [],
currentNode,
onSelectNode,
isRemote = false,
}: HeaderProps) {
const mode: ViewportMode = useViewportMode();
const isMobile = mode === "mobile";
@@ -112,6 +126,7 @@ export function Header({
const [isNonMobileSearchExplicitlyClosed, setIsNonMobileSearchExplicitlyClosed] = useState(false);
const [isOverflowMenuOpen, setIsOverflowMenuOpen] = useState(false);
const [isTerminalSubmenuOpen, setIsTerminalSubmenuOpen] = useState(false);
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [overflowScripts, setOverflowScripts] = useState<Record<string, string>>({});
const [overflowScriptsLoading, setOverflowScriptsLoading] = useState(false);
const overflowButtonRef = useRef<HTMLButtonElement>(null);
@@ -119,6 +134,14 @@ export function Header({
const mobileSearchRef = useRef<HTMLDivElement>(null);
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
const terminalSubmenuOpenRef = useRef(false);
const nodeSelectorRef = useRef<HTMLDivElement>(null);
// Get remote nodes only (exclude local node type)
const remoteNodes = useMemo(() =>
availableNodes.filter((node) => node.type === "remote"),
[availableNodes]
);
const showNodeSelector = remoteNodes.length > 0;
// Script entries sorted alphabetically for overflow submenu
const overflowScriptEntries = useMemo(() => {
@@ -194,6 +217,23 @@ export function Header({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOverflowMenuOpen]);
// Close node selector on outside click
useEffect(() => {
if (!isNodeSelectorOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (
nodeSelectorRef.current &&
!nodeSelectorRef.current.contains(e.target as Node)
) {
setIsNodeSelectorOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isNodeSelectorOpen]);
// Close menus on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -204,6 +244,7 @@ export function Header({
}
setIsOverflowMenuOpen(false);
setIsMobileSearchOpen(false);
setIsNodeSelectorOpen(false);
}
};
@@ -309,6 +350,69 @@ export function Header({
<span>Projects</span>
</button>
)}
{/* Node selector and status indicator */}
{showNodeSelector && (
<div className="header-node-selector" ref={nodeSelectorRef}>
{/* Node status indicator - always visible */}
<NodeStatusIndicator node={currentNode ?? null} showDetails />
{/* Node selector dropdown */}
<button
className={`btn-icon node-selector-trigger${isNodeSelectorOpen ? " node-selector-trigger--open" : ""}`}
onClick={() => setIsNodeSelectorOpen((prev) => !prev)}
title="Switch node"
aria-label="Switch node"
aria-expanded={isNodeSelectorOpen}
aria-haspopup="listbox"
data-testid="node-selector-trigger"
>
<ChevronRight
size={12}
className={`node-selector-chevron${isNodeSelectorOpen ? " node-selector-chevron--open" : ""}`}
/>
</button>
{/* Node selector dropdown menu */}
{isNodeSelectorOpen && (
<div className="node-selector-dropdown" role="listbox" aria-label="Select node">
{/* Local option */}
<button
className={`node-selector-option${!isRemote ? " node-selector-option--active" : ""}`}
onClick={() => {
onSelectNode?.(null);
setIsNodeSelectorOpen(false);
}}
role="option"
aria-selected={!isRemote}
data-testid="node-option-local"
>
<span className="node-selector-option-dot node-selector-option-dot--local" />
<span className="node-selector-option-label">Local</span>
</button>
{/* Remote nodes */}
{remoteNodes.map((node) => (
<button
key={node.id}
className={`node-selector-option${currentNode?.id === node.id ? " node-selector-option--active" : ""}`}
onClick={() => {
onSelectNode?.(node);
setIsNodeSelectorOpen(false);
}}
role="option"
aria-selected={currentNode?.id === node.id}
data-testid={`node-option-${node.id}`}
>
<span className={`node-selector-option-dot node-selector-option-dot--${node.status}`} />
<span className="node-selector-option-label">{node.name}</span>
<span className="node-selector-option-status">{node.status}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<div className="header-actions">

View File

@@ -68,6 +68,40 @@ vi.mock("../../hooks/useTasks", () => ({
useTasks: () => mockUseTasks(),
}));
// Mock useRemoteNodeData
vi.mock("../../hooks/useRemoteNodeData", () => ({
useRemoteNodeData: vi.fn(() => ({
projects: [],
tasks: [],
health: null,
loading: false,
error: null,
refresh: vi.fn(),
})),
}));
// Mock useRemoteNodeEvents
vi.mock("../../hooks/useRemoteNodeEvents", () => ({
useRemoteNodeEvents: vi.fn(() => ({
isConnected: false,
lastEvent: null,
})),
}));
// Mock NodeContext - default to local mode
const mockNodeContextValue = {
currentNode: null,
currentNodeId: null,
isRemote: false,
setCurrentNode: vi.fn(),
clearCurrentNode: vi.fn(),
};
vi.mock("../../context/NodeContext", () => ({
NodeProvider: ({ children }: { children: React.ReactNode }) => children,
useNodeContext: vi.fn(() => mockNodeContextValue),
}));
// Mock state holders for dynamic mocking
const mockProjectsState = {
projects: [] as any[],
@@ -116,6 +150,20 @@ vi.mock("../../hooks/useTerminal", () => ({
}),
}));
// Mock useNodes for node selector
vi.mock("../../hooks/useNodes", () => ({
useNodes: vi.fn(() => ({
nodes: [],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
})),
}));
import { App } from "../../App";
import { fetchAuthStatus, fetchSettings, fetchGlobalSettings, fetchTaskDetail, updateSettings, runScript, fetchScripts } from "../../api";
@@ -141,6 +189,14 @@ beforeEach(() => {
mockCurrentProjectState.currentProject = { id: DEFAULT_PROJECT_ID, name: "Test Project", path: "/test", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" };
mockCurrentProjectState.setCurrentProject.mockClear();
mockCurrentProjectState.clearCurrentProject.mockClear();
// Reset node context mocks
mockNodeContextValue.currentNode = null;
mockNodeContextValue.currentNodeId = null;
mockNodeContextValue.isRemote = false;
mockNodeContextValue.setCurrentNode.mockClear();
mockNodeContextValue.clearCurrentNode.mockClear();
// Clear node selection from localStorage to avoid cross-test leakage
localStorage.removeItem("fusion-dashboard-current-node");
});
describe("App deep link handling", () => {
@@ -1426,3 +1482,256 @@ describe("App footer-safe project layout", () => {
});
});
});
describe("App node mode switching", () => {
it("does not render node selector when no remote nodes are available", async () => {
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
// Wait for initial load to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
// Node selector should not be visible when no remote nodes available
expect(screen.queryByTestId("node-selector-trigger")).toBeNull();
});
it("renders node selector trigger when remote nodes are available", async () => {
// Get the mocked useNodes and set up the return value
const { useNodes } = await import("../../hooks/useNodes");
vi.mocked(useNodes).mockReturnValue({
nodes: [
{
id: "node_remote_1",
name: "Remote Node 1",
type: "remote" as const,
url: "http://remote:4040",
status: "online" as const,
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
},
],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
});
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
// Wait for initial load to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
// Node selector trigger should be visible when remote nodes available
expect(screen.getByTestId("node-selector-trigger")).toBeInTheDocument();
});
it("shows remote node name when remote node is selected", async () => {
// Get the mocked useNodes and set up the return value
const { useNodes } = await import("../../hooks/useNodes");
vi.mocked(useNodes).mockReturnValue({
nodes: [
{
id: "node_remote_1",
name: "Remote Node 1",
type: "remote" as const,
url: "http://remote:4040",
status: "online" as const,
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
},
],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
});
// Mock node context to return remote node
mockNodeContextValue.currentNode = {
id: "node_remote_1",
name: "Remote Node 1",
type: "remote",
url: "http://remote:4040",
status: "online",
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
};
mockNodeContextValue.currentNodeId = "node_remote_1";
mockNodeContextValue.isRemote = true;
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
// Wait for initial load to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
// Should show remote node name
await waitFor(() => {
expect(screen.getByText("Remote Node 1")).toBeInTheDocument();
});
});
it("calls clearCurrentNode when Local option is selected", async () => {
// Get the mocked useNodes and set up the return value
const { useNodes } = await import("../../hooks/useNodes");
vi.mocked(useNodes).mockReturnValue({
nodes: [
{
id: "node_remote_1",
name: "Remote Node 1",
type: "remote" as const,
url: "http://remote:4040",
status: "online" as const,
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
},
],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
});
// Mock node context to return remote node
mockNodeContextValue.currentNode = {
id: "node_remote_1",
name: "Remote Node 1",
type: "remote",
url: "http://remote:4040",
status: "online",
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
};
mockNodeContextValue.currentNodeId = "node_remote_1";
mockNodeContextValue.isRemote = true;
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
// Wait for initial load to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
await waitFor(() => {
expect(screen.getByText("Remote Node 1")).toBeInTheDocument();
});
// Open the node selector
fireEvent.click(screen.getByTestId("node-selector-trigger"));
await waitFor(() => {
expect(screen.getByTestId("node-option-local")).toBeInTheDocument();
});
// Click Local option
fireEvent.click(screen.getByTestId("node-option-local"));
await waitFor(() => {
expect(mockNodeContextValue.clearCurrentNode).toHaveBeenCalled();
});
});
it("calls setCurrentNode when a remote node is selected", async () => {
// Get the mocked useNodes and set up the return value
const { useNodes } = await import("../../hooks/useNodes");
vi.mocked(useNodes).mockReturnValue({
nodes: [
{
id: "node_remote_1",
name: "Remote Node 1",
type: "remote" as const,
url: "http://remote:4040",
status: "online" as const,
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
},
{
id: "node_remote_2",
name: "Remote Node 2",
type: "remote" as const,
url: "http://remote2:4040",
status: "offline" as const,
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
},
],
loading: false,
error: null,
refresh: vi.fn(),
register: vi.fn(),
update: vi.fn(),
unregister: vi.fn(),
healthCheck: vi.fn(),
});
render(<App />);
await waitFor(() => {
expect(fetchSettings).toHaveBeenCalled();
});
// Wait for initial load to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
});
// Open the node selector
fireEvent.click(screen.getByTestId("node-selector-trigger"));
await waitFor(() => {
expect(screen.getByTestId("node-option-node_remote_2")).toBeInTheDocument();
});
// Click the second remote node
fireEvent.click(screen.getByTestId("node-option-node_remote_2"));
await waitFor(() => {
expect(mockNodeContextValue.setCurrentNode).toHaveBeenCalledWith({
id: "node_remote_2",
name: "Remote Node 2",
type: "remote",
url: "http://remote2:4040",
status: "offline",
maxConcurrent: 2,
createdAt: "",
updatedAt: "",
});
});
});
});

View File

@@ -610,6 +610,209 @@ body {
flex-shrink: 0;
}
/* Node Selector */
.header-node-selector {
position: relative;
display: flex;
align-items: center;
gap: var(--space-xs);
margin-right: var(--space-sm);
}
.node-selector-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.node-selector-trigger:hover {
color: var(--text);
background: var(--card-hover);
}
.node-selector-trigger--open {
color: var(--todo);
background: var(--card-hover);
}
.node-selector-chevron {
transition: transform var(--transition-fast);
}
.node-selector-chevron--open {
transform: rotate(90deg);
}
.node-selector-dropdown {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
min-width: 180px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: var(--space-xs);
z-index: 100;
}
.node-selector-option {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: var(--space-sm) var(--space-md);
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background var(--transition-fast);
}
.node-selector-option:hover {
background: var(--card-hover);
}
.node-selector-option--active {
background: var(--card);
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;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-selector-option-status {
font-size: 11px;
color: var(--text-muted);
text-transform: capitalize;
}
/* Node Status Indicator */
.node-status-indicator {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 2px var(--space-sm);
border-radius: var(--radius-sm);
font-size: 12px;
}
.node-status-indicator--local {
background: var(--state-active-bg);
color: var(--state-active-text);
}
.node-status-indicator--remote {
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
}
.node-status-indicator__dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.node-status-indicator__dot--online {
background: var(--color-success);
}
.node-status-indicator__dot--offline {
background: var(--color-error);
}
.node-status-indicator__dot--connecting {
background: var(--triage);
animation: node-connecting-pulse 1s ease-in-out infinite;
}
.node-status-indicator__dot--error {
background: var(--color-error);
}
.node-status-indicator__spinner {
display: block;
width: 6px;
height: 6px;
border: 1px solid var(--triage);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.node-status-indicator__label {
font-weight: 500;
}
.node-status-indicator__name {
font-weight: 500;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-status-indicator__details {
font-size: 11px;
color: var(--text-muted);
text-transform: capitalize;
}
/* View Toggle */
.view-toggle {
display: flex;