Mobile App Shell for Published Apps

Problem

The published app page (/app/:slug) currently stacks the desktop layout vertically on mobile with an overlay sidebar. This doesn’t feel like a native mobile app — it’s a responsive compromise that results in cramped controls, poor navigation, and no clear separation between inputs, outputs, and run history.

Solution

A dedicated mobile layout (detected via useIsMobile(): max-width: 899px + pointer: coarse) that renders an entirely different component tree with a native-app feel: fixed header, bottom tab bar, and dedicated screens for inputs, outputs, and runs.

Scope

Step 0: Prerequisite Refactor

Before building the mobile shell, extract shared rendering logic from presentation-view.tsx so both desktop and mobile can reuse it:

  1. Extract InputCard (lines 1119-1225) → frontend/src/components/presentation/input-card.tsx
  2. Extract OutputCard (lines 1228-1257) → frontend/src/components/presentation/output-card.tsx
  3. Extract getCardTitle helper → move to frontend/src/lib/presentation-utils.ts (alongside existing getNodeLabel, getNodeResult, etc.)
  4. Do NOT extract renderOutputCard as a shared function — it has closure dependencies on 5+ callbacks (getResult, getNodeStatus, getCardTitle, handleOpenMedia, setConfigNode). Instead, both PresentationView and MobileAppShell compose their own renderOutputCard from the shared primitives (OutputCard, CONFIG_INPUT_TYPES, PlatformPreview).
  5. Update presentation-view.tsx to import from the new locations (functionality unchanged).

Note: getCardTitle should live in frontend/src/components/presentation/helpers.ts (not presentation-utils.ts which is a re-export file). It depends on PresentationSettings.cardMeta which is frontend-only.

This prerequisite refactor modifies presentation-view.tsx but does not change any behavior.

Architecture

AppRunnerPage
├── Desktop (non-mobile): existing AppRunnerLayout + PresentationView (unchanged)
└── Mobile (useIsMobile() = true): MobileAppShell
    ├── MobileAppHeader (fixed top, 48px + safe-area-top)
    │   ├── Logo icon (links to /)
    │   ├── Truncated app name
    │   ├── Theme toggle
    │   └── Hamburger menu button → sheet dropdown
    ├── MobileAppContent (scrollable middle, height fills between header and tab bar)
    │   ├── InputsTab: ordered input cards + sticky Run button above tab bar
    │   ├── OutputsTab: ordered output cards with status badges
    │   └── RunsTab: run history list (authenticated users only)
    ├── MobileStickyAction (sticky above tab bar, 56px — Run/Stop/SignIn/GetCredits)
    └── MobileTabBar (fixed bottom, 56px + safe-area-bottom)

Detection

Uses the existing useIsMobile() hook at frontend/src/hooks/use-is-mobile.ts. This hook uses (max-width: 899px) and (pointer: coarse) — the pointer: coarse check prevents desktop browsers resized narrow from false-triggering mobile mode. No new hook needed.

Conditional Rendering in AppRunnerPage

// app-runner-page.tsx
const isMobile = useIsMobile()

if (isMobile) {
  return <MobileAppShell slug={slug} app={app} user={user} runSlots={runSlots} />
}

return (
  <AppRunnerLayout ...>
    <PresentationView ... />
  </AppRunnerLayout>
)

Components

MobileAppHeader

Fixed top bar. Height: 48px + var(--safe-area-top).

Layout:

┌─────────────────────────────────┐
│  [logo] App Name         [◑][☰]│
├─────────────────────────────────┤
│  ███████████░░░░░░  (progress)  │  ← 2px, only during execution
└─────────────────────────────────┘

Hamburger Menu Sheet

Slides down from header (not a sidebar). Rendered as an absolutely positioned panel below the header with backdrop overlay.

Contents (top to bottom):

  1. Credit balance<CreditBalance> (if logged in + hasCredits())
  2. Divider
  3. Run target selector — sub-workflow picker (if workflow has sub-workflows)
  4. View mode selector — for power users who want gallery/fullscreen/compare
  5. Version picker — dropdown (if multiple versions)
  6. Divider
  7. Remix button (if supportsRemix)
  8. More Apps — links to /apps
  9. Divider
  10. Sign in / Sign out — with user email shown when signed in

MobileTabBar

Fixed bottom bar. Height: 56px + var(--safe-area-bottom).

┌───────────┬───────────┬───────────┐
│  ✏ Input  │  ◻ Output │  ⏱ Runs  │
└───────────┴───────────┴───────────┘

MobileAppContent

Fills the space between header and tab bar: calc(100dvh - header - tabbar - stickyAction?).

Each tab is a scrollable container. Only the active tab is mounted (no offscreen rendering — keeps memory low on mobile).

Inputs Tab

Outputs Tab

Runs Tab

MobileStickyAction

Uses position: fixed (not sticky) with bottom: calc(56px + env(safe-area-inset-bottom)) to sit directly above the tab bar. Only visible on the Inputs tab when inputs are not read-only. Hidden when a text input is focused (see Keyboard Handling).

Height: 56px. Full-width with horizontal padding.

Layout: two buttons side by side:

┌─────────────────────────────────┐
│  [New Run]  [▶ Run (12 CR)]    │
└─────────────────────────────────┘

Left button (secondary, optional):

Right button (primary, full width if no left button):

State Management

The MobileAppShell manages one piece of local state: activeTab: "inputs" | "outputs" | "runs".

All other state comes from existing hooks/stores:

The needsMoreCredits derived value: user && hasCredits() && userCredits && estimatedCost > 0 && userCredits.total < estimatedCost.

The shell extracts the same derived values that PresentationView currently computes:

These are computed in MobileAppShell and passed down to child components as props.

Auto-tab-switch Logic

