// Copyright (c) 2025-2026, RTE (https://www.rte-france.com) // This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0. // If a copy of the Mozilla Public License, version 2.0 was not distributed with this file, // you can obtain one at http://mozilla.org/MPL/2.0/. // SPDX-License-Identifier: MPL-2.0 // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. import React, { useState, useEffect, useRef, useMemo } from 'react'; import type { ActionDetail, NodeMeta, EdgeMeta, AvailableAction, AnalysisResult, CombinedAction, DiagramData, ActionOverviewFilters } from '../types'; import { actionPassesOverviewFilter } from '../utils/svgUtils'; import { DEFAULT_ACTION_OVERVIEW_FILTERS, matchesActionTypeFilter, rowPassesActionFilters } from '../utils/actionTypes'; import { api } from '../api'; import type { ModelDescriptor } from '../api'; import { interactionLogger } from '../utils/interactionLogger'; import { parseNdjsonStream } from '../utils/ndjsonStream'; import CombinedActionsModal from './CombinedActionsModal'; import ActionCard from './ActionCard'; import AdditionalLinesPicker from './AdditionalLinesPicker'; import ActionSearchDropdown from './ActionSearchDropdown'; import { colors } from '../styles/tokens'; import { apiErrorMessage } from '../utils/apiError'; /** * Per-stage analysis execution-time breakdown (seconds), grouped (D4) * instead of six flat props. All are echoed by the backend in the step-2 * ``result`` event and rendered together as the one-line breakdown under * "Suggestions produced by …". ``overflowGraphTime`` is ``null`` when the * active model does not consume the overflow graph (its column is hidden * in that case). ``step1Time`` is the contingency simulation that runs * before step 2; ``enrichmentTime`` is the Co-Study4Grid post-processing * after assessment. ``wallClockTime`` is the frontend-measured round-trip * from the click to the result event. Optional as a whole. */ export interface AnalysisTimingControls { overflowGraphTime?: number | null; actionPredictionTime?: number | null; assessmentTime?: number | null; step1Time?: number | null; enrichmentTime?: number | null; wallClockTime?: number | null; } /** * Recommendation-model selector wiring, grouped (D4). The dropdown above * "Analyze & Suggest" (mirror of the same control in the Settings modal) * lets the operator pick a different model and re-run without opening * Settings; ``activeModelLabel`` is the display label of the model that * produced the currently-shown suggestions (echoed by the backend in the * step-2 ``result`` event as ``active_model``). Optional as a whole. */ export interface ModelSelectorControls { recommenderModel?: string; setRecommenderModel?: (v: string) => void; availableModels?: ModelDescriptor[]; activeModelLabel?: string | null; } /** * "Additional lines to cut" picker wiring, grouped (D4): the catalogue of * disconnectable elements feeding the "additional lines to prevent flow * increase" picker rendered above the Analyze & Suggest button, the * operator-selected extras (ExpertAgent's ``additionalLinesToCut``), and * the N-1 detected overloads excluded from the picker suggestions. * Optional as a whole. */ export interface AdditionalLinesControls { branches?: string[]; additionalLinesToCut?: Set; onToggleAdditionalLineToCut?: (line: string) => void; n1Overloads?: string[]; } interface ActionFeedProps { actions: Record; actionScores?: Record>; linesOverloaded: string[]; selectedActionId: string | null; selectedActionIds: Set; rejectedActionIds: Set; pendingAnalysisResult: AnalysisResult | null; /** * When set, the feed scrolls to the action card matching the id. * The `seq` counter lets the same action trigger a re-scroll * (e.g. tapping the same pin twice). Driven by pin single-click * on the action overview diagram. */ scrollTarget?: { id: string; seq: number } | null; onDisplayPrioritizedActions: () => void; onRunAnalysis: () => void; /** Abort an in-flight analysis run. When provided, a Cancel control * appears alongside the "Analyzing…" indicator (D5). */ onCancelAnalysis?: () => void; canRunAnalysis: boolean; onActionSelect: (actionId: string | null) => void; onActionFavorite: (actionId: string) => void; onActionReject: (actionId: string) => void; onAssetClick: (actionId: string, assetName: string, tab?: 'action' | 'contingency') => void; nodesByEquipmentId: Map | null; edgesByEquipmentId: Map | null; /** Currently APPLIED contingency (list of element IDs disconnected). */ disconnectedElement: string[]; onManualActionAdded: (actionId: string, detail: ActionDetail, linesOverloaded: string[]) => void; onActionResimulated: (actionId: string, detail: ActionDetail, linesOverloaded: string[]) => void; analysisLoading: boolean; monitoringFactor: number; manuallyAddedIds: Set; onVlDoubleClick?: (actionId: string, vlName: string) => void; combinedActions: Record | null; onUpdateCombinedEstimation?: (pairId: string, estimation: { estimated_max_rho: number; estimated_max_rho_line: string }) => void; /** Resolve an element/VL ID to its human-readable display name. Falls back to the ID. */ displayName?: (id: string) => string; /** * Optional pre-fetch hook. When provided, the Add-action / target-MW / * target-tap handlers stream both the simulation metrics AND the * post-action NAD in a single request, and invoke this callback with * the ready-to-render diagram. A subsequent click on the action card * reads this cache (see useDiagrams.handleActionSelect) and paints * instantly — saving ~5-7 s of pypowsybl NAD regeneration on large * grids. When the prop is absent, the legacy `simulateManualAction` * single-shot call is used, preserving backward compat for tests and * call sites that do not wire the cache through. */ onActionDiagramPrimed?: (actionId: string, diagram: DiagramData & { svg: string }, voltageLevelsLength: number) => void; /** Current voltage-levels count, forwarded to the primer callback's * `processSvg` pass. Unused when onActionDiagramPrimed is absent. */ voltageLevelsLength?: number; /** * Shared category + threshold filters from the Remedial Action * overview. When provided, action cards whose severity bucket is * disabled OR whose max_rho exceeds the threshold are hidden from * the Suggested / Rejected / Selected lists — so the operator * sees the same set of actions on the overview and in the feed. */ overviewFilters?: ActionOverviewFilters; /** Update the shared filter state (owned by App.tsx). */ onOverviewFiltersChange?: (next: ActionOverviewFilters) => void; /** "Additional lines to cut" picker wiring, grouped (D4). See AdditionalLinesControls. */ additionalLines?: AdditionalLinesControls; /** Recommendation-model selector wiring, grouped (D4). See ModelSelectorControls. */ modelSelector?: ModelSelectorControls; /** Per-stage analysis execution-time breakdown, grouped (D4). See AnalysisTimingControls. */ timing?: AnalysisTimingControls; /** * Clear the un-touched recommender suggestions — wipes entries * still in ``suggestedByRecommenderIds`` that the operator has * NOT starred / rejected / manually added. Lets the user relaunch * with a different model without losing their decisions. */ onClearSuggested?: () => void; } const ActionFeed: React.FC = ({ actions, actionScores, linesOverloaded, selectedActionId, scrollTarget, selectedActionIds, rejectedActionIds, pendingAnalysisResult, onDisplayPrioritizedActions, onRunAnalysis, onCancelAnalysis, canRunAnalysis, onActionSelect, onActionFavorite, onActionReject, onAssetClick, nodesByEquipmentId, edgesByEquipmentId, disconnectedElement, onManualActionAdded, onActionResimulated, analysisLoading, monitoringFactor, manuallyAddedIds, onVlDoubleClick, combinedActions, onUpdateCombinedEstimation, displayName = (id: string) => id, onActionDiagramPrimed, voltageLevelsLength, overviewFilters, additionalLines, modelSelector, timing, onClearSuggested, }) => { // Unpack the grouped D4 controls so the body below keeps referring to // the individual fields unchanged (defaults preserved from the former // flat props). const { branches, additionalLinesToCut, onToggleAdditionalLineToCut, n1Overloads, } = additionalLines ?? {}; const { recommenderModel, setRecommenderModel, availableModels, activeModelLabel, } = modelSelector ?? {}; const { overflowGraphTime, actionPredictionTime, assessmentTime, step1Time, enrichmentTime, wallClockTime, } = timing ?? {}; const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [combineModalOpen, setCombineModalOpen] = useState(false); const [availableActions, setAvailableActions] = useState([]); const [loadingActions, setLoadingActions] = useState(false); const [simulating, setSimulating] = useState(null); const [error, setError] = useState(null); const searchInputRef = useRef(null); const dropdownRef = useRef(null); const [tooltip, setTooltip] = useState<{ content: React.ReactNode; x: number; y: number } | null>(null); const [suggestedTab, setSuggestedTab] = useState<'prioritized' | 'rejected'>('prioritized'); const [dismissedSelectedWarning, setDismissedSelectedWarning] = useState(false); // Per-action editable MW for LS/RC actions. This state is shared between // the score table row input (in the manual-selection dropdown) and the // action card input, so that editing one reflects immediately in the other. const [cardEditMw, setCardEditMw] = useState>({}); // Per-action editable tap position for PST action re-simulation (keyed by actionId) const [cardEditTap, setCardEditTap] = useState>({}); const [resimulating, setResimulating] = useState(null); const [dismissedRejectedWarning, setDismissedRejectedWarning] = useState(false); const showTooltip = (e: React.MouseEvent, content: React.ReactNode) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setTooltip({ content, x: rect.left, y: rect.bottom + 5 }); }; const hideTooltip = () => setTooltip(null); // Shared helper: simulate a manual action and — when the parent hook // provided `onActionDiagramPrimed` — also pre-fetch the post-action NAD // in the same streamed request. Returns the same `simulate_manual_action` // shape used downstream so the three call sites (Add / target_mw / // target_tap re-sim) stay structurally identical to the pre-stream code. // // When `onActionDiagramPrimed` is not wired up (older tests, call sites // that don't care about the cache), this transparently falls back to // the single-shot `api.simulateManualAction` endpoint. // Whether the parent hook wired up the pre-fetch primer. When false we // keep firing the legacy single-shot `api.simulateManualAction` directly // from each call site (preserving exact call arity for tests that assert // on it). When true each call site funnels through // `streamSimulateAndPrimeDiagram` instead, which consumes the NDJSON // stream, returns the metrics event shape (same as // `simulate_manual_action`), and pushes the diagram event into the // `useDiagrams` cache so a subsequent click on the action card paints // the SVG instantly. const canPrimeDiagram = !!onActionDiagramPrimed && voltageLevelsLength != null; const streamSimulateAndPrimeDiagram = async ( actionId: string, disconnectedEls: string[], actionContent: Record | null, linesOvl: string[] | null, targetMw: number | null | undefined, targetTap: number | null | undefined, ): Promise>> => { const response = await api.simulateAndVariantDiagramStream({ action_id: actionId, disconnected_elements: disconnectedEls, action_content: actionContent, lines_overloaded: linesOvl, target_mw: targetMw ?? null, target_tap: targetTap ?? null, }); let metrics: Awaited> | null = null; let streamErr: string | null = null; for await (const raw of parseNdjsonStream(response)) { const event = raw as Record; if (event.type === 'metrics') { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { type: _t, ...rest } = event; metrics = rest as Awaited>; } else if (event.type === 'diagram') { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { type: _t, ...rest } = event; // `canPrimeDiagram` is verified before this helper is called. onActionDiagramPrimed!(actionId, rest as unknown as DiagramData & { svg: string }, voltageLevelsLength!); } else if (event.type === 'error') { streamErr = (event.message as string) || 'stream error'; } } if (streamErr) throw new Error(streamErr); if (!metrics) throw new Error('Stream ended without metrics event'); return metrics; }; // Fetch available actions when search is opened const handleOpenSearch = async () => { if (searchOpen) { setSearchOpen(false); return; } setSearchOpen(true); setSearchQuery(''); setError(null); if (availableActions.length === 0) { setLoadingActions(true); try { const list = await api.getAvailableActions(); setAvailableActions(list); } catch (e) { console.error('Failed to fetch actions:', e); setError('Failed to load actions list'); } finally { setLoadingActions(false); } } setTimeout(() => searchInputRef.current?.focus(), 50); }; // Shared severity + action-type filter for the manual-selection // dropdown — its own `ActionFilterRings` instance, independent of // the sidebar's global overviewFilters and the Combine modal's. const [dropdownFilters, setDropdownFilters] = useState(DEFAULT_ACTION_OVERVIEW_FILTERS); const filteredActions = useMemo(() => { const q = searchQuery.toLowerCase(); const alreadyShown = new Set(Object.keys(actions)); // Catalogue search rows have no simulated / estimated value, so // only the action-type ring applies here — the severity ring // would otherwise empty the raw-ID search entirely. return availableActions .filter(a => !alreadyShown.has(a.id)) .filter(a => matchesActionTypeFilter(dropdownFilters.actionType, a.id, a.description || null, a.type || null)) .filter(a => a.id.toLowerCase().includes(q) || (a.description || '').toLowerCase().includes(q)) .slice(0, 20); }, [searchQuery, availableActions, actions, dropdownFilters.actionType]); // Whether the analysis has produced ANY scored action — independent // of the current chip-filter selection. Drives the wide-modal // layout so the operator stays in the centered overlay even when a // type filter narrows ``scoredActionsList`` down to zero rows; the // alternative (toggling between the wide modal and the small // button-anchored dropdown on every chip click) reads as the modal // "closing" mid-interaction. const hasAnyScoredAction = useMemo(() => { if (!actionScores) return false; return Object.values(actionScores).some(d => d && typeof d === 'object' && Object.keys((d as { scores?: Record }).scores ?? {}).length > 0 ); }, [actionScores]); // Format scored actions — filtered by the shared rings (type + // severity). Severity uses the simulated max-loading once the // action has been computed; scored-but-untested rows have no // value and drop out as soon as a severity bucket is deselected. const scoredActionsList = useMemo(() => { if (!actionScores) return []; const list: { type: string; actionId: string; score: number; mwStart: number | null }[] = []; for (const [type, data] of Object.entries(actionScores)) { const scores = data?.scores || {}; for (const [actionId, score] of Object.entries(scores)) { const detail = actions[actionId]; const passes = rowPassesActionFilters(dropdownFilters, { actionId, description: detail?.description_unitaire ?? null, scoreType: type, simulatedMaxRho: detail?.max_rho ?? null, estimatedMaxRho: detail?.estimated_max_rho ?? null, isFault: !!(detail?.is_islanded || detail?.non_convergence), }, monitoringFactor); if (!passes) continue; const mwStartMap = (data as { mw_start?: Record })?.mw_start; const mwStart = mwStartMap?.[actionId] ?? null; list.push({ type, actionId, score: Number(score), mwStart: mwStart != null ? Number(mwStart) : null }); } } return list.sort((a, b) => { if (a.type !== b.type) { if (a.type === 'line_disconnection') return 1; if (b.type === 'line_disconnection') return -1; return a.type.localeCompare(b.type); } return b.score - a.score; }); }, [actionScores, dropdownFilters, actions, monitoringFactor]); // Close dropdown on outside click useEffect(() => { if (!searchOpen) return; const handler = (e: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setSearchOpen(false); setSearchQuery(''); setError(null); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [searchOpen]); const handleAddAction = async (actionId: string, targetMw?: number, targetTap?: number) => { const trimmedId = actionId.trim(); if (!disconnectedElement || disconnectedElement.length === 0) { setError('Select a contingency first.'); return; } setSimulating(trimmedId); setError(null); try { // Build actionContent from topologies if available (especially for combined actions) let actionContent: Record | null = null; if (trimmedId.includes('+')) { const parts = trimmedId.split('+').map(p => p.trim()); const perAction: Record = {}; for (const part of parts) { const partDetail = actions[part]; if (partDetail?.action_topology) { perAction[part] = partDetail.action_topology; } } if (Object.keys(perAction).length > 0) { actionContent = perAction; } } else { const detail = actions[trimmedId]; if (detail?.action_topology) { actionContent = detail.action_topology as unknown as Record; } } const result = canPrimeDiagram ? await streamSimulateAndPrimeDiagram(trimmedId, disconnectedElement, actionContent, linesOverloaded, targetMw, targetTap) : await api.simulateManualAction(trimmedId, disconnectedElement, actionContent, linesOverloaded, targetMw, targetTap); const detail: ActionDetail = { description_unitaire: result.description_unitaire, rho_before: result.rho_before, rho_after: result.rho_after, max_rho: result.max_rho, max_rho_line: result.max_rho_line, is_rho_reduction: result.is_rho_reduction, is_islanded: result.is_islanded, n_components: result.n_components, disconnected_mw: result.disconnected_mw, non_convergence: result.non_convergence, lines_overloaded_after: result.lines_overloaded_after, load_shedding_details: result.load_shedding_details, curtailment_details: result.curtailment_details, redispatch_details: result.redispatch_details, pst_details: result.pst_details, }; onManualActionAdded(trimmedId, detail, result.lines_overloaded || []); // Keep the wide score-table modal open after a successful // simulation so the operator can chain several manual // simulations in a row (same UX contract as // ``CombinedActionsModal.handleSimulate``). Closing was // forcing a re-open + re-scroll for every row the // operator wanted to compare. The narrow no-score search // dropdown still auto-dismisses since that mode is // typically a one-shot "type an ID, hit enter" flow. if (!hasAnyScoredAction) { setSearchOpen(false); setSearchQuery(''); } } catch (e: unknown) { console.error('Simulation failed:', e); const err = e as { response?: { data?: { detail?: string } } }; setError(apiErrorMessage(err, 'Simulation failed')); } finally { setSimulating(null); } }; // Refresh combined estimations for all pairs that include the given action const refreshCombinedEstimations = async (actionId: string) => { console.log('[refreshCombinedEstimations] called for:', actionId, 'combinedActions:', combinedActions ? Object.keys(combinedActions).length : null, 'disconnectedElement:', disconnectedElement, 'hasCallback:', !!onUpdateCombinedEstimation); if (!combinedActions || !disconnectedElement || disconnectedElement.length === 0 || !onUpdateCombinedEstimation) return; const relatedPairs = Object.entries(combinedActions).filter(([pairId]) => { const parts = pairId.split('+').map(p => p.trim()); return parts.includes(actionId); }); console.log('[refreshCombinedEstimations] found', relatedPairs.length, 'related pairs:', relatedPairs.map(([id]) => id)); for (const [pairId] of relatedPairs) { const parts = pairId.split('+').map(p => p.trim()); const [id1, id2] = parts; try { const result = await api.computeSuperposition(id1, id2, disconnectedElement); console.log('[refreshCombinedEstimations] superposition result for', pairId, ':', { error: result.error, estimated_max_rho: result.estimated_max_rho, max_rho: result.max_rho, max_rho_line: result.max_rho_line, }); if (!result.error) { const estRho = result.estimated_max_rho ?? result.max_rho; const estLine = result.estimated_max_rho_line ?? result.max_rho_line; console.log('[refreshCombinedEstimations] updating pair', pairId, 'with estRho:', estRho, 'estLine:', estLine); onUpdateCombinedEstimation(pairId, { estimated_max_rho: estRho, estimated_max_rho_line: estLine }); } } catch (e) { console.error(`Failed to refresh estimation for pair ${pairId}:`, e); } } }; // Re-simulate an existing action with a new target MW value const handleResimulate = async (actionId: string, newTargetMw: number) => { if (!disconnectedElement || disconnectedElement.length === 0) return; // Log the user-edited target value so a replay agent can type // the exact same MW into the card's input before clicking // Re-simulate. Distinct from manual_action_simulated because // re-simulation keeps the action in its current bucket // (suggested vs. manually added). interactionLogger.record('action_mw_resimulated', { action_id: actionId, target_mw: newTargetMw, }); setResimulating(actionId); try { const detail = actions[actionId]; const actionContent = detail?.action_topology ? detail.action_topology as unknown as Record : null; const result = canPrimeDiagram ? await streamSimulateAndPrimeDiagram(actionId, disconnectedElement, actionContent, linesOverloaded, newTargetMw, undefined) : await api.simulateManualAction(actionId, disconnectedElement, actionContent, linesOverloaded, newTargetMw); const newDetail: ActionDetail = { description_unitaire: result.description_unitaire, rho_before: result.rho_before, rho_after: result.rho_after, max_rho: result.max_rho, max_rho_line: result.max_rho_line, is_rho_reduction: result.is_rho_reduction, is_islanded: result.is_islanded, n_components: result.n_components, disconnected_mw: result.disconnected_mw, non_convergence: result.non_convergence, lines_overloaded_after: result.lines_overloaded_after, load_shedding_details: result.load_shedding_details, curtailment_details: result.curtailment_details, redispatch_details: result.redispatch_details, pst_details: result.pst_details, }; onActionResimulated(actionId, newDetail, result.lines_overloaded || []); // Clear the edit input so it picks up the new shedded/curtailed MW from results setCardEditMw(prev => { if (!prev[actionId]) return prev; const next = { ...prev }; delete next[actionId]; return next; }); // Refresh combined estimations for pairs containing this action refreshCombinedEstimations(actionId); } catch (e: unknown) { console.error('Re-simulation failed:', e); } finally { setResimulating(null); } }; // Re-simulate an existing PST action with a new tap position const handleResimulateTap = async (actionId: string, newTap: number) => { if (!disconnectedElement || disconnectedElement.length === 0) return; // Log the new tap position so a replay agent can enter the // same value in the PST detail input before clicking // Re-simulate. Backend clamps out-of-range values to // [low_tap, high_tap], but the logged value is the raw // user-entered integer. interactionLogger.record('pst_tap_resimulated', { action_id: actionId, target_tap: newTap, }); setResimulating(actionId); try { const detail = actions[actionId]; const actionContent = detail?.action_topology ? detail.action_topology as unknown as Record : null; const result = canPrimeDiagram ? await streamSimulateAndPrimeDiagram(actionId, disconnectedElement, actionContent, linesOverloaded, null, newTap) : await api.simulateManualAction(actionId, disconnectedElement, actionContent, linesOverloaded, null, newTap); const newDetail: ActionDetail = { description_unitaire: result.description_unitaire, rho_before: result.rho_before, rho_after: result.rho_after, max_rho: result.max_rho, max_rho_line: result.max_rho_line, is_rho_reduction: result.is_rho_reduction, is_islanded: result.is_islanded, n_components: result.n_components, disconnected_mw: result.disconnected_mw, non_convergence: result.non_convergence, lines_overloaded_after: result.lines_overloaded_after, load_shedding_details: result.load_shedding_details, curtailment_details: result.curtailment_details, redispatch_details: result.redispatch_details, pst_details: result.pst_details, }; onActionResimulated(actionId, newDetail, result.lines_overloaded || []); // Clear the edit input so it picks up the new tap from results setCardEditTap(prev => { if (!prev[actionId]) return prev; const next = { ...prev }; delete next[actionId]; return next; }); // Refresh combined estimations for pairs containing this action refreshCombinedEstimations(actionId); } catch (e: unknown) { console.error('PST re-simulation failed:', e); } finally { setResimulating(null); } }; // actionId → recommender score-type bucket (`line_reconnection`, // `pst_tap_change`, …). The action-type filter feeds this `type` // into `classifyActionType` — the SAME signal the Combine-modal // Explore-Pairs filter uses (`ExplorePairsTab` passes `item.type`). // Without it, `classifyActionType` can't tell reco from disco for // ids like ``reco_GEN.PY762`` (its disco/reco heuristics look at // the score type + description, never the id), so the whole // reco / disco bucket would be filtered out of the feed. const scoreTypeByActionId = useMemo(() => { const m = new Map(); if (!actionScores) return m; for (const [type, data] of Object.entries(actionScores)) { const scores = data?.scores || {}; for (const actionId of Object.keys(scores)) { if (!m.has(actionId)) m.set(actionId, type); } } return m; }, [actionScores]); // Sort actions by max_rho ascending (matching standalone) // Filter out combined actions that are only estimations (they will have '+' in ID but no rho_after yet) const sortedActionEntries = useMemo(() => { return Object.entries(actions) .filter(([id, details]) => { const isCombined = id.includes('+'); if (isCombined) { // Only show combined if it has been fully simulated (not just estimated) // Simulated actions will NOT have the is_estimated flag set. if (details.is_estimated) return false; if (!details.rho_after || details.rho_after.length === 0) return false; } // Shared overview filter: hide cards whose severity or // max_rho falls outside the active category/threshold // picked from the overview header. We skip the filter // when overviewFilters is undefined so isolated tests // (which don't wire it) keep their existing behaviour. if (overviewFilters && !actionPassesOverviewFilter( details, monitoringFactor, overviewFilters.categories, overviewFilters.threshold, )) return false; // Shared action-type chip filter. Combined actions // (key contains '+') are considered in scope when // EITHER constituent matches — they're inherently // multi-type, so hiding them because only one side // matches would surprise the operator. // `actionType ?? 'all'` keeps legacy call sites that // don't yet set the field (older session reloads / // tests) behaving as "no type filter". const typeFilter = overviewFilters?.actionType ?? 'all'; if (typeFilter !== 'all') { if (id.includes('+')) { const [id1, id2] = id.split('+'); const d1 = actions[id1]; const d2 = actions[id2]; const ok = (d1 && matchesActionTypeFilter(typeFilter, id1, d1.description_unitaire, scoreTypeByActionId.get(id1) ?? null)) || (d2 && matchesActionTypeFilter(typeFilter, id2, d2.description_unitaire, scoreTypeByActionId.get(id2) ?? null)); if (!ok) return false; } else if (!matchesActionTypeFilter( typeFilter, id, details.description_unitaire, scoreTypeByActionId.get(id) ?? null, )) { return false; } } return true; }) .sort(([, a], [, b]) => { // Sink load-flow faults (divergent / islanded) to the bottom of // the stack regardless of their reported max_rho. The backend // emits ``max_rho = 0`` (not ``null``) for non-convergent // simulations — sorting by max_rho alone would float those // cards to the top, ahead of legitimate solving actions. const aFault = !!(a.is_islanded || a.non_convergence); const bFault = !!(b.is_islanded || b.non_convergence); if (aFault !== bFault) return aFault ? 1 : -1; return (a.max_rho ?? 999) - (b.max_rho ?? 999); }); }, [actions, overviewFilters, monitoringFactor, scoreTypeByActionId]); const analysisActionIds = useMemo(() => { const ids = new Set(); if (!actionScores) return ids; for (const data of Object.values(actionScores)) { const scores = data?.scores || {}; for (const actionId of Object.keys(scores)) { ids.add(actionId); } } return ids; }, [actionScores]); const selectedEntries = useMemo(() => { return sortedActionEntries.filter(([id]) => selectedActionIds.has(id)); }, [sortedActionEntries, selectedActionIds]); const prioritizedEntries = useMemo(() => { if (analysisLoading) return []; return sortedActionEntries.filter(([id]) => !selectedActionIds.has(id) && !rejectedActionIds.has(id)); }, [sortedActionEntries, selectedActionIds, rejectedActionIds, analysisLoading]); // Inline contextual hint counter — "N actions hidden by the // overview filter" — surfaces when the user has tightened the // shared overview filters so action cards disappear from the feed. // Implements the second half of recommendation #4 in // `docs/proposals/ui-design-critique.md`: tier the warnings, drop // the redundant yellow banner stack, and add small grey contextual // hints under the relevant control. Only counts non-estimation, // non-combined-incomplete entries so the hint matches what the // operator would actually see if they cleared the filter. const overviewFilteredOutCount = useMemo(() => { if (!overviewFilters) return 0; const typeFilter = overviewFilters.actionType ?? 'all'; let hidden = 0; for (const [id, details] of Object.entries(actions)) { if (id.includes('+')) { if (details.is_estimated) continue; if (!details.rho_after || details.rho_after.length === 0) continue; } const passesCategory = actionPassesOverviewFilter( details, monitoringFactor, overviewFilters.categories, overviewFilters.threshold, ); let passesType = true; if (typeFilter !== 'all') { if (id.includes('+')) { const [id1, id2] = id.split('+'); const d1 = actions[id1]; const d2 = actions[id2]; passesType = !!((d1 && matchesActionTypeFilter(typeFilter, id1, d1.description_unitaire, scoreTypeByActionId.get(id1) ?? null)) || (d2 && matchesActionTypeFilter(typeFilter, id2, d2.description_unitaire, scoreTypeByActionId.get(id2) ?? null))); } else { passesType = matchesActionTypeFilter(typeFilter, id, details.description_unitaire, scoreTypeByActionId.get(id) ?? null); } } if (!passesCategory || !passesType) hidden += 1; } return hidden; }, [actions, overviewFilters, monitoringFactor, scoreTypeByActionId]); // When an action becomes the currently-viewed one (typically // after the user double-clicks a pin in the action overview // diagram, or clicks an action card body), scroll the // sidebar so the matching card is centred in the viewport. // Without this, double-clicking a pin can activate an action // that is many cards down the feed and the operator has to // hunt for it manually. // // Implementation note: we look up the card by its existing // `data-testid="action-card-${id}"` attribute and call // `scrollIntoView({ block: 'center' })`. The browser walks // up to the nearest scrollable ancestor (the sidebar's // overflow-y: auto wrapper in App.tsx) and scrolls that. // // Wrapped in a rAF so the scroll runs after the matching // card has had a chance to mount/move in response to the // selection change in the same render cycle. useEffect(() => { if (!selectedActionId) return; let cancelled = false; const rafId = requestAnimationFrame(() => { if (cancelled) return; const el = document.querySelector( `[data-testid="action-card-${CSS.escape(selectedActionId)}"]`, ); if (el && typeof (el as HTMLElement).scrollIntoView === 'function') { (el as HTMLElement).scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth', }); } }); return () => { cancelled = true; cancelAnimationFrame(rafId); }; }, [selectedActionId]); // Scroll to a card when a pin on the action overview is // single-clicked (preview). This is separate from the // selectedActionId scroll above because pin preview does NOT // select the action (no drill-down). The `seq` counter in the // scrollTarget object ensures that clicking the same pin twice // still triggers a fresh scroll. useEffect(() => { if (!scrollTarget) return; let cancelled = false; const rafId = requestAnimationFrame(() => { if (cancelled) return; const el = document.querySelector( `[data-testid="action-card-${CSS.escape(scrollTarget.id)}"]`, ); if (el && typeof (el as HTMLElement).scrollIntoView === 'function') { (el as HTMLElement).scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth', }); } }); return () => { cancelled = true; cancelAnimationFrame(rafId); }; }, [scrollTarget]); const rejectedEntries = useMemo(() => { return sortedActionEntries.filter(([id]) => rejectedActionIds.has(id)); }, [sortedActionEntries, rejectedActionIds]); const activeAnalysisResult = useMemo(() => { if (pendingAnalysisResult) return pendingAnalysisResult; return { actions, combined_actions: combinedActions || {}, lines_overloaded: linesOverloaded, action_scores: actionScores, } as AnalysisResult; }, [pendingAnalysisResult, actions, combinedActions, linesOverloaded, actionScores]); const renderActionList = (entries: [string, ActionDetail][]) => { return entries.map(([id, details], index) => { if (!details) return null; return ( setCardEditMw(prev => ({ ...prev, [actionId]: value }))} onCardEditTapChange={(actionId, value) => setCardEditTap(prev => ({ ...prev, [actionId]: value }))} onResimulate={handleResimulate} onResimulateTap={handleResimulateTap} displayName={displayName} availableModels={availableModels} /> ); }); }; return (
{/* Header with search */}

