From c804d89b181480b186bdee3e591d0d47adc5a49b Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 21 Sep 2026 09:19:14 +0300 Subject: [PATCH] fix(lab): separate direct device launches from planning profiles --- apps/control-station/src/App.tsx | 22 +++- .../missions/PlanningCaptureGuard.tsx | 23 ++++ .../missions/PlanningConnectionWindow.tsx | 6 +- .../src/core/missions/PlanningTestContext.tsx | 21 ++-- .../src/core/observation/workspaceLaunch.ts | 10 ++ .../src/workspaces/DeviceWorkspace.tsx | 7 +- .../src/workspaces/Workspaces.tsx | 4 +- .../src/workspaces/contracts.ts | 4 +- .../missions/MissionPlannerWorkspace.tsx | 6 +- .../workspaces/spatial/SpatialWorkspace.tsx | 3 +- .../test/workspaceLaunch.test.mjs | 106 ++++++++++++++++++ docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md | 39 +++++++ 12 files changed, 222 insertions(+), 29 deletions(-) create mode 100644 apps/control-station/src/components/missions/PlanningCaptureGuard.tsx create mode 100644 apps/control-station/src/core/observation/workspaceLaunch.ts create mode 100644 apps/control-station/test/workspaceLaunch.test.mjs diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index b75b7cf..dfd639c 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -69,6 +69,9 @@ import { 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"; @@ -151,6 +154,7 @@ export default function App() { 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); @@ -431,9 +435,10 @@ export default function App() { } }; - const openView = (viewId: string) => { + 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); }; @@ -806,15 +811,25 @@ export default function App() { utilityActions={contentActions} onClose={workspace.closeView} > + openView('local-device', 'planning')} + > {activeDefinition.kind === "device" ? ( - openView("spatial-scene")} + launchProfile === 'planning' ? + openView("spatial-scene", 'planning')} + onActivateAutomaticSpatialSource={activateAutomaticSpatialSource} + /> + : openView("spatial-scene", 'direct')} onActivateAutomaticSpatialSource={activateAutomaticSpatialSource} /> ) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? ( ) : ( )} + ) : null} /> diff --git a/apps/control-station/src/components/missions/PlanningCaptureGuard.tsx b/apps/control-station/src/components/missions/PlanningCaptureGuard.tsx new file mode 100644 index 0000000..5350a3d --- /dev/null +++ b/apps/control-station/src/components/missions/PlanningCaptureGuard.tsx @@ -0,0 +1,23 @@ +import type {ReactNode} from 'react'; +import {Button, StatusBadge} from '@nodedc/ui-react'; +import {planningAwaitsCapture, usePlanningTest} from '../../core/missions/PlanningTestContext'; + +/** Composition-level handoff; never sends a device or acquisition command. */ +export function PlanningCaptureGuard({enabled, onResume, children}: { + enabled: boolean; + onResume: () => void; + children: ReactNode; +}) { + const planning = usePlanningTest(); + if (!enabled || !planningAwaitsCapture(planning.test)) return <>{children}; + return
+ Исследование ожидает новую запись +

{planning.test!.draft.name}

+

Для самостоятельной съёмки завершите подготовленное исследование. Запись и подключение сканера не будут остановлены.

+ {planning.error &&

{planning.error}

} +
+ + +
+
; +} diff --git a/apps/control-station/src/components/missions/PlanningConnectionWindow.tsx b/apps/control-station/src/components/missions/PlanningConnectionWindow.tsx index 1cda799..9146d25 100644 --- a/apps/control-station/src/components/missions/PlanningConnectionWindow.tsx +++ b/apps/control-station/src/components/missions/PlanningConnectionWindow.tsx @@ -8,11 +8,11 @@ export function PlanningConnectionWindow({children}:{children:ReactNode}){ const p=usePlanningTest(); const [open,setOpen]=useState(true); const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost(); useEffect(()=>{ - if(!p.selected||!p.test||selection||selectionTransitionPending)return; + if(!p.test||planningTestTerminal(p.test)||selection||selectionTransitionPending)return; const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id); if(models.length===1)void selectModel(models[0].model.id); - },[p.selected,p.test?.plugin_id,selection,registry,selectModel,selectionTransitionPending]); - if(!p.selected||!p.test)return <>{children}; + },[p.test?.plugin_id,p.test?.state,selection,registry,selectModel,selectionTransitionPending]); + if(!p.test)return <>{children}; const t=p.test, terminal=planningTestTerminal(t); const details=<>Профиль · Планирование