Two triggers for switching to the Outputs tab:

  1. Immediate on Run: When user taps Run, switch to Outputs tab right away so they see shimmer/progress.
  2. On completion (if navigated away): If user moved to Inputs or Runs tab during execution, auto-switch back when status transitions to “completed”.
// Inside MobileAppShell

// 1. Immediate switch on Run
const handleRun = useCallback(() => {
  // ... existing run logic (auth check, presRun(), etc.)
  setActiveTab("outputs")
}, [...])

// 2. Auto-switch on completion if user navigated away
const prevStatus = useRef(executionStatus)
useEffect(() => {
  if (prevStatus.current === "running" && executionStatus === "completed") {
    setActiveTab("outputs")
    setHasUnseenOutputs(false)
  }
  prevStatus.current = executionStatus
}, [executionStatus])

Output Badge Logic

Merged into a single effect to prevent badge flicker from split state updates:

const [hasUnseenOutputs, setHasUnseenOutputs] = useState(false)

// Single effect handles both auto-switch and badge — prevents race condition
const prevStatus = useRef(executionStatus)
useEffect(() => {
  if (prevStatus.current === "running" && executionStatus === "completed") {
    // Auto-switch to outputs on completion (if user navigated away)
    setActiveTab("outputs")
    setHasUnseenOutputs(false)
  } else if (executionStatus === "completed" && activeTab !== "outputs") {
    // Badge when completed but user is on another tab
    setHasUnseenOutputs(true)
  }
  prevStatus.current = executionStatus
}, [executionStatus, activeTab])

// Clear badge when switching to outputs tab manually
useEffect(() => {
  if (activeTab === "outputs") setHasUnseenOutputs(false)
}, [activeTab])

Execution Flow

  1. User opens app on mobile → sees Inputs tab with input cards and Run button
  2. Fills in inputs (text, uploads, parameters)
  3. Taps Run → progress bar appears in header, button changes to “Stop”, auto-switches to Outputs tab
  4. Output cards show shimmer/spinner per node as execution proceeds
  5. As nodes complete, output cards populate with results
  6. On completion: progress bar fills to 100% and fades (300ms). Output badge clears.
  7. Run appears in Runs tab for logged-in users
  8. User can tap a previous run in Runs tab → loads that run’s state, switches to Outputs tab

Authentication on Mobile

When unauthenticated user taps “Sign in to Run”, use the redirect flow (not popup). Mobile Safari aggressively blocks popups. Save current URL via AUTH_REDIRECT_KEY before navigating to /login. This matches the non-iframe branch of the existing handleRunClick logic.

Modals in MobileAppShell

The shell’s render tree must include:

CSS / Styling

New CSS in globals.css

/* Mobile app shell safe area */
.mobile-app-header {
  padding-top: max(0.5rem, var(--safe-area-top, 0px));
}

.mobile-tab-bar {
  padding-bottom: max(0.25rem, var(--safe-area-bottom, 0px));
}

/* Progress bar animation */
.mobile-progress-bar {
  transition: width 300ms ease;
}

/* Menu sheet backdrop */
.mobile-menu-backdrop {
  position: fixed;
  inset: 0;
  z-index: 40;
  background: rgba(0, 0, 0, 0.3);
}

.mobile-menu-sheet {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  z-index: 50;
  background: var(--card);
  border-bottom: 1px solid var(--border);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

Design Tokens

Files

New Files

File Purpose Est. Lines
frontend/src/components/presentation/input-card.tsx Extracted InputCard from presentation-view.tsx (Step 0) ~120
frontend/src/components/presentation/output-card.tsx Extracted OutputCard + render logic from presentation-view.tsx (Step 0) ~120
frontend/src/components/presentation/helpers.ts getCardTitle(node, cardMeta) helper (Step 0) ~15
frontend/src/components/app-runner/mobile-app-shell.tsx Main mobile orchestrator: state derivation, tab switching, auto-switch, modals ~450
frontend/src/components/app-runner/mobile-app-header.tsx Fixed header + progress bar + hamburger menu sheet ~150
frontend/src/components/app-runner/mobile-tab-bar.tsx Bottom tab bar with badges ~80
frontend/src/components/app-runner/mobile-sticky-action.tsx Sticky Run/Stop/SignIn button bar ~100

Modified Files

File Change
frontend/src/components/presentation/presentation-view.tsx Step 0: extract InputCard, OutputCard, getCardTitle; import from new files. No behavior change.
frontend/src/routes/app-runner-page.tsx Import useIsMobile + MobileAppShell, conditional render; hoist delete dialog above mobile/desktop branch
frontend/src/globals.css Add mobile shell CSS classes + pointer: coarse override for always-visible output card actions
frontend/src/components/app-runner/index.ts Export new mobile components

No Changes To

Mobile-Specific UX

Keyboard Handling

On iOS Safari, position: fixed elements misbehave when the virtual keyboard opens. Strategy:

File Upload on Mobile

The existing FileDropZone component uses drag-and-drop + click-to-open-picker. Drag-and-drop does not work on mobile. The click-to-open path works fine, but:

Output Media Actions on Mobile

Desktop output cards use hover overlays (GlassButton with opacity-0 group-hover:opacity-100) for download/fullscreen. Hover doesn’t exist on mobile. Strategy:

Failed Output Cards

When a node fails during execution, the output card should display:

Deep-Linking on Mobile

The ?run=<runId> query param works on mobile: on load, useRunSlots selects that run, and the mobile shell starts on the Outputs tab (since a specific run was requested). The ?sidebar= param is ignored on mobile (no sidebar).

Delete Confirmation Dialog

Hoisted to app-runner-page.tsx above the isMobile conditional branch, shared by both desktop and mobile layouts. This avoids duplication.

Edge Cases