Remedial Actions

{/* Search dropdown — when scored actions are available (analysis has produced suggestions), expand to a wide centered overlay that mirrors the Combine Actions modal layout so the score table has room for its Action ID, MW Start and Score columns. */} {searchOpen && ( setCardEditMw(prev => ({ ...prev, [actionId]: value }))} cardEditTap={cardEditTap} onCardEditTapChange={(actionId, value) => setCardEditTap(prev => ({ ...prev, [actionId]: value }))} simulating={simulating} resimulating={resimulating} onAddAction={handleAddAction} onResimulate={handleResimulate} onResimulateTap={handleResimulateTap} monitoringFactor={monitoringFactor} displayName={displayName} onClose={() => { setSearchOpen(false); setSearchQuery(''); }} onShowTooltip={showTooltip} onHideTooltip={hideTooltip} wide={hasAnyScoredAction} /> )}
{/* Action-dictionary info now lives in NoticesPanel (tier-warning-system PR — see `docs/proposals/ui-design-critique.md` recommendation #4). The inline yellow banner that stacked here was retired so the sidebar header carries a single dismissable pill instead of up to five concurrent banners. */}

Selected Actions {selectedEntries.length > 0 && {selectedEntries.length}}

{selectedEntries.length > 0 ? ( <> {!dismissedSelectedWarning && selectedEntries.some(([id]) => manuallyAddedIds.has(id) && analysisActionIds.has(id)) && (() => { const overlapIds = selectedEntries.filter(([id]) => manuallyAddedIds.has(id) && analysisActionIds.has(id)).map(([id]) => id).join(', '); return (
Also recommended by the recent analysis: {overlapIds}
); })()} {renderActionList(selectedEntries)} ) : (

Select an action manually or from suggested ones.

{/* "Make a first guess" is a pre-analysis shortcut. Once the operator has launched "Analyze & Suggest" (or the analysis has completed / is pending display), we stop offering the shortcut so the UI funnels the user through the Manual Selection dropdown (which now carries the score table). The button re-appears only after a full reset (contingency change, study reload) — at which point `actionScores` / `pendingAnalysisResult` / `actions` are all cleared by `resetAllState`. */} {!analysisLoading && !pendingAnalysisResult && (!actionScores || Object.keys(actionScores).length === 0) && Object.keys(actions).length === 0 && ( )}
)}
{overviewFilteredOutCount > 0 && (
{overviewFilteredOutCount} action{overviewFilteredOutCount === 1 ? '' : 's'} hidden by the overview filter.
)}
{suggestedTab === 'prioritized' && prioritizedEntries.length > 0 && activeModelLabel && (() => { const fmt = (s: number) => s >= 10 ? `${s.toFixed(1)}s` : `${s.toFixed(2)}s`; const hasPrediction = typeof actionPredictionTime === 'number'; const hasAssessment = typeof assessmentTime === 'number'; const hasOverflow = typeof overflowGraphTime === 'number'; const hasStep1 = typeof step1Time === 'number'; const hasEnrichment = typeof enrichmentTime === 'number'; const hasWallClock = typeof wallClockTime === 'number'; const backendSum = (hasStep1 ? step1Time! : 0) + (hasOverflow ? overflowGraphTime! : 0) + (hasPrediction ? actionPredictionTime! : 0) + (hasAssessment ? assessmentTime! : 0) + (hasEnrichment ? enrichmentTime! : 0); // Prefer the wall-clock figure as the headline number — // it matches what the operator perceives between // pressing "Analyze & Suggest" and the "Display N // prioritized actions" button appearing. The per-stage // backend buckets explain how that wall-clock was // spent; the residual is network / NDJSON overhead. const total = hasWallClock ? wallClockTime! : backendSum; const showBreakdown = hasPrediction || hasAssessment || hasOverflow || hasStep1 || hasEnrichment || hasWallClock; // Native tooltip lists the per-stage breakdown // so the operator can see where each chunk of the // total came from on hover, without dedicating // sidebar real-estate to a multi-column row. const breakdownLines: string[] = []; if (hasStep1) breakdownLines.push(`Step 1 (contingency simulation): ${fmt(step1Time!)}`); if (hasOverflow) breakdownLines.push(`Overflow analysis: ${fmt(overflowGraphTime!)}`); if (hasPrediction) breakdownLines.push(`Action prediction: ${fmt(actionPredictionTime!)}`); if (hasAssessment) breakdownLines.push(`Action assessment: ${fmt(assessmentTime!)}`); if (hasEnrichment) breakdownLines.push(`Enrichment / post-process: ${fmt(enrichmentTime!)}`); if (hasWallClock) { const residual = Math.max(0, wallClockTime! - backendSum); breakdownLines.push(`Other (network / streaming): ${fmt(residual)}`); breakdownLines.push(`──`); breakdownLines.push(`Total (wall-clock, click → display): ${fmt(wallClockTime!)}`); } const breakdownTooltip = breakdownLines.length ? `Execution time breakdown\n ${breakdownLines.join('\n ')}` : ''; return ( <div data-testid="active-model-reminder" style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px', fontSize: '11px', color: colors.textTertiary, fontStyle: 'italic', }} > <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}> <span> Suggestions produced by <strong style={{ fontStyle: 'normal', color: colors.textSecondary }}>{activeModelLabel}</strong> {showBreakdown && ( <> {' '}in{' '} <span data-testid="execution-time-total" title={breakdownTooltip} style={{ fontStyle: 'normal', cursor: 'help', fontVariantNumeric: 'tabular-nums', color: colors.textSecondary, fontWeight: 600, borderBottom: `1px dotted ${colors.borderStrong}`, }} > {fmt(total)} <span aria-hidden="true" style={{ marginLeft: '3px', fontWeight: 700, color: colors.textTertiary, fontSize: '10px', }}>ⓘ</span> </span> </> )} </span> {onClearSuggested && ( <button type="button" onClick={onClearSuggested} title="Clear un-touched suggestions (keeps starred / rejected / manually-added actions) so a new analysis can be launched, optionally with a different model." style={{ padding: '3px 10px', background: colors.danger, color: colors.textOnBrand, border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '11px', fontStyle: 'normal', fontWeight: 'bold', flexShrink: 0, }} > Clear </button> )} </div> </div> ); })()} {/* Unified analysis action slot: Analyze & Suggest → Analyzing… → Display N prioritized actions */} {/* Show the analysis trigger slot whenever the Suggested feed is empty — not just when result.actions has no recommender-produced entry. After "Clear", the operator's kept rejected actions stay in result.actions with is_manual=false; gating on prioritizedEntries (which already excludes selected + rejected) is what makes the Analyze & Suggest button reappear post-Clear. When the overview filter hides every otherwise- eligible action, suppress the Analyze & Suggest button and surface a "no actions match the filter" notice instead — the operator should clear the filter before re-running an analysis. */} {(analysisLoading || pendingAnalysisResult || (prioritizedEntries.length === 0 && overviewFilteredOutCount === 0)) && ( <div style={{ marginBottom: '10px' }}> {analysisLoading ? ( <> <button disabled style={{ width: '100%', padding: '10px 16px', background: colors.warningSoft, color: colors.warningText, border: `1px solid ${colors.warningBorder}`, borderRadius: '8px', cursor: 'not-allowed', fontSize: '14px', fontWeight: 700, boxShadow: '0 2px 8px rgba(0,0,0,0.05)', }}> ⚙️ Analyzing… </button> {onCancelAnalysis && ( <button data-testid="cancel-analysis" onClick={onCancelAnalysis} style={{ width: '100%', marginTop: '6px', padding: '8px 16px', background: 'transparent', color: colors.danger, border: `1px solid ${colors.danger}`, borderRadius: '8px', cursor: 'pointer', fontSize: '13px', fontWeight: 600, }} > ✕ Cancel </button> )} </> ) : pendingAnalysisResult ? ( <button data-testid="display-prioritized-actions" onClick={onDisplayPrioritizedActions} style={{ width: '100%', padding: '10px 16px', background: colors.success, color: colors.textOnBrand, border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '14px', fontWeight: 700, boxShadow: '0 2px 8px rgba(39,174,96,0.3)', transition: 'transform 0.1s', }} onMouseEnter={(e) => (e.target as HTMLButtonElement).style.transform = 'scale(1.02)'} onMouseLeave={(e) => (e.target as HTMLButtonElement).style.transform = 'scale(1)'} > 📊 Display {Object.keys(pendingAnalysisResult.actions || {}).length} prioritized actions </button> ) : ( <> {branches && additionalLinesToCut && onToggleAdditionalLineToCut && ( <AdditionalLinesPicker branches={branches} n1Overloads={n1Overloads ?? []} additionalLinesToCut={additionalLinesToCut} onToggle={onToggleAdditionalLineToCut} displayName={displayName} /> )} {recommenderModel != null && setRecommenderModel && ( <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '8px', fontSize: '12px', color: colors.textSecondary, }}> <label htmlFor="analyzeRecommenderModel" style={{ flexShrink: 0 }} >Model:</label> <select id="analyzeRecommenderModel" value={recommenderModel} onChange={e => { interactionLogger.record('recommender_model_changed', { model: e.target.value, source: 'action_feed', }); setRecommenderModel(e.target.value); }} style={{ flex: 1, padding: '4px 6px', border: `1px solid ${colors.border}`, borderRadius: '4px', background: colors.surface, color: colors.textPrimary, fontSize: '12px', }} > {(!availableModels || availableModels.length === 0) && ( <option value="expert">Expert system</option> )} {availableModels?.map(m => ( <option key={m.name} value={m.name}>{m.label}</option> ))} </select> </div> )} <button data-testid="analyze-suggest" onClick={onRunAnalysis} disabled={!canRunAnalysis} style={{ width: '100%', padding: '10px 16px', background: canRunAnalysis ? colors.success : colors.disabled, color: colors.textOnBrand, border: 'none', borderRadius: '8px', cursor: canRunAnalysis ? 'pointer' : 'not-allowed', fontSize: '14px', fontWeight: 700, boxShadow: canRunAnalysis ? '0 2px 8px rgba(39,174,96,0.3)' : 'none', transition: 'transform 0.1s', }} onMouseEnter={(e) => { if (canRunAnalysis) (e.target as HTMLButtonElement).style.transform = 'scale(1.02)'; }} onMouseLeave={(e) => (e.target as HTMLButtonElement).style.transform = 'scale(1)'} > 🔍 Analyze & Suggest </button> </> )} </div> )} {suggestedTab === 'prioritized' && ( prioritizedEntries.length > 0 ? renderActionList(prioritizedEntries) : ( !analysisLoading ? ( <div style={{ textAlign: 'center' }}> <p data-testid={overviewFilteredOutCount > 0 && !pendingAnalysisResult ? 'no-actions-match-filter' : undefined} style={{ color: colors.textTertiary, fontStyle: 'italic', fontSize: '13px', margin: '5px 0' }} > {overviewFilteredOutCount > 0 && !pendingAnalysisResult ? 'No actions match the active filter. Clear the filter to see suggestions.' : (!pendingAnalysisResult ? 'Click \u201cAnalyze & Suggest\u201d above to get action suggestions.' : 'No suggested actions available.')} </p> {/* Recommender thresholds banner moved into NoticesPanel (tier-warning-system PR — see `docs/proposals/ui-design-critique.md` recommendation #4). */} </div> ) : null ) )} {suggestedTab === 'rejected' && ( rejectedEntries.length > 0 ? ( <> {!dismissedRejectedWarning && rejectedEntries.some(([id]) => analysisActionIds.has(id)) && (() => { const overlapIds = rejectedEntries.filter(([id]) => analysisActionIds.has(id)).map(([id]) => id).join(', '); return ( <div data-testid="rejected-overlap-hint" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '6px', marginBottom: '8px', fontSize: '11px', color: colors.textTertiary, fontStyle: 'italic', }} > <div>Recommended by the recent analysis: {overlapIds}</div> <button onClick={() => setDismissedRejectedWarning(true)} style={{ flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: '0', fontSize: '14px', lineHeight: 1, color: colors.textTertiary }} title="Dismiss">×</button> </div> ); })()} {renderActionList(rejectedEntries)} </> ) : ( <p style={{ color: colors.textTertiary, fontStyle: 'italic', fontSize: '13px', margin: '5px 0', textAlign: 'center' }}>No rejected actions.</p> ) )} </div> {/* Fixed-position tooltip rendered outside any overflow context */} {tooltip && ( <div style={{ position: 'fixed', top: tooltip.y, left: tooltip.x, zIndex: 99999, backgroundColor: colors.chrome, color: colors.textOnBrand, textAlign: 'left', borderRadius: '4px', padding: '6px 8px', fontSize: '10px', fontWeight: 'normal', whiteSpace: 'nowrap', boxShadow: '0 2px 8px rgba(0,0,0,0.4)', lineHeight: 1.4, pointerEvents: 'none', maxWidth: '90vw', }}> {tooltip.content} </div> )} {/* Combined Actions Modal */} <CombinedActionsModal isOpen={combineModalOpen} onClose={() => setCombineModalOpen(false)} analysisResult={activeAnalysisResult} simulatedActions={actions} disconnectedElement={disconnectedElement} onSimulateCombined={onManualActionAdded} onSimulateSingleAction={onActionResimulated} monitoringFactor={monitoringFactor} linesOverloaded={linesOverloaded} displayName={displayName} /> </div> ); }; export default React.memo(ActionFeed);