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