Spaces:
Running
Running
| // 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<string>; | |
| onToggleAdditionalLineToCut?: (line: string) => void; | |
| n1Overloads?: string[]; | |
| } | |
| interface ActionFeedProps { | |
| actions: Record<string, ActionDetail>; | |
| actionScores?: Record<string, Record<string, unknown>>; | |
| linesOverloaded: string[]; | |
| selectedActionId: string | null; | |
| selectedActionIds: Set<string>; | |
| rejectedActionIds: Set<string>; | |
| 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<string, NodeMeta> | null; | |
| edgesByEquipmentId: Map<string, EdgeMeta> | 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<string>; | |
| onVlDoubleClick?: (actionId: string, vlName: string) => void; | |
| combinedActions: Record<string, CombinedAction> | 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<ActionFeedProps> = ({ | |
| 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<AvailableAction[]>([]); | |
| const [loadingActions, setLoadingActions] = useState(false); | |
| const [simulating, setSimulating] = useState<string | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const searchInputRef = useRef<HTMLInputElement>(null); | |
| const dropdownRef = useRef<HTMLDivElement>(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<Record<string, string>>({}); | |
| // Per-action editable tap position for PST action re-simulation (keyed by actionId) | |
| const [cardEditTap, setCardEditTap] = useState<Record<string, string>>({}); | |
| const [resimulating, setResimulating] = useState<string | null>(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<string, unknown> | null, | |
| linesOvl: string[] | null, | |
| targetMw: number | null | undefined, | |
| targetTap: number | null | undefined, | |
| ): Promise<Awaited<ReturnType<typeof api.simulateManualAction>>> => { | |
| 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<ReturnType<typeof api.simulateManualAction>> | null = null; | |
| let streamErr: string | null = null; | |
| for await (const raw of parseNdjsonStream(response)) { | |
| const event = raw as Record<string, unknown>; | |
| if (event.type === 'metrics') { | |
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | |
| const { type: _t, ...rest } = event; | |
| metrics = rest as Awaited<ReturnType<typeof api.simulateManualAction>>; | |
| } 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<ActionOverviewFilters>(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<string, number> }).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<string, number | null> })?.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<string, unknown> | null = null; | |
| if (trimmedId.includes('+')) { | |
| const parts = trimmedId.split('+').map(p => p.trim()); | |
| const perAction: Record<string, unknown> = {}; | |
| 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<string, unknown>; | |
| } | |
| } | |
| 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<string, unknown> : 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<string, unknown> : 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<string, string>(); | |
| 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<string>(); | |
| 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 ( | |
| <ActionCard | |
| key={id} | |
| id={id} | |
| details={details} | |
| index={index} | |
| isViewing={selectedActionId === id} | |
| isSelected={selectedActionIds.has(id)} | |
| isRejected={rejectedActionIds.has(id)} | |
| linesOverloaded={linesOverloaded} | |
| monitoringFactor={monitoringFactor} | |
| nodesByEquipmentId={nodesByEquipmentId} | |
| edgesByEquipmentId={edgesByEquipmentId} | |
| cardEditMw={cardEditMw} | |
| cardEditTap={cardEditTap} | |
| resimulating={resimulating} | |
| onActionSelect={onActionSelect} | |
| onActionFavorite={onActionFavorite} | |
| onActionReject={onActionReject} | |
| onAssetClick={onAssetClick} | |
| onVlDoubleClick={onVlDoubleClick} | |
| onCardEditMwChange={(actionId, value) => setCardEditMw(prev => ({ ...prev, [actionId]: value }))} | |
| onCardEditTapChange={(actionId, value) => setCardEditTap(prev => ({ ...prev, [actionId]: value }))} | |
| onResimulate={handleResimulate} | |
| onResimulateTap={handleResimulateTap} | |
| displayName={displayName} | |
| availableModels={availableModels} | |
| /> | |
| ); | |
| }); | |
| }; | |
| return ( | |
| <div style={{ padding: '15px' }}> | |
| {/* Header with search */} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px', position: 'relative' }}> | |
| <h3 data-testid="action-feed-header" style={{ margin: 0, flex: 1 }}>Remedial Actions</h3> | |
| <button | |
| onClick={handleOpenSearch} | |
| style={{ | |
| background: searchOpen ? colors.brand : colors.surfaceMuted, | |
| color: searchOpen ? colors.textOnBrand : colors.textPrimary, | |
| border: 'none', | |
| borderRadius: '6px', | |
| padding: '4px 10px', | |
| cursor: 'pointer', | |
| fontSize: '13px', | |
| fontWeight: 600, | |
| marginRight: '6px' | |
| }} | |
| > | |
| + Manual Selection | |
| </button> | |
| <button | |
| onClick={() => setCombineModalOpen(true)} | |
| style={{ | |
| background: combineModalOpen ? colors.brand : colors.surfaceMuted, | |
| color: combineModalOpen ? colors.textOnBrand : colors.textPrimary, | |
| border: 'none', | |
| borderRadius: '6px', | |
| padding: '4px 10px', | |
| cursor: 'pointer', | |
| fontSize: '13px', | |
| fontWeight: 600, | |
| }} | |
| > | |
| ++ Combine | |
| </button> | |
| {/* 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 && ( | |
| <ActionSearchDropdown | |
| dropdownRef={dropdownRef} | |
| searchInputRef={searchInputRef} | |
| searchQuery={searchQuery} | |
| onSearchQueryChange={setSearchQuery} | |
| filters={dropdownFilters} | |
| onFiltersChange={setDropdownFilters} | |
| error={error} | |
| loadingActions={loadingActions} | |
| scoredActionsList={scoredActionsList} | |
| filteredActions={filteredActions} | |
| actionScores={actionScores} | |
| actions={actions} | |
| cardEditMw={cardEditMw} | |
| onCardEditMwChange={(actionId, value) => 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} | |
| /> | |
| )} | |
| </div> | |
| {/* 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. */} | |
| <div style={{ marginBottom: '15px' }}> | |
| <h4 style={{ margin: '0 0 10px 0', fontSize: '14px', color: colors.textPrimary, borderBottom: `1px solid ${colors.borderSubtle}`, paddingBottom: '4px', display: 'flex', justifyContent: 'flex-start', alignItems: 'center', gap: '8px' }}> | |
| Selected Actions | |
| {selectedEntries.length > 0 && <span style={{ background: colors.surfaceMuted, color: colors.textSecondary, fontSize: '11px', padding: '2px 6px', borderRadius: '10px' }}>{selectedEntries.length}</span>} | |
| </h4> | |
| {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 ( | |
| <div | |
| data-testid="selected-overlap-hint" | |
| style={{ | |
| display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', | |
| gap: '6px', marginBottom: '8px', | |
| fontSize: '11px', color: colors.textTertiary, fontStyle: 'italic', | |
| }} | |
| > | |
| <div>Also recommended by the recent analysis: {overlapIds}</div> | |
| <button onClick={() => setDismissedSelectedWarning(true)} style={{ flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: '0', fontSize: '14px', lineHeight: 1, color: colors.textTertiary }} title="Dismiss">×</button> | |
| </div> | |
| ); | |
| })()} | |
| {renderActionList(selectedEntries)} | |
| </> | |
| ) : ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', margin: '5px 0 15px 0' }}> | |
| <p style={{ color: colors.textTertiary, fontStyle: 'italic', fontSize: '13px', margin: 0 }}>Select an action manually or from suggested ones.</p> | |
| {/* "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 && ( | |
| <button | |
| onClick={handleOpenSearch} | |
| data-testid="make-first-guess-button" | |
| style={{ | |
| padding: '10px', | |
| backgroundColor: colors.surfaceMuted, | |
| border: `1px dashed ${colors.brand}`, | |
| color: colors.brand, | |
| borderRadius: '8px', | |
| cursor: 'pointer', | |
| fontWeight: 600, | |
| fontSize: '14px', | |
| transition: 'all 0.2s', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| gap: '8px', | |
| }} | |
| onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.backgroundColor = colors.brandSoft; }} | |
| onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.backgroundColor = colors.surfaceMuted; }} | |
| > | |
| <span style={{ fontSize: '16px' }}>💡</span> Make a first guess | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| <div style={{ marginBottom: '15px' }}> | |
| {overviewFilteredOutCount > 0 && ( | |
| <div | |
| data-testid="overview-filter-hint" | |
| style={{ | |
| marginBottom: '6px', | |
| fontSize: '11px', | |
| color: colors.textTertiary, | |
| fontStyle: 'italic', | |
| }} | |
| > | |
| {overviewFilteredOutCount} action{overviewFilteredOutCount === 1 ? '' : 's'} hidden by the overview filter. | |
| </div> | |
| )} | |
| <div style={{ display: 'flex', borderBottom: `1px solid ${colors.borderSubtle}`, marginBottom: '10px' }}> | |
| <button | |
| onClick={() => setSuggestedTab('prioritized')} | |
| style={{ flex: 1, padding: '8px', cursor: 'pointer', border: 'none', background: 'none', borderBottom: suggestedTab === 'prioritized' ? `2px solid ${colors.brand}` : 'none', fontWeight: suggestedTab === 'prioritized' ? 'bold' : 'normal', color: suggestedTab === 'prioritized' ? colors.brand : colors.textTertiary, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '6px' }} | |
| >Suggested Actions {prioritizedEntries.length > 0 && <span style={{ background: suggestedTab === 'prioritized' ? colors.brandSoft : colors.surfaceMuted, color: suggestedTab === 'prioritized' ? colors.brand : colors.textSecondary, fontSize: '11px', padding: '2px 6px', borderRadius: '10px', fontWeight: 'bold' }}>{prioritizedEntries.length}</span>}</button> | |
| <button | |
| onClick={() => setSuggestedTab('rejected')} | |
| style={{ flex: 1, padding: '8px', cursor: 'pointer', border: 'none', background: 'none', borderBottom: suggestedTab === 'rejected' ? `2px solid ${colors.danger}` : 'none', fontWeight: suggestedTab === 'rejected' ? 'bold' : 'normal', color: suggestedTab === 'rejected' ? colors.danger : colors.textTertiary, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '6px' }} | |
| >Rejected Actions {rejectedEntries.length > 0 && <span style={{ background: suggestedTab === 'rejected' ? colors.dangerSoft : colors.surfaceMuted, color: suggestedTab === 'rejected' ? colors.danger : colors.textSecondary, fontSize: '11px', padding: '2px 6px', borderRadius: '10px', fontWeight: 'bold' }}>{rejectedEntries.length}</span>}</button> | |
| </div> | |
| {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 <title> 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); | |