fix(gui): open add-an-app choices through the deep-link intro, not a + tile morph

60686643b made App Center and the AI builder morph out of the + tile the
user clicked, which answers where the window came from but not the
question these two options exist to answer: where the APP went. The grid
never showed it arriving, so the user still ended a launch with nothing
to find, no tile for minimize to fly back to, and no lesson learned.

Choosing either option now plays exactly what an /app/<name> landing
plays (beginDeepLinkLaunch): when the app already has a tile the intro
travels to it, plays the click flourish, and the window grows out of the
app's own slot; when it doesn't, the tile is INSTALLED first — the slot
opens, the progress stroke draws, the icon springs in — and only then
does the window morph out of the tile that just arrived. Same beats,
same exposure decay, same interruptibility, same duplicate-launch claim.

The app info is prefetched in parallel and handed to launch_app, the
same contract as initgui's landing path, so the intro (which may need to
draw the arriving tile from it) never costs a second round-trip.

This also reverts 60686643b's anchor plumbing (UIWindow's
dashboard_tile_el option and the dashboard_tile_in_view export): the
intro installs a real tile for the by-app-name morph to find, so no
stand-in anchor is needed.
This commit is contained in:
Nariman Jelveh
2026-08-09 18:02:39 -07:00
parent 60686643b3
commit ccec3ce39b
2 changed files with 50 additions and 53 deletions
+37 -24
View File
@@ -2,7 +2,7 @@ import UIContextMenu from '../UIContextMenu.js';
import UIAlert from '../UIAlert.js';
import launch_app from '../../helpers/launch_app.js';
import revokeAppSessions from '../../helpers/revoke_app_sessions.js';
import { begin_dashboard_tile_launch, dashboard_tile_in_view, settle_dashboard_tile_launch } from '../UIWindow.js';
import { begin_dashboard_tile_launch, settle_dashboard_tile_launch } from '../UIWindow.js';
import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js';
import { parseRemovedApps, serializeRemovedApps, REMOVED_APPS_KV_KEY } from './removedApps.js';
@@ -654,33 +654,46 @@ function showAddAppModal ({ $el_window }) {
else close();
});
// Opened exactly as a tile click opens it, click flourish and
// grow-out-of-the-icon morph included. The anchor is resolved HERE, once,
// so the icon half and the window half can never disagree about which
// tile this launch came from: the app's own tile when it already has one
// on screen (minimize will fly the window back to it, so that is where
// opening should come from), and otherwise the + tile the user clicked —
// which is the whole point for an app they don't have yet, since
// UIWindow's by-app-name lookup can't find a tile that doesn't exist.
// Opened exactly like an /app/<name> landing: the same pedagogical
// click→morph→open intro plays (see beginDeepLinkLaunch) — and since
// choosing App Center or the AI builder is usually asking for an app the
// grid doesn't have yet, the intro is also what INSTALLS it: the tile
// arrives in the grid with the install choreography, and only then does
// the window grow out of the app's own slot. The user watches the app
// they asked for become a tile they can find again; the window never
// just appears from nowhere.
const openApp = appName => {
if ( focusExistingAppWindow(appName) ) return;
const tile = dashboard_tile_in_view(appName)
|| $el_window.find('.myapps-add-tile')[0]
|| null;
begin_dashboard_tile_launch(tile);
launch_app({
name: appName,
maximized: true,
window_options: {
morph_from_dashboard_tile: true,
dashboard_tile_el: tile,
},
})
.catch(err => {
// Same duplicate-launch guard as the tile click handler — a second
// trip through the modal mid-intro must not spawn a second instance.
if ( TabApps._launchingApps.has(appName) ) return;
// Fetched in parallel with the intro, which may need it to DRAW the
// arriving tile, then handed to launch_app so the intro never costs
// a second app-info round-trip — the same contract as the landing's
// prefetch in initgui. A failed prefetch hands nothing over;
// launch_app refetches and fails the way it always did.
const app_info_promise = puter.apps.get(appName, { icon_size: 128 })
.catch(() => null);
(async () => {
let tile = null;
try {
tile = await TabApps.beginDeepLinkLaunch(appName, $el_window, app_info_promise);
} catch ( _e ) {
// No intro — still launch.
}
const app_obj = await app_info_promise;
launch_app({
name: appName,
maximized: true,
...(app_obj ? { app_obj } : {}),
window_options: { morph_from_dashboard_tile: true },
}).catch(err => {
console.error(`Failed to launch ${appName}:`, err);
UIAlert(i18n('something_went_wrong'));
})
.finally(() => settle_dashboard_tile_launch(tile));
}).finally(() => {
TabApps.settleDeepLinkLaunch(appName, tile);
});
})();
};
$overlay.on('click', '.myapps-add-option', function () {
+13 -29
View File
@@ -716,14 +716,7 @@ async function UIWindow (options) {
if ( options.morph_from_dashboard_tile && window.animate_window_opening
&& options.is_visible && ! options.fadeIn
&& ! (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) ) {
// A named tile wins over the by-app-name lookup: the launcher knows
// which icon the user actually clicked, and it is the icon whose
// click feedback has already played. The grid's add-an-app tile uses
// this to open apps that have no tile of their own — an app you
// don't have yet can't be found by name (see showAddAppModal).
const tile = (options.dashboard_tile_el && dashboard_tile_visible(options.dashboard_tile_el))
? options.dashboard_tile_el
: dashboard_tile_in_view(options.app);
const tile = dashboard_tile_in_view(options.app);
if ( tile ) {
// .window is display:none from the stylesheet until the show()
// further down; the morph needs the window laid out to measure
@@ -4161,14 +4154,23 @@ $.fn.focusWindow = function (event) {
* the user will actually look for the app (and where opening it from will put
* it back). See buildGroupTileHtml for the data-group-apps this reads.
*/
export function dashboard_tile_in_view (app_name) {
function dashboard_tile_in_view (app_name) {
if ( ! app_name || typeof CSS === 'undefined' || ! CSS.escape ) return null;
const in_view = tile => {
const rect = tile.getBoundingClientRect();
if ( rect.width <= 0 || rect.height <= 0 ) return false;
const scroller = tile.closest('.myapps-pager-scroller');
const clip = (scroller || tile.parentElement).getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
return cx >= clip.left && cx <= clip.right && cy >= clip.top && cy <= clip.bottom;
};
const tiles = document.querySelectorAll(
`.dashboard-section-apps.active .myapps-tile[data-app-name="${CSS.escape(app_name)}"]`,
);
for ( const tile of tiles ) {
if ( dashboard_tile_visible(tile) ) return tile;
if ( in_view(tile) ) return tile;
}
const folders = document.querySelectorAll('.dashboard-section-apps.active .myapps-group-tile');
@@ -4179,29 +4181,11 @@ export function dashboard_tile_in_view (app_name) {
} catch ( _e ) {
continue;
}
if ( Array.isArray(names) && names.includes(app_name) && dashboard_tile_visible(folder) ) return folder;
if ( Array.isArray(names) && names.includes(app_name) && in_view(folder) ) return folder;
}
return null;
}
/**
* Whether a tile is rendered AND on the pager page currently in view. The
* page test is the point: pages sit side by side in a horizontal scroller,
* so an off-page tile has a real rendered box just past the scroller's clip
* edge, and a window morphing out of one would grow from nowhere the user
* is looking.
*/
function dashboard_tile_visible (tile) {
if ( ! tile || ! tile.getBoundingClientRect ) return false;
const rect = tile.getBoundingClientRect();
if ( rect.width <= 0 || rect.height <= 0 ) return false;
const scroller = tile.closest('.myapps-pager-scroller');
const clip = (scroller || tile.parentElement).getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
return cx >= clip.left && cx <= clip.right && cy >= clip.top && cy <= clip.bottom;
}
// ---------------------------------------------------------------------
// Dashboard app URL ownership
// ---------------------------------------------------------------------