feat(sidebar): surface unreachable nodes in cross-node stack search (#1195)

Per-node fetch failures in useCrossNodeStackSearch were silently
swallowed into []. The user could not tell the difference between
"this node has no matching stack" and "this node timed out or 502'd".

Adds a third return value to the hook: failedNodes: FailedNode[]
where FailedNode is { nodeId, nodeName, reason }. The reason captures
the HTTP status text (e.g. "list returned HTTP 502") or the
underlying Error message ("connect ECONNREFUSED 192.168.x.x:1852")
without leaking node URLs. AbortError from the effect cleanup path
is intentionally excluded - it's expected when the user keeps typing.

Threads the new field through useStackListState and EditorLayout into
StackList. StackList renders a warning chip below the "Other nodes"
header when the array is non-empty:

  ! N nodes unreachable    > (expand)

Click expands the chip to a vertical list of "node: reason" lines.
Hover surfaces the full reasons in the title attribute too, so a
user inspecting at a glance can read them without clicking. The chip
is suppressed entirely when the search yields no failures (no zero
state). Color is the existing warning token, not error - the failure
is recoverable (node may come back) and not a system-wide problem.

GlobalCommandPalette also calls useCrossNodeStackSearch but ignores
the third return value; its destructuring is additive-safe.

Resolves M-2 from the stack-management audit.
This commit is contained in:
Anso
2026-05-24 15:40:25 -04:00
committed by GitHub
parent 07a2e8f0e3
commit 5196f0440e
4 changed files with 88 additions and 12 deletions
+2
View File
@@ -94,6 +94,7 @@ export default function EditorLayout() {
pinned,
isCollapsed, toggleCollapse,
remoteSearchLoading,
remoteSearchFailedNodes,
} = stackListState;
const { nodes, activeNode, setActiveNode } = useNodes();
@@ -390,6 +391,7 @@ export default function EditorLayout() {
buildMenuCtx,
remoteResults,
remoteLoading: remoteSearchLoading,
remoteFailedNodes: remoteSearchFailedNodes,
onSelectRemoteFile: (nodeId, file) => {
const node = nodes.find(n => n.id === nodeId);
if (node) void stackActions.loadFileOnNode(node, file);
@@ -54,7 +54,7 @@ export function useStackListState() {
const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id);
const { runBulk } = useBulkStackActions();
const { hits: remoteSearchHits, loading: remoteSearchLoading } = useCrossNodeStackSearch({
const { hits: remoteSearchHits, failedNodes: remoteSearchFailedNodes, loading: remoteSearchLoading } = useCrossNodeStackSearch({
query: searchQuery,
enabled: true,
excludeNodeId: activeNode?.id,
@@ -359,5 +359,6 @@ export function useStackListState() {
pinned, pin, unpin, isPinned,
isCollapsed, toggleCollapse,
remoteSearchLoading,
remoteSearchFailedNodes,
} as const;
}
+40 -4
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { ArrowUpRight, Loader2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { ArrowUpRight, Loader2, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react';
import { useStackKeyboardShortcuts } from '@/hooks/useStackKeyboardShortcuts';
import { CommandItem, CommandList } from '@/components/ui/command';
import { Skeleton } from '@/components/ui/skeleton';
@@ -18,6 +18,12 @@ interface RemoteNodeResult {
files: { file: string; status: StackRowStatus }[];
}
interface RemoteSearchFailure {
nodeId: number;
nodeName: string;
reason: string;
}
export interface StackListProps {
files: string[];
isLoading: boolean;
@@ -37,6 +43,7 @@ export interface StackListProps {
buildMenuCtx: (file: string) => StackMenuCtx;
remoteResults: RemoteNodeResult[];
remoteLoading: boolean;
remoteFailedNodes: RemoteSearchFailure[];
onSelectRemoteFile: (nodeId: number, file: string) => void;
}
@@ -107,9 +114,11 @@ export function StackList(props: StackListProps & StackListBulkProps) {
stackUpdates, gitSourcePendingMap, pinnedFiles, isCollapsed, toggleCollapse,
isBusy, getDisplayName, onSelectFile, buildMenuCtx,
bulkMode, selectedFiles, onToggleSelect,
remoteResults, remoteLoading, onSelectRemoteFile,
remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile,
} = props;
const [failedNodesExpanded, setFailedNodesExpanded] = useState(false);
const groups = useMemo(
() => buildGroups(files, pinnedFiles, stackLabelMap),
[files, pinnedFiles, stackLabelMap],
@@ -171,12 +180,39 @@ export function StackList(props: StackListProps & StackListBulkProps) {
</StackGroup>
))}
{searchQuery.trim() && (remoteLoading || remoteResults.length > 0) && (
{searchQuery.trim() && (remoteLoading || remoteResults.length > 0 || remoteFailedNodes.length > 0) && (
<div className="mt-3 pt-3 border-t border-glass-border">
<h3 className="text-[10px] font-medium tracking-[0.08em] uppercase text-stat-subtitle px-4 pb-2 flex items-center gap-2">
Other nodes
{remoteLoading && <Loader2 className="w-3 h-3 animate-spin text-stat-icon" strokeWidth={1.5} />}
</h3>
{remoteFailedNodes.length > 0 && (
<button
type="button"
onClick={() => setFailedNodesExpanded(prev => !prev)}
className="w-full mx-4 mb-2 flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-[0.06em] text-warning hover:text-warning/80 cursor-pointer"
style={{ width: 'calc(100% - 2rem)' }}
aria-expanded={failedNodesExpanded}
title={remoteFailedNodes.map(f => `${f.nodeName}: ${f.reason}`).join('\n')}
>
<AlertCircle className="w-3 h-3 shrink-0" strokeWidth={1.5} />
<span className="shrink-0">
{remoteFailedNodes.length} {remoteFailedNodes.length === 1 ? 'node' : 'nodes'} unreachable
</span>
{failedNodesExpanded
? <ChevronDown className="w-3 h-3 shrink-0" strokeWidth={1.5} />
: <ChevronRight className="w-3 h-3 shrink-0" strokeWidth={1.5} />}
</button>
)}
{failedNodesExpanded && remoteFailedNodes.length > 0 && (
<div className="px-4 pb-2 space-y-0.5">
{remoteFailedNodes.map(f => (
<div key={f.nodeId} className="text-[10px] font-mono text-stat-subtitle truncate">
<span className="text-warning">·</span> {f.nodeName}: {f.reason}
</div>
))}
</div>
)}
{remoteResults.map(({ nodeId, nodeName, files: remoteFiles }) => (
<div key={nodeId} className="mb-2">
<div className="px-4 pb-1 flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-[0.06em] text-stat-subtitle">