* refactor(web): extend MessageMetadata to accept aggregated turnCount Add an optional `turnCount` prop to MessageMetadata so the same builder can render an aggregated response-group footer when the caller has already summed usage and dedup-joined model ids. The label set switches to `Models` / `Total` / `N turns` only when `turnCount >= 2`, leaving single-turn footers byte-identical with the existing `Invoke · Model · Usage` output. Also expose `turnCount?: number` on `HappyChatMessageMetadata` so a later commit can inject the aggregated metadata through the library's ThreadMessageLike payload without widening the type at the same time. No call site passes `turnCount` yet, so this commit is behavior-neutral on all existing surfaces (proof-of-invariance test included). * feat(web): aggregate per-response metadata so multi-turn cards show total usage The `@assistant-ui/react` converter joins adjacent assistant messages into one card but only preserves `metadata.custom` from the first block, so multi-turn responses currently show the first turn's usage and model only. Compute response-group aggregates in `useHappyRuntime` and inject the sum on each group's first visible block, where the library will keep them. Per group: usage tokens are summed across distinct turns, model ids are dedup-joined in first-seen order, and the invoke time is the first turn's so the footer keeps showing when the response started (regression-guarded by unit test). `durationMs` is explicitly cleared on aggregated blocks because the first turn's value would otherwise leak through the join. Turn identity prefers the CLI-stamped `localId`. When that is null (claude code spawn sessions today emit `localId=null` on every chunk) the aggregator falls back to a fingerprint built from `model` plus the shared `usage` totals — every block emitted within one Claude SDK message carries an identical usage object, so the fingerprint dedups those chunks without merging distinct turns whose token counts naturally differ. Tool-result chunks with no model or usage are skipped so they cannot inflate the turn count. Single-turn responses get no aggregate entry, so their footers stay byte-identical with the existing behavior. Test plan - `assistant-runtime.test.ts` covers the six grouping scenarios spelled out in the design note (localId-based + null-localId fingerprint fallback) plus two defensive cases for tool_result chunks and cache token preservation. * fix(web): preserve explicit zero sums and count tool-group turns in response aggregator Two correctness gaps in aggregateResponseGroups: - addUsage folded `0 + 0` through `|| undefined`, dropping an explicit-zero cache token sum from the aggregated metadata. Replace the falsy fold with sumOptional(): undefined only when both operands are absent, otherwise (a ?? 0) + (b ?? 0). - turnSourceFromBlock returned null for tool-group blocks, so a card whose visible-first block is a tool-group dropped its turn entirely. Read the first underlying tool-call instead; degrade to null only when the group somehow holds zero tools. Unit tests cover both regressions: tool-group as the first visible block in a response group, explicit-zero cache sums preserved, and the empty-tool-group degrade-to-null path. * fix(web): dedup response-group turns by adjacency rather than set membership The fingerprint fallback (used when localId is null) compared each turn key against a Set of every key seen in the group. A response group whose first and third turns happened to carry the same (model, usage) fingerprint would collapse the third turn into the first, under-counting the visible turn count. Switch to ordering-based dedup: each block's turn key only collides with the immediately previous turn. Adjacent blocks within one SDK message still collapse (their usage object is identical), but non-adjacent fingerprint matches across separate turns stay distinct. Behavior under localId-stamped flows is unchanged because distinct turns always carry distinct localIds. Unit test covers a three-turn group whose first and third turns share a fingerprint with a different middle turn between them. * fix(web): aggregate every tool-call in a tool-group and dedup by createdAt fingerprint `buildVisibleChatBlocks` merges adjacent eligible tool-calls into a single `tool-group` without checking that they share a turn. Reading only the first underlying tool would drop every later tool turn from the aggregate, so each tool-call in the group now contributes its own turn source. The fingerprint fallback (used when the CLI does not stamp `localId`) gains `createdAt` as a third axis. The reducer copies `msg.createdAt` onto every derived ChatBlock, so blocks from one SDK message still collapse to one turn, while two adjacent turns that happen to coincide on `(model, usage)` no longer dedup against each other. Same wall-clock millisecond collisions remain theoretically possible but are bounded by the hub stamp resolution. Helper layer consolidates: `turnSourceFromBlock` (single-or-null) is gone, replaced by `turnSourcesFromBlock` returning the array directly. Test renames clarify the contract — the existing tool-group test now documents the same-turn collapse case — and one new test pins the fingerprint coincidence case. * fix(web): make tool-only response cards expose aggregate metadata `aggregateResponseGroups` keys aggregate metadata onto a response group's first visible block, which can be a `tool-group` when the assistant turn starts with tools. The `toolOnly` render branch did not wire the click toggle that the default/codex branches use, so the new Models/Total/N-turns footer stayed unreachable for those cards. Wrap the toolOnly content with the same cursor-pointer div used in the sibling branches (toggleMetadata, onMetadataKeyDown, role=button, aria-expanded). Carry `min-w-0` on the wrapper so long tool labels keep clipping under the existing `overflow-x-hidden` on MessagePrimitive.Root. The shared `isNestedInteractiveEvent` guard prevents the wrapper toggle from firing when nested tool buttons or disclosures are clicked.
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>(orCLI_API_TOKENfor 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/bypassPermissions/plan).
- Model selection (default/sonnet/sonnet[1m]/opus/opus[1m]).
- Session abort and mode switch controls.
- Context size display.
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_URLto the public HTTPS URL of the dev server.CORS_ORIGINSto include the dev server origin.
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:
- Build the web app. If your static host uses a subpath, set the Vite base:
bun run build:web -- --base /<repo>/
- Deploy
web/distto your static host. - Set hub CORS to allow the static origin (
HAPI_PUBLIC_URLorCORS_ORIGINS). - 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.