From 17a07355cfebae22ba491fa3bc294965f01bdd5f Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 14 Aug 2026 19:21:11 +0300 Subject: [PATCH] fix(k1): keep reconnect feedback in place --- .../devicePluginFrontendBoundary.test.mjs | 2 +- .../test/k1SupervisorPresentation.test.mjs | 126 +++++- .../src/components/K1ProvisioningPipeline.tsx | 369 ++++++++++-------- 3 files changed, 328 insertions(+), 169 deletions(-) diff --git a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs index a656d02..94a333c 100644 --- a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs +++ b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs @@ -633,7 +633,7 @@ test("top-right device utility is an explicit pending-aware K1 scenario reset", /const modeResetInFlight = pendingAction === "mode" \|\| modeResetPending !== null/, ); assert.match(provisioning, /if \(modeResetInFlight\) return/); - assert.equal((provisioning.match(/disabled=\{modeResetInFlight\}/g) ?? []).length, 2); + assert.equal((provisioning.match(/disabled=\{modeResetInFlight\}/g) ?? []).length, 1); assert.match(provisioning, /disabled=\{isBusy \|\| modeResetInFlight\}/); assert.match(connection, /const hydratedScenarioResetKey = useRef\(null\)/); diff --git a/apps/control-station/test/k1SupervisorPresentation.test.mjs b/apps/control-station/test/k1SupervisorPresentation.test.mjs index c4a65f8..ece5d88 100644 --- a/apps/control-station/test/k1SupervisorPresentation.test.mjs +++ b/apps/control-station/test/k1SupervisorPresentation.test.mjs @@ -19,6 +19,7 @@ let K1ProvisioningPipeline; let RuntimeActionFenceTestContext; let emptySearchPresentation; let emptyProvisioningAttemptPresentation; +let emptyReadOnlyReconnectPresentation; let provisioningAttemptViewState; let unavailablePhysicalRetirementAuthority; let connectionAttemptOwnsAppliedNetworkRecovery; @@ -125,6 +126,7 @@ before(async () => { RuntimeActionFenceTestContext, emptySearchPresentation, emptyProvisioningAttemptPresentation, + emptyReadOnlyReconnectPresentation, provisioningAttemptViewState, unavailablePhysicalRetirementAuthority, connectionAttemptOwnsAppliedNetworkRecovery, @@ -5228,11 +5230,21 @@ test("an unresolved physical command has one explicit server-bound read-only rec }, desiredMode: "bridge", }, { publicSearch: false }); - assert.equal( - (pendingMarkup.match(/class="connection-action-progress"/g) ?? []).length, - 1, + const pendingReconnect = buttonMarkupWithText( + pendingMarkup, + "Переподключаемся…", ); - assert.doesNotMatch(pendingMarkup, /Переподключиться|Подключить новый K1|Подтверждаю:/); + assert.equal(pendingReconnect.length, 1); + assert.match(pendingReconnect[0], /disabled/); + assert.match(pendingReconnect[0], /aria-busy="true"/); + assert.match(pendingReconnect[0], /nodedc-activity-indicator/); + const pendingConnectNew = buttonMarkupWithText( + pendingMarkup, + "Подключить новый K1", + ); + assert.equal(pendingConnectNew.length, 1); + assert.match(pendingConnectNew[0], /disabled/); + assert.doesNotMatch(pendingMarkup, /connection-action-progress|Подтверждаю:/); const source = readFileSync(provisioningSourceUrl, "utf8"); const physicalRecovery = sourceSlice( @@ -5443,18 +5455,16 @@ test("unavailable physical replacement stays CAS-protected behind the simple new }, desiredMode: "bridge", }, { publicSearch: false }); - assert.match( + const bothPendingReconnect = buttonMarkupWithText( bothPendingMarkup, - /Проверяем прежний K1 без повторения START, STOP или настроек сети/, - ); - assert.equal( - (bothPendingMarkup.match(/class="connection-action-progress"/g) ?? []).length, - 1, - ); - assert.doesNotMatch( - bothPendingMarkup, - /Переподключиться|Подключить новый K1|Подтверждаю:/, + "Переподключаемся…", ); + assert.equal(bothPendingReconnect.length, 1); + assert.match(bothPendingReconnect[0], /disabled/); + assert.match(bothPendingReconnect[0], /aria-busy="true"/); + assert.match(bothPendingReconnect[0], /nodedc-activity-indicator/); + assert.match(bothPendingMarkup, /Прежнее подключение не подтверждено/); + assert.doesNotMatch(bothPendingMarkup, /connection-action-progress|Подтверждаю:/); const bothResolvedActive = structuredClone(bothAllowed); bothResolvedActive.physical_command.status = "resolved"; @@ -6140,6 +6150,92 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p assert.match(unrelatedMarkup, /Результат применения сети не подтверждён/); }); +test("read-only reconnect keeps its card and loader across a transient projection", async () => { + const initialState = terminalConnectionRecoveryState(); + initialState.connection_attempt = null; + let currentState = initialState; + let resolveVerification; + let markVerificationStarted; + const verificationStarted = new Promise((resolve) => { + markVerificationStarted = resolve; + }); + const controller = { + ...provisioningController(initialState), + verifyConnection: async () => { + markVerificationStarted(); + return new Promise((resolve) => { + resolveVerification = resolve; + }); + }, + getConnectionRecoveryObservationTarget: () => + recommendedConnectionRecoveryObservationTarget(currentState), + isSnapshotRuntimeCurrent: (runtimeId) => + runtimeId === currentState.snapshot_runtime_id, + }; + const props = () => ({ + controller: { ...controller, state: currentState }, + desiredMode: "bridge", + }); + const harness = createStatefulProvisioningHarness(props()); + + try { + let tree = harness.render(props()); + const reconnect = actionByLabel(tree, "Переподключиться"); + assert.ok(reconnect); + reconnect.props.onClick(); + await Promise.race([ + verificationStarted, + new Promise((_, reject) => setTimeout( + () => reject(new Error("read-only reconnect did not dispatch")), + 100, + )), + ]); + + const transientState = structuredClone(initialState); + transientState.connection_policy.allowed_actions = + transientState.connection_policy.allowed_actions.filter( + (action) => action !== "observe-configured-device-network", + ); + delete transientState.connection_policy.actions[ + "observe-configured-device-network" + ]; + currentState = transientState; + tree = harness.render(props()); + const pendingMarkup = renderToStaticMarkup(tree); + const pendingReconnect = buttonMarkupWithText( + pendingMarkup, + "Переподключаемся…", + ); + assert.equal(pendingReconnect.length, 1); + assert.match(pendingReconnect[0], /disabled/); + assert.match(pendingReconnect[0], /aria-busy="true"/); + assert.match(pendingReconnect[0], /nodedc-activity-indicator/); + assert.match(pendingMarkup, /Сохранённое подключение требует проверки/); + assert.doesNotMatch(pendingMarkup, /connection-action-progress/); + assert.equal( + harness.stateValue(emptyReadOnlyReconnectPresentation)?.kind, + "connection", + ); + + currentState = initialState; + resolveVerification({ + succeeded: false, + reconciliationCompleted: false, + observedState: initialState, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + tree = harness.render(props()); + const failedMarkup = renderToStaticMarkup(tree); + const retry = buttonMarkupWithText(failedMarkup, "Переподключиться"); + assert.equal(retry.length, 1); + assert.doesNotMatch(retry[0], /disabled|aria-busy="true"/); + assert.match(failedMarkup, /Проверьте питание и сеть, затем повторите проверку/); + assert.equal(harness.stateValue(emptyReadOnlyReconnectPresentation), null); + } finally { + harness.dispose(); + } +}); + test("cold saved new-device path resets once, performs zero Scan, and survives reload", async () => { const state = terminalConnectionRecoveryState(); state.connection_attempt = null; @@ -6753,7 +6849,7 @@ test("applied-network recovery spends the old intent and admits only explicit se assert.match(source, /BLE-команда не повторяется/); assert.match( source, - /status=\{\s*searchActive[\s\S]*?: physicalRecoveryRequired[\s\S]*?: networkRecoveryRequired[\s\S]*?>\s*\{searchActive \? \([\s\S]*?Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с[\s\S]*?: networkRecoveryRequired && !connectionAttemptSettling \? \(/, + /status=\{\s*searchActive[\s\S]*?: presentedPhysicalRecoveryRequired[\s\S]*?: networkRecoveryRequired[\s\S]*?>\s*\{searchActive \? \([\s\S]*?Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с[\s\S]*?: networkRecoveryRequired && !connectionAttemptSettling \? \(/, ); assert.match( diff --git a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx index 47fc2fb..98a4ffd 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx @@ -85,6 +85,20 @@ interface DevicePresentationSnapshot { rssi: number | null; } +export interface ReadOnlyReconnectPresentation { + kind: "connection" | "physical"; + key: string; + actionEpoch: number; + snapshotRuntimeId: string; + deviceId: string; + connectionMode: ConnectionMode; +} + +export function emptyReadOnlyReconnectPresentation(): + ReadOnlyReconnectPresentation | null { + return null; +} + export interface PhysicalReopenPresentation { key: string; snapshotRuntimeId: string; @@ -1256,10 +1270,10 @@ export function K1ProvisioningPipeline({ useState(null); const [escapedConnectionRecoveryKey, setEscapedConnectionRecoveryKey] = useState(null); - const [failedConnectionRecoveryKey, setFailedConnectionRecoveryKey] = - useState(null); - const [failedPhysicalRecoveryKey, setFailedPhysicalRecoveryKey] = - useState(null); + const [readOnlyReconnectPresentation, setReadOnlyReconnectPresentation] = + useState( + emptyReadOnlyReconnectPresentation, + ); // Browser-local generation for work which may settle after an awaited // controller call. A scenario reset does not replace snapshot_runtime_id, // so runtime identity alone cannot prevent an old Scan/Verify/Retire result @@ -1607,8 +1621,7 @@ export function K1ProvisioningPipeline({ setCandidateUnavailableMessage(null); setEscapedAppliedAttemptKey(null); setEscapedConnectionRecoveryKey(null); - setFailedConnectionRecoveryKey(null); - setFailedPhysicalRecoveryKey(null); + setReadOnlyReconnectPresentation(null); setSearchPresentation(null); setScanSecondsRemaining(null); }, [scenarioResetPresentationKey]); @@ -1638,12 +1651,21 @@ export function K1ProvisioningPipeline({ const connectionAttemptSettling = connectionAttemptView === "submitting" || connectionAttemptView === "settling"; const connectionAttemptFailed = connectionAttemptView === "failed"; + const currentReadOnlyReconnectPresentation = + readOnlyReconnectPresentation?.snapshotRuntimeId === snapshotRuntimeId + ? readOnlyReconnectPresentation + : null; + const connectionRecoveryVerificationPending = + currentReadOnlyReconnectPresentation?.kind === "connection"; + const physicalRecoveryVerificationPending = + currentReadOnlyReconnectPresentation?.kind === "physical"; const provisioningMutationBusy = Boolean( presentedPendingAction || modeResetPending !== null || currentPreparingReconfigurationRequest || searchDisplayActive || connectionAttemptSettling + || currentReadOnlyReconnectPresentation ); const isBusy = provisioningMutationBusy; const activeSearchSequence = searchDisplayActive @@ -1783,10 +1805,10 @@ export function K1ProvisioningPipeline({ connectionRecoveryKey, escapedConnectionRecoveryKey, ) && !(searchRequested && !searchDisplayActive); - const connectionRecoveryVerificationFailed = Boolean( - connectionRecoveryKey - && failedConnectionRecoveryKey === connectionRecoveryKey, - ); + const presentedConnectionRecoveryRequired = connectionRecoveryRequired + || connectionRecoveryVerificationPending; + const presentedPhysicalRecoveryRequired = physicalRecoveryRequired + || physicalRecoveryVerificationPending; const appliedControlSettlementPending = Boolean( unresolvedAppliedAttempt && ["accepted", "running"].includes(unresolvedAppliedAttempt.status) @@ -1818,26 +1840,22 @@ export function K1ProvisioningPipeline({ const physicalRecoveryAuthorityKey = physicalRecoveryPresentationAuthorityKey( state, ); - const physicalRecoveryVerificationFailed = Boolean( - physicalRecoveryAuthorityKey - && failedPhysicalRecoveryKey === physicalRecoveryAuthorityKey, - ); const physicalReadOnlyVerificationAvailable = Boolean( - physicalRecoveryTarget?.serverBound, + physicalRecoveryTarget?.serverBound || physicalRecoveryVerificationPending, ); - useEffect(() => { - if ( - failedPhysicalRecoveryKey - && failedPhysicalRecoveryKey !== physicalRecoveryAuthorityKey - ) { - setFailedPhysicalRecoveryKey(null); - } - }, [failedPhysicalRecoveryKey, physicalRecoveryAuthorityKey]); - const physicalRecoveryModeLabel = physicalRecoveryBinding + const presentedPhysicalRecoveryMode = physicalRecoveryBinding?.connectionMode + ?? (physicalRecoveryVerificationPending + ? currentReadOnlyReconnectPresentation?.connectionMode + : null); + const physicalRecoveryModeLabel = presentedPhysicalRecoveryMode ? connectionModeOptions.find( - (option) => option.value === physicalRecoveryBinding.connectionMode, - )?.label ?? physicalRecoveryBinding.connectionMode + (option) => option.value === presentedPhysicalRecoveryMode, + )?.label ?? presentedPhysicalRecoveryMode : "Прежнее подключение"; + const presentedPhysicalRecoveryDeviceId = physicalRecoveryBinding?.deviceId + ?? (physicalRecoveryVerificationPending + ? currentReadOnlyReconnectPresentation?.deviceId + : null); const reconfigurationTargetAllowed = reconfiguration === null || ( selectedTarget !== null @@ -1888,9 +1906,15 @@ export function K1ProvisioningPipeline({ || connectedEndpoint || "Активное подключение" : null; + const verificationActionPending = presentedPendingAction === "verify" + || connectionRecoveryVerificationPending + || physicalRecoveryVerificationPending; + const connectionReconnectPending = presentedConnectionRecoveryRequired + && verificationActionPending; + const physicalReconnectPending = presentedPhysicalRecoveryRequired + && verificationActionPending; const connectionActionPending = presentedPendingAction === "connect" - || presentedPendingAction === "verify"; - const verificationActionPending = presentedPendingAction === "verify"; + || verificationActionPending; const networkActionPending = presentedPendingAction === "connect" || presentedPendingAction === "mode" || Boolean( @@ -1924,8 +1948,8 @@ export function K1ProvisioningPipeline({ const changeNetworkActionApplicable = connectionMode === "bridge" && connectedReconfigurationActionApplicable(state, "prepare-change-network"); const showNetworkStep = Boolean( - !physicalRecoveryRequired - && !connectionRecoveryRequired + !presentedPhysicalRecoveryRequired + && !presentedConnectionRecoveryRequired && ( selectedModeConnected || explicitProvisioningDraftRetained @@ -2141,8 +2165,6 @@ export function K1ProvisioningPipeline({ setPreparingReconfigurationRequest(null); setCandidateUnavailableMessage(null); setEscapedConnectionRecoveryKey(null); - setFailedConnectionRecoveryKey(null); - setFailedPhysicalRecoveryKey(null); resetSearchPresentation(); }, [ activeBindingKey, @@ -2550,51 +2572,67 @@ export function K1ProvisioningPipeline({ if ( !connectionRecoveryTarget?.serverBound || !snapshotRuntimeId + || !connectionRecoveryKey || isBusy || typeof getConnectionRecoveryObservationTarget !== "function" ) return; const actionEpoch = localScenarioActionEpoch.current; const verificationKey = connectionRecoveryKey; - setCandidateUnavailableMessage(null); - const dispatched = await dispatchConnectionRecoveryObservationForCurrentRuntime( - snapshotRuntimeId, - connectionRecoveryTarget, - isSnapshotRuntimeCurrent, - getConnectionRecoveryObservationTarget, - async (target) => { - const request = observationRequest( - target, - reconfigurationRevision, - reconfigurationIntentId, - ); - if (!request) return null; - return verifyConnection(request, { surfaceErrors: false }); - }, - ); - if (!localScenarioActionEpochIsCurrent( + setReadOnlyReconnectPresentation({ + kind: "connection", + key: verificationKey, actionEpoch, - localScenarioActionEpoch.current, - )) return; - if (!dispatched.dispatched || !dispatched.result) { - setFailedConnectionRecoveryKey(verificationKey); - setCandidateUnavailableMessage( - "Не удалось начать переподключение: состояние K1 изменилось. Подключите новый K1 через ручной Bluetooth-поиск.", - ); - return; - } - if ( - typeof isSnapshotRuntimeCurrent === "function" - && !isSnapshotRuntimeCurrent(snapshotRuntimeId) - ) return; - if (!dispatched.result.succeeded) { - setFailedConnectionRecoveryKey(verificationKey); - setCandidateUnavailableMessage( - "Не удалось переподключиться к прежнему K1. Подключите новый K1 через ручной Bluetooth-поиск.", - ); - return; - } - setFailedConnectionRecoveryKey(null); + snapshotRuntimeId, + deviceId: connectionRecoveryTarget.deviceId, + connectionMode: connectionRecoveryTarget.connectionMode, + }); setCandidateUnavailableMessage(null); + try { + const dispatched = await dispatchConnectionRecoveryObservationForCurrentRuntime( + snapshotRuntimeId, + connectionRecoveryTarget, + isSnapshotRuntimeCurrent, + getConnectionRecoveryObservationTarget, + async (target) => { + const request = observationRequest( + target, + reconfigurationRevision, + reconfigurationIntentId, + ); + if (!request) return null; + return verifyConnection(request, { surfaceErrors: false }); + }, + ); + if (!localScenarioActionEpochIsCurrent( + actionEpoch, + localScenarioActionEpoch.current, + )) return; + if (!dispatched.dispatched || !dispatched.result) { + setCandidateUnavailableMessage( + "Не удалось начать переподключение: состояние K1 изменилось. Повторите проверку или подключите новый K1 через ручной Bluetooth-поиск.", + ); + return; + } + if ( + typeof isSnapshotRuntimeCurrent === "function" + && !isSnapshotRuntimeCurrent(snapshotRuntimeId) + ) return; + if (!dispatched.result.succeeded) { + setCandidateUnavailableMessage( + "Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.", + ); + return; + } + setCandidateUnavailableMessage(null); + } finally { + setReadOnlyReconnectPresentation((current) => ( + current?.kind === "connection" + && current.key === verificationKey + && current.actionEpoch === actionEpoch + ? null + : current + )); + } }; const refreshCorrelatedConnectionFailure = async () => { @@ -2611,8 +2649,7 @@ export function K1ProvisioningPipeline({ setPasswordVisible(false); setConnectionAttemptPresentation(null); setCandidateUnavailableMessage(null); - setFailedConnectionRecoveryKey(null); - setFailedPhysicalRecoveryKey(null); + setReadOnlyReconnectPresentation(null); }; const verifyPhysicalRecovery = async () => { @@ -2621,6 +2658,8 @@ export function K1ProvisioningPipeline({ if ( !physicalRecoveryRequired || !physicalRecoveryTarget?.serverBound + || !snapshotRuntimeId + || !verificationKey || isBusy ) return; const request = observationRequest( @@ -2629,57 +2668,69 @@ export function K1ProvisioningPipeline({ reconfigurationIntentId, ); if (!request) { - setFailedPhysicalRecoveryKey(verificationKey); setCandidateUnavailableMessage( "Не удалось переподключиться к прежнему K1. Подключите новый K1 вручную.", ); return; } - - const modeAuthority = await commitDesiredModeForExplicitAction( - physicalRecoveryTarget.connectionMode, - ); - if (!localScenarioActionEpochIsCurrent( + setReadOnlyReconnectPresentation({ + kind: "physical", + key: verificationKey, actionEpoch, - localScenarioActionEpoch.current, - )) return; - if (!modeAuthority) { - setFailedPhysicalRecoveryKey(verificationKey); - setCandidateUnavailableMessage( - "Не удалось переподключиться: состояние K1 изменилось. Подключите новый K1 вручную.", - ); - return; - } - const actionFence = activateRuntimeActionFence(modeAuthority); - if (!actionFence || !runtimeActionIsCurrent(actionFence)) return; - + snapshotRuntimeId, + deviceId: physicalRecoveryTarget.deviceId, + connectionMode: physicalRecoveryTarget.connectionMode, + }); setCandidateUnavailableMessage(null); try { - const result = await verifyConnection(request); - if (!runtimeClickIsCurrent(actionFence)) return; - if (!result.succeeded) { - setFailedPhysicalRecoveryKey(verificationKey); + const modeAuthority = await commitDesiredModeForExplicitAction( + physicalRecoveryTarget.connectionMode, + ); + if (!localScenarioActionEpochIsCurrent( + actionEpoch, + localScenarioActionEpoch.current, + )) return; + if (!modeAuthority) { setCandidateUnavailableMessage( - "Не удалось переподключиться к прежнему K1. Подключите новый K1 вручную.", + "Не удалось переподключиться: состояние K1 изменилось. Повторите проверку или подключите новый K1 вручную.", ); return; } - if ( - result.observedState - && isRecoveredPhysicalScanning( - result.observedState, - physicalRecoveryTarget.connectionMode, - ) - ) { - setCandidateUnavailableMessage( - "K1 подтвердил продолжающееся сканирование. Новая команда не отправлялась; доступна одна явная остановка.", - ); - return; + const actionFence = activateRuntimeActionFence(modeAuthority); + if (!actionFence || !runtimeActionIsCurrent(actionFence)) return; + try { + const result = await verifyConnection(request); + if (!runtimeClickIsCurrent(actionFence)) return; + if (!result.succeeded) { + setCandidateUnavailableMessage( + "Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.", + ); + return; + } + if ( + result.observedState + && isRecoveredPhysicalScanning( + result.observedState, + physicalRecoveryTarget.connectionMode, + ) + ) { + setCandidateUnavailableMessage( + "K1 подтвердил продолжающееся сканирование. Новая команда не отправлялась; доступна одна явная остановка.", + ); + return; + } + setCandidateUnavailableMessage(null); + } finally { + retireRuntimeClickFence(actionFence); } - setFailedPhysicalRecoveryKey(null); - setCandidateUnavailableMessage(null); } finally { - retireRuntimeClickFence(actionFence); + setReadOnlyReconnectPresentation((current) => ( + current?.kind === "physical" + && current.key === verificationKey + && current.actionEpoch === actionEpoch + ? null + : current + )); } }; @@ -2779,8 +2830,6 @@ export function K1ProvisioningPipeline({ setReconfigurationDevicePresentation(null); setEscapedAppliedAttemptKey(null); setEscapedConnectionRecoveryKey(null); - setFailedConnectionRecoveryKey(null); - setFailedPhysicalRecoveryKey(null); resetSearchPresentation(); await onDesiredModeChange(value); } finally { @@ -3023,30 +3072,41 @@ export function K1ProvisioningPipeline({ ) : null} ); - const connectionRecoveryModeLabel = connectionRecoveryTarget + const presentedConnectionRecoveryMode = connectionRecoveryTarget?.connectionMode + ?? (connectionRecoveryVerificationPending + ? currentReadOnlyReconnectPresentation?.connectionMode + : null); + const connectionRecoveryModeLabel = presentedConnectionRecoveryMode ? connectionModeOptions.find( - (option) => option.value === connectionRecoveryTarget.connectionMode, - )?.label ?? connectionRecoveryTarget.connectionMode + (option) => option.value === presentedConnectionRecoveryMode, + )?.label ?? presentedConnectionRecoveryMode : null; - const connectionRecoveryActions = connectionRecoveryRequired ? ( + const presentedConnectionRecoveryDeviceId = connectionRecoveryTarget?.deviceId + ?? (connectionRecoveryVerificationPending + ? currentReadOnlyReconnectPresentation?.deviceId + : null); + const connectionRecoveryActions = presentedConnectionRecoveryRequired ? ( <> - {connectionRecoveryTarget && !connectionRecoveryVerificationFailed ? ( + {connectionRecoveryTarget || connectionRecoveryVerificationPending ? ( ) : null} ) : null}