import {SceneDisplayControls} from "../../../packages/spatial-ui/src/SceneDisplayControls"; import {SceneLayerControls} from "../../../packages/spatial-ui/src/SceneLayerControls"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AdminNavigationPanel, AppHeader, ApplicationPanel, ApplicationShell, Button, ControlRow, HeaderNavigation, HeaderProfile, Icon, Inspector, StatusBadge, TextField, UserProfileMenu, Window, WindowFooterActions, useApplicationWorkspace, } from "@nodedc/ui-react"; import { LandingStage } from "./components/LandingStage"; import { EnvironmentSettingsWindow } from "./components/EnvironmentSettingsWindow"; import { ObservationSessionSelect } from "./components/ObservationSessionSelect"; import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel"; import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector"; import { useComputeContourSettings } from "./components/system/useComputeContourSettings"; import { useApplicationPanelActions } from "./components/useApplicationPanelActions"; import { useSessionOverviewMode } from "./core/observation/useSessionOverview"; import { SessionOverviewWorkspace } from "./workspaces/recordings/SessionOverviewWorkspace"; import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings"; import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost"; import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext"; import type { ViewerSettings } from "./core/runtime/contracts"; import { SPATIAL_SOURCE_SWITCH_BLOCKED_REASON, isSpatialSourceSwitchBlocked, } from "./core/runtime/acquisitionGuard"; import { createLatestAsyncCommitter, type LatestAsyncCommitter, } from "./core/runtime/latestAsyncCommitter"; import { useObservationLayout } from "./core/observation/useObservationLayout"; import { recordedObservationSources } from "./core/observation/recordedObservationSources"; import type { ObservationSessionReplayLaunch, ObservationSessionSummary, } from "./core/observation/sessionArchive"; import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission"; import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile"; import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget"; import { resolvePolygonRunRoute } from "./core/polygon/runArchive"; import { OBSERVATION_WORKSPACE_ID, OBSERVATION_WORKSPACE_LAYOUT_VERSION, type ObservationWorkspaceLayoutProfile, } from "./core/observation/workspaceLayout"; import { rootById, roots, workspaceById, workspacesForRoot, type RootId, } from "./productModel"; import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation"; import { defaultSceneSettings, type SceneSettings, } from "./sceneSettings"; import { DeviceWorkspace } from "./workspaces/DeviceWorkspace"; import { PlanningConnectionWindow } from "./components/missions/PlanningConnectionWindow"; import { PlanningCaptureGuard } from "./components/missions/PlanningCaptureGuard"; import { workspaceLaunchProfile, type WorkspaceLaunchProfile } from "./core/observation/workspaceLaunch"; import { WorkspaceRenderer } from "./workspaces/Workspaces"; import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader"; import "./styles/scene-windows.css"; type SceneToolWindowId = "sources" | "display" | "layers"; const viewerSettingsQuietPeriodMs = 750; interface LivePerceptionLayers { detections2d: boolean; segmentation: boolean; cuboids3d: boolean; } const defaultLivePerceptionLayers: LivePerceptionLayers = { detections2d: false, segmentation: false, cuboids3d: false, }; function toViewerSettings( settings: SceneSettings, perception: LivePerceptionLayers = defaultLivePerceptionLayers, ): ViewerSettings { return { point_size: settings.pointSize, color_mode: settings.colorMode, palette: settings.palette, custom_color: settings.customColor, accumulation_seconds: settings.accumulationSeconds, show_points: settings.showPoints, show_trajectory: settings.showTrajectory, show_grid: settings.showGrid, show_detections_2d: perception.detections2d, show_segmentation: perception.segmentation, show_cuboids_3d: perception.cuboids3d, }; } function mergeViewerSettings( current: SceneSettings, remote: ViewerSettings, ): SceneSettings { const next = { ...current, pointSize: remote.point_size, colorMode: remote.color_mode, palette: remote.palette, customColor: remote.custom_color, accumulationSeconds: remote.accumulation_seconds, showPoints: remote.show_points, showTrajectory: remote.show_trajectory, showGrid: remote.show_grid, }; const unchanged = current.pointSize === next.pointSize && current.colorMode === next.colorMode && current.palette === next.palette && current.customColor === next.customColor && current.accumulationSeconds === next.accumulationSeconds && current.showPoints === next.showPoints && current.showTrajectory === next.showTrajectory && current.showGrid === next.showGrid; return unchanged ? current : next; } export default function App() { const runtime = useMissionRuntime(); const environment = useEnvironmentSettings(); const computeContourSettings = useComputeContourSettings(); const { selection } = useDevicePluginHost(); const polygonDatasetRoute = useMemo( () => resolvePolygonRunRoute(typeof window === "undefined" ? "" : window.location.search), [], ); const workspace = useApplicationWorkspace({ navigationOpen: false, contentExpanded: true, }); const [activeRoot, setActiveRoot] = useState( polygonDatasetRoute.active ? "data" : null, ); const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false); const [launchProfile, setLaunchProfile] = useState('direct'); const [sourceUrl, setSourceUrl] = useState(""); const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState(null); const [recordedReplay, setRecordedReplay] = useState(null); const [recordedReplayLabel, setRecordedReplayLabel] = useState(null); const [replayTransitioning, setReplayTransitioning] = useState(false); const [sourceDraft, setSourceDraft] = useState(""); const [sourceWindowOpen, setSourceWindowOpen] = useState(false); const [displayWindowOpen, setDisplayWindowOpen] = useState(false); const [layerInspectorOpen, setLayerInspectorOpen] = useState(false); const [sceneWindowOrder, setSceneWindowOrder] = useState([]); const [layoutSaveNotice, setLayoutSaveNotice] = useState(null); const laboratoryAnnotation = useLaboratoryAnnotationHeader(); const [sceneSettings, setSceneSettings] = useState(defaultSceneSettings); const [displayDraft, setDisplayDraft] = useState(defaultSceneSettings); const [livePerceptionLayers, setLivePerceptionLayers] = useState( defaultLivePerceptionLayers, ); const sourceSwitchBlocked = isSpatialSourceSwitchBlocked(runtime.state); const sourceSwitchBlockedReason = sourceSwitchBlocked ? SPATIAL_SOURCE_SWITCH_BLOCKED_REASON : null; const sourceSwitchBlockedRef = useRef(sourceSwitchBlocked); sourceSwitchBlockedRef.current = sourceSwitchBlocked; const appliedProfileKeyRef = useRef(null); const sceneSettingsRef = useRef(defaultSceneSettings); const displayDraftRef = useRef(defaultSceneSettings); const confirmedSceneSettingsRef = useRef(defaultSceneSettings); const viewerSettingsCommitTimerRef = useRef(null); const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings); const livePerceptionLayersRef = useRef(defaultLivePerceptionLayers); const livePerceptionRevisionRef = useRef(0); const replayActiveRef = useRef(false); const selectedRecordedSessionIdRef = useRef(null); const sceneSettingsCommitterActiveRef = useRef(true); const sceneSettingsCommitterRef = useRef | null>(null); const polygonDatasetRouteOpenedRef = useRef(false); const currentRoot = rootById(activeRoot); const visibleRoots = roots; const activeEnvironmentSurface = activeRoot ?? "home"; const landingPage = environment.settings.pages[activeEnvironmentSurface]; const landingQuickActions = [ landingPage.primaryWorkspaceId, landingPage.secondaryWorkspaceId, ].flatMap((workspaceId) => { const definition = workspaceById(workspaceId); if (!definition) return []; if (activeRoot !== null && definition.root !== activeRoot) return []; return [definition]; }); const activeDefinition = workspaceById(workspace.activeView); const sceneWorkspaceActive = Boolean( workspace.contentOpen && activeDefinition && ["spatial", "recordings"].includes(activeDefinition.kind), ); const archiveWorkspaceActive = Boolean( activeDefinition && ["recordings", "lab-archive"].includes(activeDefinition.kind), ); const rootWorkspaces = workspacesForRoot(activeRoot); const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null; const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? ""; const effectiveSourceUrl = archiveWorkspaceActive ? replayTransitioning ? "" : sourceUrl : automaticSourceUrl; const replayActive = Boolean(recordedReplay && sourceUrl === recordedReplay.sourceUrl); const replayPresented = archiveWorkspaceActive && replayActive; const recordedSessionAdmission = useRecordedSessionAdmission( replayPresented ? recordedReplay : null, ); runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings; replayActiveRef.current = replayPresented; useEffect(() => { if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return; polygonDatasetRouteOpenedRef.current = true; workspace.openView("datasets"); }, [polygonDatasetRoute.active, workspace]); if (!sceneSettingsCommitterRef.current) { sceneSettingsCommitterRef.current = createLatestAsyncCommitter({ commit: (settings) => replayActiveRef.current ? Promise.resolve(true) : runtimeUpdateViewerSettingsRef.current( toViewerSettings(settings, livePerceptionLayersRef.current), ), onSettled: ({ value, applied, superseded }) => { if (!sceneSettingsCommitterActiveRef.current) return; if (applied) confirmedSceneSettingsRef.current = value; if (superseded) return; const next = applied ? value : confirmedSceneSettingsRef.current; sceneSettingsRef.current = next; setSceneSettings(next); if (!applied && displayDraftRef.current === value) { displayDraftRef.current = next; setDisplayDraft(next); } }, }); } const replaySources = useMemo( () => recordedObservationSources(recordedReplay), [recordedReplay], ); const activeObservationSources = replayPresented ? replaySources : runtime.state?.observationSources ?? []; const activeRuntimeState = useMemo(() => { if (!replayPresented || !recordedReplay) return runtime.state; return { ...(runtime.state ?? { phase: "replaying" as const }), phase: "replaying" as const, sourceMode: "replay" as const, spatialSource: { id: "recorded.spatial.primary", url: recordedReplay.sourceUrl, label: "Сохранённая пространственная сцена", kind: "rrd" as const, }, observationSources: replaySources, observationTimeline: { mode: "recorded" as const, seekable: true, sessionRecording: true, synchronization: "host-arrival-best-effort" as const, range: { startSeconds: recordedReplay.timelineStartSeconds, endSeconds: recordedReplay.timelineEndSeconds, }, }, }; }, [recordedReplay, replayPresented, replaySources, runtime.state]); const viewerSettingsTarget = viewerSettingsTargetIdentity(runtime.state); const observationLayout = useObservationLayout( activeObservationSources, replayPresented ? undefined : runtime.setObservationSourceActive, ); const workspaceLayoutProfile = useWorkspaceLayoutProfile(); const focusedObservationSource = activeObservationSources.find( (source) => source.id === observationLayout.focusedSourceId, ); const observationFullscreenActive = Boolean( activeDefinition?.kind === "spatial" ? observationLayout.maximizedFloatingSourceId || focusedObservationSource?.modality === "point-cloud" : activeDefinition?.kind === "lab-archive" ? observationLayout.maximizedFloatingSourceId || focusedObservationSource?.modality === "point-cloud" : activeDefinition?.kind === "cameras" && focusedObservationSource, ); useEffect(() => { const remote = runtime.state?.viewerSettings; if ( !remote || workspaceLayoutProfile.profile || sceneSettingsCommitterRef.current?.isBusy() ) return; const merged = mergeViewerSettings(sceneSettingsRef.current, remote); const remoteLayers = { detections2d: remote.show_detections_2d ?? false, segmentation: remote.show_segmentation ?? false, cuboids3d: remote.show_cuboids_3d ?? false, }; livePerceptionLayersRef.current = remoteLayers; setLivePerceptionLayers(remoteLayers); sceneSettingsRef.current = merged; confirmedSceneSettingsRef.current = merged; setSceneSettings(merged); if (viewerSettingsCommitTimerRef.current === null) { displayDraftRef.current = merged; setDisplayDraft(merged); } }, [runtime.state?.viewerSettings, workspaceLayoutProfile.profile]); useEffect(() => { const profile = workspaceLayoutProfile.profile; if (!profile) return; if (viewerSettingsCommitTimerRef.current !== null) { window.clearTimeout(viewerSettingsCommitTimerRef.current); viewerSettingsCommitTimerRef.current = null; } sceneSettingsRef.current = profile.sceneSettings; displayDraftRef.current = profile.sceneSettings; confirmedSceneSettingsRef.current = profile.sceneSettings; setSceneSettings(profile.sceneSettings); setDisplayDraft(profile.sceneSettings); observationLayout.restore(profile); }, [observationLayout.restore, workspaceLayoutProfile.profile]); useEffect(() => { if (sceneWorkspaceActive) return; // Scene tools are transient children of the spatial workspace. They must // never survive a route/root/content close or appear over the landing page. setSourceWindowOpen(false); setDisplayWindowOpen(false); setLayerInspectorOpen(false); setSceneWindowOrder([]); }, [sceneWorkspaceActive]); useEffect(() => { sceneSettingsCommitterActiveRef.current = true; return () => { sceneSettingsCommitterActiveRef.current = false; if (viewerSettingsCommitTimerRef.current !== null) { window.clearTimeout(viewerSettingsCommitTimerRef.current); viewerSettingsCommitTimerRef.current = null; } }; }, []); useEffect(() => { const profile = workspaceLayoutProfile.profile; const applicationKey = profile ? `${profile.revision}:${viewerSettingsTarget}` : null; if ( !profile || runtime.backendStatus !== "online" || !applicationKey || appliedProfileKeyRef.current === applicationKey ) { return; } appliedProfileKeyRef.current = applicationKey; void runtime.updateViewerSettings( toViewerSettings(profile.sceneSettings, livePerceptionLayersRef.current), ).then((applied) => { if (!applied && appliedProfileKeyRef.current === applicationKey) { appliedProfileKeyRef.current = null; } }); }, [ runtime.backendStatus, runtime.updateViewerSettings, viewerSettingsTarget, workspaceLayoutProfile.profile, ]); const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => { setSceneWindowOrder((current) => [ ...current.filter((candidate) => candidate !== windowId), windowId, ]); }, []); const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => { if (windowId === "sources") setSourceWindowOpen(false); if (windowId === "display") setDisplayWindowOpen(false); if (windowId === "layers") setLayerInspectorOpen(false); setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId)); }, []); useEffect(() => { if (!sceneWorkspaceActive || !activeSceneWindow) return; const closeActiveWindow = (event: KeyboardEvent) => { if (event.key !== "Escape" || event.defaultPrevented) return; if (document.querySelector(".nodedc-dropdown-surface")) return; if (document.querySelector('.nodedc-overlay[data-placement="center"]')) return; event.preventDefault(); closeSceneWindow(activeSceneWindow); }; document.addEventListener("keydown", closeActiveWindow); return () => document.removeEventListener("keydown", closeActiveWindow); }, [activeSceneWindow, closeSceneWindow, sceneWorkspaceActive]); const selectRoot = (rootId: RootId) => { setActiveRoot(rootId); workspace.openNavigation(); if (rootId === "system") { workspace.openView( activeDefinition?.root === "system" ? activeDefinition.id : "modules", ); } else { workspace.closeView(); } }; const openView = (viewId: string, profile?: WorkspaceLaunchProfile) => { const definition = workspaceById(viewId); if (!definition) return; setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile)); setActiveRoot(definition.root); workspace.openView(viewId); }; const openSource = () => { if (!sceneWorkspaceActive) return; setSourceDraft(sourceUrl); setSourceWindowOpen(true); activateSceneWindow("sources"); }; const commitDisplaySettings = useCallback((next: SceneSettings) => { if (viewerSettingsCommitTimerRef.current !== null) { window.clearTimeout(viewerSettingsCommitTimerRef.current); viewerSettingsCommitTimerRef.current = null; } displayDraftRef.current = next; setDisplayDraft(next); sceneSettingsCommitterRef.current?.enqueue(next); }, []); const stageDisplayPatch = useCallback((patch: Partial) => { const next = { ...displayDraftRef.current, ...patch }; displayDraftRef.current = next; setDisplayDraft(next); if (viewerSettingsCommitTimerRef.current !== null) { window.clearTimeout(viewerSettingsCommitTimerRef.current); } viewerSettingsCommitTimerRef.current = window.setTimeout(() => { viewerSettingsCommitTimerRef.current = null; sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current); }, viewerSettingsQuietPeriodMs); }, []); const flushDisplaySettings = useCallback(() => { if (viewerSettingsCommitTimerRef.current === null) return; window.clearTimeout(viewerSettingsCommitTimerRef.current); viewerSettingsCommitTimerRef.current = null; sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current); }, []); const commitDisplayPatch = useCallback((patch: Partial) => { commitDisplaySettings({ ...displayDraftRef.current, ...patch }); }, [commitDisplaySettings]); const openDisplay = () => { if (!sceneWorkspaceActive) return; setDisplayWindowOpen(true); activateSceneWindow("display"); }; const openLayers = () => { if (!sceneWorkspaceActive) return; setLayerInspectorOpen(true); activateSceneWindow("layers"); }; const changeLivePerceptionLayers = useCallback((next: LivePerceptionLayers) => { const previous = livePerceptionLayersRef.current; const revision = ++livePerceptionRevisionRef.current; livePerceptionLayersRef.current = next; setLivePerceptionLayers(next); if (replayActiveRef.current) return; void runtimeUpdateViewerSettingsRef.current( toViewerSettings(sceneSettingsRef.current, next), ).then((applied) => { if (applied || livePerceptionRevisionRef.current !== revision) return; livePerceptionLayersRef.current = previous; setLivePerceptionLayers(previous); }); }, []); const beginRecordedReplaySwitch = useCallback(async () => { if (sourceSwitchBlockedRef.current) { throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON); } // useObservationSessions calls this only after backend preparation has // produced a validated launch descriptor. Keep the old scene mounted // before this point; now perform one controlled receiver teardown before // accepting the already-ready archive. selectedRecordedSessionIdRef.current = null; setReplayTransitioning(true); setRecordedReplay(null); setRecordedReplayLabel(null); setSourceUrl(""); setSourceDraft(""); await new Promise((resolve) => { window.requestAnimationFrame(() => window.setTimeout(resolve, 0)); }); }, []); const acceptRecordedReplay = useCallback(( session: ObservationSessionSummary, launch: ObservationSessionReplayLaunch, ) => { if (sourceSwitchBlockedRef.current) { throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON); } selectedRecordedSessionIdRef.current = session.id; setRecordedReplay(launch); setRecordedReplayLabel(session.label); setSourceUrl(launch.sourceUrl); setSourceDraft(launch.sourceUrl); setReplayTransitioning(false); }, []); const settleRecordedReplaySwitch = useCallback((outcome: "accepted" | "error" | "cancelled") => { if (outcome !== "accepted") setReplayTransitioning(false); }, []); const releaseRecordedReplayForDelete = useCallback(async (sessionId: string) => { if (selectedRecordedSessionIdRef.current !== sessionId) return; // Deleting the selected archive is a source transition. Unmount Rerun and // recorded media first so no admission request can outlive the evidence. selectedRecordedSessionIdRef.current = null; setReplayTransitioning(true); setRecordedReplay(null); setRecordedReplayLabel(null); setSourceUrl(""); setSourceDraft(""); await new Promise((resolve) => { window.requestAnimationFrame(() => window.setTimeout(resolve, 0)); }); if (selectedRecordedSessionIdRef.current === null) setReplayTransitioning(false); }, []); const activateAutomaticSpatialSource = useCallback(() => { // A plugin-owned live/file-replay start must release any host-selected // archive or manual URL before the new acquisition becomes non-terminal. // This transition is an internal start boundary, not an operator source // switch, so it intentionally does not consult the acquisition guard. selectedRecordedSessionIdRef.current = null; setReplayTransitioning(false); setRecordedReplay(null); setRecordedReplayLabel(null); setSourceUrl(""); setSourceDraft(""); observationLayout.activateAutomaticDefaults(); }, [observationLayout.activateAutomaticDefaults]); useEffect(() => { if (!["recordings", "lab-archive"].includes(activeDefinition?.kind ?? "")) { setReplayTransitioning(false); } }, [activeDefinition?.kind]); const applyScenePatch = (patch: Partial) => commitDisplayPatch(patch); const saveWorkspaceLayout = useCallback(async () => { flushDisplaySettings(); await sceneSettingsCommitterRef.current?.waitForIdle(); const layout = observationLayout.snapshot(); if (!layout) { setLayoutSaveNotice("Сцена ещё не измерила рабочую область."); return; } const draft: ObservationWorkspaceLayoutProfile = { version: OBSERVATION_WORKSPACE_LAYOUT_VERSION, revision: workspaceLayoutProfile.profile?.revision ?? 0, workspaceId: OBSERVATION_WORKSPACE_ID, sceneSettings: sceneSettingsRef.current, ...layout, }; const saved = await workspaceLayoutProfile.save(draft); setLayoutSaveNotice(saved ? "Компоновка сохранена" : workspaceLayoutProfile.error); }, [ flushDisplaySettings, observationLayout.snapshot, workspaceLayoutProfile, ]); useEffect(() => { if (!layoutSaveNotice || layoutSaveNotice !== "Компоновка сохранена") return; const timer = window.setTimeout(() => setLayoutSaveNotice(null), 2600); return () => window.clearTimeout(timer); }, [layoutSaveNotice]); const [fleetCreateRequest, setFleetCreateRequest] = useState(0); const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings"); const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []); const contentActions = useApplicationPanelActions({ onAddVehicle, definition: activeDefinition, refreshRuntime: runtime.refresh, resetConnectionScenario: runtime.resetConnectionScenario, connectionScenarioResetting: runtime.pendingAction === "mode", saveWorkspaceLayout, workspaceLayoutSaving: workspaceLayoutProfile.state === "saving", systemUtilityActions: computeContourSettings.utilityActions, sessionOverview: { ...sessionOverview, available: Boolean(recordedReplay && replayPresented && !sourceSwitchBlocked) }, }); const header = ( } brandHref="/" brandLabel="NODEDC MISSION CORE" center={ ({ value: root.id, label: environment.settings.pages[root.id].headerLabel, }))} onChange={selectRoot} /> } right={ void runtime.refresh(), }, { id: "settings", label: "Настройки", icon: "settings", onSelect: () => setEnvironmentSettingsOpen(true), }, ]} /> } /> ); return ( <> } navigation={currentRoot && activeRoot === "system" ? ( ) : currentRoot ? ( , active: runtime.backendStatus !== "offline" && runtime.backendStatus !== "unconfigured", }, ]} items={rootWorkspaces.map((item) => ({ id: item.id, label: item.label, icon: , }))} activeId={workspace.activeView ?? undefined} onItemChange={openView} footer={ <> {rootWorkspaces.length}{" "} {rootWorkspaces.length === 1 ? "рабочая поверхность" : "рабочих поверхностей"} } /> ) : null} content={activeDefinition ? ( {localConnectionPhaseLabel(runtime.state?.phase)} ) : activeDefinition.kind === "spatial" ? (
{runtime.state?.sourceMode === "live" ? "Эфир" : runtime.state?.sourceMode === "replay" ? "Повтор записи" : "Ожидание эфира"} {layoutSaveNotice || workspaceLayoutProfile.error ? (