diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx
index 46b17e26..654fb12b 100644
--- a/frontend/src/components/EditorLayout.tsx
+++ b/frontend/src/components/EditorLayout.tsx
@@ -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);
diff --git a/frontend/src/components/EditorLayout/hooks/useStackListState.ts b/frontend/src/components/EditorLayout/hooks/useStackListState.ts
index d53ac502..408f08df 100644
--- a/frontend/src/components/EditorLayout/hooks/useStackListState.ts
+++ b/frontend/src/components/EditorLayout/hooks/useStackListState.ts
@@ -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;
}
diff --git a/frontend/src/components/sidebar/StackList.tsx b/frontend/src/components/sidebar/StackList.tsx
index 315a7d33..a415254e 100644
--- a/frontend/src/components/sidebar/StackList.tsx
+++ b/frontend/src/components/sidebar/StackList.tsx
@@ -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) {
))}
- {searchQuery.trim() && (remoteLoading || remoteResults.length > 0) && (
+ {searchQuery.trim() && (remoteLoading || remoteResults.length > 0 || remoteFailedNodes.length > 0) && (
Other nodes
{remoteLoading && }
+ {remoteFailedNodes.length > 0 && (
+
+ )}
+ {failedNodesExpanded && remoteFailedNodes.length > 0 && (
+
+ {remoteFailedNodes.map(f => (
+
+ ยท {f.nodeName}: {f.reason}
+
+ ))}
+
+ )}
{remoteResults.map(({ nodeId, nodeName, files: remoteFiles }) => (
diff --git a/frontend/src/hooks/useCrossNodeStackSearch.ts b/frontend/src/hooks/useCrossNodeStackSearch.ts
index e198ccb2..6b41288c 100644
--- a/frontend/src/hooks/useCrossNodeStackSearch.ts
+++ b/frontend/src/hooks/useCrossNodeStackSearch.ts
@@ -15,6 +15,12 @@ export interface StackHit {
status: StackStatus;
}
+export interface FailedNode {
+ nodeId: number;
+ nodeName: string;
+ reason: string;
+}
+
interface Options {
query: string;
enabled: boolean;
@@ -23,9 +29,15 @@ interface Options {
const DEBOUNCE_MS = 250;
+interface NodeOutcome {
+ hits: StackHit[];
+ failure: FailedNode | null;
+}
+
export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Options) {
const { nodes } = useNodes();
const [hits, setHits] = useState([]);
+ const [failedNodes, setFailedNodes] = useState([]);
const [loading, setLoading] = useState(false);
// Ref avoids re-running the effect on every NodeContext status tick
@@ -36,6 +48,7 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
const q = query.trim().toLowerCase();
if (!enabled || !q) {
setHits([]);
+ setFailedNodes([]);
setLoading(false);
return;
}
@@ -44,19 +57,29 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
);
if (targets.length === 0) {
setHits([]);
+ setFailedNodes([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setLoading(true);
try {
- const perNode = await Promise.all(targets.map(async (node) => {
+ const perNode = await Promise.all(targets.map(async (node): Promise => {
try {
const [listRes, statusRes] = await Promise.all([
fetchForNode('/stacks', node.id, { signal: controller.signal }),
fetchForNode('/stacks/statuses', node.id, { signal: controller.signal }),
]);
- if (!listRes.ok) return [] as StackHit[];
+ if (!listRes.ok) {
+ return {
+ hits: [],
+ failure: {
+ nodeId: node.id,
+ nodeName: node.name,
+ reason: `list returned HTTP ${listRes.status}`,
+ },
+ };
+ }
const rawList = await listRes.json();
const files: string[] = Array.isArray(rawList) ? rawList : [];
const statuses: Record = {};
@@ -70,7 +93,7 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
}
}
}
- return files
+ const nodeHits = files
.filter(f => f.toLowerCase().includes(q))
.map(file => ({
nodeId: node.id,
@@ -78,12 +101,26 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
file,
status: statuses[file] ?? 'unknown',
}));
- } catch {
- return [] as StackHit[];
+ return { hits: nodeHits, failure: null };
+ } catch (err) {
+ // AbortError is expected when the effect cleans up; don't
+ // surface it as a node failure to the user.
+ if ((err as Error)?.name === 'AbortError') {
+ return { hits: [], failure: null };
+ }
+ return {
+ hits: [],
+ failure: {
+ nodeId: node.id,
+ nodeName: node.name,
+ reason: (err as Error)?.message ?? 'unreachable',
+ },
+ };
}
}));
if (controller.signal.aborted) return;
- setHits(perNode.flat());
+ setHits(perNode.flatMap(o => o.hits));
+ setFailedNodes(perNode.flatMap(o => (o.failure ? [o.failure] : [])));
} finally {
if (!controller.signal.aborted) setLoading(false);
}
@@ -94,5 +131,5 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
};
}, [enabled, query, excludeNodeId]);
- return { hits, loading };
+ return { hits, failedNodes, loading };
}