diff --git a/changelog.d/816-radio-owner-restoration.md b/changelog.d/816-radio-owner-restoration.md new file mode 100644 index 000000000..df38617ee --- /dev/null +++ b/changelog.d/816-radio-owner-restoration.md @@ -0,0 +1,7 @@ +- Close the compact Radio disclosure when an adaptive tactical right-rail + restore directly expands Context, before Space Missions can be covered. + A repeated Context-open request also clears stale disclosure state. + Real PanelChrome constructor wiring and the rail reflow path are covered + by focused regressions. Credits: kvnloo (#816), raysung-2026's original + #804 report, asturksever's review, and quanticsoul4772's independent + Windows diagnosis. diff --git a/changelog.d/880-world-packet-boundary.md b/changelog.d/880-world-packet-boundary.md new file mode 100644 index 000000000..7ee574e35 --- /dev/null +++ b/changelog.d/880-world-packet-boundary.md @@ -0,0 +1,6 @@ +- Extract a reusable model-free World Packet from the existing voice + `get_current_view_state` owner without restoring disabled layers to the + model context. Keep the enabled-only payload, disabled-layer count, source + provenance, action result and view-state contract; read the layer roster once + per packet; voice retains the speech/card envelope at its own boundary. + Credits: original GEV voice/layer contributors and #880 implementation. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 77677d08f..613cf640c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -504,6 +504,11 @@ to layer toggles, fly_to_location, frame_overhead, select_nearest_aircraft, get_entity_context, annotate_map and analyst_query. Analyst speech and the card share one deterministic count/scope headline; lower bounds use "At least" and partial answers name the unanswered layers. `get_current_view_state` lists enabled layers only. +The model-free `gods-eye-view/agent/world-packet` export exposes +`getWorldPacket({ viewer, styleManager, dataManager, sceneDirector })` to +non-voice consumers. It reads the same enabled-only state and feed +provenance before voice adds speech/card fields, with no new network requests +or mutations. The voice card nested in the mic control (`voiceCard.js`) shows captions, plan steps and the result. `resultDisplay.js` is the one adapter from a result to the card: builder results show as built, and analyst results show a title @@ -1211,6 +1216,10 @@ Automatic collapse remains presentation only, prioritizes the latest explicit panel, and honors keyboard focus on the right. Mobile layout still releases desktop allocation styles. Stable Display allocation avoids unnecessary style writes. This extraction does not change panel positions or layout defaults. +Context expansion through the panel owner or adaptive right-rail restoration +closes an open compact Radio disclosure before Context/Space Missions becomes +visible. The mini remains available while Context is collapsed. This fixes the +Radio-overlap path reported in #804 without changing z-index or layout policy. The right rail skips panels hidden by other CSS (computed `display: none`), but keeps the collapsed panels its own exclusive presentation hides, so automatic collapse can still be undone. diff --git a/package.json b/package.json index 99e35c0c5..a6396db73 100644 --- a/package.json +++ b/package.json @@ -277,6 +277,7 @@ "./tools/mcp": "./src/tools/mcp/index.js", "./tools/panel": "./src/tools/panel.js", "./tools/services": "./src/tools/services.js", - "./view": "./src/view/index.js" + "./view": "./src/view/index.js", + "./agent/world-packet": "./src/agent/worldPacket.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b353f7067..4230c23ae 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -637,6 +637,7 @@ "src/voice/control.js", "src/voice/realtimeBackend.js", "src/voice/realtimeBackend.test.mjs", + "src/agent/worldPacket.test.mjs", "src/voice/layerSummary.js", "build/application-html.js", "src/app/sourceSlot.js", @@ -692,6 +693,7 @@ "src/ui/shareRestoration.js", "src/ui/visualSettings.js", "src/ui/panelChrome.js", + "src/ui/panelChrome.radioOwnership.test.mjs", "src/testSupport/readShellSource.mjs", "src/ui/stateOwners.test.mjs", "src/layers/flights/records.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 627c9307f..264f58440 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2221,6 +2221,7 @@ "src/voice/commands.js", "src/voice/control.js", "src/voice/gevActions.js", + "src/agent/worldPacket.js", "src/voice/speech.js", "src/ui/layerNames.js", "src/voice/earcon.js", @@ -3951,5 +3952,18 @@ "src/i18n/marker.js" ], "external": [] + }, + "agent-world-packet": { + "exports": ["./agent/world-packet"], + "modules": [ + "src/agent/worldPacket.js", + "src/data/layerSnapshot.js", + "src/data/feedState.js", + "src/contextModePolicy.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js", + "src/i18n/marker.js" + ], + "external": ["cesium"] } } diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js new file mode 100644 index 000000000..4f8dad144 --- /dev/null +++ b/src/agent/worldPacket.js @@ -0,0 +1,170 @@ +import * as Cesium from 'cesium'; +import { + feedProvenanceEnvelope, + layerSnapshot, + layerSnapshots, +} from '../data/layerSnapshot.js'; +import { contextModeWord } from '../contextModePolicy.js'; + +export const TRACKABLE_FAMILIES = Object.freeze([ + { layerId: 'flights', kind: 'aircraft' }, + { layerId: 'military', kind: 'aircraft' }, + { layerId: 'ais-live-vessels', kind: 'vessel' }, + { layerId: 'satellites', kind: 'satellite' }, +]); + +const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([ + { field: 'mode', emptyAs: 'off' }, + { field: 'entering', emptyAs: null }, + { field: 'priorMode', emptyAs: null }, +]); + +const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ + 'context', + 'contextRollback', +]); + +/** + * Translate context-mode payloads into the public tool vocabulary while + * retaining the internal id beside each translated field. + */ +export function withContextModeVocabulary(state) { + if (!state || typeof state !== 'object') return state; + let out = state; + const mutable = () => { + if (out === state) out = { ...state }; + return out; + }; + for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) { + if (!(field in state)) continue; + const internal = state[field] ?? null; + const target = mutable(); + target[field] = contextModeWord(internal, { emptyAs }); + target[`${field}Internal`] = internal; + } + for (const field of NESTED_CONTEXT_RESULT_FIELDS) { + const nested = state[field]; + if (!nested || typeof nested !== 'object') continue; + const translated = withContextModeVocabulary(nested); + if (translated !== nested) mutable()[field] = translated; + } + return out; +} + +/** Read tracked entities from their canonical layer owners. */ +export function collectTrackedEntities(dataManager) { + const tracked = []; + for (const family of TRACKABLE_FAMILIES) { + const module = dataManager?.layers?.get(family.layerId)?.module; + if (!module) continue; + try { + const info = + family.kind === 'vessel' + ? module.getSelectedInfo?.() + : module.getTrackedInfo?.(); + if (info) + tracked.push({ + kind: family.kind, + layerId: family.layerId, + ...info, + }); + } catch { + // A not-yet-ready layer contributes no tracked observation. + } + } + return tracked; +} + +/** Read the active Contacts window from its owning awareness layer. */ +export function activeContactsWindow(dataManager) { + try { + const layer = dataManager?.layers?.get('military-awareness')?.module; + return ( + layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null + ); + } catch { + return null; + } +} + +/** + * Build the model-free fast World Packet from existing application owners. + * + * This performs no network requests and intentionally mirrors the historical + * get_current_view_state payload so voice can delegate without behavior drift. + */ +export function getWorldPacket(options = {}) { + const { + viewer, + styleManager, + dataManager, + sceneDirector = null, + now: requestedNow, + } = options ?? {}; + // A default parameter only covers undefined; null or NaN would age every + // feed against the epoch and report stale data as "just now". + const now = Number.isFinite(requestedNow) ? requestedNow : Date.now(); + if (!viewer?.camera?.positionWC) + throw new TypeError('World Packet requires a viewer camera'); + if (!dataManager || typeof dataManager.getAll !== 'function') + throw new TypeError('World Packet requires a data manager'); + + const cartographic = Cesium.Cartographic.fromCartesian( + viewer.camera.positionWC, + ); + // Read the roster once. Voice must not rewalk disabled layers: preserving the + // current enabled-only view keeps the model's context budget bounded. + const rows = dataManager.getAll(); + const enabledRows = rows.filter((layer) => layer.enabled); + const contactsWindow = activeContactsWindow(dataManager); + const contextState = + typeof styleManager?.getContextModeState === 'function' + ? withContextModeVocabulary(styleManager.getContextModeState()) + : null; + + return { + ok: true, + action: 'get_current_view_state', + camera: { + latitude: Cesium.Math.toDegrees(cartographic.latitude), + longitude: Cesium.Math.toDegrees(cartographic.longitude), + heightM: cartographic.height, + }, + style: styleManager?.activeStyle || 'normal', + context: contextState + ? { + ...contextState, + ...(contactsWindow ? { contactsWindow } : {}), + } + : null, + cockpit: + typeof styleManager?.getCockpitState === 'function' + ? styleManager.getCockpitState() + : null, + controls: + typeof styleManager?.getControlState === 'function' + ? styleManager.getControlState() + : null, + scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, + tracked: collectTrackedEntities(dataManager), + disabledLayerCount: rows.length - enabledRows.length, + // Preserve get_current_view_state's existing compact enabled-only shape. + layers: enabledRows.map((layer) => { + const snapshot = layerSnapshot(layer, { now }); + return { + id: layer.id, + name: layer.name, + enabled: layer.enabled, + count: layer.stats?.count || 0, + error: layer.stats?.error || null, + feedState: snapshot.feedState, + source: snapshot.source, + lastUpdate: snapshot.lastUpdate, + }; + }), + feedProvenance: feedProvenanceEnvelope( + layerSnapshots(rows, { now }).filter((layer) => layer.enabled), + { now }, + ), + }; +} diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs new file mode 100644 index 000000000..063e3652c --- /dev/null +++ b/src/agent/worldPacket.test.mjs @@ -0,0 +1,335 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; + +import { getWorldPacket } from './worldPacket.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; + +function withoutVoicePresentation(result) { + const packet = { ...result }; + delete packet.say; + delete packet.display; + delete packet.schedule; + return packet; +} + +function harness() { + const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500); + const viewer = { + trackedEntity: undefined, + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { + canvas: { + clientWidth: 1200, + clientHeight: 800, + addEventListener() {}, + removeEventListener() {}, + }, + globe: { getHeight: () => 0 }, + tweens: [], + }, + camera: { + positionWC: position, + positionCartographic: Cesium.Cartographic.fromCartesian(position), + heading: 0, + pitch: Cesium.Math.toRadians(-45), + moveEnd: { addEventListener: () => () => {} }, + cancelFlight() {}, + lookAtTransform() {}, + }, + }; + const rows = [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 3, + source: 'fixture', + lastUpdate: null, + }, + }, + ]; + const dataManager = { + layers: new Map([['traffic', { module: null }]]), + getAll: () => rows, + }; + const styleManager = { + activeStyle: 'normal', + getContextModeState: () => ({ mode: 'flights' }), + getCockpitState: () => null, + getControlState: () => ({ hud: { visible: 'auto' } }), + }; + return { viewer, dataManager, styleManager }; +} + +test('World Packet preserves get_current_view_state parity', async () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const voice = await runner('get_current_view_state'); + + assert.deepEqual(withoutVoicePresentation(voice), packet); + assert.equal(packet.context.mode, 'contacts'); + assert.equal(packet.context.modeInternal, 'flights'); + assert.equal(packet.layers[0].feedState, 'nominal'); + assert.equal(packet.feedProvenance.overall, 'nominal'); +}); + +test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => { + const { viewer, dataManager, styleManager } = harness(); + dataManager.getAll = () => [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 0, + source: 'fixture', + error: 'fixture unavailable', + lastUpdate: null, + }, + }, + ]; + + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(packet.layers[0].count, 0); + assert.notEqual(packet.layers[0].feedState, 'nominal'); + assert.notEqual(packet.feedProvenance.overall, 'nominal'); + assert.match(packet.feedProvenance.note, /fixture unavailable/i); +}); + +// The 2026-10 main voice contract omits disabled layers from the model payload. +test('World Packet and voice preserve enabled-only payload and count disabled layers', async () => { + const { viewer, dataManager, styleManager } = harness(); + const rows = dataManager.getAll(); + rows.push({ + id: 'earthquakes', + name: 'Earthquakes', + enabled: false, + stats: { count: 99, source: 'fixture', error: 'stale' }, + }); + let reads = 0; + dataManager.getAll = () => { + reads += 1; + return rows; + }; + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(reads, 1, 'collect the roster once rather than repeatedly'); + assert.equal(packet.disabledLayerCount, 1); + assert.deepEqual( + packet.layers.map((layer) => layer.id), + ['traffic'], + ); + assert.equal(packet.feedProvenance.overall, 'nominal'); + + const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const voice = await runner('get_current_view_state'); + assert.deepEqual(withoutVoicePresentation(voice), packet); +}); + +const PINNED_NOW = Date.UTC(2026, 9, 17, 12, 0, 0); + +// Exercises every packet field: context vocabulary and Contacts window, +// cockpit, controls, scene playback, tracked families (one not ready), +// enabled and disabled layers, and an aged and an unavailable feed. +function pinnedHarness() { + const { viewer } = harness(); + const rows = [ + { + id: 'flights', + name: 'Flights', + enabled: true, + stats: { + count: 12, + source: 'fixture-adsb', + lastUpdate: PINNED_NOW - 90_000, + }, + }, + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 0, + source: 'fixture', + error: 'fixture unavailable', + lastUpdate: null, + }, + }, + { + id: 'earthquakes', + name: 'Earthquakes', + enabled: false, + stats: { count: 99, source: 'fixture', error: 'stale' }, + }, + ]; + const module = (methods) => ({ module: methods }); + const dataManager = { + layers: new Map([ + [ + 'flights', + module({ + getTrackedInfo: () => ({ icao24: 'abc123', callsign: 'TEST1' }), + }), + ], + [ + 'ais-live-vessels', + module({ getSelectedInfo: () => ({ mmsi: '366000001' }) }), + ], + [ + 'satellites', + module({ + getTrackedInfo: () => { + throw new Error('not ready'); + }, + }), + ], + [ + 'military-awareness', + module({ + getContextSnapshot: () => ({ snapshot: true }), + contactsWindowFromSnapshot: (snapshot) => + snapshot ? { windowMin: 30, count: 4 } : null, + }), + ], + ]), + getAll: () => rows, + }; + const styleManager = { + activeStyle: 'night-vision', + getContextModeState: () => ({ + mode: 'flights', + entering: null, + priorMode: null, + }), + getCockpitState: () => ({ active: false }), + getControlState: () => ({ hud: { visible: 'auto' } }), + }; + const sceneDirector = { + getPlaybackStatus: () => ({ playing: false, sceneId: 'fixture' }), + }; + return { viewer, dataManager, styleManager, sceneDirector }; +} + +// Captured from main's get_current_view_state before the extraction +// (bilawalsidhu/gods-eye-view@3d8e1ef0) with Date.now pinned to PINNED_NOW, +// minus the speech envelope. Key order is part of the contract. +const PRE_EXTRACTION_PAYLOAD = { + ok: true, + action: 'get_current_view_state', + camera: { + latitude: 30.259999999999994, + longitude: -97.74, + heightM: 499.99999999901877, + }, + style: 'night-vision', + context: { + mode: 'contacts', + entering: null, + priorMode: null, + modeInternal: 'flights', + enteringInternal: null, + priorModeInternal: null, + contactsWindow: { windowMin: 30, count: 4 }, + }, + cockpit: { active: false }, + controls: { hud: { visible: 'auto' } }, + scenePlayback: { playing: false, sceneId: 'fixture' }, + tracked: [ + { + kind: 'aircraft', + layerId: 'flights', + icao24: 'abc123', + callsign: 'TEST1', + }, + { kind: 'vessel', layerId: 'ais-live-vessels', mmsi: '366000001' }, + ], + disabledLayerCount: 1, + layers: [ + { + id: 'flights', + name: 'Flights', + enabled: true, + count: 12, + error: null, + feedState: 'nominal', + source: 'fixture-adsb', + lastUpdate: 1792238310000, + }, + { + id: 'traffic', + name: 'Traffic', + enabled: true, + count: 0, + error: 'fixture unavailable', + feedState: 'unavailable', + source: 'fixture', + lastUpdate: 0, + }, + ], + feedProvenance: { + overall: 'unavailable', + layers: [ + { + id: 'flights', + name: 'Flights', + enabled: true, + feedState: 'nominal', + source: 'fixture-adsb', + count: 12, + lastUpdate: 1792238310000, + ageSec: 90, + ageLabel: '1m ago', + error: null, + }, + { + id: 'traffic', + name: 'Traffic', + enabled: true, + feedState: 'unavailable', + source: 'fixture', + count: 0, + lastUpdate: 0, + ageSec: null, + ageLabel: null, + error: 'fixture unavailable', + }, + ], + note: 'Traffic is UNAVAILABLE (fixture, fixture unavailable). Narrate that provenance with any count; do not describe this as live.', + }, +}; + +function assertPinnedPayload(actual) { + assert.deepEqual(actual, PRE_EXTRACTION_PAYLOAD); + // deepEqual ignores key order; the serialized form does not. + assert.equal(JSON.stringify(actual), JSON.stringify(PRE_EXTRACTION_PAYLOAD)); +} + +test('World Packet matches the pre-extraction get_current_view_state payload', () => { + const packet = getWorldPacket({ ...pinnedHarness(), now: PINNED_NOW }); + assertPinnedPayload(packet); +}); + +test('voice get_current_view_state still returns the pre-extraction payload', async (t) => { + t.mock.method(Date, 'now', () => PINNED_NOW); + const runner = createGevActionRunner(pinnedHarness()); + const voice = await runner('get_current_view_state'); + assertPinnedPayload(withoutVoicePresentation(voice)); +}); + +test('World Packet ages feeds against the clock when now is not a finite number', (t) => { + t.mock.method(Date, 'now', () => PINNED_NOW); + for (const now of [null, Number.NaN, '2026-10-17']) { + const packet = getWorldPacket({ ...pinnedHarness(), now }); + const flights = packet.feedProvenance.layers.find( + (layer) => layer.id === 'flights', + ); + assert.equal(flights.ageSec, 90, `now: ${String(now)}`); + assert.equal(flights.ageLabel, '1m ago', `now: ${String(now)}`); + } +}); + +test('World Packet rejects null options with the camera requirement', () => { + assert.throws(() => getWorldPacket(null), /requires a viewer camera/); +}); diff --git a/src/navigationPolicyFrame.test.mjs b/src/navigationPolicyFrame.test.mjs index a861870cf..5110232f1 100644 --- a/src/navigationPolicyFrame.test.mjs +++ b/src/navigationPolicyFrame.test.mjs @@ -6,6 +6,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { TRACKABLE_FAMILIES } from './agent/worldPacket.js'; import { setLocale } from './i18n/index.js'; import { COCKPIT_REFUSAL_NOUNS, @@ -68,10 +69,8 @@ const NOUN_LITERAL = /\bnoun\s*=\s*'([^']+)'|\bconst noun\s*=\s*[^?;]*\?\s*'([^']+)'\s*:\s*'([^']+)'/g; function trackableKinds() { - const source = readFileSync(join(SRC, 'voice/gevActions.js'), 'utf8'); - const block = source.match(/const TRACKABLE_FAMILIES = \[([\s\S]*?)\];/); - assert.ok(block, 'TRACKABLE_FAMILIES is still in gevActions.js'); - return [...block[1].matchAll(/kind:\s*'([^']+)'/g)].map((m) => m[1]); + // This is shared with voice; test the exported owner, not its file position. + return TRACKABLE_FAMILIES.map(({ kind }) => kind); } // Non-literal arguments: what each one can be. A wrapper's own `noun` (or all diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 359ac3792..6fd04554a 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -72,7 +72,8 @@ export class PanelChrome { variant: this.hud.getVariant(), }), scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(), - syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel), + syncPanelCollapseButton: (panel) => + this._syncRailPanelCollapseButton(panel), readDisplayScrollTop: () => this._displayPortalScrollRestoreOwner === 'standard' ? this._standardDisplayScrollTop @@ -287,6 +288,19 @@ export class PanelChrome { return this._panelLayout._scheduleLeftPanelLayout(options0); } + _syncRailPanelCollapseButton(panelEl) { + // Adaptive rail layout may directly restore an auto-collapsed Context + // without invoking setPanelCollapsed(). Enforce Radio ownership there too. + if ( + panelEl?.id === 'global-context-panel' && + !panelEl.classList.contains('collapsed') && + this._contextRadioDock?.classList.contains('disclosure-open') + ) { + this._setRadioDisclosure?.(false); + } + this._syncPanelCollapseButton(panelEl); + } + _syncPanelCollapseButton(panelEl) { const isRightRail = [ 'pp-toggles', @@ -501,6 +515,15 @@ export class PanelChrome { syncShare, }); } + // A repeated Context-open request must clear a stale compact Radio mini + // even when no collapsed class changes. + if ( + !nextCollapsed && + panelId === 'global-context-panel' && + this._contextRadioDock?.classList.contains('disclosure-open') + ) { + this._setRadioDisclosure?.(false); + } if ( panelEl.classList.contains('collapsed') === nextCollapsed && !wasAutoCollapsed diff --git a/src/ui/panelChrome.radioOwnership.test.mjs b/src/ui/panelChrome.radioOwnership.test.mjs new file mode 100644 index 000000000..123eda6e6 --- /dev/null +++ b/src/ui/panelChrome.radioOwnership.test.mjs @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PanelChrome } from './panelChrome.js'; +import { PanelLayoutController } from './panelLayoutController.js'; + +function classList(initial = []) { + const values = new Set(initial); + return { + contains: (value) => values.has(value), + add: (...items) => items.forEach((item) => values.add(item)), + remove: (...items) => items.forEach((item) => values.delete(item)), + toggle(value, force) { + if (force === true) values.add(value); + else if (force === false) values.delete(value); + else if (values.has(value)) values.delete(value); + else values.add(value); + return values.has(value); + }, + }; +} + +test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => { + const priorDocument = globalThis.document; + const contextPanel = { + id: 'global-context-panel', + classList: classList(), + }; + globalThis.document = { + documentElement: { dataset: { uiTheme: 'tactical' } }, + getElementById(id) { + return id === 'global-context-panel' ? contextPanel : null; + }, + }; + t.after(() => { + globalThis.document = priorDocument; + }); + + const radioDock = { classList: classList(['disclosure-open']) }; + const calls = []; + const owner = { + _contextRadioDock: radioDock, + _setRadioDisclosure(open) { + calls.push(open); + radioDock.classList.toggle('disclosure-open', open); + }, + _leftPanelStack: null, + _rightPanelStack: null, + _panelLayout: { + _leftStackPreferredPanelId: null, + _rightStackPreferredPanelId: null, + }, + _syncPanelCollapseButton() {}, + _scheduleLeftPanelLayout() {}, + _scheduleRightPanelLayout() {}, + shareLinkManager: null, + cockpitView: null, + }; + + PanelChrome.prototype.setPanelCollapsed.call( + owner, + 'global-context-panel', + false, + ); + + assert.deepEqual(calls, [false]); + assert.equal(radioDock.classList.contains('disclosure-open'), false); +}); + +test('adaptive rail restore hides compact Radio before revealing Context', (t) => { + const priorRaf = globalThis.requestAnimationFrame; + let nextFrame; + globalThis.requestAnimationFrame = (callback) => { + nextFrame = callback; + return 1; + }; + t.after(() => { + globalThis.requestAnimationFrame = priorRaf; + }); + + // Valid starting state: the tactical rail automatically collapsed Context, + // and the user opened the compact Radio controls from its header. + const contextPanel = { + id: 'global-context-panel', + classList: classList(['collapsed', 'layout-auto-collapsed']), + }; + const radioDock = { classList: classList(['disclosure-open']) }; + const radioMini = { hidden: false }; + const calls = []; + const chrome = { + _contextRadioDock: radioDock, + _setRadioDisclosure(open) { + calls.push(`disclosure:${open}`); + radioDock.classList.toggle('disclosure-open', open); + radioMini.hidden = !open; + }, + _syncPanelCollapseButton(panel) { + calls.push(`button:${panel.id}`); + }, + }; + const layout = { + destroyed: false, + _rightStackLayoutFrame: null, + _rightStackReconsiderAutoCollapse: false, + _rightPanelStack: { + querySelectorAll: (selector) => + selector === '.layout-auto-collapsed' ? [contextPanel] : [], + }, + _syncPanelCollapseButton(panel) { + PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel); + }, + _syncRightPanelAdaptiveLayout() { + calls.push('layout'); + }, + }; + + PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, { + reconsiderAutoCollapse: true, + }); + assert.equal(contextPanel.classList.contains('collapsed'), true); + assert.equal(radioMini.hidden, false); + nextFrame(); + + assert.equal(contextPanel.classList.contains('collapsed'), false); + assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false); + assert.equal(radioDock.classList.contains('disclosure-open'), false); + assert.equal(radioMini.hidden, true); + assert.deepEqual(calls, [ + 'disclosure:false', + 'button:global-context-panel', + 'layout', + ]); +}); + +test('real PanelChrome constructor wires rail restore to compact Radio owner', (t) => { + const priorDocument = globalThis.document; + const priorRaf = globalThis.requestAnimationFrame; + let nextFrame; + const contextPanel = { + id: 'global-context-panel', + classList: classList(['collapsed', 'layout-auto-collapsed']), + }; + const rightRail = { + querySelectorAll(selector) { + return selector === '.layout-auto-collapsed' ? [contextPanel] : []; + }, + }; + globalThis.document = { + getElementById(id) { + return id === 'right-context-rail' ? rightRail : null; + }, + }; + globalThis.requestAnimationFrame = (callback) => { + nextFrame = callback; + return 1; + }; + t.after(() => { + globalThis.document = priorDocument; + globalThis.requestAnimationFrame = priorRaf; + }); + + const dock = { classList: classList(['disclosure-open']) }; + const calls = []; + const chrome = new PanelChrome({ + elements: { _contextRadioDock: dock }, + operations: { + _setRadioDisclosure(open) { + calls.push(`disclosure:${open}`); + dock.classList.toggle('disclosure-open', open); + }, + _syncPanelCollapseButton(panel) { + calls.push(`button:${panel.id}`); + }, + }, + readHud: () => ({ visible: true, getVariant: () => 'tactical' }), + readCockpit: () => null, + readShareLinks: () => null, + readInitialShare: () => null, + readScrollRestoreOwner: () => null, + readDisplayScrollTop: () => 0, + }); + chrome._panelLayout._syncRightPanelAdaptiveLayout = () => { + calls.push('layout'); + }; + + chrome._panelLayout._scheduleRightPanelLayout({ + reconsiderAutoCollapse: true, + }); + assert.equal(typeof nextFrame, 'function'); + assert.equal(dock.classList.contains('disclosure-open'), true); + nextFrame(); + + assert.equal(contextPanel.classList.contains('collapsed'), false); + assert.equal(dock.classList.contains('disclosure-open'), false); + assert.deepEqual(calls, [ + 'disclosure:false', + 'button:global-context-panel', + 'layout', + ]); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 26974ce60..bf3e5b8b8 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1,6 +1,5 @@ import { layerSnapshot, - layerSnapshots, feedProvenanceEnvelope, } from '../data/layerSnapshot.js'; import { readLayerLifecycleSummary } from './layerSummary.js'; @@ -27,7 +26,6 @@ import { isContextRecordActive, } from '../data/contextStore.js'; import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; -import { contextModeWord } from '../contextModePolicy.js'; import { createAnalystEngine, normalizeAnalystSpec, @@ -48,6 +46,12 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; +import { + getWorldPacket, + withContextModeVocabulary, + activeContactsWindow, + TRACKABLE_FAMILIES, +} from '../agent/worldPacket.js'; const ALLOWED_STYLES = new Set([ 'normal', @@ -119,64 +123,6 @@ const CONTEXT_MODE_ALIASES = new Map([ ['space-missions', 'space-missions'], ['missions', 'space-missions'], ]); -/** - * Every model-readable field that carries a context-mode id, and what an - * absent value means for each. - * - * `mode` always names a mode, so nothing is 'off'. `entering` and `priorMode` - * are absent when there is no such mode at all — calling those 'off' would - * assert a state that does not exist. - */ -const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([ - { field: 'mode', emptyAs: 'off' }, - { field: 'entering', emptyAs: null }, - { field: 'priorMode', emptyAs: null }, -]); - -/** Nested results that are themselves context-mode payloads the model reads. */ -const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ - 'context', - 'contextRollback', -]); - -/** - * Report a context-mode payload in the tools' own vocabulary. - * - * `set_context_mode` accepts 'contacts' while the mode's internal id is - * 'flights'. Reporting the internal id back made the model read - * `mode:'flights'` as "Contacts is off" and refuse to answer from the Contacts - * window counts sitting in the very same payload (owner field session - * 2026-08-21). Secondary fields and nested transition/rollback results are - * translated too — one leaked internal id is enough to recreate the confusion, - * and a rollback result is exactly what the model reads when something went - * wrong. Each internal id is kept alongside as `Internal` for anything - * reasoning about layers. - * @param {object|null|undefined} state Any payload carrying context-mode fields. - * @returns {object|null|undefined} The same payload, modes translated. - */ -function withContextModeVocabulary(state) { - if (!state || typeof state !== 'object') return state; - let out = state; - const mutable = () => { - if (out === state) out = { ...state }; - return out; - }; - for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) { - if (!(field in state)) continue; - const internal = state[field] ?? null; - const target = mutable(); - target[field] = contextModeWord(internal, { emptyAs }); - target[`${field}Internal`] = internal; - } - for (const field of NESTED_CONTEXT_RESULT_FIELDS) { - const nested = state[field]; - if (!nested || typeof nested !== 'object') continue; - const translated = withContextModeVocabulary(nested); - if (translated !== nested) mutable()[field] = translated; - } - return out; -} - const COCKPIT_ACTION_ALIASES = new Map([ ['next', 'next'], ['previous', 'previous'], @@ -246,14 +192,6 @@ const STACK_ALIASES = new Map([ ['road map', 'osm'], ]); -/** Search order for track_entity across entity layer families. */ -const TRACKABLE_FAMILIES = [ - { layerId: 'flights', kind: 'aircraft' }, - { layerId: 'military', kind: 'aircraft' }, - { layerId: 'ais-live-vessels', kind: 'vessel' }, - { layerId: 'satellites', kind: 'satellite' }, -]; - const FRAME_TARGETS = new Map([ ['flights', 'flights'], ['planes', 'flights'], @@ -2553,26 +2491,6 @@ function runManagedVoiceNavigation( }; } -/** Gathers tracked/selected entities across layer families for read-back. */ -function collectTrackedEntities(dataManager) { - const tracked = []; - for (const family of TRACKABLE_FAMILIES) { - const module = dataManager.layers.get(family.layerId)?.module; - if (!module) continue; - try { - const info = - family.kind === 'vessel' - ? module.getSelectedInfo?.() - : module.getTrackedInfo?.(); - if (info) - tracked.push({ kind: family.kind, layerId: family.layerId, ...info }); - } catch { - // layer not ready - } - } - return tracked; -} - export async function getBasemapLabelContext( viewer, service = defaultGeospatial, @@ -3210,60 +3128,12 @@ function getCurrentViewState( dataManager, sceneDirector = null, ) { - const cartographic = Cesium.Cartographic.fromCartesian( - viewer.camera.positionWC, - ); - return { - ok: true, - action: 'get_current_view_state', - camera: { - latitude: Cesium.Math.toDegrees(cartographic.latitude), - longitude: Cesium.Math.toDegrees(cartographic.longitude), - heightM: cartographic.height, - }, - style: styleManager.activeStyle || 'normal', - context: - typeof styleManager.getContextModeState === 'function' - ? { - ...withContextModeVocabulary(styleManager.getContextModeState()), - // The numbers on the operator's Contacts panel, so a window/count - // question can be answered from what they are looking at. - ...(activeContactsWindow(dataManager) - ? { contactsWindow: activeContactsWindow(dataManager) } - : {}), - } - : null, - cockpit: - typeof styleManager.getCockpitState === 'function' - ? styleManager.getCockpitState() - : null, - controls: - typeof styleManager.getControlState === 'function' - ? styleManager.getControlState() - : null, - scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, - tracked: collectTrackedEntities(dataManager), - // Enabled layers only: the full registry cost ~1k tokens per call and - // the model only reasons about what is on. - disabledLayerCount: dataManager.getAll().filter((layer) => !layer.enabled) - .length, - layers: dataManager - .getAll() - .filter((layer) => layer.enabled) - .map((layer) => ({ - id: layer.id, - name: layer.name, - enabled: layer.enabled, - count: layer.stats?.count || 0, - error: layer.stats?.error || null, - feedState: layerSnapshot(layer).feedState, - source: layerSnapshot(layer).source, - lastUpdate: layerSnapshot(layer).lastUpdate, - })), - feedProvenance: feedProvenanceEnvelope( - layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled), - ), - }; + return getWorldPacket({ + viewer, + styleManager, + dataManager, + sceneDirector, + }); } async function getEntityContext( @@ -4635,23 +4505,6 @@ function clampNumber(value, min, max, fallback) { * ("which of those is closest?") scoped to the session. */ -/** - * The Contacts panel's own counts, or null when Contacts has no subject. - * Read through the awareness snapshot the panel renders, so the two cannot - * diverge no matter which surface asks. - * @returns {object|null} Panel-equivalent window counts. - */ -function activeContactsWindow(dataManager) { - try { - const layer = dataManager?.layers?.get('military-awareness')?.module; - return ( - layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null - ); - } catch { - return null; - } -} - /** * Most records one layer hands a single query. Above every loaded feed seen * so far (flights ~8k, vessels ~12k, a 24 h global FIRMS day ~160k), so