grid search

This commit is contained in:
lovebird 2026-04-13 18:53:15 +02:00
parent 8b951ddf5c
commit fd0ba11fa8
8 changed files with 358 additions and 23 deletions

View File

@ -19,7 +19,10 @@ Going beyond simple contact extraction, PAC-BOT provides structured, multi-dimen
As part of its ongoing product roadmap, [Company Name] confirmed that PAC-BOT will soon evolve from a lead discovery engine into an automated global sales deployment. Upcoming features will enable the system to automatically generate hyper-tailored, localized outreach emails for any niche, in any language—allowing businesses to drop a pin anywhere in the world and instantly dispatch the perfect customized pitch.
- [ ] gridsearch progress | pause | resume | settings : presets ( lang , overview, nearby, discover )
### Todos
- [x] gridsearch progress | pause | resume
- [ ] settings : presets ( lang , overview, nearby, discover )
- [ ] share => noFilters | columns | filters
- [ ] types => partial / fuzzy match | post filters => import contacts
- [ ] report => email

View File

@ -130,6 +130,16 @@ export const retryPlacesGridSearchJob = async (id: string): Promise<any> => {
});
};
export const postPlacesGridSearchControl = async (
id: string,
action: 'pause' | 'resume' | 'cancel',
): Promise<{ ok: boolean }> => {
return apiClient<{ ok: boolean }>(`/api/places/gridsearch/${id}/control`, {
method: 'POST',
body: JSON.stringify({ action }),
});
};
export const expandPlacesGridSearch = async (
parentId: string,
areas: { gid: string; name: string; level: number; raw?: any }[],

View File

@ -1,6 +1,27 @@
import React, { useEffect } from 'react';
import maplibregl from 'maplibre-gl';
/** Active = amber (in progress); finished = emerald (done). */
function liveAreaFillColorExpr(isDark: boolean): maplibregl.ExpressionSpecification {
return [
'match',
['get', 'liveAreaState'],
'finished',
isDark ? 'rgba(52, 211, 153, 0.26)' : 'rgba(16, 185, 129, 0.3)',
isDark ? 'rgba(250, 204, 21, 0.22)' : 'rgba(234, 179, 8, 0.2)',
];
}
function liveAreaLineColorExpr(isDark: boolean): maplibregl.ExpressionSpecification {
return [
'match',
['get', 'liveAreaState'],
'finished',
isDark ? '#34d399' : '#059669',
isDark ? '#fbbf24' : '#ca8a04',
];
}
export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkStyle }: { map: maplibregl.Map, liveAreas: any[], liveRadii: any[], liveNodes: any[], isDarkStyle: boolean }) {
useEffect(() => {
if (!map) return;
@ -16,7 +37,7 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
type: 'fill',
source: 'live-areas',
paint: {
'fill-color': '#eab308',
'fill-color': liveAreaFillColorExpr(isDarkStyle),
'fill-opacity': 0.15
}
});
@ -25,7 +46,7 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
type: 'line',
source: 'live-areas',
paint: {
'line-color': '#eab308',
'line-color': liveAreaLineColorExpr(isDarkStyle),
'line-width': 2,
'line-dasharray': [2, 2]
}
@ -64,6 +85,17 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
};
}, [map]);
useEffect(() => {
if (!map || !map.getStyle()) return;
if (!map.getLayer('live-areas-fill')) return;
try {
map.setPaintProperty('live-areas-fill', 'fill-color', liveAreaFillColorExpr(isDarkStyle));
map.setPaintProperty('live-areas-line', 'line-color', liveAreaLineColorExpr(isDarkStyle));
} catch {
/* style swap race */
}
}, [map, isDarkStyle]);
useEffect(() => {
if (!map || !map.getStyle() || !map.getSource('live-areas')) return;
@ -73,7 +105,10 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
if (geom) {
return {
type: 'Feature',
properties: { name: a.name },
properties: {
name: a.name,
liveAreaState: a.liveAreaState === 'finished' ? 'finished' : 'active',
},
geometry: geom
};
}

View File

@ -1,6 +1,6 @@
import React, { useState, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen, Merge } from 'lucide-react';
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen, Merge, Pause, Play, Square } from 'lucide-react';
import { CompetitorsGridView } from '../PlacesGridView';
import { PlacesMapView } from '../PlacesMapView';
@ -8,7 +8,8 @@ import { PlacesThumbView } from '../PlacesThumbView';
import { PlacesMetaView } from '../PlacesMetaView';
import { PlacesReportView } from './PlacesReportView';
import { useRestoredSearch } from './RestoredSearchContext';
import { expandPlacesGridSearch } from '../client-gridsearch';
import { expandPlacesGridSearch, postPlacesGridSearchControl } from '../client-gridsearch';
import { useGridSearchStream, computeGridSearchOverallPercent } from './GridSearchStreamContext';
import { POSTER_THEMES } from '../utils/poster-themes';
import { type PlaceFull } from '@polymech/shared';
@ -65,6 +66,33 @@ import { UserPlus } from 'lucide-react';
export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted, onMergeSubmitted, isOwner = false, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: GridSearchResultsProps) => {
const { controlPaused, stats: streamStats, statusMessage: streamStatus } = useGridSearchStream();
const overallPct = streaming ? computeGridSearchOverallPercent(streamStats) : null;
const phaseLabel =
streamStats.phase === 'enriching'
? translate('Enrichment')
: streamStats.phase === 'searching' || streamStats.phase === 'grid'
? translate('Grid search')
: streamStats.phase === 'complete'
? translate('Done')
: translate('Starting…');
const [runControlBusy, setRunControlBusy] = useState(false);
const controlInFlight = useRef(false);
const sendRunControl = useCallback(async (action: 'pause' | 'resume' | 'cancel') => {
if (!streaming || controlInFlight.current) return;
controlInFlight.current = true;
setRunControlBusy(true);
try {
await postPlacesGridSearchControl(jobId, action);
} catch (e) {
console.error('Grid search control failed:', e);
} finally {
controlInFlight.current = false;
setRunControlBusy(false);
}
}, [jobId, streaming]);
const [showMergeDialog, setShowMergeDialog] = useState(false);
const [showImportToContactsDialog, setShowImportToContactsDialog] = useState(false);
@ -280,7 +308,44 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
return (
<div className="flex flex-col h-full overflow-hidden w-full relative">
<div className="flex justify-between items-center mb-4 mt-2 px-2">
{streaming && (
<div className="px-2 mt-2 mb-1 shrink-0" aria-label={translate('Overall progress')}>
<div className="flex items-center justify-between gap-2 text-[11px] text-gray-500 dark:text-gray-400 mb-0.5">
<span className="truncate">
{phaseLabel}
{streamStats.totalWaypoints > 0 && streamStats.phase !== 'enriching' && (
<span className="text-gray-400 dark:text-gray-500">
{' '}
· {streamStats.completedWaypoints}/{streamStats.totalWaypoints} {translate('cells')}
</span>
)}
{streamStats.phase === 'enriching' && streamStats.enrichTotal > 0 && (
<span className="text-gray-400 dark:text-gray-500">
{' '}
· {streamStats.totalLocationsEnriched}/{streamStats.enrichTotal}
</span>
)}
</span>
{overallPct != null && (
<span className="tabular-nums text-gray-600 dark:text-gray-300">{overallPct}%</span>
)}
</div>
<div className="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
className={`h-full rounded-full transition-[width] duration-500 ease-out ${
overallPct == null ? 'bg-indigo-400/90 animate-pulse' : 'bg-indigo-600 dark:bg-indigo-500'
}`}
style={{ width: overallPct != null ? `${overallPct}%` : '36%' }}
/>
</div>
{streamStatus && (
<p className="text-[10px] text-gray-400 dark:text-gray-500 truncate mt-0.5" title={streamStatus}>
{streamStatus}
</p>
)}
</div>
)}
<div className={`flex justify-between items-center mb-4 px-2 ${streaming ? 'mt-1' : 'mt-2'}`}>
{/* Left: sidebar toggle + expand button */}
<div className="flex items-center gap-2">
{onToggleSidebar && (
@ -292,6 +357,36 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
{isSidebarOpen ? <PanelLeftClose className="h-4 w-4" /> : <PanelLeftOpen className="h-4 w-4" />}
</button>
)}
{streaming && (
<div className="flex items-center gap-1 rounded-lg border border-amber-200 dark:border-amber-800/60 bg-amber-50/90 dark:bg-amber-950/40 px-1 py-0.5">
<button
type="button"
onClick={() => sendRunControl(controlPaused ? 'resume' : 'pause')}
disabled={runControlBusy}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md text-amber-900 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-900/50 disabled:opacity-50"
title={controlPaused ? translate('Resume') : translate('Pause')}
>
{runControlBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : controlPaused ? (
<Play className="h-3.5 w-3.5" />
) : (
<Pause className="h-3.5 w-3.5" />
)}
<span className="hidden sm:inline">{controlPaused ? translate('Resume') : translate('Pause')}</span>
</button>
<button
type="button"
onClick={() => sendRunControl('cancel')}
disabled={runControlBusy}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-950/50 disabled:opacity-50"
title={translate('Stop search')}
>
<Square className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{translate('Stop')}</span>
</button>
</div>
)}
{isOwner && freshRegions.length > 0 && (
<button
onClick={handleExpand}

View File

@ -8,6 +8,8 @@ export type StreamPhase = 'idle' | 'grid' | 'searching' | 'enriching' | 'complet
export interface GridSearchStreamState {
streaming: boolean;
connected: boolean;
/** Worker acknowledged pause (UDS pause_ack). */
controlPaused: boolean;
statusMessage: string;
competitors: any[];
liveAreas: any[];
@ -16,6 +18,8 @@ export interface GridSearchStreamState {
stats: {
completedWaypoints: number;
totalWaypoints: number;
/** From enrich-start (C++ `locationCount`). */
enrichTotal: number;
totalResults: number;
apiCalls: number;
totalLocationsEnriched: number;
@ -27,9 +31,72 @@ export interface GridSearchStreamState {
sseLogs: LogEntry[];
}
const GS_PROGRESS_STORAGE_PREFIX = 'gs-progress-';
/** Overall 0100 for the run; `null` = unknown / indeterminate (no grid yet). */
export function computeGridSearchOverallPercent(
stats: GridSearchStreamState['stats'],
): number | null {
const {
phase,
completedWaypoints,
totalWaypoints,
totalLocationsEnriched,
enrichTotal,
} = stats;
if (phase === 'complete') return 100;
if (phase === 'failed') return null;
if (phase === 'enriching') {
const et = enrichTotal > 0 ? enrichTotal : Math.max(1, totalLocationsEnriched || 1);
const en = Math.min(totalLocationsEnriched, et);
return Math.min(99, Math.round(60 + (en / et) * 39));
}
if (totalWaypoints > 0) {
const tw = totalWaypoints;
const cw = Math.min(completedWaypoints, tw);
return Math.round(5 + (cw / tw) * 55);
}
if (phase === 'searching' || phase === 'grid') {
return 5;
}
return null;
}
export function clearStoredGridSearchProgress(jobId: string): void {
try {
sessionStorage.removeItem(`${GS_PROGRESS_STORAGE_PREFIX}${jobId}`);
} catch {
/* ignore */
}
}
/** Last persisted % from an open tab (see provider sync). */
export function readStoredGridSearchProgress(jobId: string): {
pct: number;
phase?: string;
at: number;
} | null {
try {
const raw = sessionStorage.getItem(`${GS_PROGRESS_STORAGE_PREFIX}${jobId}`);
if (!raw) return null;
const p = JSON.parse(raw) as { pct: number; phase?: string; at: number };
if (typeof p.pct !== 'number' || typeof p.at !== 'number') return null;
if (Date.now() - p.at > 120_000) return null;
return p;
} catch {
return null;
}
}
const initialState: GridSearchStreamState = {
streaming: false,
connected: false,
controlPaused: false,
statusMessage: 'Connecting...',
competitors: [],
liveAreas: [],
@ -38,6 +105,7 @@ const initialState: GridSearchStreamState = {
stats: {
completedWaypoints: 0,
totalWaypoints: 0,
enrichTotal: 0,
totalResults: 0,
apiCalls: 0,
totalLocationsEnriched: 0,
@ -153,13 +221,20 @@ function applyEventToState(
break;
case 'area-start':
next.liveAreas = [...next.liveAreas, data];
next.liveAreas = [...next.liveAreas, { ...data, liveAreaState: 'active' as const }];
next.statusMessage = `Searching area: ${data?.name || '...'}`;
break;
case 'area-finish':
next.statusMessage = `Finished area: ${data?.area_gid || '...'}`;
case 'area-finish': {
const gid = data?.gid ?? data?.area_gid;
if (gid) {
next.liveAreas = next.liveAreas.map((a: any) =>
a.gid === gid ? { ...a, liveAreaState: 'finished' as const } : a,
);
}
next.statusMessage = `Finished area: ${gid || '...'}`;
break;
}
case 'radius-start':
case 'waypoint-start': {
@ -237,8 +312,12 @@ function applyEventToState(
}
case 'enrich-start':
next.stats = { ...next.stats, phase: 'enriching' };
next.statusMessage = `Enriching ${data?.locationCount || ''} locations...`;
next.stats = {
...next.stats,
phase: 'enriching',
enrichTotal: typeof data?.locationCount === 'number' ? data.locationCount : (next.stats.enrichTotal || 0),
};
next.statusMessage = `Enriching ${data?.locationCount ?? ''} locations...`;
break;
case 'node-error':
@ -254,7 +333,23 @@ function applyEventToState(
totalResults: data?.totalResults ?? next.stats.totalResults,
apiCalls: data?.freshApiCalls ?? next.stats.apiCalls,
};
next.statusMessage = 'Search Complete';
next.statusMessage = data?.cancelled ? 'Stopped (partial results)' : 'Search Complete';
next.controlPaused = false;
next.liveAreas = next.liveAreas.map((a: any) => ({ ...a, liveAreaState: 'finished' as const }));
break;
case 'pause_ack':
next.controlPaused = true;
next.statusMessage = 'Paused';
break;
case 'resume_ack':
next.controlPaused = false;
next.statusMessage = 'Resumed';
break;
case 'cancel_ack':
next.statusMessage = 'Stopping…';
break;
case 'failed':
@ -276,7 +371,8 @@ const STREAM_EVENTS = [
'progress', 'area-start', 'area-finish', 'waypoint-start',
'waypoint-finish', 'radius-start', 'node-start', 'location',
'node', 'node-error', 'nodePage', 'log', 'job_result', 'stats',
'grid-ready', 'enrich-start', 'failed'
'grid-ready', 'enrich-start', 'failed',
'pause_ack', 'resume_ack', 'cancel_ack',
] as const;
const GridSearchStreamContext = createContext<GridSearchStreamState>(initialState);
@ -321,7 +417,7 @@ export function GridSearchStreamProvider({
const url = getPlacesGridSearchStreamUrl(jobId, token);
es = new EventSource(url);
setState(prev => ({ ...prev, streaming: true, connected: true }));
setState(prev => ({ ...prev, streaming: true, connected: true, controlPaused: false }));
es.onopen = () => {
if (isActive) setState(prev => ({ ...prev, connected: true, statusMessage: 'Stream connected' }));
@ -331,12 +427,14 @@ export function GridSearchStreamProvider({
es.addEventListener('complete', () => {
if (!isActive) return;
clearStoredGridSearchProgress(jobId);
setState(prev => ({ ...prev, streaming: false, connected: false, statusMessage: 'Search Complete', stats: { ...prev.stats, phase: 'complete' } }));
es?.close();
});
es.addEventListener('failed', (e: MessageEvent) => {
if (!isActive) return;
clearStoredGridSearchProgress(jobId);
try {
const parsed = JSON.parse(e.data);
setState(prev => ({ ...prev, streaming: false, connected: false, statusMessage: parsed?.error || 'Job failed', stats: { ...prev.stats, phase: 'failed' } }));
@ -357,6 +455,25 @@ export function GridSearchStreamProvider({
return () => { isActive = false; es?.close(); };
}, [jobId, active]);
// Persist coarse progress so the sidebar list can show a bar without opening SSE per row.
useEffect(() => {
if (!jobId || !state.streaming) return;
const pct = computeGridSearchOverallPercent(state.stats);
if (pct == null) return;
try {
sessionStorage.setItem(
`${GS_PROGRESS_STORAGE_PREFIX}${jobId}`,
JSON.stringify({
pct,
phase: state.stats.phase,
at: Date.now(),
}),
);
} catch {
/* quota / private mode */
}
}, [jobId, state.streaming, state.stats]);
return (
<GridSearchStreamContext.Provider value={state}>
{children}

View File

@ -315,7 +315,7 @@ export const JobViewer = React.memo(({ jobId, isSidebarOpen, onToggleSidebar }:
</div>
</div>
<div className="flex-grow min-h-0 bg-gray-50 dark:bg-gray-900 border-t dark:border-gray-700">
<GridSearchStreamProvider jobId={jobId} active={isLocked} initialCompetitors={competitors}>
<GridSearchStreamProvider key={jobId} jobId={jobId} active={isLocked} initialCompetitors={competitors}>
<LiveGridSearchResults
jobId={jobId}
excludedTypes={excludedTypes}

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { fetchPlacesGridSearches, deletePlacesGridSearch, GridSearchSummary } from '../client-gridsearch';
import { Loader2, XCircle, CheckCircle, ChevronRight, Trash2, Clock, GitBranch, MapPin, Mail } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { fetchPlacesGridSearches, deletePlacesGridSearch, postPlacesGridSearchControl, GridSearchSummary } from '../client-gridsearch';
import { Loader2, XCircle, CheckCircle, ChevronRight, Trash2, Clock, GitBranch, MapPin, Mail, CircleStop, Square } from 'lucide-react';
import { readStoredGridSearchProgress } from './GridSearchStreamContext';
import { getCurrentLang } from '@/i18n';
function getRelativeTime(dateString: string): string {
@ -39,6 +40,9 @@ const StatusIcon = ({ status, isSelected }: { status: string; isSelected: boolea
if (status === 'failed') {
return <XCircle className="shrink-0 w-4 h-4 text-red-500" />;
}
if (status === 'cancelled') {
return <CircleStop className="shrink-0 w-4 h-4 text-amber-600 dark:text-amber-400" />;
}
return <CheckCircle className={`shrink-0 w-4 h-4 ${isSelected ? 'text-indigo-600 dark:text-indigo-400' : 'text-green-500'}`} />;
};
@ -48,23 +52,30 @@ const SearchRow = ({
isChild,
onSelect,
onDelete,
storedProgressPct,
onStopActive,
}: {
search: GridSearchSummary;
isSelected: boolean;
isChild?: boolean;
onSelect: () => void;
onDelete: () => void;
}) => (
storedProgressPct: number | null;
onStopActive?: (e: React.MouseEvent) => void;
}) => {
const isActiveJob = search.status === 'searching' || search.status === 'enriching';
return (
<div
onClick={onSelect}
className={`flex items-center justify-between cursor-pointer border-b dark:border-gray-800 transition-colors
className={`flex flex-col cursor-pointer border-b dark:border-gray-800 transition-colors
${isChild ? 'pl-7 pr-3 py-2' : 'p-3'}
${isSelected
? 'bg-indigo-50 dark:bg-indigo-900/30 border-l-2 border-l-indigo-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-800 border-l-2 border-l-transparent'
}`}
>
<div className="flex items-center space-x-3 overflow-hidden flex-1">
<div className="flex items-center justify-between w-full">
<div className="flex items-center space-x-3 overflow-hidden flex-1 min-w-0">
{isChild ? (
<GitBranch className="shrink-0 w-3.5 h-3.5 text-gray-400 dark:text-gray-500" />
) : null}
@ -112,6 +123,19 @@ const SearchRow = ({
</div>
</div>
<div className="flex items-center shrink-0 space-x-1">
{isActiveJob && onStopActive && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onStopActive(e);
}}
className="p-1.5 rounded-md transition-colors text-amber-600 hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-amber-950/40"
title="Stop search"
>
<Square className="w-3.5 h-3.5 fill-current" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
@ -127,11 +151,40 @@ const SearchRow = ({
</button>
{isSelected && <ChevronRight className="w-4 h-4 text-indigo-500 ml-1" />}
</div>
</div>
{isActiveJob && (
<div className="mt-1.5 pl-0 pr-1 flex items-center gap-2 w-full min-w-0">
<div className="flex-1 h-1 rounded-full bg-gray-200 dark:bg-gray-600 overflow-hidden min-w-0">
<div
className={`h-full rounded-full bg-indigo-500 dark:bg-indigo-400 transition-all duration-500 ${
storedProgressPct == null ? 'opacity-80 animate-pulse' : ''
}`}
style={{ width: storedProgressPct != null ? `${storedProgressPct}%` : '32%' }}
/>
</div>
{storedProgressPct != null && (
<span className="text-[10px] tabular-nums text-gray-500 dark:text-gray-400 shrink-0">{storedProgressPct}%</span>
)}
</div>
)}
</div>
);
};
export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }: { onSelectJob?: (jobId: string) => void, selectedJobId?: string | null, onSearchDeleted?: (jobId: string) => void }) => {
const [pastSearches, setPastSearches] = useState<GridSearchSummary[]>([]);
const [, bumpProgressRead] = useState(0);
const hasActiveInList = useMemo(() => {
const active = (s: GridSearchSummary) => s.status === 'searching' || s.status === 'enriching';
return pastSearches.some(s => active(s) || s.children?.some(active));
}, [pastSearches]);
useEffect(() => {
if (!hasActiveInList) return;
const t = setInterval(() => bumpProgressRead(n => n + 1), 1500);
return () => clearInterval(t);
}, [hasActiveInList]);
const loadPast = async () => {
try {
@ -149,6 +202,16 @@ export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }:
return () => clearInterval(interval);
}, []);
const handleStopSearch = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
try {
await postPlacesGridSearchControl(id, 'cancel');
await loadPast();
} catch (err) {
console.error('Stop search failed:', err);
}
};
const handleDelete = async (id: string, parent?: string) => {
if (confirm('Are you sure you want to delete this search?')) {
try {
@ -180,6 +243,12 @@ export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }:
isSelected={selectedJobId === search.id}
onSelect={() => onSelectJob && onSelectJob(search.id)}
onDelete={() => handleDelete(search.id)}
storedProgressPct={
search.status === 'searching' || search.status === 'enriching'
? readStoredGridSearchProgress(search.id)?.pct ?? null
: null
}
onStopActive={(e) => handleStopSearch(e, search.id)}
/>
{search.children && search.children.length > 0 && search.children.map(child => (
<SearchRow
@ -189,6 +258,12 @@ export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }:
isChild
onSelect={() => onSelectJob && onSelectJob(child.id)}
onDelete={() => handleDelete(child.id, search.id)}
storedProgressPct={
child.status === 'searching' || child.status === 'enriching'
? readStoredGridSearchProgress(child.id)?.pct ?? null
: null
}
onStopActive={(e) => handleStopSearch(e, child.id)}
/>
))}
</React.Fragment>

View File

@ -66,7 +66,7 @@ export function RestoredSearchProvider({
const status = state?.run?.status || '';
const isLocked = status === 'searching' || status === 'enriching';
const isComplete = status === 'complete';
const isComplete = status === 'complete' || status === 'cancelled';
return (
<RestoredSearchContext.Provider