Drop a queued voice motion when another navigation takes the camera mid-flight

A motion queued behind a voice fly-to checked only that its reply was current and that the user had not touched the map. A location-panel search, share link or file import stamps a deferred navigation without stopping the flight, so the flight landed, the motion took navigation, and the search's reassert then failed: the search was silently dropped while the camera kept orbiting.

The runner now counts camera handoffs from subscribeCameraHandoff, less the ones voice fly-tos make themselves, and each voice flight records that count when it starts. A motion whose flight saw another navigation since then is not run and says so. Voice fly-tos' own stamps do not count, so a later fly-to in the same reply, including a lookup that finds nothing, is still followed as before. The test harness now publishes handoffs and stamps its voice lookups, as the shell does.
This commit is contained in:
Sameh Khamis committed 2026-11-05 15:48:21 -08:00
1 parent c4910d24d0
commit 1eef14454a
3 files changed
+113 -6

No files matched your search

+26
View File
@@ -296,6 +296,16 @@ export function createGevActionRunner({
let voiceFlight = null;
// Waiters to tell when a newer voice flight begins.
const voiceFlightWatchers = new Set();
// Camera handoffs (navigation stamps) seen, and how many of them voice
// fly-tos made. A search, share link or import that stamps during a flight
// leaves it flying; the motion queued behind it then yields to that one.
let cameraHandoffs = 0;
let voiceFlightHandoffs = 0;
const otherHandoffs = () => cameraHandoffs - voiceFlightHandoffs;
const unsubscribeCameraHandoff =
styleManager?.subscribeCameraHandoff?.(() => {
cameraHandoffs += 1;
}) ?? null;
let analystEngine;
// Area handles live for this runner (the page): resolve_area, the
// analyst's region/area/drawn scopes, find_imagery and osm_query all read
@@ -1155,6 +1165,8 @@ export function createGevActionRunner({
prior && !prior.done ? prior.inputCount : undefined,
prior,
);
// Navigation by anything but a voice fly-to since this one began.
flight.handoffs = otherHandoffs();
voiceFlight = flight;
for (const notify of [...voiceFlightWatchers]) notify();
// A motion already running (one the model sent before this fly-to) is
@@ -1164,9 +1176,13 @@ export function createGevActionRunner({
(hook) =>
(...hookArgs) => {
const before = getActiveCameraMotion();
const handoffsBefore = cameraHandoffs;
try {
return hook(...hookArgs);
} finally {
// A voice fly-to's own stamps are not another navigation, even
// for the flight it replaces (a later lookup may find nothing).
voiceFlightHandoffs += cameraHandoffs - handoffsBefore;
if (
before &&
!stoppedMotion &&
@@ -1383,6 +1399,15 @@ export function createGevActionRunner({
motion,
error: `Not run: the user moved the camera during the flight, so the ${motion} was dropped.`,
};
// A search, share link or import that took navigation during the
// flight leaves it flying; running now would cancel that navigation.
if (otherHandoffs() !== flight.handoffs)
return {
ok: false,
action: 'move_camera',
motion,
error: `Not run: another navigation took the camera during the flight, so the ${motion} was dropped.`,
};
}
return moveCamera(args, (navigate, releaseOptions) =>
runManagedVoiceNavigation(
@@ -1712,6 +1737,7 @@ export function createGevActionRunner({
if (disposed) return;
runVoiceAction.resetConversation();
disposed = true;
unsubscribeCameraHandoff?.();
voiceAreas.dispose();
};
return runVoiceAction;
+86 -5
View File
@@ -143,6 +143,14 @@ test('track_entity runner narrates a callsign-less aircraft by its registration'
function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
const order = [];
let generation = 0;
// Camera handoffs, published on every navigation stamp as the shell does.
const handoffListeners = new Set();
const stamp = (noun) => {
generation += 1;
order.push(`stamp:${noun}`);
for (const listener of [...handoffListeners]) listener({ generation });
return generation;
};
const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500);
const viewer = {
trackedEntity: { id: 'prior-aircraft' },
@@ -179,11 +187,7 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
return runExplicitNavigation({
cockpitActive,
noun,
stamp: () => {
generation += 1;
order.push(`stamp:${noun}`);
return generation;
},
stamp: () => stamp(noun),
release: () => {
order.push('release');
viewer.trackedEntity = undefined;
@@ -195,12 +199,21 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) {
});
},
getDetectionState: () => ({ detectionMode: 'DENSE' }),
subscribeCameraHandoff(listener) {
handoffListeners.add(listener);
return () => handoffListeners.delete(listener);
},
// A voice lookup stamps when it begins and leaves any flight flying.
beginDeferredLocationNavigation: () => stamp('location-lookup'),
reassertDeferredLocationNavigation: (stamped) => stamped === generation,
};
return {
order,
viewer,
styleManager,
currentGeneration: () => generation,
// A location-panel search: a deferred stamp that leaves a flight flying.
panelSearch: () => stamp('panel-search'),
};
}
@@ -921,6 +934,74 @@ test('a motion follows a newer fly-to that lands while its own lookup stalls', a
assert.equal(result.motion, 'orbit');
});
// "Fly to Paris and orbit it", then a location-panel search while the camera
// flies: the search stamps a deferred navigation without stopping the flight.
// Run where the flight landed, the orbit took navigation and the search's
// reassert failed, so the search was silently dropped.
test('a motion queued behind a fly-to yields to a search started during the flight', async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager, flights, panelSearch, currentGeneration } =
tiltAfterFlightHarness();
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
});
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
const moved = runner('move_camera', { motion: 'orbit' });
await new Promise((resolve) => setTimeout(resolve, 0));
const search = panelSearch();
flights[0].complete();
const result = await moved;
assert.equal(result.ok, false);
assert.match(result.error, /Not run: another navigation took the camera/);
assert.equal(getActiveCameraMotion(), null);
assert.equal(currentGeneration(), search, 'the search still owns navigation');
});
test('a motion behind a geocoded fly-to yields to a search started while it flies', async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager, panelSearch } = tiltAfterFlightHarness();
const searches = [];
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
searchNavigation: (_viewer, query, options) => new Promise((resolve) => {
searches.push({ query, options, resolve });
}),
});
const fly = runner('fly_to_location', { query: 'Place A' });
const moved = runner('move_camera', { motion: 'orbit' });
await new Promise((resolve) => setTimeout(resolve, 0));
searches[0].resolve({ label: 'Place A', navigationMode: 'city-overview' });
assert.equal((await fly).ok, true);
panelSearch();
searches[0].options.onComplete();
const result = await moved;
assert.equal(result.ok, false);
assert.match(result.error, /another navigation took the camera/);
assert.equal(getActiveCameraMotion(), null);
});
test('a search before the fly-to does not drop the motion queued behind it', async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager, flights, panelSearch } = tiltAfterFlightHarness();
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
});
panelSearch();
assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true);
const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 });
await new Promise((resolve) => setTimeout(resolve, 0));
flights[0].complete();
const result = await moved;
assert.equal(result.ok, true);
assert.equal(result.motion, 'tilt');
});
test('a fly-to that stops a running motion says so', async (t) => {
t.after(() => interruptCameraMotion('test-cleanup'));
const { viewer, styleManager } = tiltAfterFlightHarness();