* test: reproduce issue #866
* feat(web): OLED Black theme + per-appearance custom colors (closes#866)
Add an explicit OLED Black appearance (true #000 canvas, border-based
elevation) alongside system/dark/light, and a curated "key color"
customizer. Each key color (background, surface, text, hint, accent,
border, user bubble) cascades to its --app-* tokens and is stored per
appearance so a color tuned for light never leaks onto pure black.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
* feat(web): in-app PWA update prompt when new service worker is available (closes#938)
User-controlled reload with a persistent banner, visibility-triggered SW
checks, and an expandable rationale. Switches registerType to prompt.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): align vite.config with soup layers for clean driver merge
Keeps registerType prompt while matching garden IWER stubs and PWA
share_target shape expected by feat/pwa-share-target in the manifest.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "fix(web): align vite.config with soup layers for clean driver merge"
This reverts commit 6f0915b0884d029a2413d8819a4dfe81d7c4e595.
* fix(web): make PWA reload apply waiting service worker updates
Handle SKIP_WAITING in injectManifest sw.ts and reload via controllerchange
with a timed fallback when vite-plugin-pwa prompt mode does not navigate.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): satisfy setTimeout mock typing in PWA reload tests
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): register PWA service worker before auth gates
Mount PwaUpdateProvider at app root and show the update banner on login
and error screens so registerSW runs for logged-out users too.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): offset PWA update banner below top status banners
Reserve top-12 when syncing or reconnecting so the reload prompt stays
visible above SyncingBanner and ReconnectingBanner.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): offset PWA update banner below voice error banner
Use PwaUpdateBannerWithStatusOffset inside VoiceProvider so voice errors
share the same top-12 reservation as sync and reconnect banners.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* perf(web): suppress useSession refetch storm (closes#884)
Two compounding behaviours in the React client were producing a sustained
~100 req/sec stream of GET /api/sessions/<uuid> to the hub on installs with
a moderately-sized session fleet:
1. `useSession` had no `staleTime`, so window-focus and remount each
triggered a fresh REST round-trip even though SSE was already pushing
the same data via `patchSessionDetail`.
2. `useSSE`'s `session-added`/`session-updated` handler unconditionally
queued a per-session `invalidateQueries({queryKey: ['session', id]})`
whenever the incoming SSE payload was not a structured patch, fanning
out a refetch to every still-observed `useSession` regardless of
whether the user was currently viewing that session detail.
Fixes:
- `useSession` now sets `staleTime: 30_000` (exported as
`SESSION_DETAIL_STALE_TIME_MS` for testability/tuning). SSE remains
authoritative for freshness; the REST endpoint is a cold-start path.
- `useSSE` only queues per-session detail invalidation when an active
observer is mounted for that session. The new
`hasActiveSessionDetailObserver` helper checks the TanStack query cache
for `getObserversCount() > 0`. List-summary invalidation is unchanged
(sidebar still updates).
No behaviour change for the patch-path: structured `SessionPatch` events
still flow through `patchSessionDetail` + `patchSessionSummary` and update
in place. The fallback path is what we are taming.
Measured on the reporter's box pre-fix: 31,944 GET /api/sessions/<uuid>
hits over a 5-minute idle window across 132 distinct session UUIDs
(~106 req/sec). Expected post-fix: ~0 for sessions whose detail page
is not currently open, gated by `staleTime` for navigation thrash.
Tests:
- `useSession.test.ts` asserts `SESSION_DETAIL_STALE_TIME_MS` is set.
- `useSSE.test.ts` adds 4 cases for `hasActiveSessionDetailObserver`
covering no-cache, cache-without-observer, mounted-observer, and
cross-session isolation.
* fix(web): revert observer-gating in useSSE (address PR #885 review)
The Codex review on #885 correctly flagged that
`hasActiveSessionDetailObserver`-gating around the two
`queueSessionDetailInvalidation` fallback paths broke an important
correctness invariant: with `staleTime: 30_000` in place, skipping
the invalidation entirely (instead of letting TanStack mark the
cache stale) means a subsequent remount within 30s will serve the
stale cached detail without a REST recovery fetch.
This regressed real backend code paths. Hub emits
`session-updated` events with no structured `data` field on todos /
teamState / metadata / agentState changes (see
`hub/src/socket/handlers/cli/sessionHandlers.ts:117,128,216,263`),
which hit the gated `else` branch.
Root cause of the over-correction was a misunderstanding of TanStack
v5 semantics: `invalidateQueries` with the default
`refetchType: 'active'` is *already* a network no-op for unobserved
queries — it just marks them stale. The manual observer-count check
was structurally redundant *and* incorrectly suppressed the stale
marking.
Revert: restore the original unconditional
`queueSessionDetailInvalidation` calls on both fallback branches.
Drop the `hasActiveSessionDetailObserver` helper export and its 4
unit-test cases. Keep Fix A (`staleTime: 30_000` on `useSession`)
intact — that change is independently safe and addresses the
focus-refetch / remount-refetch class of redundant requests.
* docs(web): correct staleTime rationale in useSession (#884)
Stand-in cold review on PR #885 caught that the comment overstated the
fix's reach. `web/src/lib/query-client.ts:7` already sets the global
default `refetchOnWindowFocus: false` and `staleTime: 5_000`, so the
per-query `staleTime: 30_000` does NOT cut focus-refetches (there were
none) and only extends the remount/reconnect-no-refetch window from
5s to 30s.
Rewrite the comment to be accurate about scope: the change suppresses
remount refetches within a 30s window, and explicit `invalidateQueries`
(SSE fallback path, reconnect-recovery in `App.tsx`) still refetches
active observers — so live updates and recovery flows are preserved.
No code behaviour change; comment-only edit.
* fix(web): invalidate all cached session details on SSE reconnect
Codex review on PR #885 caught a real regression introduced by the
`SESSION_DETAIL_STALE_TIME_MS = 30_000` change: the reconnect-recovery
handler in `App.tsx` only invalidated the *currently-selected* session's
detail. With per-query staleTime extended from 5s (global default) to
30s, a previously-viewed but non-selected session whose cache was still
within the freshness window could serve stale data after the SSE channel
missed updates during the disconnect.
Scenario:
1. User views session A → cache populated, fresh.
2. User switches to session B → A's observer unmounts, cache lingers
(gcTime: 5min).
3. SSE disconnects. Session A receives updates server-side that no
patch event reaches the client.
4. SSE reconnects. Old `handleSseConnect` only invalidated
`session(selectedSessionId=B)`, NOT A.
5. User navigates back to A within 30s → useSession remounts → cache
is still considered fresh by staleTime → no REST recovery fetch →
user sees stale A data.
Fix: broaden the per-session invalidation in `handleSseConnect` from
`['session', selectedSessionId]` to the prefix `['session']`, which
matches every cached session-detail entry. Active observers refetch
(same as before — only the selected session was active), inactive
cached entries get marked stale so the next remount refetches.
Performance impact: zero new fetches on reconnect (the selected
session is still the only one with an active observer in practice).
Marking inactive entries stale is metadata-only, free.
This restores the pre-staleTime invariant where every cached session
detail was either fresh (just fetched) or actively re-fetched on
reconnect, and matches the documented contract that SSE is the
authoritative freshness signal while REST is the cold-start /
reconnect-recovery path.
---------
Co-authored-by: heavygee <heavygee@users.noreply.github.com>
When a session is open, the web app now keeps an always-on all:true SSE
connection for sidebar session-updated events while using a second
session-scoped stream for message delivery. Also bump session activity on
hub sendMessage so web-originated sends refresh list timestamps.
Fixestiann/hapi#693
Co-authored-by: Cursor <cursoragent@cursor.com>
Normalize hub toast text in the web client for i18n coverage (including Ready for input notifications) and stop deduplicating session rows by agentSessionId so outline/group counts reflect user-visible sessions.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Add ReconnectingBanner component to show "Reconnecting..." status
- Track SSE connection state in App.tsx with onDisconnect handler
- Add onBlocked callback to useSendMessage for handling blocked send attempts
- Provide haptic feedback when send is blocked due to no API or session
- Show toast notification when message cannot be sent due to server disconnection
- Add translation keys for reconnecting message and send blocked feedback in en/zh-CN
close#125
Previously, useTerminalSocket used a relative path `/terminal` for Socket.IO,
which would connect to the frontend host instead of the actual HAPI server.
Now it uses the baseUrl from context to connect to the correct server, enabling
scenarios like hosting the frontend on GitHub Pages while the server runs elsewhere.
Integrates tunwg (WireGuard tunnel) to enable optional public access
to the hapi server. Tunnel is disabled by default and enabled via
--relay flag or HAPI_TUNNEL=true environment variable.
Users can now run 'hapi server --relay' and get a direct link like:
https://app.hapi.run/?server=https://xxx.relay.hapi.run&token=xxx
Implement a visibility-aware notification system that delivers toast messages via SSE to visible browser tabs instead of sending push notifications, reducing unnecessary push requests. Includes VisibilityTracker for monitoring connection visibility, toast UI components, and enhanced SSEManager with toast delivery capability.
Introduces a new message-window-store module to manage message state with
automatic windowing of visible and pending messages. This replaces manual
React Query cache operations with a centralized, observable state system.
close#39
Key changes:
- New MessageWindowState tracks visible/pending messages with size limits
- Automatic trimming of message windows (400 visible, 200 pending messages)
- Pending message buffering when user scrolls away from bottom
- Centralized status updates for optimistic messages
- Thread component simplified with forwarded scroll and pending callbacks
- Removes message count tracking from components
This improves performance for chats with many messages and provides a
cleaner separation of concerns between UI and state management.
Implement push notification system with VAPID keys, client service worker integration, and push subscription management. Includes server-side PushService for sending notifications and PushNotifier for reactive event handling, plus client-side usePushNotifications hook and service worker support.
Adds support for hosting the web UI separately from the hapi server on static hosts (GitHub Pages, Cloudflare Pages). Users can now set a custom server origin via a dialog on the login screen, with the ability to return to same-origin behavior.
Changes include:
- New useServerUrl hook for managing server URL configuration and storage
- Updated API client to support baseUrl parameter for all requests
- Enhanced login UI with server picker dialog (top-right button)
- Auth system now keys tokens per baseUrl to support multiple servers
- SSE connection updated to use configured baseUrl
- Documentation updates for standalone hosting setup
Add additional condition to check for the intermediate auth state where
authSource exists but token hasn't been fetched yet. This prevents the
LoginPrompt from briefly flashing when the page is refreshed and the user
already has a stored access token.
Changes the condition from just `isAuthLoading` to also cover the gap
before the useAuth effect starts fetching the token, displaying the
loading state instead of the login prompt.
Introduces two reusable loading components to standardize loading states:
- Spinner: Base spinner with accessibility support (role, aria-label)
- LoadingState: Semantic loading indicator combining spinner and label
Updates loading patterns across the app:
- Full-screen/block loading now uses LoadingState (App, router, files, file)
- Inline button loading states show spinner + text with aria-busy
- Message list loading replaced with MessageSkeleton component
- Removes duplicate SpinnerIcon definition from PermissionFooter
Improves UX and accessibility:
- Loading screens centered and full-height
- Consistent ellipsis character (…)
- Semantic accessibility (role="status", aria-live, aria-busy)
- Adds CSS variables for banner styling
Added isFirstConnectRef to distinguish between first SSE connection
(page load) and subsequent reconnects (session switches). Only the
first connection forces the banner to show; subsequent connects use
non-forced mode which only displays the banner when returning from
background within 30s, eliminating unwanted banner flashes on
session changes.
Implement two key UX improvements:
1. Syncing banner with visibility tracking:
- New useSyncingState hook manages syncing state with safety timeout
- Banner shows when SSE connects/reconnects
- Auto-hides after 10s to prevent stuck spinner
- Smart visibility detection to suppress banner when returning from background
2. Smart scroll behavior for chat thread:
- Only auto-scrolls when user is near bottom (<120px threshold)
- Shows "X new messages" indicator when user is reading history
- Smooth scroll to bottom with indicator button
- Resets state on session change
Files:
- New: useSyncingState hook for syncing state management
- New: SyncingBanner component for non-blocking indicator
- Modified: App.tsx integrates syncing with SSE handling
- Modified: HappyThread implements smart scroll with dynamic autoScroll
- Modified: index.css adds spinner and bounce-in animations
Replace state-based screen navigation with proper URL-based routing. This includes:
- New router configuration with routes for sessions, machines, and spawn pages
- App context provider to share API and token across the app
- useAppGoBack hook for handling browser and Telegram back navigation
- Refactored App component to render outlet and use router hooks
- Memory history for Telegram app, browser history for web
Replace manual state management with TanStack Query (React Query) for more robust server state handling. This refactoring introduces:
- New hooks for queries: useSessions, useSession, useMessages, useMachines
- New hooks for mutations: useSendMessage, useSessionActions, useSpawnSession
- Centralized query client with optimized configuration (5s staleTime, disabled window focus refetch)
- Query key factory for consistent cache invalidation
- Improved message synchronization via socket events with cache updates
- Optimistic updates for message sending with retry capability
- Simplified App.tsx by removing manual state management logic
- Integrated React Query devtools in development mode
This enables automatic cache management, better error handling, and a foundation for more sophisticated data fetching patterns.
Add lazy loading of Telegram SDK only when running inside Telegram Mini App
environment instead of unconditionally loading in all browsers. Includes:
- New environment detection functions: isTelegramEnvironment(), isTelegramApp()
- Dynamic SDK loading with 3s timeout via loadTelegramSdk()
- Removed static script tag from index.html
- Made haptic feedback lazy (SDK check on each call)
- Made theme listeners lazy (attached in initializeTheme())
- Updated all components to use unified detection functions
This prevents SDK load time overhead in regular browser environments while
ensuring the app works correctly both in Telegram and browser contexts.
Syncs React routing state with browser History API. Clicking the browser
back button now navigates within the app instead of exiting. Telegram
BackButton behavior is preserved by checking for initData.
- Add iOS Safari install prompt with step-by-step modal guide
- Fix Telegram environment detection (check initData, not just SDK presence)
- Fix install prompt re-entrancy issue (clear deferredPrompt immediately)
- Extract icon components to separate file (web/src/components/icons.tsx)
- Rename "Happy App" to "Hapi" throughout the UI and manifests
- Change button/icon colors from blue (--app-button) to neutral theme (--app-fg/--app-bg)
- Add install dismiss state to prevent repeated prompts
- Improve iOS Safari detection with robust user agent parsing
Add complete PWA implementation including service worker registration,
offline support, and installation prompts:
- Add vite-plugin-pwa and workbox-window dependencies for PWA tooling
- Configure VitePWA plugin with web app manifest and app metadata
- Set up Workbox caching strategies for API endpoints and CDN assets
- Implement service worker auto-update with user-triggered refresh
- Create usePWAInstall hook to handle beforeinstallprompt events
- Create useOnlineStatus hook for monitoring network connectivity
- Add InstallPrompt component with haptic feedback integration
- Add OfflineBanner component to notify users of offline status
- Configure PWA icons and assets (64x64, 192x192, 512x512 variants)
- Add TypeScript type declarations for virtual PWA register module
- Integrate PWA components and service worker into App.tsx and main.tsx
- Add PWA meta tags and viewport configuration to index.html
Enable the web client to run in plain browser environments alongside Telegram Mini App support:
- Server: Add CLI_API_TOKEN authentication path as alternative to Telegram initData
- Client: Add useAuthSource hook to detect and manage Telegram vs browser auth sources
- Client: Add usePlatform hook for platform abstraction with graceful haptic feedback degradation
- Client: Add LoginPrompt component for browser access token login
- Client: Extend useTheme to fall back to system prefers-color-scheme in browser
- Client: Migrate all direct HapticFeedback calls to use usePlatform hook