Skip to content

feat(ui): add PWA standalone reload button and pull-to-refresh gesture#2548

Merged
2 commits merged into
nesquena:masterfrom
espokaos-ops:feat/pwa-standalone-reload
May 19, 2026
Merged

feat(ui): add PWA standalone reload button and pull-to-refresh gesture#2548
2 commits merged into
nesquena:masterfrom
espokaos-ops:feat/pwa-standalone-reload

Conversation

@espokaos-ops
Copy link
Copy Markdown

@espokaos-ops espokaos-ops commented May 18, 2026

When the WebUI is installed as a PWA (home-screen shortcut) on Android, there is no browser address bar or reload button. The only way to refresh the page is to close and reopen the app.

This PR adds two ways to reload from a PWA standalone context:

Reload button — A small refresh icon in the app titlebar, next to the message count. Only visible in display-mode: standalone or fullscreen. Tapping it triggers window.location.reload().

Pull-to-refresh gesture — Dragging down from the top of the message list shows a visual indicator (Pull to refresh / Release to refresh) and reloads on release past an 80px threshold. When triggered mid-conversation (not at the top), the message list smooth-scrolls to the top first so the user does not have to manually scroll up in long sessions.

Both features are gated behind a matchMedia check and have no effect in normal browser mode.

Changes

  • static/index.html — Reload button in the app titlebar
  • static/style.css — Button styles + pull indicator, scoped to standalone/fullscreen
  • static/ui.js — Touch gesture handler with scroll-to-top fallback

Screenshot_2026-05-18-19-59-17-826_com.android.chrome.jpg

Screenshot_2026-05-18-19-53-13-579_com.android.chrome.jpg

Adds a reload button to the app titlebar visible only in PWA standalone
or fullscreen mode, and a pull-to-refresh gesture on the messages container
that smooth-scrolls to the top before activating.

The reload button sits next to the message count label and provides a
one-tap refresh for users who installed the WebUI as a home-screen app
where browser navigation controls are unavailable.

The pull-to-refresh gesture detects downward drag at the top of the
message list, shows a visual indicator ('Pull to refresh' / 'Release to refresh'),
and reloads on release past the 80px threshold. When triggered mid-conversation,
it smooth-scrolls to the top first.
@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Phase 0 fit + UX gate — agent-review queued, applying ux label

Thanks @espokaos-ops — narrow PWA quality-of-life improvement, the kind of thing PWA users have been asking for. Two notes before this can route to a batch:

