Files
hapi/web
78155a9d27 perf(web): add staleTime to useSession to suppress focus/mount refetches (#884) (#885)
* 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>
2026-06-18 10:13:36 +08:00
..
2026-03-24 12:32:37 +08:00
2025-12-19 15:26:33 +08:00
2026-05-20 20:47:16 +08:00

hapi-web

React Mini App / PWA for monitoring and controlling hapi sessions.

What it does

  • Session list with status, pending approvals, todos, and summaries.
  • Chat view with streaming updates and message sending.
  • Permission approval and denial workflows.
  • Permission mode and model selection.
  • Machine list and remote session spawn.
  • File browser and git status/diff views.
  • PWA install prompt and offline banner.

Runtime behavior

  • When opened inside Telegram, auth uses Telegram WebApp init data.
  • When opened in a normal browser, you can log in with CLI_API_TOKEN:<namespace> (or CLI_API_TOKEN for the default namespace).
  • The login screen includes a top-right hub picker; if unset, the app uses the same origin it was loaded from.
  • Live updates come from the hub via SSE.

Routes

See src/router.tsx for route definitions.

  • / - Redirect to /sessions.
  • /sessions - Session list.
  • /sessions/$sessionId - Chat interface.
  • /sessions/new - Create new session.
  • /sessions/$sessionId/files - File browser with git status.
  • /sessions/$sessionId/file - File viewer with diff support.
  • /sessions/$sessionId/terminal - Terminal interface.
  • /settings - Application settings.

Features

Session list (src/components/SessionList.tsx)

  • Active/inactive status indicator.
  • Session title from name, summary, or path.
  • Todo progress display.
  • Pending permission request count.
  • Agent flavor label (claude/codex/gemini).
  • Model mode display.

Chat interface (src/components/SessionChat.tsx)

  • Message thread with infinite scroll.
  • Composer for sending messages.
  • Permission mode toggle (default/acceptEdits/auto/bypassPermissions/plan).
  • Model selection (default/sonnet/sonnet[1m]/opus/opus[1m]).
  • Session abort and mode switch controls.
  • Context size display.
  • Per-session scratchlist (src/components/AssistantChat/ScratchlistPanel.tsx)
    • Workbench panel for held notes/drafts; distinct from the queue.
    • Add/delete/reorder entries; promote to composer (copy) or queue (send).
    • Persists across reloads via localStorage keyed per session.
    • Keyboard shortcut: Ctrl/Cmd+Shift+S to focus the add-input.

File browser (src/routes/sessions/files.tsx)

  • Git status view (staged/unstaged files).
  • File search with ripgrep.
  • Navigate to file viewer.

File viewer (src/routes/sessions/file.tsx)

  • File content display with syntax highlighting.
  • Staged/unstaged diff view.

Terminal (src/routes/sessions/terminal.tsx)

  • Remote terminal via xterm.js
  • Real-time via Socket.IO
  • Resize handling

Voice assistant

  • ElevenLabs integration (@elevenlabs/react)
  • Real-time voice control

New session (src/components/NewSession/)

Modular session creation:

  • Machine selector
  • Directory input with recent paths
  • Agent type selector
  • Model selector
  • Permission mode toggle (YOLO mode)

Authentication

See src/hooks/useAuth.ts and src/hooks/useAuthSource.ts.

  • Telegram Mini App: Uses initData from WebApp SDK.
  • Browser: Uses CLI_API_TOKEN from login prompt.
  • JWT tokens with auto-refresh.

Data fetching

See src/hooks/queries/ for query hooks and src/hooks/mutations/ for mutations.

  • Sessions, messages, machines via TanStack Query.
  • Git status and file operations.
  • Optimistic updates for message sending.

Real-time updates

See src/hooks/useSSE.ts.

  • SSE connection to /api/events.
  • Session/message/machine update events.
  • Automatic cache invalidation on events.

Stack

React 19 + Vite + TanStack Router/Query + Tailwind + @assistant-ui/react + xterm.js + @elevenlabs/react + socket.io-client + workbox + shiki.

Source structure

  • src/router.tsx - Route definitions.
  • src/components/ - UI components.
  • src/hooks/ - Data fetching and state hooks.
  • src/api/client.ts - API client.
  • src/types/api.ts - Type definitions.

Development

From the repo root:

bun install
bun run dev:web

If testing in Telegram, set:

  • HAPI_PUBLIC_URL to the public HTTPS URL of the dev server.
  • CORS_ORIGINS to include the dev server origin.

Tests

Unit tests run under vitest + jsdom:

bun run test:web

End-to-end browser tests for the scratchlist component (real Chromium, real inert focus blocking, real localStorage round-trips) live at the repo root under e2e/:

bun run test:e2e          # headless
bun run test:e2e:ui       # Playwright UI mode (debug)

The spec drives a Vite-served fixture page (web/e2e-fixtures/scratchlist-fixture.html) that mounts the production ScratchlistPanel in isolation, so no hub / auth / socket setup is required.

Build

bun run build:web

The built assets land in web/dist and are served by hapi-hub. The single executable can embed these assets.

Standalone hosting

You can host web/dist on a static host (GitHub Pages, Cloudflare Pages) and point it at any hapi hub:

  1. Build the web app. If your static host uses a subpath, set the Vite base:
bun run build:web -- --base /<repo>/
  1. Deploy web/dist to your static host.
  2. Set hub CORS to allow the static origin (HAPI_PUBLIC_URL or CORS_ORIGINS).
  3. Open the static site, click the top-right Hub button on the login screen, and enter the hapi hub origin.

Clear the hub override in the same dialog to return to same-origin behavior.