From 0325805eb0cbe6660013c078232916ff4620c41c Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 13 Nov 2026 17:26:34 +0000 Subject: [PATCH 1/5] Collapse the CCTV panel when the layer is switched off The panel opened itself when a camera became active, but nothing closed it when the layer went off: it stayed expanded, empty, and its meta line still described the last camera. The collapse runs only on the on-to-off change, so a panel opened by hand while the layer is off stays open, and a context-mode layer clear keeps the panel as Radio does. --- src/ui/applicationShell.js | 2 + src/ui/cctvControls.js | 1 + src/ui/cctvPanel.test.mjs | 78 ++++++++++++++++++++++++++++++++++++++ src/ui/cctvPresentation.js | 17 ++++++++- 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index b6d77b912..f5df3c51a 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1197,6 +1197,8 @@ export class StyleManager extends ShellFacade { toggleEnabled: (...args) => this._toggleCctvEnabled(...args), runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args), setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), + preservePanelStateDuringClear: () => + this._preservePanelStateDuringLayerClear, showToast: (message) => this._showToast(message), syncViewport: () => this._syncCctvPanelViewport(), }, diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js index 4e8911695..1e227c92e 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -54,6 +54,7 @@ export class CctvControls { this._cctvSummaryTypingTimer = null; this._lastCctvSummaryText = ''; this._lastSeenCctvActiveId = null; + this._wasCctvEnabled = false; this._cctvChipHideTimer = null; this._cctvChipWasBusy = false; // The camera grid chip as last published; its label stays while hidden. diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index a3dbef786..65c5c724d 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -513,3 +513,81 @@ test('the panel meta line reads a bearing that rounds to north as HDG 0°', () = assert.match(meta(359.4), /· HDG 359° ·/); assert.match(meta(359.6), /· HDG 0° ·/); }); + +/** The panel collapses the test asked for, as `[panelId, collapsed]` pairs. */ +function collapseCalls(controls) { + const calls = []; + controls.actions.setPanelCollapsed = (panelId, collapsed) => { + calls.push([panelId, collapsed]); + // The panel chrome owns the class the controls read back. + controls._cctvPanel.classList.toggle('collapsed', collapsed); + }; + return calls; +} + +const activeState = { + enabled: true, + count: 1, + cameraIndex: [], + activeCameraId: camera.id, + activeCamera: camera, +}; + +test('switching the layer off collapses the panel it opened', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + controls._renderCctvState(activeState); + assert.deepEqual(calls, [['cctv-panel', false]]); + // The layer keeps its active camera while off. + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); + // Later notifications while off leave a panel the user reopened alone. + controls._renderCctvState({ ...activeState, enabled: false }); + assert.equal(calls.length, 2); +}); + +test('a layer that was never on leaves an open panel alone', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] }); + controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] }); + assert.deepEqual(calls, []); +}); + +test('the data manager switching the layer off collapses the panel too', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + let on = true; + controls.actions.isEnabled = () => on; + controls._renderCctvState(activeState); + on = false; + controls._renderCctvState(activeState); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); +}); + +test('a context-mode layer clear keeps the panel open', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + controls.actions.preservePanelStateDuringClear = () => true; + controls._renderCctvState(activeState); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls, [['cctv-panel', false]]); +}); + +test('an already collapsed panel is not collapsed again', (t) => { + const { controls } = panel(t); + controls._renderCctvState(activeState); + const calls = collapseCalls(controls); + controls._cctvPanel.classList.add('collapsed'); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls, []); +}); + +test('the meta line of a disabled layer does not describe its last camera', () => { + const off = { ...activeState, enabled: false, count: 3 }; + assert.equal( + presentCctvPanel(off, false).meta, + '3 cameras loaded · enable CCTV to activate', + ); + assert.match(presentCctvPanel(activeState, true).meta, /^Austin · HDG /); +}); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 7eab9bc2d..eff9d528b 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -90,7 +90,9 @@ export function presentCctvPanel(state, enabled) { } function metaText(state, enabled, activeCamera, catalogCount) { - if (activeCamera) { + // The layer keeps its active camera while off: a disabled panel must not + // describe a feed it no longer shows. + if (enabled && activeCamera) { const provider = cctvSourceLabelText( activeCamera.sourceLabel || activeCamera.provider || 'Configured Source', ); @@ -188,6 +190,19 @@ export function _renderCctvState(state) { }); } this._lastSeenCctvActiveId = effectiveActiveId; + // Switching the layer off closes the panel it opened: nothing is left to + // show. Only the on → off change collapses, so a panel the user opens + // while the layer is off stays open, and a context-mode layer clear keeps + // the panel for the layers it restores. + if ( + this._wasCctvEnabled && + !enabled && + !this.actions.preservePanelStateDuringClear?.() && + !this._cctvPanel?.classList?.contains('collapsed') + ) { + this.actions.setPanelCollapsed('cctv-panel', true); + } + this._wasCctvEnabled = enabled; this._updateCctvSyncChip(state?.loading, enabled); From 7f18943ec4b80afeb61544ee3d365b79a551fa7f Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 13 Nov 2026 17:27:22 +0000 Subject: [PATCH 2/5] docs: CCTV panel collapse on switch-off, and changelog fragment --- changelog.d/1723-cctv-panel-collapse-on-disable.md | 4 ++++ docs/CURRENT-STATE.md | 5 +++++ 2 files changed, 9 insertions(+) create mode 100644 changelog.d/1723-cctv-panel-collapse-on-disable.md diff --git a/changelog.d/1723-cctv-panel-collapse-on-disable.md b/changelog.d/1723-cctv-panel-collapse-on-disable.md new file mode 100644 index 000000000..fe9d3522b --- /dev/null +++ b/changelog.d/1723-cctv-panel-collapse-on-disable.md @@ -0,0 +1,4 @@ +- Collapse the CCTV panel when the layer is switched off. It stayed open with + an empty frame and a meta line that still described the last camera; the + meta line of a disabled layer now reads the catalog count (asturksever, + #1723). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9a530406c..509a0e6c3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2229,6 +2229,11 @@ shown without a fade once it is decoded (or after one second if its decode never settles), so a new camera's preview is blank only while its frame loads. Disposal releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. +The panel opens when a camera becomes active and collapses when the layer is +switched off, from its own switch or anywhere else; a context-mode layer clear +keeps it as it is. A panel opened by hand while the layer is off stays open, +and its meta line reads the catalog count, not the last active camera. + ## UI disposal UI disposal releases the CCTV subscription, command-dock observer, legacy drag From f29b0b26391fb70a1de1c0a87a7f40e4737443f8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 13 Nov 2026 17:46:43 +0000 Subject: [PATCH 3/5] Keep the CCTV panel through Context isolation, and collapse through the panel chrome Contacts and Space Missions switch CCTV off to isolate themselves and restore it on the way out: the panel and its share preference now stay as they are for that round trip, as they do for Clear selected data layers. The controls no longer read the collapsed class before asking: the panel chrome tells a panel the accordion folded from one whose saved state is closed. The camera controls gate asserts the collapse, reopens CCTV from the right rail before switching it back on, and runs a Contacts entry and exit with the panel open. --- .../1723-cctv-panel-collapse-on-disable.md | 5 +- docs/CURRENT-STATE.md | 10 ++- scripts/qa-camera-controls.mjs | 75 +++++++++++++++++++ src/ui/applicationShell.js | 7 +- src/ui/cctvPanel.test.mjs | 29 +++++-- src/ui/cctvPresentation.js | 9 ++- src/ui/contextControls.js | 9 +++ src/ui/contextControls.test.mjs | 36 +++++++++ src/ui/shellFacade.js | 4 + 9 files changed, 166 insertions(+), 18 deletions(-) diff --git a/changelog.d/1723-cctv-panel-collapse-on-disable.md b/changelog.d/1723-cctv-panel-collapse-on-disable.md index fe9d3522b..ae5a49445 100644 --- a/changelog.d/1723-cctv-panel-collapse-on-disable.md +++ b/changelog.d/1723-cctv-panel-collapse-on-disable.md @@ -1,4 +1,5 @@ - Collapse the CCTV panel when the layer is switched off. It stayed open with an empty frame and a meta line that still described the last camera; the - meta line of a disabled layer now reads the catalog count (asturksever, - #1723). + meta line of a disabled layer now reads the catalog count. Contacts, Space + Missions and Clear selected data layers leave the panel as it is + (asturksever, #1723). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 509a0e6c3..fd9b2c75f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2230,9 +2230,13 @@ settles), so a new camera's preview is blank only while its frame loads. Disposa releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. The panel opens when a camera becomes active and collapses when the layer is -switched off, from its own switch or anywhere else; a context-mode layer clear -keeps it as it is. A panel opened by hand while the layer is off stays open, -and its meta line reads the catalog count, not the last active camera. +switched off, from its own switch or anywhere else. The panel chrome applies +the collapse, so a panel the desktop accordion only folded has its saved and +shared state closed too. Clear selected data layers and Context isolation +(Contacts, Space Missions) switch the layer off without the user asking: the +panel and its share preference stay as they are, and the layer comes back to an +open panel. A panel opened by hand while the layer is off stays open, and its +meta line reads the catalog count, not the last active camera. ## UI disposal diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index c2d2b5646..b16a793df 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -339,6 +339,45 @@ try { '#cctv-frame', (image) => !image.getAttribute('src') && image.dataset.cameraId === '', ); + // The address bar follows the share state after a short delay: wait for the + // CCTV panel token (1 collapsed, 0 open) the step expects. + const panelState = async (expectedToken) => { + const settled = await page + .waitForFunction( + (token) => + new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test( + new URLSearchParams(location.hash.slice(1)).get('ui') || '', + ), + { timeout: 5000 }, + expectedToken, + ) + .then( + () => true, + () => false, + ); + return page.evaluate( + (shared) => ({ + collapsed: document + .getElementById('cctv-panel') + .classList.contains('collapsed'), + shared, + ui: new URLSearchParams(location.hash.slice(1)).get('ui'), + }), + settled ? expectedToken : null, + ); + }; + const closedOnDisable = await panelState('1'); + check( + 'disable collapses the panel and its share preference', + closedOnDisable.collapsed && closedOnDisable.shared === '1', + JSON.stringify(closedOnDisable), + ); + // The switch sits inside the collapsed panel: reopen it from the right rail. + await page.click('[data-rail-item="cctv-panel"]'); + await page.waitForFunction( + () => + !document.getElementById('cctv-panel').classList.contains('collapsed'), + ); await page.click('#cctv-enable-btn'); await page.waitForFunction( () => @@ -351,6 +390,42 @@ try { 'disable clears the preview and re-enable reacquires the camera frame', disabledCleanly, ); + // Contacts switches CCTV off to isolate itself and restores it on the way + // out: the open panel and its share preference survive the round trip. + const cameraBeforeContacts = await page.evaluate( + () => + window.__godsEyeView.styleManager._cctvControls.getState() + ?.activeCameraId, + ); + const entered = await page.evaluate(() => + window.__godsEyeView.styleManager.setContextMode('flights'), + ); + await page.waitForFunction( + () => !window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const duringContacts = await panelState('0'); + await page.evaluate(() => + window.__godsEyeView.styleManager.setContextMode(null), + ); + await page.waitForFunction(() => + window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const afterContacts = await panelState('0'); + check( + 'Contacts isolation keeps the open CCTV panel and restores its camera', + entered?.ok === true && + !duringContacts.collapsed && + duringContacts.shared === '0' && + !afterContacts.collapsed && + afterContacts.shared === '0' && + (await page.evaluate( + (cameraId) => + window.__godsEyeView.styleManager._cctvControls.getState() + ?.activeCameraId === cameraId, + cameraBeforeContacts, + )), + JSON.stringify({ entered, duringContacts, afterContacts }), + ); await page.$eval('#cctv-frame', (image) => image.scrollIntoView({ block: 'nearest' }), ); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index f5df3c51a..8122bda29 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1197,8 +1197,11 @@ export class StyleManager extends ShellFacade { toggleEnabled: (...args) => this._toggleCctvEnabled(...args), runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args), setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), - preservePanelStateDuringClear: () => - this._preservePanelStateDuringLayerClear, + // Clear selected data layers and Context isolation switch the layer + // off without the user asking: the panel keeps its state for both. + preservePanelState: () => + this._preservePanelStateDuringLayerClear || + this._contextSuspendsLayer('cctv'), showToast: (message) => this._showToast(message), syncViewport: () => this._syncCctvPanelViewport(), }, diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index 65c5c724d..bc9c83c9b 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -541,9 +541,12 @@ test('switching the layer off collapses the panel it opened', (t) => { // The layer keeps its active camera while off. controls._renderCctvState({ ...activeState, enabled: false }); assert.deepEqual(calls.at(-1), ['cctv-panel', true]); - // Later notifications while off leave a panel the user reopened alone. + // A panel reopened by hand stays open through later notifications while + // the layer is off: only the on → off change collapses. + controls._cctvPanel.classList.remove('collapsed'); controls._renderCctvState({ ...activeState, enabled: false }); assert.equal(calls.length, 2); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); }); test('a layer that was never on leaves an open panel alone', (t) => { @@ -565,22 +568,34 @@ test('the data manager switching the layer off collapses the panel too', (t) => assert.deepEqual(calls.at(-1), ['cctv-panel', true]); }); -test('a context-mode layer clear keeps the panel open', (t) => { +test('a layer the application suspends keeps its panel through the round trip', (t) => { const { controls } = panel(t); const calls = collapseCalls(controls); - controls.actions.preservePanelStateDuringClear = () => true; + // Clear selected data layers and Context isolation both answer true. + let suspended = true; + controls.actions.preservePanelState = () => suspended; controls._renderCctvState(activeState); controls._renderCctvState({ ...activeState, enabled: false }); - assert.deepEqual(calls, [['cctv-panel', false]]); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); + suspended = false; + controls._renderCctvState(activeState); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); + assert.ok(calls.every(([, collapsed]) => collapsed === false)); + // The next switch-off is the user's again. + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); }); -test('an already collapsed panel is not collapsed again', (t) => { +test('a panel the accordion folded still has its saved state closed', (t) => { const { controls } = panel(t); controls._renderCctvState(activeState); - const calls = collapseCalls(controls); + const calls = []; + controls.actions.setPanelCollapsed = (...args) => calls.push(args); + // Folded for layout only: the class is there, the saved state is open. The + // panel chrome tells the two apart, so the controls always ask it. controls._cctvPanel.classList.add('collapsed'); controls._renderCctvState({ ...activeState, enabled: false }); - assert.deepEqual(calls, []); + assert.deepEqual(calls, [['cctv-panel', true]]); }); test('the meta line of a disabled layer does not describe its last camera', () => { diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index eff9d528b..2f4cb8584 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -192,13 +192,14 @@ export function _renderCctvState(state) { this._lastSeenCctvActiveId = effectiveActiveId; // Switching the layer off closes the panel it opened: nothing is left to // show. Only the on → off change collapses, so a panel the user opens - // while the layer is off stays open, and a context-mode layer clear keeps - // the panel for the layers it restores. + // while the layer is off stays open. Clear selected data layers and + // Context isolation keep the panel: the layer comes back with it. The + // panel chrome decides whether anything changes: a panel the desktop + // accordion only folded still has an open saved state to close. if ( this._wasCctvEnabled && !enabled && - !this.actions.preservePanelStateDuringClear?.() && - !this._cctvPanel?.classList?.contains('collapsed') + !this.actions.preservePanelState?.() ) { this.actions.setPanelCollapsed('cctv-panel', true); } diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js index 81da5f20e..552a9e3e9 100644 --- a/src/ui/contextControls.js +++ b/src/ui/contextControls.js @@ -59,6 +59,15 @@ export class ContextControls { showToast(...args) { if (!this.destroyed) this.actions.showToast(...args); } + /** + * Whether Context isolation switched this layer off and leaving the mode + * restores it. Such a layer is suspended, not switched off by the user. + * @param {string} layerId Layer id. + * @returns {boolean} + */ + suspendsLayer(layerId) { + return Boolean(this._contextSessionSnapshot?.enabledLayerIds?.has(layerId)); + } setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); } diff --git a/src/ui/contextControls.test.mjs b/src/ui/contextControls.test.mjs index 33b3c4d10..4a4e69a9f 100644 --- a/src/ui/contextControls.test.mjs +++ b/src/ui/contextControls.test.mjs @@ -153,3 +153,39 @@ test('a rejected tracked reaction settles without an unhandled rejection', async await controls._waitForContextLayerSettlement(); assert.equal(controls._contextLayerReactionPromises.size, 0); }); + +test('a layer Context isolation switches off is suspended until the mode is left', async (t) => { + const { controls } = fixture(t); + const enabled = new Set(['cctv']); + // What the CCTV panel is told at each visibility change of its layer. + const seen = []; + controls._dataManager = { + layers: new Map([['cctv', {}]]), + getEnabledLayerIds: () => [...enabled], + getLayerParams: () => null, + setLayerParams() {}, + isEnabled: (layerId) => enabled.has(layerId), + isEffectivelyEnabled: (layerId) => enabled.has(layerId), + async setEnabled(layerId, next) { + if (next) enabled.add(layerId); + else enabled.delete(layerId); + seen.push([layerId, next, controls.suspendsLayer(layerId)]); + return true; + }, + async restoreEnabledLayerIds(layerIds) { + for (const layerId of layerIds) await this.setEnabled(layerId, true); + }, + }; + assert.equal(controls.suspendsLayer('cctv'), false); + controls._captureContextSessionSnapshot(); + await controls._clearLayersOutsideContextMode('flights'); + assert.deepEqual(seen, [['cctv', false, true]]); + assert.equal(controls.suspendsLayer('cctv'), true); + assert.equal(controls.suspendsLayer('street-level'), false); + await controls._restoreContextSession(); + assert.equal(enabled.has('cctv'), true); + assert.equal(controls.suspendsLayer('cctv'), false); + // A switch-off after the mode is left is the user's own. + await controls._dataManager.setEnabled('cctv', false); + assert.deepEqual(seen.at(-1), ['cctv', false, false]); +}); diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 678b69c79..276a95547 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -891,6 +891,10 @@ export class ShellFacade { return this._contextControls?._preservePanelStateDuringLayerClear ?? false; } + _contextSuspendsLayer(layerId) { + return this._contextControls?.suspendsLayer(layerId) ?? false; + } + _runUserFacingContextAction(...args) { return this._contextControls?._runUserFacingContextAction(...args); } From f0ee1b95caf12d48414787b3103de74ae9cb68d3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 13 Nov 2026 18:12:44 -0800 Subject: [PATCH 4/5] Collapse the CCTV panel when the user switches CCTV off inside Contacts Turning a layer on does not leave Contacts, so a user could turn CCTV back on inside the mode and off again; the layer was still in the session snapshot and the panel stayed open on an empty frame. The snapshot now records every layer the user switched during the session, and suspendsLayer is true only for a layer isolation switched off that the user has not touched since. --- src/contextModePolicy.js | 1 + src/ui/contextControls.js | 9 +++++++-- src/ui/contextControls.test.mjs | 36 +++++++++++++++++++++++++++++++++ src/ui/contextSession.js | 3 +++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index 6e3238ec5..ef485fe97 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -277,6 +277,7 @@ export function recordContextSessionUserChange({ ) { return false; } + snapshot.userSwitched?.add(change.layerId); const isCompanion = CONTEXT_COMPANIONS.has(change.layerId); if (change.enabled) { if (CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)) return false; diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js index 552a9e3e9..3c297f277 100644 --- a/src/ui/contextControls.js +++ b/src/ui/contextControls.js @@ -61,12 +61,17 @@ export class ContextControls { } /** * Whether Context isolation switched this layer off and leaving the mode - * restores it. Such a layer is suspended, not switched off by the user. + * restores it. Such a layer is suspended, not switched off by the user: a + * layer the user switched during the session is theirs again. * @param {string} layerId Layer id. * @returns {boolean} */ suspendsLayer(layerId) { - return Boolean(this._contextSessionSnapshot?.enabledLayerIds?.has(layerId)); + const snapshot = this._contextSessionSnapshot; + return Boolean( + snapshot?.enabledLayerIds?.has(layerId) && + !snapshot.userSwitched?.has(layerId), + ); } setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); diff --git a/src/ui/contextControls.test.mjs b/src/ui/contextControls.test.mjs index 4a4e69a9f..caaba2060 100644 --- a/src/ui/contextControls.test.mjs +++ b/src/ui/contextControls.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { ContextControls } from './contextControls.js'; +import { recordContextSessionUserChange } from '../contextModePolicy.js'; class Button extends EventTarget { constructor() { @@ -189,3 +190,38 @@ test('a layer Context isolation switches off is suspended until the mode is left await controls._dataManager.setEnabled('cctv', false); assert.deepEqual(seen.at(-1), ['cctv', false, false]); }); + +test('a layer the user switches inside the mode is no longer suspended', async (t) => { + const { controls } = fixture(t); + const enabled = new Set(['cctv']); + controls._dataManager = { + layers: new Map([['cctv', {}]]), + getEnabledLayerIds: () => [...enabled], + getLayerParams: () => null, + isEffectivelyEnabled: (layerId) => enabled.has(layerId), + isEnabled: (layerId) => enabled.has(layerId), + async setEnabled(layerId, next) { + if (next) enabled.add(layerId); + else enabled.delete(layerId); + return true; + }, + }; + controls._captureContextSessionSnapshot(); + await controls._clearLayersOutsideContextMode('flights'); + assert.equal(controls.suspendsLayer('cctv'), true); + // Inside Contacts the user turns CCTV back on, then off again: the panel + // must close like any other switch-off. + for (const next of [true, false]) { + recordContextSessionUserChange({ + snapshot: controls._contextSessionSnapshot, + change: { + type: 'visibility', + origin: 'user', + layerId: 'cctv', + enabled: next, + }, + effectiveContextMode: 'flights', + }); + } + assert.equal(controls.suspendsLayer('cctv'), false); +}); diff --git a/src/ui/contextSession.js b/src/ui/contextSession.js index 936fc2cb1..3f1f2eeb1 100644 --- a/src/ui/contextSession.js +++ b/src/ui/contextSession.js @@ -36,6 +36,8 @@ export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) { ), userAdded: new Set(), userRemoved: new Set(), + // Every layer the user switched during the session, either way. + userSwitched: new Set(), params, }; } @@ -149,6 +151,7 @@ export async function _restoreContextSession({ enabledLayerIds: new Set(restoreState.enabledLayerIds), userAdded: new Set(), userRemoved: new Set(), + userSwitched: new Set(), params: snapshot.params, }; } From 9b46692c64ed2a401b1ab7315c57612a5420fd8f Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 13 Nov 2026 18:32:57 -0800 Subject: [PATCH 5/5] Suspend only the layers Context isolation is switching off The session snapshot recorded a user's switch only after the CCTV panel had already rendered the layer off, so a first switch-off by the user inside Contacts, or after a partly failed restore, still kept the panel open. Context controls now hold the layers _clearLayersOutsideContextMode is switching off while its transitions run; suspendsLayer reads that set, and the snapshot is no longer consulted. --- docs/CURRENT-STATE.md | 4 ++- src/contextModePolicy.js | 1 - src/ui/contextControls.js | 14 +++++----- src/ui/contextControls.test.mjs | 47 ++++++++++----------------------- src/ui/contextSession.js | 3 --- src/ui/contextTransactions.js | 44 ++++++++++++++++++------------ 6 files changed, 50 insertions(+), 63 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fd9b2c75f..ec1292b3f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2235,7 +2235,9 @@ the collapse, so a panel the desktop accordion only folded has its saved and shared state closed too. Clear selected data layers and Context isolation (Contacts, Space Missions) switch the layer off without the user asking: the panel and its share preference stay as they are, and the layer comes back to an -open panel. A panel opened by hand while the layer is off stays open, and its +open panel. Only the switch-off isolation makes counts (`suspendsLayer`, true +while `_clearLayersOutsideContextMode` switches the layer off): the user +switching CCTV off inside Contacts closes the panel as anywhere else. A panel opened by hand while the layer is off stays open, and its meta line reads the catalog count, not the last active camera. ## UI disposal diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index ef485fe97..6e3238ec5 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -277,7 +277,6 @@ export function recordContextSessionUserChange({ ) { return false; } - snapshot.userSwitched?.add(change.layerId); const isCompanion = CONTEXT_COMPANIONS.has(change.layerId); if (change.enabled) { if (CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)) return false; diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js index 3c297f277..7acb31ef5 100644 --- a/src/ui/contextControls.js +++ b/src/ui/contextControls.js @@ -51,6 +51,8 @@ export class ContextControls { this._contextRestoreState = null; this._contextLayerReactionPromises = new Set(); this._preservePanelStateDuringLayerClear = false; + /** Layers Context isolation is switching off right now. */ + this._isolatingLayerIds = new Set(); this._userFacingContextNotificationTokens = new Set(); this._clearSelectedLayersPromise = null; this._clearSelectedLayersManagerPromise = null; @@ -60,18 +62,14 @@ export class ContextControls { if (!this.destroyed) this.actions.showToast(...args); } /** - * Whether Context isolation switched this layer off and leaving the mode - * restores it. Such a layer is suspended, not switched off by the user: a - * layer the user switched during the session is theirs again. + * Whether Context isolation is switching this layer off right now: it is + * suspended, and leaving the mode restores it. A switch-off by the user, + * inside the mode or out of it, is not. * @param {string} layerId Layer id. * @returns {boolean} */ suspendsLayer(layerId) { - const snapshot = this._contextSessionSnapshot; - return Boolean( - snapshot?.enabledLayerIds?.has(layerId) && - !snapshot.userSwitched?.has(layerId), - ); + return this._isolatingLayerIds.has(layerId); } setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); diff --git a/src/ui/contextControls.test.mjs b/src/ui/contextControls.test.mjs index caaba2060..2bceb7e75 100644 --- a/src/ui/contextControls.test.mjs +++ b/src/ui/contextControls.test.mjs @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { ContextControls } from './contextControls.js'; -import { recordContextSessionUserChange } from '../contextModePolicy.js'; class Button extends EventTarget { constructor() { @@ -155,7 +154,7 @@ test('a rejected tracked reaction settles without an unhandled rejection', async assert.equal(controls._contextLayerReactionPromises.size, 0); }); -test('a layer Context isolation switches off is suspended until the mode is left', async (t) => { +test('only a layer Context isolation is switching off is suspended', async (t) => { const { controls } = fixture(t); const enabled = new Set(['cctv']); // What the CCTV panel is told at each visibility change of its layer. @@ -168,6 +167,7 @@ test('a layer Context isolation switches off is suspended until the mode is left isEnabled: (layerId) => enabled.has(layerId), isEffectivelyEnabled: (layerId) => enabled.has(layerId), async setEnabled(layerId, next) { + // The layer tells its panel it is off while it is being disabled. if (next) enabled.add(layerId); else enabled.delete(layerId); seen.push([layerId, next, controls.suspendsLayer(layerId)]); @@ -181,47 +181,28 @@ test('a layer Context isolation switches off is suspended until the mode is left controls._captureContextSessionSnapshot(); await controls._clearLayersOutsideContextMode('flights'); assert.deepEqual(seen, [['cctv', false, true]]); - assert.equal(controls.suspendsLayer('cctv'), true); - assert.equal(controls.suspendsLayer('street-level'), false); + assert.equal(controls.suspendsLayer('cctv'), false, 'only while isolating'); + // Inside the mode the user turns CCTV back on, then off: their own. + await controls._dataManager.setEnabled('cctv', true); + await controls._dataManager.setEnabled('cctv', false); + assert.deepEqual(seen.at(-1), ['cctv', false, false]); await controls._restoreContextSession(); assert.equal(enabled.has('cctv'), true); - assert.equal(controls.suspendsLayer('cctv'), false); - // A switch-off after the mode is left is the user's own. + // A switch-off after the mode is left is the user's own too. await controls._dataManager.setEnabled('cctv', false); assert.deepEqual(seen.at(-1), ['cctv', false, false]); }); -test('a layer the user switches inside the mode is no longer suspended', async (t) => { +test('a failed isolation leaves no layer suspended', async (t) => { const { controls } = fixture(t); - const enabled = new Set(['cctv']); controls._dataManager = { layers: new Map([['cctv', {}]]), - getEnabledLayerIds: () => [...enabled], - getLayerParams: () => null, - isEffectivelyEnabled: (layerId) => enabled.has(layerId), - isEnabled: (layerId) => enabled.has(layerId), - async setEnabled(layerId, next) { - if (next) enabled.add(layerId); - else enabled.delete(layerId); - return true; + isEffectivelyEnabled: () => true, + isEnabled: () => true, + setEnabled: async () => { + throw new Error('disable failed'); }, }; - controls._captureContextSessionSnapshot(); - await controls._clearLayersOutsideContextMode('flights'); - assert.equal(controls.suspendsLayer('cctv'), true); - // Inside Contacts the user turns CCTV back on, then off again: the panel - // must close like any other switch-off. - for (const next of [true, false]) { - recordContextSessionUserChange({ - snapshot: controls._contextSessionSnapshot, - change: { - type: 'visibility', - origin: 'user', - layerId: 'cctv', - enabled: next, - }, - effectiveContextMode: 'flights', - }); - } + await assert.rejects(controls._clearLayersOutsideContextMode('flights')); assert.equal(controls.suspendsLayer('cctv'), false); }); diff --git a/src/ui/contextSession.js b/src/ui/contextSession.js index 3f1f2eeb1..936fc2cb1 100644 --- a/src/ui/contextSession.js +++ b/src/ui/contextSession.js @@ -36,8 +36,6 @@ export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) { ), userAdded: new Set(), userRemoved: new Set(), - // Every layer the user switched during the session, either way. - userSwitched: new Set(), params, }; } @@ -151,7 +149,6 @@ export async function _restoreContextSession({ enabledLayerIds: new Set(restoreState.enabledLayerIds), userAdded: new Set(), userRemoved: new Set(), - userSwitched: new Set(), params: snapshot.params, }; } diff --git a/src/ui/contextTransactions.js b/src/ui/contextTransactions.js index e8a69b5fc..1f17faeef 100644 --- a/src/ui/contextTransactions.js +++ b/src/ui/contextTransactions.js @@ -240,25 +240,35 @@ export async function _clearLayersOutsideContextMode( ) { const allowed = contextAllowedLayerIds(mode); const pending = []; - for (const [layerId] of this._dataManager.layers || []) { - // Effective visibility: a disallowed layer still mid-ENABLING must be - // isolated too, or it settles ON inside the exclusive mode. - if ( - !allowed.has(layerId) && - this._dataManager.isEffectivelyEnabled(layerId) - ) { - pending.push({ - layerId, - transition: this._dataManager.setEnabled(layerId, false, { - notificationToken, - ...(signal ? { signal } : {}), - }), - }); + // Panels read this while their layer goes off (suspendsLayer): a layer + // isolation switches off is suspended, not switched off by the user. + const isolating = []; + let results; + try { + for (const [layerId] of this._dataManager.layers || []) { + // Effective visibility: a disallowed layer still mid-ENABLING must be + // isolated too, or it settles ON inside the exclusive mode. + if ( + !allowed.has(layerId) && + this._dataManager.isEffectivelyEnabled(layerId) + ) { + if (!this._isolatingLayerIds.has(layerId)) { + this._isolatingLayerIds.add(layerId); + isolating.push(layerId); + } + pending.push({ + layerId, + transition: this._dataManager.setEnabled(layerId, false, { + notificationToken, + ...(signal ? { signal } : {}), + }), + }); + } } + results = await Promise.all(pending.map(({ transition }) => transition)); + } finally { + for (const layerId of isolating) this._isolatingLayerIds.delete(layerId); } - const results = await Promise.all( - pending.map(({ transition }) => transition), - ); const failed = pending .filter( ({ layerId }, index) =>