Files
hapi/web
2643f17840 feat(web): Web Share Target -> Android system share sheet integration (#933)
* feat(web): Web Share Target -> composer attachment preload

PWA manifest now declares a `share_target` so Android Chrome surfaces
HAPI in the system share sheet for any app (Photos, Files, browser).

Pipeline on share:
  1. Service worker intercepts POST /share, parses the multipart payload
     (title/text/url + N files), persists it in IndexedDB under a
     transfer id, and 303-redirects to /share?id=<id>. The 303 forces
     Chrome to convert the POST into a GET so the SPA route mounts.
  2. New /share route loads the transfer, previews the content, and
     lets the user pick a recent active session (top 5 by activeAt) or
     a "+ New session". Tapping a session stashes the transfer id in
     sessionStorage and navigates to /sessions/:id.
  3. SessionChat mounts a ShareSeedConsumer once the AssistantRuntime
     is up; it consumes the pending transfer once per mount, seeds
     composer text + per-file attachments via the existing
     attachmentAdapter, then deletes the IDB row so a refresh of the
     session page does not replay the upload.

The whole feature reuses the existing /sessions/:id/upload endpoint;
no hub or shared changes.

Limitations (also disclosed in the PR body):
  - PWA must be installed; Android Chrome only registers share_target
    on install. iOS Safari ignores the manifest field entirely.
  - File MIME accept list is broad (`*/*` fallback); some Chrome
    versions still filter despite this.

Tests:
  - shareTransfer.test.ts (8) covers payload parse, multi-file order,
    type fallback, ingest redirect shape and error propagation.
  - sharePendingState.test.ts (3) covers atomic consume + overwrite.

Closes: pending upstream issue (filed before PR per intake doc).
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): freeze picker session list at mount, sort by updatedAt, drop top-5 cap

The /share picker was visually re-shuffling under the operator's finger
as SSE events rolled in: every session metadata patch refreshed the
React Query cache, the useMemo recomputed, and items reordered (often
within a second of opening the share sheet). Sort key was activeAt,
which heartbeats every few seconds while a session is connected,
making the noise floor even higher.

Three changes:
  - Snapshot the active-session list once when sessions finish loading
    via useState + a deferred useEffect. The picker is a one-shot
    interaction; closing the share sheet and re-sharing produces a
    fresh snapshot, so freezing for the duration of the picker view is
    the right trade.
  - Sort by updatedAt desc to match SessionList's canonical "most
    recent interaction first" order. updatedAt only moves on
    user-meaningful events, not heartbeats.
  - Drop the TOP_SESSIONS=5 cap. The picker is already inside an
    app-scroll-y container, so showing all active sessions and letting
    the operator scroll matches the operator's mental model better
    than an arbitrary truncation.

Per operator dogfood report: "list of recent sessions is constantly
updating; should be just a scrollable list, from most recent
interaction to not."

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): base-aware share_target paths for subpath PWA deploys

Manifest share_target.action, SW POST matching, and ingest 303 redirects
were hard-coded to /share. Standalone builds with --base /<repo>/ put
scope/start_url under the subpath but left the share action at origin
root, so Chrome posted outside the SW scope and the handler never ran.

Extract shareTargetPathnameFromBase() (used at build time in
vite.config.ts and at runtime via import.meta.env.BASE_URL in sw.ts and
shareTransfer.ts). Normalizes base to a trailing slash before URL
resolution so /repo and /repo/ both resolve to /repo/share.

Addresses upstream PR #933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): defer sessionStorage arm until new session spawn succeeds

The "+ New session" picker path called setSharePendingTransfer before a
session existed. Cancel, spawn failure, or backing out left a stale id in
sessionStorage that the next unrelated SessionChat mount would consume.

Pass shareTransferId via /sessions/new search params instead; arm the
consumer only in handleSuccess after spawn, and delete the IDB row on
cancel.

Addresses upstream PR #933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): append share text to existing composer draft

ShareSeedConsumer called setText(seedText) unconditionally, clobbering
per-session drafts restored by useComposerDraft from sessionStorage.

Merge share title/text/url after any in-composer text or saved draft,
joined with a blank line. Pass sessionId into ShareSeedConsumer so
getDraft() can be consulted when the composer is still empty.

Addresses upstream PR #933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): preserve shareTransferId through /browse detour

New-session spawn from the share picker could lose shareTransferId when
the operator opened /browse to pick a folder: handleChooseFolder and
BrowsePage handleStartSession dropped the search param, so handleSuccess
never armed the composer consumer.

Thread shareTransferId through browseRoute search validation and both
navigation hops.

Addresses upstream PR #933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): consume pending transfer in effect for StrictMode

ShareSeedConsumer called consumeSharePendingTransfer during render.
React.StrictMode double-invokes render in dev; the discarded pass
deleted the sessionStorage key before the committed render seeded.

Move consume into a mount-only useEffect and gate the seed effect on
transferReady.

Addresses upstream PR #933 review (Minor).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:16:03 +08:00
..
2026-03-24 12:32:37 +08:00
2025-12-19 15:26:33 +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.