Files
hapi/web
8b5cde7e44 feat(web): persist and send voice input message across session navigation (#1435) (#1436)
* feat(web): persist and send voice input message across session navigation (#1435)

* fix(web): address bot review findings for voice send across session switch (#1435)

* fix(web): resolve teardown, inactive session, send error recovery and mention serialization findings (#1435)

* fix(web): preserve draft initialText on error and handle async cross-session permission lookup (#1435)

* fix(web): preserve session draft on background failure and send via composer delivery policy (#1435)

* fix(web): send dictation directly via API and fix standard dictation language and recording support (#1435)

* trigger CI rerun for voice dictation session switch fixes (#1435)

* fix(web): prevent failed voice sends from clobbering replacement composer text (#1435)

* fix(web): prevent zero-byte recording failure from clobbering replacement composer draft (#1435)

* fix(web): prevent unmount voice transcription, clear draft on send success, and clear partial transcript (#1435)

* fix(web): stop button transcribes into composer without auto-sending; only send button triggers stopAndSend (#1435)

* ci: rerun CI for flaky claudeRemote test timeout (#1435)

* ci: retry flaky claudeRemote test (#1435)

* fix(web): guard dictation send path against attachments/schedule/connecting and protect newer drafts from late overwrite (#1435)

* fix(web): preserve Pi steer delivery mode for background dictation sends (#1435)

* fix(web): include active state in handleSend dependency array (#1435)

* fix(voice): surface MediaRecorder failures instead of transcribing partial audio

A MediaRecorder error can still be followed by dataavailable and stop
with partial bytes, so the previous onstop-only flow could transcribe
and auto-send corrupt audio. Track recorder.onerror and short-circuit
the stop handler: restore the draft and surface an error instead of
transcribing. Add a regression test for error -> dataavailable -> stop.

* fix(web): resume inactive sessions for voice dictation direct-sends

A voice dictation direct-send (mic -> send) posts the transcribed message
straight to the session, bypassing the text pipeline's inactive-session
resume. The hub rejects message POSTs to inactive sessions with 409
session_inactive, so sending to an archived session always failed (while
reopening it first made the same send succeed).

- stopAndSend now accepts { resolveSessionId, onSessionResolved } and
  resolves the target session after transcription, before the POST
- HappyComposer allows direct voice sends on inactive sessions when a
  resolver is provided (instead of downgrading to a plain stop), and
  threads SessionPage's canonical resolveSessionId + handleSessionResolved
  (the same ones the text send path uses) into the dictation hooks
- after the message is delivered to the resumed session, the UI
  navigates/seeds the live session; failed sends keep the existing
  draft-restore + error surfacing
- realtime dictation sends apply the same resolution

* fix(web): satisfy HappyChatContextValue in markdown-a test helper

Upstream 51ae260a added the required showSessionSummaryInChat field to
HappyChatContextValue but missed the markdown-a test helper, so web
typecheck (part of CI) fails at the upstream tip. Provide the field like
the sibling test helpers do.

* fix(web): expose a Send action during connected dictation

The visible composer button becomes Stop-only while dictation is
connected, so mouse/touch users could never reach the session-bound
stopAndSend path in handleSend (keyboard Enter was the only entry
point). Add an explicit Send button next to Stop when dictation is
connected; it routes through onSend('default') so the transcript is
captured to the target session and survives navigation (per #1435),
while Stop keeps its stop-without-sending semantics.

Addresses HAPI bot review on #1436.

* fix(web): hide dictation Send button when direct send is not eligible

The Send button shown during connected dictation only performs a real
send when handleSend's direct-send path runs; with attachments, a
pending schedule, or scratchlist mode it silently falls back to
dictation.toggle() (stop + transcribe). Gate the button on the same
eligibility conditions as handleSend so a visible Send control always
actually submits.

Addresses HAPI bot review (Minor) on #1436.

* fix(web): drop superseded markdown-a test context fix

Upstream merged its own showSessionSummaryInChat fix for the markdown-a
test helper; the duplicate property added for the pre-merge CI break is
no longer needed.

* fix(web): gate dictation direct-send on full attachment state and guard late navigation

- dictationDirectSendEligible now uses blocksScheduling (visible + hidden
  persisted + still-hydrating attachment drafts) instead of only the
  visible attachments array. An inactive composer keeps persisted
  attachment blobs out of the visible array, so the previous check could
  expose direct Send, resume the session, and POST only the voice text,
  stranding the blobs on the obsolete source session. handleSend's
  canDirectSend and the Send-button gate share one helper so they can
  never disagree.
- The voice send survives unmount by design (#1435); a send that finishes
  after the operator left the source session no longer navigates them
  back. handleVoiceSessionResolved only navigates when still on the
  source session's page.

Addresses HAPI bot review (Major + Minor) on #1436.

* fix(web): read live route state when a background voice send completes

The previous guard captured the render-time pathname; once the source
SessionPage unmounts, that captured value still points at the source
route, so a voice send that finishes after the operator left would still
navigate them back. Read router.state.location.pathname at callback time
instead (the router instance is stable; its state is always current).

Addresses HAPI bot review (Minor) on #1436.

* fix(web): recover post-resume send failures under the resumed session id

When a voice dictation send fails after the inactive session was already
resumed, the catch path persisted the retryable transcript under the
archived source session id — which the hub has superseded — so the
recovery was invisible on the live session. Save the draft under the
resumed id, and fire onSessionResolved so the operator is taken to the
live session where the retryable text lives. Applies to both standard
and realtime dictation.

Addresses HAPI bot review (Major) on #1436.

* fix(web): snapshot resumed-session draft before the post-resume send

The previous recovery guard read getDraft(targetSessionId) inside the
catch — after the send already rejected — so it compared the current
draft to itself and always overwrote text the operator typed into the
resumed composer while the request was in flight. Snapshot the resumed
session's draft at resolve time instead, so a newer draft is never
clobbered. Applies to both standard and realtime dictation.

Addresses HAPI bot review (Major) on #1436.

* fix(web): preserve both drafts when a post-resume send fails

When the operator typed a newer draft into the resumed composer while
the voice send was in flight and the send then failed, the recovery
guard correctly refused to overwrite the newer draft — but that dropped
the failed voice text entirely. Merge instead: append the failed message
after the newer draft so both remain recoverable. Applies to both
standard and realtime dictation.

Addresses HAPI bot review (Major) on #1436.

* fix(web): merge live composer text into failed voice-send recovery

Failure recovery previously read only the persisted draft store, but
while the composer is mounted its edits live in memory (session storage
is written only on unmount). If the operator typed replacement text
while transcription or delivery was pending, recovery either skipped the
composer restore (persisting only the voice text, which the later unmount
then overwrote) or clobbered the replacement. Recovery now reads the live
composer text when mounted and appends the failed voice text after it,
reseating the composer to the merged draft; the unmounted case merges a
persisted draft written since the snapshot. Applies to recording,
zero-byte, transcription, send, and realtime failure paths in both
dictation implementations.

Addresses HAPI bot review (Major) on #1436.

* fix(web): preserve dictation audio constraints

* fix(web): transfer follow-up drafts after voice resume

* fix(web): merge source drafts on failed voice resume

* Preserve resumed voice drafts and replace recovered composer text

Sample destination/source draft merges at persistence time; use a replacement callback for realtime recovery. Six regressions fail before the fix; 66 focused tests, all five package suites, typecheck and 16 Playwright checks pass.

* Recover delayed voice failures into the current composer mount

Resolve live recovery targets by session identity and update the draft ref synchronously before later unmount. Both standard/realtime remount regressions fail before the fix. 85 focused tests, full package suites, typecheck and 16 Playwright checks pass.

* fix: preserve live composer drafts after background voice success

Use shared transfer sampling for standard and realtime sends; update the mounted destination before navigation and unmount. Verified focused voice/transfer tests, typecheck, all five full package suites (CLI rerun serially), and 16 browser checks.

* fix: retain resumed voice follow-ups and destination attachments

Verified focused regressions, root typecheck, all five complete package suites (environment-sensitive failures rerun serially), and 16 browser checks.

* fix: preserve independent destination drafts during voice recovery

Cross-session recovery always retains destination text and independently merges source follow-ups. Remove destination baseline replacement from both hooks. Verified 72 focused regressions, root typecheck, all five full package suites (Hub rerun serially), and 16 browser checks.

* fix: preserve voice recovery drafts before composer hydration

Expose persisted draft text until hydration and update the text ref synchronously when restoring. Cover both voice modes with a held animation frame and intentional empty text after hydration. Verified 94 focused checks, full package suites, root typecheck, and 16 browser checks.

* fix(web): retain ElevenLabs finalization across navigation

* fix(web): preserve new same-text drafts across pending voice sends

* fix(web): refresh voice send callback after draft hydration

* fix(web): resolve voice destinations from current session activity

* fix(web): give voice direct-sends a localId so agent echoes dedupe

Voice dictation, realtime dictation and the cross-session voice tool sent
through api.sendMessage with localId null. Shared Codex then minted a
random clientUserMessageId for the turn and echoed the user message back
with it; the hub dedupes only on (session, localId), so every voice
message was stored and shown twice.

Route all voice sends through sendVoiceMessage, which mints the same
local-* id useSendMessage uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:13:53 +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.
  • Codex request_user_input choices honor isOther: None of the above focuses optional notes, supports empty notes, and preserves the canonical wire value across languages. Recorded other answers and notes remain visible in live and historical cards; Pi/MCP forms without the flag are unchanged.
  • Permission mode and model selection.
  • Machine list and remote session spawn.
  • File browser and git status/diff views.
  • PWA install prompt and offline banner.
  • Optional unread-session count on the Windows taskbar when installed as an Edge/Chrome PWA (toggleable in Display settings; off by default).

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.
  • Session @ suggestions require conversation content, including untitled conversations. Names and directory labels affect display/search, not eligibility; empty stubs stay excluded. See the session list behavior guide for search and picker rules.

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.
  • /browse - Workspace browser, enabled by the runner's configured workspace roots.
  • /share - Share-target landing (Web Share Target POST → ?id=, or native /share#url=&text=&title=).
  • /settings - Settings category hub (mobile) and responsive master-detail shell.
  • /settings/general - Language preferences.
  • /settings/display - Appearance, typography, colors, and session list preferences.
  • /settings/chat - Message input, tool card, and conversation color preferences.
  • /settings/voice - Everyday voice assistant preferences.
  • /settings/voice/voices - Full-page voice picker.
  • /settings/voice/advanced - Voice persona, tuning, and diagnostics.
  • /settings/machines - Machine management and runner status.
  • /settings/storage - SQLite storage sizes for the hub owner.
  • /settings/usage - Cache-aware token usage dashboard for the hub owner.
  • /settings/about - Application links and version information.

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 name and model display.
  • Metadata search with relevance ranking, wildcard matching, and date/unread/machine filters.
  • Machine/project grouping, pinned and in-progress sections, and configurable collapsed previews.

Chat interface (src/components/SessionChat.tsx)

  • Message thread with infinite scroll.
  • Composer for sending messages.
  • Permission mode and model selection for supported agents. Cursor Auto uses CLI Auto for new/resumed sessions configured with Auto. Switching a live session to Auto when ACP does not advertise a literal Auto option relaunches the Cursor ACP process in place under --model auto while preserving the hub session id.
  • Session abort and handoff controls.
  • Codex Continue planning hides the current proposal's action menu locally and focuses the composer without sending a message or replacing its draft. The plan document remains readable; recycled cards stay dismissed and new proposals get fresh action menus.
  • 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).
    • Entries and attachments saved on the hub and synced across devices.
    • Reordering affects only the current view and resets when entries refresh.
    • 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 /terminal
  • Resize handling

Voice assistant

  • ElevenLabs (@elevenlabs/react), Gemini Live, and Qwen Realtime backends
  • Real-time voice control
  • Standard and realtime composer dictation with provider capability selection

New session (src/components/NewSession/)

Modular session creation:

  • Machine selector
  • Directory input with recent paths
  • Agent type selector
  • Model selector
  • Per-agent permission, effort, and collaboration controls when supported

First-User-Experience (FUE)

For a new, non-essential feature whose affordance would otherwise be hard to discover, consider the existing FUE primitive rather than a permanent UI block. Optional, not a requirement for every feature or a reason to expand a bug fix.

  • src/lib/use-fue.ts: useFue(featureId) returns { status, engage, dismiss }; acknowledgement is isolated per feature in hapi.fue.v1.<featureId> localStorage keys.
  • src/components/Fue.tsx: FueDot marks the affordance; FueCallout explains it while status === 'engaging'.
  • Dismissal requires an affirmative user action ("Got it"), never an auto-timeout.
  • The FUE dot and feature-specific badges/counters are mutually exclusive; onboarding wins until acknowledged.
  • Opt in per feature; skip the wrapper if an upstream component already supplies onboarding.
  • Working example: ScratchlistToggleButton in src/components/AssistantChat/ComposerButtons.tsx. Use the source rather than maintaining a copied example here.

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.