diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js
index 821946f58..fbff12f27 100644
--- a/src/gui/src/UI/Dashboard/TabApps.js
+++ b/src/gui/src/UI/Dashboard/TabApps.js
@@ -51,6 +51,12 @@ const APP_NAMES_NO_UNINSTALL = new Set([
* along with this program. If not, see .
*/
+// -- Add-an-app tile --
+/** Puter's app marketplace — the add-an-app tile's route to installing one. */
+const APP_CENTER_APP_NAME = 'app-center';
+/** Puter's AI app builder, which generates an app from a description. */
+const BUILDER_APP_NAME = 'builder';
+
// -- Drag-to-reorder tuning --
const DRAG_START_DISTANCE = 5; // px a pointer must travel before a drag begins
const MODE_LONGPRESS_MS = 500; // hold on empty grid space before reorder mode engages
@@ -222,6 +228,39 @@ function buildTileHtml (app) {
return h;
}
+// The tile at the tail of the grid that is not an app but the way to GET one
+// (see showAddAppModal). It wears the same .myapps-tile skeleton as an app so
+// hover, focus, and keyboard navigation treat it identically, and carries
+// neither an app name nor a group id — which is what keeps it out of the saved
+// order, the running dots, and the deep-link tile lookups, all of which key
+// off those attributes.
+function buildAddTileHtml () {
+ const label = i18n('add_app');
+ let h = `
`;
+ h += '
';
+ h += '';
+ h += '
';
+ h += `${label}`;
+ h += '
';
+ return h;
+}
+
+// One instance per app when opened from the dashboard: un-hide a minimized
+// instance / focus a visible one instead of launching a duplicate. Returns
+// whether an existing window took the request.
+function focusExistingAppWindow (appName) {
+ const $existing = $(`.window[data-app="${html_encode(appName)}"]`);
+ if ( ! $existing.length ) return false;
+ const $win = $existing.last();
+ const minimized = $win.attr('data-is_minimized');
+ if ( minimized === '1' || minimized === 'true' ) {
+ $win.showWindow();
+ } else {
+ $win.focusWindow();
+ }
+ return true;
+}
+
// The label a folder shows: its own name, or the default one if a rename
// somehow left it blank (a nameless tile is one the user can't tell apart).
function groupLabel (group) {
@@ -284,6 +323,7 @@ function parseTileGroupApps (tileEl) {
}
function buildGridItemHtml (item) {
+ if ( item.type === 'add' ) return buildAddTileHtml();
return item.type === 'group'
? buildGroupTileHtml(item.group, item.apps)
: buildTileHtml(item.app);
@@ -497,6 +537,189 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) {
});
}
+// What the grid's add-an-app tile asks: which of the three ways to get an app
+// do you want? Install one that already exists, have one built for you, or ask
+// for one that doesn't exist yet. Uses the same modal shell as the uninstall
+// confirmation above, with the options as rows rather than a question.
+function showAddAppModal ({ $el_window }) {
+ const options = [
+ {
+ key: 'browse',
+ icon: '',
+ title: i18n('add_app_browse'),
+ desc: i18n('add_app_browse_desc'),
+ },
+ {
+ key: 'ai',
+ icon: '',
+ title: i18n('add_app_ai'),
+ desc: i18n('add_app_ai_desc'),
+ },
+ {
+ key: 'request',
+ icon: '',
+ title: i18n('add_app_request'),
+ desc: i18n('add_app_request_desc'),
+ },
+ ];
+
+ let h = '
';
+ h += `
`;
+
+ // The three ways in.
+ h += '
';
+ h += `
${i18n('add_app')}
`;
+ h += '
';
+ for ( const option of options ) {
+ h += `';
+ }
+ h += '
';
+ h += '
';
+ h += ``;
+ h += '
';
+ h += '
';
+
+ // The request form. Lives in the same card so asking for an app is one
+ // step deeper into the same decision, not a second window over the top
+ // of it — Back returns to the options with whatever was typed intact.
+ h += '
';
+ h += '
';
+ h += `';
+ h += `
${i18n('add_app_request')}
`;
+ h += '
';
+ h += `
${i18n('add_app_request_c2a')}
`;
+ h += ``;
+ h += `
${i18n('add_app_request_failed')}
`;
+ h += '
';
+ h += ``;
+ h += ``;
+ h += '
';
+ h += '
';
+
+ // Sent.
+ h += '
';
+ h += '
';
+ h += '';
+ h += `
${i18n('add_app_request_sent')}
`;
+ h += '
';
+ h += '
';
+ h += ``;
+ h += '
';
+ h += '
';
+
+ h += '
';
+ h += '
';
+
+ const $overlay = $(h);
+ $el_window.append($overlay);
+
+ const close = () => {
+ $overlay.remove();
+ $(document).off('keydown.add-app-modal');
+ };
+
+ const currentPane = () => $overlay.find('.myapps-add-pane:not([hidden])').attr('data-pane');
+
+ const showPane = name => {
+ $overlay.find('.myapps-add-pane').attr('hidden', 'hidden');
+ $overlay.find(`.myapps-add-pane[data-pane="${name}"]`).removeAttr('hidden');
+ if ( name === 'request' ) {
+ $overlay.find('.myapps-add-message').focus();
+ } else if ( name === 'sent' ) {
+ $overlay.find('[data-pane="sent"] .myapps-modal-cancel').focus();
+ } else {
+ $overlay.find('.myapps-add-option').first().focus();
+ }
+ };
+
+ $overlay.on('click', '.myapps-modal-cancel', close);
+ $overlay.on('click', function (e) {
+ if ( e.target === $overlay[0] ) close();
+ });
+ $overlay.on('click', '.myapps-add-back', () => showPane('options'));
+ $(document).on('keydown.add-app-modal', function (e) {
+ if ( e.key !== 'Escape' ) return;
+ // Escape steps back out of the form before it closes the modal, so a
+ // half-written request survives a mis-hit key.
+ if ( currentPane() === 'request' ) showPane('options');
+ else close();
+ });
+
+ // Maximized like a tile launch, and without a morph: the add tile is not
+ // the app's own icon, so there is nothing for the window to grow out of.
+ const openApp = appName => {
+ if ( focusExistingAppWindow(appName) ) return;
+ launch_app({ name: appName, maximized: true }).catch(err => {
+ console.error(`Failed to launch ${appName}:`, err);
+ UIAlert(i18n('something_went_wrong'));
+ });
+ };
+
+ $overlay.on('click', '.myapps-add-option', function () {
+ const key = this.dataset.addOption;
+ if ( key === 'request' ) {
+ showPane('request');
+ return;
+ }
+ // An app is opening — the modal has nothing left to say.
+ close();
+ if ( key === 'browse' ) openApp(APP_CENTER_APP_NAME);
+ else if ( key === 'ai' ) openApp(BUILDER_APP_NAME);
+ });
+
+ // Nothing to send until something has been written.
+ $overlay.on('input', '.myapps-add-message', function () {
+ $overlay.find('.myapps-add-send').prop('disabled', this.value.trim() === '');
+ });
+
+ const send = async () => {
+ const $btn = $overlay.find('.myapps-add-send');
+ const message = String($overlay.find('.myapps-add-message').val() || '').trim();
+ if ( ! message || $btn.prop('disabled') ) return;
+ $btn.prop('disabled', true);
+ $overlay.find('.myapps-add-error').attr('hidden', 'hidden');
+ try {
+ const res = await fetch(`${window.api_origin}/contactUs`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${puter.authToken}`,
+ },
+ body: JSON.stringify({ message }),
+ });
+ if ( ! res.ok ) throw new Error(`contactUs responded ${res.status}`);
+ showPane('sent');
+ } catch ( err ) {
+ // Said inside the form rather than in an alert: the request is
+ // still here to retry, and an alert window over the modal is one
+ // the next click on the dashboard would bury.
+ console.error('Failed to send the app request:', err);
+ $overlay.find('.myapps-add-error').removeAttr('hidden');
+ $btn.prop('disabled', false);
+ }
+ };
+
+ $overlay.on('click', '.myapps-add-send', send);
+ // Enter alone belongs to the textarea (a request may need paragraphs).
+ $overlay.on('keydown', '.myapps-add-message', function (e) {
+ if ( e.key === 'Enter' && (e.metaKey || e.ctrlKey) ) {
+ e.preventDefault();
+ send();
+ }
+ });
+
+ showPane('options');
+}
+
function revealWhenLoaded ($container) {
const $pager = $container.find('.myapps-pager-loading');
if ( $pager.length === 0 ) return;
@@ -676,6 +899,13 @@ const TabApps = {
self._justDragged = false;
return;
}
+ // The tail tile isn't an app — it asks how you'd like to get one.
+ // Answered in every mode: it is never part of the arrangement
+ // reorder mode exists to edit.
+ if ( this.classList.contains('myapps-add-tile') ) {
+ showAddAppModal({ $el_window });
+ return;
+ }
// Folders open in every mode — inside reorder mode that is the
// only way to rearrange or empty one.
if ( this.dataset.groupId ) {
@@ -712,15 +942,7 @@ const TabApps = {
// 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();
- }
+ if ( focusExistingAppWindow(appName) ) {
closeFolder();
return;
}
@@ -763,6 +985,11 @@ const TabApps = {
// Context menu on right-click (and, where the platform fires it,
// touch long-press).
$el_window.on('contextmenu', '.myapps-tile', function (e) {
+ // The add-an-app tile has no app for a menu to act on.
+ if ( this.classList.contains('myapps-add-tile') ) {
+ e.preventDefault();
+ return;
+ }
// Reorder mode owns every tile gesture — no menu there; likewise
// suppress the menu (and any long-press callout) mid-drag.
if ( self._reorderMode || (self._drag && self._drag.started) ) {
@@ -1088,6 +1315,14 @@ const TabApps = {
return;
}
+ // The add-an-app tile rides at the tail of the grid: after every app,
+ // on the last page, and nowhere else — a drag can't place an app past
+ // it (see _updatePlaceholder) and a newly installed app arrives before
+ // it. Pushed here, so it counts as a slot when the pages are laid out
+ // below. Search results leave it out: a query asks which apps you
+ // HAVE, and a tile that matches nothing has no business in the answer.
+ if ( ! query ) items.push({ type: 'add' });
+
const layout = this.computeLayout($container);
if ( ! layout ) {
// Not laid out yet (e.g. hidden while the window enters full-page
@@ -1847,6 +2082,10 @@ const TabApps = {
// A press on the uninstall badge belongs to that button, not to a
// drag pickup — a finger wobble while tapping × must not lift the tile.
if ( oe.target && oe.target.closest && oe.target.closest('.myapps-tile-remove') ) return;
+ // The add-an-app tile holds the tail and isn't part of the
+ // arrangement: it can't be picked up, and nothing can be dropped onto
+ // it (see _updatePlaceholder).
+ if ( tileEl.classList.contains('myapps-add-tile') ) return;
// Reordering a filtered subset is ambiguous — only reorder the full list.
const query = String($el_window.find('.myapps-search').val() || '').trim();
if ( query ) return;
@@ -2203,6 +2442,11 @@ const TabApps = {
for ( let i = 0; i < tiles.length; i++ ) {
const t = tiles[i];
if ( t === d.tileEl ) continue;
+ // The add-an-app tile is never a drop target, so an app can never
+ // be placed after it — the probe over its slot reads as a gap and
+ // leaves the arrangement alone. It stays in `tiles` so it still
+ // FLIP-slides along when a drop from another page pushes it.
+ if ( t.classList.contains('myapps-add-tile') ) continue;
const r = t.__myappsRestRect || t.getBoundingClientRect();
const insetX = r.width * DRAG_HIT_INSET;
const insetY = r.height * DRAG_HIT_INSET;
diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css
index 0d2c03c2f..24dd3ef5f 100644
--- a/src/gui/src/css/dashboard.css
+++ b/src/gui/src/css/dashboard.css
@@ -1158,6 +1158,36 @@ input.myapps-search:disabled {
word-break: break-word;
}
+/* -- Add-an-app tile --
+ The tile at the tail of the grid (see buildAddTileHtml): a dashed, empty
+ app-shaped slot with a plus in it, so it reads as room for an app rather
+ than as an installed one. The border takes currentColor so a single
+ (theme-aware) color drives both it and the plus. */
+.myapps-add-icon {
+ border: 2px dashed;
+ border-radius: 14px;
+ color: var(--dashboard-text-tertiary);
+}
+
+.myapps-add-icon svg {
+ width: 24px;
+ height: 24px;
+}
+
+.myapps-add-tile .myapps-tile-label {
+ color: var(--dashboard-text-secondary);
+}
+
+@media (hover: hover) {
+ .myapps-add-tile:hover .myapps-add-icon {
+ color: var(--select-color);
+ }
+}
+
+.myapps-add-tile:focus-visible .myapps-add-icon {
+ color: var(--select-color);
+}
+
/* -- App folders --
A folder wears the same tile skeleton as an app, with its icon slot filled
by iOS's miniature 3x3 grid of what is inside. */
@@ -1493,6 +1523,197 @@ input.myapps-group-name:focus {
cursor: default;
}
+/* Add-an-app chooser (see showAddAppModal): the three ways to get an app, as
+ rows in the same modal shell. min-width gives the two-line rows room without
+ overflowing a narrow phone; the shell's own max-width caps the other end. */
+/* Stated rather than left to `.dashboard *`: the overlay is appended to the
+ dashboard WINDOW, which is outside that rule — without this, a width:100%
+ child (the option rows, the request form's message box) adds its padding
+ and border on top of the card's content width and hangs over the right
+ edge. Width is explicit for the same reason: it is the card's real width
+ only under border-box. */
+.myapps-add-modal,
+.myapps-add-modal * {
+ box-sizing: border-box;
+}
+.myapps-add-modal {
+ width: min(440px, calc(100vw - 32px));
+ min-width: 0;
+ max-width: none;
+}
+.myapps-add-options {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+.myapps-add-option {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ padding: 12px;
+ text-align: left;
+ border: 1px solid var(--dashboard-border);
+ border-radius: 10px;
+ background: var(--dashboard-card-background);
+ color: var(--dashboard-text-primary);
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+}
+@media (hover: hover) {
+ .myapps-add-option:hover {
+ background: var(--dashboard-hover);
+ border-color: var(--select-ring);
+ }
+}
+.myapps-add-option:focus-visible {
+ outline: 2px solid var(--select-color);
+ outline-offset: -1px;
+}
+.myapps-add-option-icon {
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 9px;
+ background: var(--select-tint);
+ color: var(--select-color);
+}
+.myapps-add-option-icon svg {
+ width: 19px;
+ height: 19px;
+}
+.myapps-add-option-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+.myapps-add-option-title {
+ font-size: 14px;
+ font-weight: 600;
+}
+.myapps-add-option-desc {
+ font-size: 12px;
+ line-height: 1.35;
+ color: var(--dashboard-text-secondary);
+}
+.myapps-add-option-chevron {
+ flex: none;
+ width: 16px;
+ height: 16px;
+ margin-left: auto;
+ color: var(--dashboard-text-tertiary);
+}
+
+/* The request form, one step deeper in the same card (see showAddAppModal):
+ a titled header whose Back arrow returns to the options, the message, and
+ the sent confirmation that replaces both. */
+.myapps-add-head {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 8px;
+}
+.myapps-add-head h3 {
+ margin: 0;
+}
+.myapps-add-back {
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ /* Pulled into the card's padding so the title, not the arrow, lines up
+ with the text below it. */
+ width: 26px;
+ height: 26px;
+ margin-left: -6px;
+ padding: 0;
+ border: none;
+ border-radius: 6px;
+ background: none;
+ color: var(--dashboard-text-secondary);
+ cursor: pointer;
+}
+.myapps-add-back svg {
+ width: 17px;
+ height: 17px;
+}
+@media (hover: hover) {
+ .myapps-add-back:hover {
+ background: var(--dashboard-hover);
+ color: var(--dashboard-text-primary);
+ }
+}
+.myapps-add-back:focus-visible {
+ outline: 2px solid var(--select-color);
+ outline-offset: -1px;
+}
+.myapps-add-message {
+ display: block;
+ width: 100%;
+ min-height: 130px;
+ margin-bottom: 12px;
+ padding: 10px;
+ resize: vertical;
+ font-family: inherit;
+ font-size: 13px;
+ line-height: 1.45;
+ border: 1px solid var(--dashboard-border);
+ border-radius: 6px;
+ background: var(--dashboard-input-background);
+ color: var(--dashboard-text-primary);
+ outline: none;
+ transition: border-color 0.15s;
+}
+.myapps-add-message:focus {
+ border-color: var(--select-color);
+}
+.myapps-add-message::placeholder {
+ color: var(--dashboard-text-muted);
+}
+p.myapps-add-error {
+ margin: -4px 0 12px;
+ font-size: 12px;
+ color: var(--dashboard-error-text);
+}
+.myapps-add-send {
+ background: var(--select-color);
+ border-color: var(--select-color);
+ color: #fff;
+}
+@media (hover: hover) {
+ .myapps-add-send:not(:disabled):hover {
+ filter: brightness(0.94);
+ }
+}
+.myapps-add-send:disabled {
+ opacity: 0.55;
+ cursor: default;
+}
+.myapps-add-sent {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 0 6px;
+ text-align: center;
+}
+.myapps-add-sent svg {
+ width: 40px;
+ height: 40px;
+ color: var(--dashboard-success-text);
+}
+.myapps-add-sent p {
+ margin: 0 0 4px;
+ font-size: 13px;
+ line-height: 1.45;
+ color: var(--dashboard-text-secondary);
+}
+
/* -- Item properties modal --
Responsive, from-scratch replacement for UIWindowItemProperties in the
Dashboard: a centered card on desktop, a bottom sheet on mobile. */
@@ -1980,6 +2201,13 @@ body.myapps-reordering .myapps-tile {
.myapps-reorder-mode .myapps-tile:nth-child(3n) .myapps-tile-icon {
animation-delay: -0.24s;
}
+
+ /* The add-an-app tile can't be dragged or uninstalled, so it has nothing
+ to say in reorder mode: it sits still while the apps wobble. Last, and
+ specific enough, to beat the nth-child rules above. */
+ .myapps-reorder-mode .myapps-tile.myapps-add-tile .myapps-tile-icon {
+ animation: none;
+ }
}
@media (prefers-reduced-motion: reduce) {
@@ -3421,6 +3649,10 @@ body.myapps-reordering .myapps-tile {
border-radius: 12px;
}
+ .myapps-add-icon {
+ border-radius: 12px;
+ }
+
.myapps-group-overlay {
padding: 16px;
}
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js
index 8a069f6d6..e23232026 100644
--- a/src/gui/src/i18n/translations/en.js
+++ b/src/gui/src/i18n/translations/en.js
@@ -26,6 +26,17 @@ const en = {
account: 'Account',
account_password: 'Verify Account Password',
access_granted_to: 'Access Granted To',
+ add_app: 'Add an app',
+ add_app_ai: 'Create an app with AI',
+ add_app_ai_desc: 'Describe the app you want and have it built for you',
+ add_app_browse: 'Find an app',
+ add_app_browse_desc: 'Browse the App Center and install what you need',
+ add_app_request: 'Request an app',
+ add_app_request_c2a: 'Tell us what app you wish Puter had. Your request goes straight to our team.',
+ add_app_request_desc: 'Tell us what we should build next',
+ add_app_request_failed: "Couldn't send your request. Please try again.",
+ add_app_request_placeholder: 'The app I wish Puter had is…',
+ add_app_request_sent: 'Thanks — your request is with our team. If your account has an email, we may follow up on it.',
add_existing_account: 'Add Existing Account',
add_to_desktop: 'Add to Desktop',
ai_app_unavailable: 'AI app is not available. Please try again later.',
@@ -42,6 +53,7 @@ const en = {
ascending: 'Ascending',
associated_websites: 'Associated Websites',
auto_arrange: 'Auto Arrange',
+ back: 'Back',
background: 'Background',
browse: 'Browse',
browser: 'Browser',