From ccec3ce39bbb7d2da7a461f5b08a9ff4c5975f67 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Sun, 9 Aug 2026 18:02:39 -0700 Subject: [PATCH] fix(gui): open add-an-app choices through the deep-link intro, not a + tile morph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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. --- src/gui/src/UI/Dashboard/TabApps.js | 61 +++++++++++++++++------------ src/gui/src/UI/UIWindow.js | 42 ++++++-------------- 2 files changed, 50 insertions(+), 53 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 7406bbb7a..7255c01dd 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -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/ 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 () { diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index f4bda463e..c0359b4ec 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -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 // ---------------------------------------------------------------------