{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м

diff --git a/apps/control-station/src/core/missions/PlanningTestContext.tsx b/apps/control-station/src/core/missions/PlanningTestContext.tsx index bcc25a9..3e7f1be 100644 --- a/apps/control-station/src/core/missions/PlanningTestContext.tsx +++ b/apps/control-station/src/core/missions/PlanningTestContext.tsx @@ -18,14 +18,13 @@ export interface PlanningRunSummary {id:string;name:string;state:string;query_se const endpoint = plannerBase+'/live-tests'; const terminal = new Set(['completed','cancelled','error','interrupted']); const Context = createContext<{ - test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise; selected: boolean; busy:boolean; error:string|null; - begin:(draft:Draft)=>Promise; finish:()=>Promise; retryInitialization:()=>Promise; resume:()=>void; + test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise; busy:boolean; error:string|null; + begin:(draft:Draft)=>Promise; finish:()=>Promise; retryInitialization:()=>Promise; }|null>(null); -/** Server owns the run; navigation and reload retain its frozen reference. */ +/** Server owns the run and its frozen reference, never the workspace launch profile. */ export function PlanningTestProvider({children}:{children:ReactNode}) { const [test,setTest]=useState(null); - const [dismissed,setDismissed]=useState(null); const [busy,setBusy]=useState(false),[error,setError]=useState(null); const [pollError,setPollError]=useState(null); const [history,setHistory]=useState([]); @@ -33,8 +32,8 @@ export function PlanningTestProvider({children}:{children:ReactNode}) { useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]); const select=useCallback(async(id:string)=>{ generation.current+=1;setBusy(true);setError(null); - try{setTest(await plannerRequest(endpoint+'/'+id+'/select',{method:'POST'}));setDismissed(null);} - catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');} + try{setTest(await plannerRequest(endpoint+'/'+id+'/select',{method:'POST'}));return true;} + catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;} finally{generation.current+=1;setBusy(false);} },[]); useEffect(()=>{ @@ -48,7 +47,7 @@ export function PlanningTestProvider({children}:{children:ReactNode}) { },[]); const begin=useCallback(async(draft:Draft)=>{ generation.current+=1;setBusy(true);setError(null); - try {const next=await plannerRequest(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);setDismissed(null);sessionStorage.removeItem('planning-dismissed');return next;} + try {const next=await plannerRequest(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);return next;} catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;} finally{generation.current+=1;setBusy(false);} },[]); @@ -64,8 +63,12 @@ export function PlanningTestProvider({children}:{children:ReactNode}) { catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');} finally{generation.current+=1;setBusy(false);} },[test]); - const resume=useCallback(()=>{setDismissed(null);sessionStorage.removeItem('planning-dismissed');},[]); - return {children}; + return {children}; } export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;} export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);} + +/** A prepared consumer owns the next capture until explicitly finished or used. */ +export function planningAwaitsCapture(test:PlanningLiveTest|null){ + return !!test&&!planningTestTerminal(test)&&!test.query_session_id; +} diff --git a/apps/control-station/src/core/observation/workspaceLaunch.ts b/apps/control-station/src/core/observation/workspaceLaunch.ts new file mode 100644 index 0000000..78bfdf5 --- /dev/null +++ b/apps/control-station/src/core/observation/workspaceLaunch.ts @@ -0,0 +1,10 @@ +/** Entry intent belongs to application navigation, not a retained server job. */ +export type WorkspaceLaunchProfile = 'direct' | 'planning'; + +export function workspaceLaunchProfile( + current: WorkspaceLaunchProfile, + kind: string, + requested: WorkspaceLaunchProfile = 'direct', +): WorkspaceLaunchProfile { + return kind === 'device' || kind === 'spatial' ? requested : current; +} diff --git a/apps/control-station/src/workspaces/DeviceWorkspace.tsx b/apps/control-station/src/workspaces/DeviceWorkspace.tsx index 22a33e5..bc8c57b 100644 --- a/apps/control-station/src/workspaces/DeviceWorkspace.tsx +++ b/apps/control-station/src/workspaces/DeviceWorkspace.tsx @@ -1,4 +1,3 @@ -import { PlanningConnectionWindow } from "../components/missions/PlanningConnectionWindow"; import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react"; import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost"; @@ -41,7 +40,7 @@ function ModelCard({ ); } -function DeviceConnectionBody({ +export function DeviceWorkspace({ onOpenSpatialScene, onActivateAutomaticSpatialSource, }: { @@ -129,7 +128,3 @@ function DeviceConnectionBody({ ); } - -export function DeviceWorkspace(props: {onOpenSpatialScene:()=>void;onActivateAutomaticSpatialSource:()=>void}) { - return ; -} diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 73fdaf3..8733927 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -1,5 +1,4 @@ import {SpatialWorkspace} from "./spatial/SpatialWorkspace"; -import { usePlanningTest } from "../core/missions/PlanningTestContext"; import { PlanningSpatialWorkspace } from "./missions/PlanningSpatialWorkspace"; import { MissionPlannerWorkspace } from "./missions/MissionPlannerWorkspace"; import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace"; @@ -416,10 +415,9 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) { } export function WorkspaceRenderer(props: WorkspaceRendererProps) { - const planning = usePlanningTest(); switch (props.definition.kind) { case "spatial": - return planning.selected ? : ; + return props.launchProfile === 'planning' ? : ; case "recordings": return ; case "cameras": diff --git a/apps/control-station/src/workspaces/contracts.ts b/apps/control-station/src/workspaces/contracts.ts index 56a53d5..01882df 100644 --- a/apps/control-station/src/workspaces/contracts.ts +++ b/apps/control-station/src/workspaces/contracts.ts @@ -14,9 +14,10 @@ import type { } from "../core/runtime/contracts"; import type { WorkspaceDefinition } from "../productModel"; import type { SceneSettings } from "../sceneSettings"; +import type { WorkspaceLaunchProfile } from "../core/observation/workspaceLaunch"; export interface WorkspaceNavigation { - openView: (viewId: string) => void; + openView: (viewId: string, profile?: WorkspaceLaunchProfile) => void; openSource: () => void; openDisplay: () => void; openLayers: () => void; @@ -35,6 +36,7 @@ export interface LaboratoryViewAction { } export interface WorkspaceRendererProps { + launchProfile?: WorkspaceLaunchProfile; fleetCreateRequest?: number; headerToolsHost?: HTMLElement | null; definition: WorkspaceDefinition; diff --git a/apps/control-station/src/workspaces/missions/MissionPlannerWorkspace.tsx b/apps/control-station/src/workspaces/missions/MissionPlannerWorkspace.tsx index 87f764c..921729a 100644 --- a/apps/control-station/src/workspaces/missions/MissionPlannerWorkspace.tsx +++ b/apps/control-station/src/workspaces/missions/MissionPlannerWorkspace.tsx @@ -15,7 +15,7 @@ import { PlanningProjectSelect } from '../../components/missions/PlanningProject import '../../styles/session-overview.css'; import '../../styles/mission-planner.css'; -export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string)=>void;headerToolsHost?:HTMLElement|null}) { +export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string,profile?:'planning')=>void;headerToolsHost?:HTMLElement|null}) { const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects(); const t=useRegistrationTest(p.saved); const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false); @@ -49,7 +49,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id if(!draft)return; if(mode==='scanner') { const next=await live.begin(draft); - if(next){projects.select('live:'+next.id);openView('local-device');} + if(next){projects.select('live:'+next.id);openView('local-device','planning');} } else { const result=await t.run(draft); if(result){setCreating(false);projects.select('recorded:'+result.id);setSettingsOpen(true);} @@ -98,7 +98,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id className="planning-project__inspector"> {editing?void start()} starting={starting}/> :project?:

Выберите проект или создайте новый кнопкой «+».

} - {project?.kind==='live'&&planningProjectPending(project)&&} + {project?.kind==='live'&&planningProjectPending(project)&&} {live.error&&editing&&

{live.error}

} } diff --git a/apps/control-station/src/workspaces/spatial/SpatialWorkspace.tsx b/apps/control-station/src/workspaces/spatial/SpatialWorkspace.tsx index fec874a..e0fc5a3 100644 --- a/apps/control-station/src/workspaces/spatial/SpatialWorkspace.tsx +++ b/apps/control-station/src/workspaces/spatial/SpatialWorkspace.tsx @@ -39,6 +39,7 @@ export interface SpatialWorkspaceProfile { activity?: import("../../core/device-plugins/contracts").SpatialActivityPresentation; } export function SpatialWorkspace({ + launchProfile = 'direct', state, sourceUrl, requestedPlaybackSeconds, @@ -441,7 +442,7 @@ export function SpatialWorkspace({ model={spatialControls.model} spatialActivity={visualProfile?.activity} host={{ - openSpatialScene: () => navigation.openView("spatial-scene"), + openSpatialScene: () => navigation.openView("spatial-scene", launchProfile), activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource, }} />):null} diff --git a/apps/control-station/test/workspaceLaunch.test.mjs b/apps/control-station/test/workspaceLaunch.test.mjs new file mode 100644 index 0000000..80504be --- /dev/null +++ b/apps/control-station/test/workspaceLaunch.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import React, {createElement} from 'react'; +import {renderToStaticMarkup} from 'react-dom/server'; +import {readFileSync} from 'node:fs'; +import {before, after, test} from 'node:test'; +import {createServer} from 'vite'; + +let server, resolve, awaitsCapture, Guard, Provider, Device, Host; +before(async () => { + server = await createServer({appType:'custom', logLevel:'silent', server:{middlewareMode:true}}); + ({workspaceLaunchProfile:resolve} = await server.ssrLoadModule('/src/core/observation/workspaceLaunch.ts')); + ({planningAwaitsCapture:awaitsCapture, PlanningTestProvider:Provider} = await server.ssrLoadModule('/src/core/missions/PlanningTestContext.tsx')); + ({PlanningCaptureGuard:Guard} = await server.ssrLoadModule('/src/components/missions/PlanningCaptureGuard.tsx')); + ({DeviceWorkspace:Device} = await server.ssrLoadModule('/src/workspaces/DeviceWorkspace.tsx')); + ({DevicePluginHostProvider:Host} = await server.ssrLoadModule('/src/core/device-plugins/DevicePluginHost.tsx')); +}); +after(async () => { await server?.close(); }); + +test('direct entry overrides an earlier planning profile for both shared surfaces', () => { + for (const kind of ['device','spatial']) { + assert.equal(resolve('planning',kind),'direct'); + assert.equal(resolve('direct',kind),'direct'); + assert.equal(resolve('direct',kind,'planning'),'planning'); + assert.equal(resolve('planning',kind,'planning'),'planning'); + } + assert.equal(resolve('planning','missions'),'planning'); + assert.equal(resolve('direct','missions'),'direct'); +}); + +test('a prepared consumer cannot silently claim a direct capture; old/bound runs do not block it', () => { + assert.equal(awaitsCapture(null),false); + for (const state of ['completed','cancelled','interrupted','error']) { + for (const query_session_id of [null,'recording']) assert.equal(awaitsCapture({state,query_session_id}),false); + } + for (const state of ['preparing','waiting','running']) { + assert.equal(awaitsCapture({state,query_session_id:null}),true); + assert.equal(awaitsCapture({state,query_session_id:'recording'}),false); + } +}); + +function withHooks(hooks, fn) { + // Same bounded pre-effect harness used by observatoryHooks.test.mjs. + const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE; + const previous = internals.H; + internals.H = hooks; + try { return fn(); } finally { internals.H = previous; } +} + +test('retained completed run renders the ordinary connection, without issuing any commands', () => { + let calls = 0; + const child = createElement('div',null,'ORDINARY DEVICE'); + const tree = withHooks({useContext:()=>({test:{state:'completed',query_session_id:'old'},finish:()=>{calls++;}})}, + () => Guard({enabled:true,onResume:()=>{calls++;},children:child})); + assert.match(renderToStaticMarkup(tree),/ORDINARY DEVICE/); + assert.equal(calls,0); +}); + +test('pending run handoff is explicit and only finishes the research, not the scanner', () => { + let stopped = 0, resumed = 0; + const context = {test:{state:'waiting',query_session_id:null,draft:{name:'PENDING'}},busy:false,error:null,finish:()=>{stopped++;}}; + const child = createElement('div',null,'ORDINARY DEVICE'); + const tree = withHooks({useContext:()=>context}, () => Guard({enabled:true,onResume:()=>{resumed++;},children:child})); + assert.doesNotMatch(renderToStaticMarkup(tree),/ORDINARY DEVICE/); + assert.equal(stopped,0); + const actions = tree.props.children.at(-1).props.children; + actions[0].props.onClick(); assert.equal(stopped,1); assert.equal(resumed,0); + actions[1].props.onClick(); assert.equal(resumed,1); + const planningTree = withHooks({useContext:()=>context}, () => Guard({enabled:false,onResume:()=>{},children:child})); + assert.match(renderToStaticMarkup(planningTree),/ORDINARY DEVICE/); +}); + +test('generic device catalog renders without any PlanningTestProvider', () => { + const html = renderToStaticMarkup(createElement(Host,{plugins:[]}, + createElement(Device,{onOpenSpatialScene:()=>{},onActivateAutomaticSpatialSource:()=>{}}))); + assert.match(html,/Выберите модель устройства/); + assert.doesNotMatch(html,/Профиль · Планирование|Подключение сканера · Планирование/); +}); + +test('failed planner selection does not grant a successful launch', async () => { + const writes = []; + const context = withHooks({ + useState:initial=>[typeof initial==='function'?initial():initial,value=>writes.push(value)], + useRef:value=>({current:value}),useCallback:fn=>fn,useEffect:()=>{}, + }, () => Provider({children:null}).props.value); + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = async () => new Response(JSON.stringify({detail:'Выбранный проход недоступен'}),{status:409}); + assert.equal(await context.select('missing'),false); + assert.ok(writes.includes('Выбранный проход недоступен')); + assert.equal('selected' in context,false); + assert.equal('resume' in context,false); + } finally { globalThis.fetch = originalFetch; } +}); + +test('composition owns explicit profile propagation; server polling cannot restore it', () => { + const source = path => readFileSync(new URL('../src/'+path,import.meta.url),'utf8'); + assert.doesNotMatch(source('workspaces/DeviceWorkspace.tsx'),/Planning|missions\//); + assert.doesNotMatch(source('core/missions/PlanningTestContext.tsx'),/dismissed|sessionStorage|launchProfile/); + assert.match(source('App.tsx'),/useState\('direct'\)/); + assert.match(source('App.tsx'),/openView\("spatial-scene", 'planning'\)/); + assert.match(source('App.tsx'),/openView\("spatial-scene", 'direct'\)/); + assert.match(source('workspaces/spatial/SpatialWorkspace.tsx'),/openView\("spatial-scene", launchProfile\)/); + assert.match(source('workspaces/Workspaces.tsx'),/props\.launchProfile === 'planning'/); + assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/openView\('local-device','planning'\)/); + assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/if\(await live.select\(project.id\)\)openView/); +}); diff --git a/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md b/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md index 25df9d6..8a48da3 100644 --- a/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md +++ b/docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md @@ -113,6 +113,45 @@ Owns shell-level orchestration: selected root/workspace, global panels, runtime providers, and passing typed controllers to a workspace. It must not absorb domain API calls, per-LAB renderers, or new visual primitives. +## Shared LAB launch profiles — 2026-09-21 + +Test devices and Spatial scene are shared infrastructure, not children of the +planner. `DeviceWorkspace` imports only the generic device host and its UI +contracts; it must render without a `PlanningTestProvider`. + +- Direct navigation to either surface selects the `direct` launch profile. + A page reload also starts with `direct`, even if the server retains a completed + or interrupted planning run. Model selection remains the device host's state. +- A successful planner start or explicit reopen passes `planning` through + `WorkspaceNavigation.openView`. The shell composes `PlanningConnectionWindow` + only for that entry. Device connection and spatial-control callbacks preserve + the originating profile when opening the scene. +- `PlanningTestProvider` owns run data and polling, not navigation selection. + Reading `/live-tests/active` must never change the launch profile. An unsuccessful + explicit run selection must not open a stale run's connection or scene. +- A nonterminal planning consumer that has not bound a query session still waits + for the next capture. The composition-level `PlanningCaptureGuard` requires an + explicit return to that study or completion of the study before direct capture; + it does not send scanner, network, or recording commands. It waits for a terminal + server state, not merely acknowledgment of the stop request. Completed runs and + runs already bound to a recording do not claim a later direct launch. + +Changing presentation does not delete evidence, end acquisition, or restart an +experiment. The planner continues to consume an explicitly started recording; +ordinary acquisition requires neither a reference route nor a planner draft. + +Regression coverage: `test/workspaceLaunch.test.mjs`, including independent model +catalog rendering, explicit handoff, terminal-run isolation, failed selection, +and both shared-scene entry paths. + +Acceptance on the canonical operator service `127.0.0.1:8000`: architecture +checks, TypeScript, all 882 frontend tests and production build passed. In-app +browser QA confirmed direct entry with retained completed evidence, catalog → +XGRIDS connection, the unchanged connection-method selector and Escape, +normal/expanded layouts, ordinary scene entry, and planner → direct-device +navigation. No BLE discovery, provisioning, acquisition or new field run was +performed during this UI acceptance. + ## CSS ownership CSS follows the same feature boundary: