Hold a voice orbit, pan or rotate until its fly-to lands

"Fly to Paris and orbit it" reaches the app as two sibling tool calls,
which OpenAI runs at once. With a place that needs a lookup, the orbit
started over the old view and the late flight then replaced it; with a
known place, pan and rotate cancelled the flight already in the air.

Orbit, pan and rotate now wait for a pending voice flight the same way
tilt does: up to 30 s, and not run (saying so) if the flight is
cancelled, finds nothing or times out. Stop and rotate_globe do not
wait. Orbit keeps its own arming for flights the voice runner does not
track.
This commit is contained in:
Manjunath committed 2026-11-03 16:42:50 +05:30
1 parent 91a3f787d4
commit 75000cf6a9
4 files changed
+117 -10

No files matched your search

+5 -3
View File
@@ -1747,9 +1747,11 @@ export function moveCamera(args = {}, runNavigation = null) {
}
if (motion === 'orbit') {
if (_viewer.scene.tweens.length > 0) {
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm
// and capture at the destination; a target existing right now is
// meaningless (the globe is always under the crosshair).
// A camera flight is in progress — ALWAYS arm and capture at the
// destination; a target existing right now is meaningless (the globe
// is always under the crosshair). The voice runner already holds an
// orbit until its own fly-to lands; this covers any other
// flight.
setActiveMotion({
kind: 'orbit',
direction: direction || 'right',
+18 -6
View File
@@ -1188,8 +1188,9 @@ export function createGevActionRunner({
}
if (name === 'move_camera') {
const motion = String(args.motion || '').toLowerCase();
if (
String(args.motion || '').toLowerCase() === 'tilt' &&
VOICE_FLIGHT_WAITING_MOTIONS.has(motion) &&
voiceFlight &&
!voiceFlight.done
) {
@@ -1200,9 +1201,8 @@ export function createGevActionRunner({
return {
ok: false,
action: 'move_camera',
motion: 'tilt',
error:
'Not run: the flight this tilt was waiting for did not arrive.',
motion,
error: `Not run: the flight this ${motion} was waiting for did not arrive.`,
};
}
return moveCamera(args, (navigate, releaseOptions) =>
@@ -2721,7 +2721,6 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) {
);
}
/** Run one validated voice camera mutation through the UI-owned authority seam. */
/** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */
function trackVoiceFlight() {
let resolve;
@@ -2739,9 +2738,21 @@ function trackVoiceFlight() {
return flight;
}
/** Longest a tilt waits for a flight (geocode plus flight) before giving up. */
/** Longest a motion waits for a flight (geocode plus flight) before giving up. */
const VOICE_FLIGHT_WAIT_MS = 30_000;
/**
* Motions that act on the place the camera ends up at, so one sent beside a
* fly-to waits for it. Stop is not one, and rotate_globe turns the
* whole Earth from Globe View rather than at a place.
*/
const VOICE_FLIGHT_WAITING_MOTIONS = new Set([
'tilt',
'orbit',
'pan',
'rotate',
]);
/** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */
function waitForVoiceFlight(flight, signal) {
if (signal?.aborted) return Promise.resolve('aborted');
@@ -2758,6 +2769,7 @@ function waitForVoiceFlight(flight, signal) {
});
}
/** Run one validated voice camera mutation through the UI-owned authority seam. */
function runManagedVoiceNavigation(
styleManager,
noun,
+93
View File
@@ -340,6 +340,99 @@ test('a tilt with no voice flight pending runs at once', async () => {
assert.equal(result.ok, true);
});
// Orbit, pan and rotate also act at the place the camera ends up, so "fly to
// Paris and orbit it" holds the orbit the same way.
for (const motion of [
{ motion: 'orbit' },
{ motion: 'pan', direction: 'left' },
{ motion: 'rotate', direction: 'right' },
]) {
test(`${motion.motion} in the same reply as a fly-to waits for the flight to land`, async () => {
const { viewer, styleManager, order, flights } = 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);
const movedDone = settledYet(moved);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(movedDone(), false, `the ${motion.motion} must wait while the camera flies`);
assert.equal(
order.slice(order.indexOf('fly')).includes('cancel'),
false,
`the ${motion.motion} must not cancel the flight`,
);
flights[0].complete();
const result = await moved;
assert.equal(result.ok, true);
assert.equal(result.motion, motion.motion);
interruptCameraMotion('test-cleanup');
});
test(`${motion.motion} waits through a geocoded fly-to search, then for its flight`, async () => {
const { viewer, styleManager } = tiltAfterFlightHarness();
let finishSearch;
let landFlight;
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
searchNavigation: (_viewer, query, options) => new Promise((resolve) => {
finishSearch = () => {
landFlight = options.onComplete;
resolve({ label: query, navigationMode: 'city-overview' });
};
}),
});
const fly = runner('fly_to_location', { query: 'Somewhere Far' });
const moved = runner('move_camera', motion);
const movedDone = settledYet(moved);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(movedDone(), false, `the ${motion.motion} must wait for the place lookup`);
assert.equal(getActiveCameraMotion(), null, 'nothing moves before the flight');
finishSearch();
assert.equal((await fly).ok, true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(movedDone(), false, `the ${motion.motion} must wait for the flight too`);
landFlight();
assert.equal((await moved).ok, true);
assert.equal(getActiveCameraMotion()?.kind, motion.motion);
interruptCameraMotion('test-cleanup');
});
test(`${motion.motion} waiting on a flight that never lands is not run`, async () => {
const { viewer, styleManager, flights } = tiltAfterFlightHarness();
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
});
await runner('fly_to_location', { locationId: 'austin' });
const moved = runner('move_camera', motion);
flights[0].cancel();
const result = await moved;
assert.equal(result.ok, false);
assert.equal(result.motion, motion.motion);
assert.match(result.error, new RegExp(`this ${motion.motion} was waiting for did not arrive`));
assert.equal(getActiveCameraMotion(), null);
});
}
test('stop does not wait for a voice flight', async () => {
const { viewer, styleManager } = tiltAfterFlightHarness();
const runner = createGevActionRunner({
viewer,
styleManager,
dataManager: { layers: new Map(), getAll: () => [] },
});
await runner('fly_to_location', { locationId: 'austin' });
const result = await runner('move_camera', { motion: 'stop' });
assert.equal(result.ok, true);
assert.equal(result.motion, 'stop');
});
test('nearest-aircraft voice action serializes layer enable, arrival, refresh, airborne query, and selection', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { viewer, styleManager } = createVoiceNavigationHarness();