diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js
index fe82c639a..095ae1e86 100644
--- a/src/gui/src/UI/Dashboard/TabApps.js
+++ b/src/gui/src/UI/Dashboard/TabApps.js
@@ -1,5 +1,6 @@
import UIContextMenu from '../UIContextMenu.js';
import UIAlert from '../UIAlert.js';
+import launch_app from '../../helpers/launch_app.js';
import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js';
@@ -313,6 +314,7 @@ const TabApps = {
_pendingLoad: null,
_savedOrderNames: null,
_orderSavedAtSeq: 0,
+ _launchingApps: new Set(),
html () {
let h = '
';
@@ -349,8 +351,9 @@ const TabApps = {
});
// Handle app tile clicks. External apps carry a target link (their
- // index_url) and open the app's website directly; everything else
- // opens the Puter app page — matching the Home tab.
+ // index_url) and open the app's website directly in a new browser tab
+ // (an external site can't be reliably iframed); everything else
+ // launches the app as a maximized window in this same page.
$el_window.on('click', '.myapps-tile', function (e) {
e.preventDefault();
e.stopPropagation();
@@ -364,7 +367,27 @@ const TabApps = {
if ( targetLink && targetLink !== '' ) {
window.open(targetLink, '_blank', 'noopener,noreferrer');
} else if ( appName ) {
- window.open(`/app/${appName}`, '_blank', 'noopener,noreferrer');
+ // One instance per app when launched from here: un-hide a
+ // minimized instance / focus a visible one rather than
+ // launching a duplicate.
+ const $existing = $(`.window[data-app="${html_encode(appName)}"]`);
+ if ( $existing.length ) {
+ const $win = $existing.last();
+ const minimized = $win.attr('data-is_minimized');
+ if ( minimized === '1' || minimized === 'true' ) {
+ $win.showWindow();
+ } else {
+ $win.focusWindow();
+ }
+ return;
+ }
+ // A second click while the first launch's fetches are still in
+ // flight has no window to find yet — swallow it instead of
+ // spawning a duplicate instance.
+ if ( self._launchingApps.has(appName) ) return;
+ self._launchingApps.add(appName);
+ launch_app({ name: appName, maximized: true })
+ .finally(() => self._launchingApps.delete(appName));
}
});
diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js
index e5655bc88..65ff6a3cf 100644
--- a/src/gui/src/UI/Dashboard/UIDashboard.js
+++ b/src/gui/src/UI/Dashboard/UIDashboard.js
@@ -65,6 +65,10 @@ async function UIDashboard (options) {
// eslint-disable-next-line no-unused-vars
options = options ?? {};
+ // Mark dashboard mode on so window chrome can adapt — e.g. app
+ // windows keep their minimize button even though fullpage-mode hides it.
+ $('body').addClass('dashboard-mode');
+
// Create mutable tabs array from built-in tabs
const tabs = [...builtinTabs];
diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js
index 4978f2892..2dcd064d6 100644
--- a/src/gui/src/UI/UIWindow.js
+++ b/src/gui/src/UI/UIWindow.js
@@ -3821,6 +3821,25 @@ $.fn.showWindow = async function (options) {
if ( $(this).hasClass('window') ) {
// show window
const el_window = this;
+
+ // A window minimized with no taskbar to animate toward (dashboard
+ // mode) was simply hidden in place by hideWindow — its geometry
+ // was never disturbed, so just un-hide it. (The explicit flag is
+ // used because data-orig-* can't distinguish the two minimize
+ // paths: drag/maximize handlers set it too.)
+ if ( $(el_window).attr('data-minimized_in_place') === '1' ) {
+ $(el_window).attr({
+ 'data-is_minimized': false,
+ 'data-minimized_in_place': '0',
+ });
+ $(el_window).fadeIn(150);
+ $(el_window).css('z-index', ++window.last_window_zindex);
+ setTimeout(() => {
+ $(this).focusWindow();
+ }, 80);
+ return;
+ }
+
$(el_window).css({
'transition': 'top 0.2s, left 0.2s, bottom 0.2s, right 0.2s, width 0.2s, height 0.2s',
top: `${$(el_window).attr('data-orig-top') }px`,
@@ -3963,6 +3982,19 @@ $.fn.hideWindow = async function (options) {
// get taskbar item location
let taskbar_item_pos = $(`.taskbar .taskbar-item[data-app="${$(this).attr('data-app')}"]`).position();
+ // No taskbar item to animate toward (e.g. dashboard mode, which
+ // has no taskbar): simply hide the window in place, geometry
+ // untouched. showWindow() un-hides it via the
+ // data-minimized_in_place flag.
+ if ( ! taskbar_item_pos ) {
+ $(this).attr({
+ 'data-is_minimized': true,
+ 'data-minimized_in_place': '1',
+ });
+ $(this).fadeOut(150);
+ return;
+ }
+
// Calculate animation target based on taskbar position
let animationTarget = {};
const taskbarPosition = window.taskbar_position || 'bottom';
diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css
index ec039bc5c..f8a1c87f9 100644
--- a/src/gui/src/css/dashboard.css
+++ b/src/gui/src/css/dashboard.css
@@ -4198,3 +4198,17 @@ body.myapps-reordering .myapps-tile {
.submenu-arrow {
margin-left: auto;
}
+
+/* Dashboard mode runs fullpage (which normally hides the minimize button
+ because there's no taskbar to restore from), but here a minimized app can
+ be brought back from its Apps-tab tile — so keep the button available. */
+body.dashboard-mode .window-minimize-btn {
+ display: inline;
+}
+
+/* Fullpage mode sizes .window-body-app to 100% because fullpage windows have
+ no titlebar — but dashboard-mode app windows keep their 29px head, so
+ without this the bottom of every app is clipped by that much. */
+body.dashboard-mode .window-body-app {
+ height: calc(100% - 29px) !important;
+}