UX gate: this adds two new visible UI affordances — a titlebar reload button (#btnReload next to message count) and a pull-to-refresh indicator that appears on touchstart at scrollTop=0. Both are gated behind (display-mode: standalone), (display-mode: fullscreen) so they're invisible to browser users, which is the right shape. But:

  1. The pull-to-refresh indicator overlays the messages container with position: absolute; top: 0. On long transcripts a user can accidentally trigger it while scrolling up to read history — please confirm via screenshot/video that the 80px threshold + smooth-scroll-to-top guard makes this feel intentional rather than surprising.
  2. The reload button styling — please show how it renders in both light and dark themes at the standard breakpoints (390/1280/1440/1920). The var(--muted)var(--text) hover transition needs to look right alongside the existing titlebar dot/count.

Applying the ux label for the next UX review tick.

Agent review queue: the codebase pattern for new visible UI surfaces is to land an agent-review pass first (per-file diff/test/integration read with a recap comment). Will let that cron pick it up on the next tick before this routes into a batch.

The diagnosis — that PWA standalone has no browser-level reload affordance — is real and worth solving. Just want to make sure the surface lands clean before it ships.

@espokaos-ops
Copy link
Copy Markdown
Author

4-Viewport Gate Evidence ✅

Screenshots uploaded to screenshots-pr2548/ covering all requested combinations:

Reload Button

390px (mobile): light · light:hover · dark · dark:hover
1280px: light · light:hover · dark · dark:hover
1440px: light · light:hover · dark · dark:hover
1920px: light · light:hover · dark · dark:hover

Pull-to-Refresh Active

390px: light · dark
1280px: light · dark
1440px: light · dark
1920px: light · dark

Full page idle (dark, 390px)

fullpage_idle_dark_390.png

Notes

  • The var(--muted) to var(--text) hover transition reads cleanly across all breakpoints, no contrast issues in either theme
  • The PTR indicator uses position absolute top 0 within messages-shell, clean overlay at every width
  • 80px threshold + scrollTop guard prevents accidental triggers on long transcripts: the guard at touchstart exits immediately if scrolled down, scrollTo smooth on touchmove gives natural feel near the top

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

UX gate review on viewport evidence — clears the 4-viewport bar; one concrete code-level concern

Read the PR head (a5a0de2a) end to end after the screenshot uploads, focusing on static/index.html:138-148, static/style.css:425-442, and static/ui.js:1914-1978. The screenshots address the visual gate, but one behavioral concern still merits a small follow-up.

What the screenshots confirm

The reload button at all 4 widths × 2 themes × hover/idle renders cleanly. The var(--muted)var(--text) hover transition is consistent against the existing titlebar treatment, no contrast inversion in either theme. The PTR indicator overlay (position:absolute;top:0; within #messages) sits correctly above the message stream and doesn't clip the existing date separator (.msg-date-sep) or sticky scroll affordances.

One behavioral concern that screenshots can't capture

Reading the touchmove handler at static/ui.js:1947-1963:

el.addEventListener('touchmove',function(e){
  if(_ptrState!==1) return;
  _ptrCurrentY=e.touches[0].clientY;
  const pull=_ptrCurrentY-_ptrStartY;
  if(pull<0){ _ptrReset(); return; }
  /* If not at the top, smooth-scroll to top first.
     Next pull gesture will trigger the refresh. */
  if(el.scrollTop>0){
    el.scrollTo({top:0,behavior:'smooth'});
    _ptrReset();
    return;
  }
  const progress=Math.min(pull/THRESHOLD,1);
  _ptrUpdate(progress);
  _ptrState=progress>=1?2:1;
  if(progress>0.3) e.preventDefault();
},{passive:false});

The touchstart guard at :1942 is el.scrollTop>0||_ptrState!==0 → return — so a touch that begins mid-scroll exits early. Good. But once _ptrState=1 is set at scrollTop=0, a user reading a long transcript can scroll up, hit the top, then continue the same swipe gesture, which then triggers progress increment with pull>0 and e.preventDefault() from progress>0.3. That feels surprising on long-form reading: the scroll abruptly stops accumulating thinking-card detail and converts to a refresh prompt.

Two ways to make this feel intentional rather than accidental:

  1. Velocity guard: track the time delta on touchstart and only enter _ptrState=1 if the gesture is "starting fresh" — e.g. require the previous touch sequence to have ended at least ~150ms ago. The current code has no idle gap, so a continuous swipe from mid-transcript to the top can carry into PTR territory without the user committing to it.

  2. Distance guard: require a clear "rest at scrollTop=0" frame before allowing pull accumulation. Something like:

    if(el.scrollTop>0){
      el.scrollTo({top:0,behavior:'smooth'});
      _ptrReset();
      return;  // already does this — but consider holding state=0 here too
    }

    Already the right idea, but _ptrState was set to 1 in the earlier touchstart, so the touch can still pull. Hoisting the "snap to top first" check into touchstart (so _ptrState=1 only sets if el.scrollTop===0 at start, not at the time touchmove fires) would close this.

Both are 5-line tweaks. Neither blocks the PR if the PWA pool is mostly people who restart from a fresh open, but it's worth a paragraph in the PR body explaining the chosen tradeoff for the next reviewer.

Other things that look fine

  • aria-label="Reload" + title="Reload page" on the button matches the existing .app-titlebar-* icon-only pattern.
  • The @media (display-mode: standalone), (display-mode: fullscreen) gate at static/style.css:425-442 is correctly duplicated against the existing --app-titlebar-safe-top block — the merge looks clean.
  • The IIFE wrap at static/ui.js:1916 keeps _ptrState / _indicator out of global scope. The early-return on !isStandalone is the right shape for browsers — zero cost when PWA isn't active.

Lifecycle question

What happens to the PTR listeners if a user installs the PWA mid-session and matchMedia('(display-mode:standalone)') becomes true after attachLiveStream is already running? The IIFE evaluates once at script-load — so the listeners simply won't bind for that user until next page load. That's likely fine (reload-to-activate is the usual PWA install flow), but worth a single-line comment in the IIFE noting this is intentional.

Otherwise UX gate clears. I'm not seeing reasons to block. The velocity/distance behavior is the only thing worth a small follow-up commit before this routes into a batch.

@nesquena-hermes nesquena-hermes closed this pull request by merging all changes into nesquena:master in 71c7035 May 19, 2026
Michaelyklam pushed a commit to Michaelyklam/hermes-webui that referenced this pull request May 19, 2026
Michaelyklam pushed a commit to Michaelyklam/hermes-webui that referenced this pull request May 19, 2026
eleboucher pushed a commit to eleboucher/homelab that referenced this pull request May 19, 2026
… 0.51.92) (#560)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/nesquena/hermes-webui](https://github.com/nesquena/hermes-webui) | patch | `0.51.90` → `0.51.92` |

---

### Release Notes

<details>
<summary>nesquena/hermes-webui (ghcr.io/nesquena/hermes-webui)</summary>

### [`v0.51.92`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v05192--2026-05-19--Release-BP-stage-385--7-PR-full-sweep-batch--RFC-Slice-3c-clarification--workspace-tree-icon-alignment--project-move-cache-refresh--auto-compression-handoff-metadata--Grok-OAuth-provider-catalog--anonymous-custom-endpoint-picker-fallback--PWA-standalone-reload--pull-to-refresh)

[Compare Source](nesquena/hermes-webui@v0.51.91...v0.51.92)

##### Fixed

- **PR [#&#8203;2563](nesquena/hermes-webui#2563 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (closes [#&#8203;2554](nesquena/hermes-webui#2554)) — Align workspace-tree file rows with sibling directory rows by reserving the same expand/collapse toggle slot for files via a new `.file-tree-toggle-placeholder` element. Expanded directories now show child files stepped in at the same icon column as child folders. Directory toggles and file interactions are unchanged; source-level regression coverage and before/after PNGs included.
- **PR [#&#8203;2561](nesquena/hermes-webui#2561 by [@&#8203;nanookclaw](https://github.com/nanookclaw) (closes [#&#8203;2551](nesquena/hermes-webui#2551)) — Refresh the authoritative `_allSessions` cache when the project picker moves a session to/from a project. Previous code mutated only the shallow sidebar row copy, so `renderSessionListFromCache()` re-read the unchanged cache and repainted a stale project dot until the next `/api/sessions` poll healed the UI. Both the "Removed from project" and "Moved to <project>" branches now write the new `project_id` into `_allSessions[idx]` before re-rendering.
- **PR [#&#8203;2567](nesquena/hermes-webui#2567 by [@&#8203;dso2ng](https://github.com/dso2ng) (refs [#&#8203;2477](nesquena/hermes-webui#2477)) — Surface automatic-compression handoff metadata through the `compressed` SSE event so the active browser stream keeps its completion card even after the backend rotates the session id from the origin to a compressed continuation. The event now carries both `old_session_id` and `new_session_id`/`continuation_session_id`; the frontend `compressed` listener accepts either, and the automatic-compression detail line names the compressed continuation session so the done state isn't silently dropped.
- **PR [#&#8203;2568](nesquena/hermes-webui#2568 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (closes [#&#8203;2545](nesquena/hermes-webui#2545)) — Add the Hermes Agent `xai-oauth` provider to the WebUI's OAuth provider catalog so Grok OAuth accounts authenticated via the Hermes CLI appear in Settings → Providers and the `/api/models` picker. The provider is treated as CLI-managed OAuth (no WebUI API-key form) and uses the live Hermes CLI model catalog when available with a Grok 4.20 static fallback.
- **PR [#&#8203;2550](nesquena/hermes-webui#2550 by [@&#8203;espokaos-ops](https://github.com/espokaos-ops) (refs [#&#8203;2542](nesquena/hermes-webui#2542)) — Keep anonymous custom OpenAI-compatible endpoints in the model picker even when the configured `/v1/models` probe fails. Lightweight relays and llama-server-style deployments that authenticate `/v1/chat/completions` but not `/v1/models` no longer have their provider group silently dropped from the picker. Users can type a model id manually in the free-form input when no live catalog is available.

##### Added

- **PR [#&#8203;2548](nesquena/hermes-webui#2548 by [@&#8203;espokaos-ops](https://github.com/espokaos-ops) — Add a PWA-standalone reload affordance. A small refresh button appears in the app titlebar (visible only under `@media (display-mode: standalone), (display-mode: fullscreen)`) so users running the WebUI as an installed home-screen PWA can reload without re-launching the app. Adds a complementary pull-to-refresh gesture on the messages container with an 80px threshold and a smooth-scroll-to-top guard so accidental triggers while reading history feel intentional. 4-viewport screenshots (390/1280/1440/1920, light/dark, hover/idle) included under `docs/pr-media/2548/`.

##### Documentation

- **PR [#&#8203;2560](nesquena/hermes-webui#2560 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (refs [#&#8203;1925](nesquena/hermes-webui#1925)) — Clarify the RuntimeAdapter Slice 3c state after [#&#8203;2544](nesquena/hermes-webui#2544) shipped. The RFC now distinguishes shipped `/api/goal` routing through `RuntimeAdapter.update_goal(...)` from the still-staged `queue_message(...)` protocol method, and explicitly warns not to add a new server-side queue endpoint or queue scheduler merely for adapter symmetry while `/queue` remains browser-side queue/drain behavior.

### [`v0.51.91`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v05191--2026-05-18--Release-BO-stage-384--5-PR-full-sweep-batch--reasoning-replay-history-fix--archive-extract-per-session-inbox--fallback-streaming-warnings--sanitized-custom-provider-env-hints--Slice-3c-queuegoal-adapter-routing)

[Compare Source](nesquena/hermes-webui@v0.51.90...v0.51.91)

##### Fixed

- **PR [#&#8203;2536](nesquena/hermes-webui#2536 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (closes [#&#8203;2514](nesquena/hermes-webui#2514), refs [#&#8203;2535](nesquena/hermes-webui#2535)) — Stop reasoning-only Thinking entries from being replayed into provider-facing history as blank assistant turns. Long WebUI sessions were accumulating duplicated stale Thinking blocks and inflated Activity/tool metadata on later turns when reasoning-only display entries (from interrupted/canceled turns) got reinserted into the restored conversation history. The fix keeps visible Thinking cards in the transcript while filtering them out of provider-facing replay. Settled compact Activity rerenders now also clear previously inserted Thinking rows before rebuilding the visible transcript.
- **PR [#&#8203;2520](nesquena/hermes-webui#2520 by [@&#8203;OneFat3](https://github.com/OneFat3) (refs [#&#8203;2247](nesquena/hermes-webui#2247)) — Route archive extraction (`/api/upload/extract`) through the per-session attachment inbox (`_session_attachment_dir`) instead of hardcoded `Path(s.workspace)`, matching the single-file upload path. Extracted archives now land at `<attachment_root>/<session_id>/<archive_stem>/` so session deletion cleanup covers them and per-session isolation is preserved when `HERMES_WEBUI_ATTACHMENT_DIR` is configured.
- **PR [#&#8203;2505](nesquena/hermes-webui#2505 by [@&#8203;cyberdyne187](https://github.com/cyberdyne187) — Surface provider fallback and rate-limit lifecycle notices as auto-clearing fallback warnings in the streaming composer status. The new bridge in `_agent_status_callback` matches agent lifecycle messages containing `rate limited` / `switching to fallback` / `falling back` / `fallback activated` / `trying fallback` and emits them as `warning` events with `type=fallback`, so the existing `static/messages.js` warning channel surfaces them with the correct auto-clear contract instead of letting them drop silently.
- **PR [#&#8203;2556](nesquena/hermes-webui#2556 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (closes [#&#8203;2541](nesquena/hermes-webui#2541)) — Sanitize auto-generated custom-provider API-key environment variable names so endpoint-derived provider ids such as `custom:gpu.local-8000` use POSIX-safe names like `CUSTOM_GPU_LOCAL_8000_API_KEY`. Runtime custom-provider key resolution now checks the sanitized env var first and falls back to the legacy punctuation-preserving name with a one-shot deprecation warning. Configured literal `api_key` values and explicit `key_env` config are unchanged.

##### Documentation

- **PR [#&#8203;2544](nesquena/hermes-webui#2544 by [@&#8203;Michaelyklam](https://github.com/Michaelyklam) (refs [#&#8203;1925](nesquena/hermes-webui#1925)) — Implement the first Slice 3c RuntimeAdapter control routing. `RuntimeAdapter` / `LegacyJournalRuntimeAdapter` now expose `queue_message(...)` and `update_goal(...)` as protocol-translator delegates, and the `/api/goal` route uses `update_goal(...)` only when `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled while preserving the legacy-direct response shape. The change keeps `/queue`'s existing browser-side drain semantics and goal post-turn evaluation in the current agent loop; no runner/sidecar, WebUI-owned queue, goal scheduler, cached-agent table, or execution-survives-restart claim is introduced.

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEwMS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/560
Isla-Liu pushed a commit to Isla-Liu/hermes-webui that referenced this pull request May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ux User experience / visual polish

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants