Compare commits
@@ -25,7 +25,11 @@ jobs:
|
||||
issues: read
|
||||
id-token: write
|
||||
env:
|
||||
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' && startsWith(github.event.pull_request.title, 'release:') }}
|
||||
# Any dev -> main PR is, by the branching model, the aggregate release PR
|
||||
# (main only ever receives release merges from dev). Detect it by base+head
|
||||
# alone — a title-format match (e.g. "release:") is fragile and silently
|
||||
# let a "Release v1.0.0 …"-titled PR run the full review and time out.
|
||||
IS_RELEASE_PR: ${{ github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev' }}
|
||||
|
||||
steps:
|
||||
- name: Skip aggregate release PR review
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# AGENTS.md
|
||||
|
||||
Universal agent instructions for **Hermes-Relay**. This is the entry point for any
|
||||
coding agent (Claude Code, Codex, Cursor, etc.).
|
||||
|
||||
## Read this first
|
||||
|
||||
The detailed, authoritative context lives in **[CLAUDE.md](CLAUDE.md)** —
|
||||
architecture, the upstream Hermes API reference, repository layout, per-language
|
||||
code style, the dev loop, and the Key Files map. Read it before touching code,
|
||||
then `docs/spec.md` and `docs/decisions.md`.
|
||||
|
||||
- Release process → **[RELEASE.md](RELEASE.md)**
|
||||
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
|
||||
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
|
||||
|
||||
## Non-negotiables (the short list)
|
||||
|
||||
- **Standard path = vanilla upstream only.** The default (no-plugin) connection —
|
||||
chat via the API server, standard voice via the Hermes dashboard — must work
|
||||
against unmodified upstream hermes-agent. Server-side needs go through upstream
|
||||
PRs or the optional relay plugin, never fork patches.
|
||||
- **Verify endpoints against upstream** (`gateway/platforms/api_server.py` /
|
||||
`tui_gateway/server.py` in hermes-agent) before assuming a route exists.
|
||||
- **Conventional Commits + `main`/`dev` branching.** Feature branches off `dev`,
|
||||
`--no-ff` merges, version bumps at release-prep on `dev`, tags cut from `main`.
|
||||
- **Android:** Jetpack Compose only (no XML), kotlinx.serialization (no Gson),
|
||||
OkHttp (no Ktor), `wss://` only. Run `./gradlew lint` before pushing Kotlin.
|
||||
|
||||
## Public-repo writing hygiene
|
||||
|
||||
Everything committed is public. In CHANGELOG, DEVLOG, README, docs, and release
|
||||
notes:
|
||||
|
||||
- **No personal names** — attribute impersonally; identity lives in git + the
|
||||
signing cert.
|
||||
- **No private infrastructure** — real hostnames/IPs, internal deployment names,
|
||||
`~/SYSTEM.md`. (Generic example IPs in setup docs are fine.)
|
||||
- **No AI/assistant process self-narration** ("I should have…", course
|
||||
corrections) — state the technical conclusion only.
|
||||
- **No internal jargon or fork/branch plumbing** in user-facing notes.
|
||||
- **CHANGELOG** uses Keep-a-Changelog grouping; condense the version block to
|
||||
crisp public bullets at release-prep (see RELEASE.md §2 "Scrub for public
|
||||
distribution"). **DEVLOG** is a depersonalized, factual engineering log.
|
||||
@@ -6,103 +6,55 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0] - 2026-06-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Persistent Realtime Agent conversation.** Realtime Agent voice now keeps one provider session/socket open across turns instead of creating a fresh session per utterance, so the provider retains the live conversation (follow-up references work) and turns skip session-setup latency. The relay needed no change — it already supported multiple turns on one socket. A **Voice Settings → Realtime Agent → Persistent session** toggle (default on) falls back to the legacy per-utterance path. See `docs/plans/2026-05-24-realtime-persistent-session.md`.
|
||||
- **Gateway chat transport with live thinking.** Chat can ride the upstream dashboard `/api/ws` (the `tui_gateway` surface the official hermes-desktop client speaks) — the only vanilla-upstream path that streams reasoning *live*, so the Thinking block and sphere light up during generation. "Auto" prefers it when the dashboard is reachable and Manage is signed in, and falls back to the SSE endpoints per turn.
|
||||
|
||||
- **Background Hermes runs in Realtime Agent voice (ADR 33).** Long Hermes tasks no longer freeze the realtime conversation. A run that exceeds a grace window is promoted to a tracked background task: the provider speaks a short handoff ("I'm on it"), the conversation stays responsive, and the answer is spoken once the run finishes. `hermes_run_task(mode="background")` starts a durable run immediately. New relay events `hermes.run.promoted` and `hermes.run.background_completed`, plus `tier`/`floor` fields on `hermes.run.progress`.
|
||||
- **Gateway desktop parity.** Native image/PDF/file attachments (with an in-chat notice when a turn falls back to a transport that can't carry files), mid-turn **steering**, **edit & resend**, interactive **approval / clarify / sudo / secret** cards, live **subagent lanes**, a **context-window meter**, server **slash commands** in autocomplete, and **turn-complete notifications** when the app is backgrounded.
|
||||
|
||||
- **Relay audio floor owner.** A single-owner audio floor (provider / relay-TTS / Android-filler) makes explicit the serialization that the old blocking design provided implicitly, so a completed background result never barges in and two voices never overlap.
|
||||
- **Gateway warm-start + Keep connected in background.** Pre-warming the gateway on foreground moves the cold session-setup cost off the send path. An opt-in foreground-service toggle (both flavors; `specialUse`, off by default) holds the socket open in the background so a long-backgrounded conversation resumes instantly.
|
||||
|
||||
- **Voice Settings → Realtime Agent → Background tasks.** New controls to enable/disable promotion, toggle the spoken handoff, and choose result delivery (speak when idle / notify / show only). A persistent "working on it" chip appears in the voice overlay while a background task runs.
|
||||
- **Switch agent profiles from chat.** Pick a different agent — model, SOUL, personality, and skills — per conversation. The selection is **ephemeral** (bound to the session like the official desktop; it never changes the server's default agent for other clients). The session drawer scopes to the active profile and loads that profile's history, and the right agent is restored on cold start. The Manage tab's server-wide **Activate Profile** action now confirms first.
|
||||
|
||||
- **Provider idle-tolerance probe.** `scripts/realtime-provider-idle-probe.py` records a per-provider verdict (hold-floor-ok / needs-keepalive / must-reopen) for holding a realtime socket quiescent during a background run; see `docs/realtime-voice-poc.md`.
|
||||
- **Manage parity with the desktop dashboard.** Change models from the full provider catalog, manage provider keys (write-only, masked, reveal), create/edit profiles and SOUL.md, and browse/install/update skills. Manage data is cached to disk for an instant cold launch.
|
||||
|
||||
- **Per-route reachability verdicts in the Routes card.** Every route row now shows the result of its last health probe — "Reachable", or "Unreachable" with the actual reason ("TLS failed — server may be http://, not https://", "Connection refused", "No answer (timed out)", "HTTP 404 from /health") — and "Re-check" shows a live checking state instead of doing invisible background work. Verdicts persist between probes so you can see what the network last said.
|
||||
- **Open & save chat images and attachments.** Tap an image for a full-screen viewer (pinch-zoom, double-tap, Share/Save); non-image attachments gain an Open/Share/Save menu. Saves land in `Pictures`/`Download/Hermes-Relay` with no permission on Android 10+, preserving the original bytes.
|
||||
|
||||
- **Manage parity with the hermes-desktop dashboard.** The Manage tab can now do what the desktop dashboard can: **Models** — change the main model from the full provider/model catalog (`/api/model/options` → `/api/model/set`), including the expensive-model confirmation round-trip; new **Keys** tab — view, set (write-only, masked), reveal (server rate-limited), and clear provider keys / env secrets; **Profiles** — create profiles (clone-from-default), edit descriptions, set per-profile models, and **edit SOUL.md** in a full-file editor; **Skills** — browse the multi-source skills hub with search, SKILL.md preview-before-install, install/uninstall (async server-side), and update-all.
|
||||
- **Persistent Realtime Agent voice + background runs (ADR 33).** The realtime engine keeps one session across turns (follow-ups retain context); a long Hermes run is promoted to a tracked background task and spoken when ready, so the conversation stays responsive.
|
||||
|
||||
- **Manage data survives app restarts.** The Manage payload cache now mirrors to a plain-JSON file in the app's private cache directory and hydrates at startup, so a cold app launch renders the last-seen dashboard data instantly while fresh data loads quietly behind it. Signing in or out wipes the disk mirror along with the in-memory cache. (Deliberately a flat file rather than encrypted prefs — the payload carries no credentials, and every encrypted-prefs build costs seconds under the Keystore's process-global lock.)
|
||||
- **Redesigned chat input bar.** A Telegram-clean pill field with one trailing button that morphs between Send / Voice / Stop / Steer / Queue; the slash button is gone (typing `/` still opens autocomplete).
|
||||
|
||||
- **Routes card reachability verdicts** ("Reachable", or the specific failure reason) and per-turn **latency tracing** (`TurnLatency`, durations only) for diagnosing transport speed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Docs site rechromed to the relay cockpit theme and repositioned around the two-path story.** `user-docs/` now mirrors the app's `RelayRefresh` palette (navy-black base, warm-white ink, electric-indigo accent, grid/dot texture) instead of the old neutral-gray/purple chrome. The homepage leads with "Runs on your machine. Lives on your devices.", a quick-path-first funnel ("Just connect" — no server install, just a running Hermes — above the "Give it hands" relay-plugin power path), role-framed surface cards (companion app / remote-hands CLI), and a How-it-works strip. "Desktop CLI" is now plain "CLI" with a Windows-today / macOS-Linux-coming-soon status on every availability claim; "self-hosted" dropped as a qualifier. Sidebar gained five previously unreachable pages (voice, voice intents, phone control tools, relay server, flavor differences); stale version pins refreshed. Sphere gaze tracking on the homepage no longer drifts over time or snaps between scroll/cursor modes (`lightAngleBlend` partial-mix leak — blend is now exactly 1 with ambient life moved into the wander term).
|
||||
- **The standard (no-plugin) path is first-class.** Chat, Manage, and voice all work against an unmodified upstream Hermes agent; standard voice rides the dashboard audio surface (`/api/audio/*`) with the Manage sign-in, and relay-paired voice is the profile-aware fallback. The relay plugin is now purely additive.
|
||||
|
||||
- **Standard (no-plugin) voice now rides the Hermes dashboard surface.** STT/TTS for the standard route uses the dashboard's `/api/audio/transcribe` + `/api/audio/speak` (the hermes-desktop voice contract) with the same cookie session Manage signs in with — a vanilla hermes-agent install needs no Relay plugin for voice. Previously the client targeted the API server, which has no audio routes, so standard-only voice always failed.
|
||||
- **Seamless connection UX.** LAN↔Tailscale handoffs and reconnects no longer reload the chat; connection and update status are now in-theme slide-down toasts over the content instead of banners that pushed the UI around.
|
||||
|
||||
- **Auto STT/TTS route prefers Relay when paired.** Paired Relay voice is profile-aware and needs no dashboard sign-in; the standard dashboard route is the zero-plugin fallback. Voice Settings now shows live per-route status (ready / sign-in required / unreachable / unsupported build) with a "Sign in via Manage" shortcut, and the Realtime Agent engine is clearly marked as requiring a paired Relay.
|
||||
- **Editable, roaming routes.** Add/edit/remove routes in Settings → Connections; bare-host URLs default their scheme and port (and preview what will be saved); remote-access (Tailscale) is surfaced in the main setup flow with a "Remote" readiness line.
|
||||
|
||||
- **Softened the active connection card.** The full-card Electric blue fill on the active connection was overpowering against body text; it now uses a muted indigo wash while small accents keep the vivid brand blue.
|
||||
- **Faster Manage.** A shared auth preamble plus concurrent payloads cut a full load from ~40 round trips to ~12; a process-lifetime cache and startup pre-warm render the last-seen data instantly, and Manage now names which dashboard URL it's talking to.
|
||||
|
||||
- **Connection wizard capability card now includes Voice.** Finishing setup shows Chat / Manage / Voice / Relay readiness in one card — voice availability (ready / unlocks with dashboard sign-in / build too old) is probed in the same pass, so the result is accurate the moment you connect.
|
||||
- **Faster, calmer cold start.** Key-less connections skip the multi-second keystore decrypt; the startup sphere is now the actual loading screen with narrated check lines, and the OS splash blends into it.
|
||||
|
||||
- **No more relay warnings on standard-only connections.** Voice Settings no longer fetches Relay voice configs (and no longer shows "unavailable" rows or error snackbars) when no Relay is configured — relay-backed sections are replaced by a quiet note that speech uses the server's configured TTS/STT, with Relay pairing called out as the way to pick providers from the phone.
|
||||
- **Docs + branding.** The docs site was rechromed to the app theme and repositioned around the two-path story; the README and Play listing were refreshed standard-first; product-name copy normalized to **Hermes-Relay**.
|
||||
|
||||
- **Skills hub opens with featured content.** The browse dialog lists the configured hub sources and the index's featured skills before the first search instead of starting blank.
|
||||
|
||||
- **Onboarding feature pages got real content.** Chat / Manage / Power tools pages now show three concrete feature rows each (streaming + profiles + voice; control + skills hub + one sign-in; terminal + bridge + realtime) instead of a single sentence.
|
||||
|
||||
- **Floating status pill.** The bottom status strip is now an inset rounded capsule floating above the gesture area instead of an edge-to-edge bordered bar that clashed with rounded display corners.
|
||||
|
||||
- **Ambient mode is now a gesture.** The top-bar sphere toggle is gone; long-press the conversation background to enter the fullscreen sphere, tap anywhere to return (a transient "tap to return to chat" pill teaches the exit on entry). Message long-press (copy) is unaffected.
|
||||
|
||||
- **Media settings labeled Relay-only.** The Media screen now states that its inbound-attachment controls apply to Relay-delivered files only, not to standard connections or images you attach in chat.
|
||||
|
||||
- **Quote in reply.** Long-pressing a message now offers Copy and "Quote in reply" — quoting drops the message into the input as a Markdown blockquote.
|
||||
|
||||
- **Share conversation.** A share icon in the chat top bar exports the visible conversation as Markdown through the system share sheet.
|
||||
|
||||
- **Manage cards declutter.** Cards with five or more actions (profiles) keep the three most-used buttons inline and fold the rest behind "More".
|
||||
|
||||
- **Ambient gesture is documented in Appearance.** Settings → Appearance now explains the long-press-to-enter / tap-to-return gesture, keeping it discoverable (including for screen-reader users) without a visible control.
|
||||
|
||||
- **User docs: Quick Start.** New two-minute Quick Start page leads the guide; the dashboard page documents the full phone Manage surface (skills hub, models, keys, profile + SOUL editing); voice docs lead with the standard no-Relay route.
|
||||
|
||||
- **Routes are now editable in Settings → Connections.** The Routes card gains "Add route" plus per-route Edit/Remove (the primary route mirrors the connection's API URL and stays protected) — the standard path's manual equivalent of the Relay QR's multi-endpoint provisioning. Add your server's Tailscale or public URL after the fact and the phone roams to it automatically; the wizard's optional Tailscale field remains the setup-time shortcut.
|
||||
|
||||
- **URL fields accept bare hosts and explain their ports.** Typing `100.71.8.56` (or any bare host/IP) into the API URL, wizard Tailscale, or route-editor fields now saves `http://100.71.8.56:8642` — scheme and API port defaulted, and the route editor previews exactly what will be saved ("Will save: http://100.71.8.56:8642") before you commit. Field copy now states which port is which (API `8642`, dashboard `9119`) and that `https://` should only be used when the server actually has TLS. Route rows display the full URL including the scheme, since an invisible `https` was the classic cause of a route that never won a probe.
|
||||
|
||||
- **Manage remembers its data and pre-warms it.** Dashboard payloads now live in a process-lifetime cache instead of screen state, so leaving and re-entering Manage shows the last data instantly (entries older than 30 s refresh quietly in the background — content stays put, only a thin progress bar shows). When a connection's saved dashboard status says it was reachable and signed in, the app pre-warms all Manage sections at startup (and again after a LAN↔Tailscale route handoff), so even the first open lands on real data. Signing in or out still clears the cache.
|
||||
|
||||
- **Manage's full load dropped from ~40 round trips to ~12.** Every section fetch used to re-run the dashboard auth preamble (status → providers → session → ws-ticket) before its payload — eight sections, strictly one after another, which over a Tailscale link read as 5–10 seconds of "still loading". The preamble is now fetched once per sweep and shared, and the section payloads download concurrently, so a full load costs roughly one preamble plus one payload's worth of latency.
|
||||
|
||||
- **Cold start no longer waits 15 seconds to learn there's no API key.** On devices with StrongBox secure hardware (recent Samsungs), every keystore operation takes ~half a second and they all run one at a time — a measured cold start spent 15 seconds decrypting the credential store before the app could even build its HTTP client, only to find the connection had no API key (the normal local setup). A plain non-sensitive "has API key?" hint now lets key-less connections build the client immediately — chat, health, and the conversation restore start within a couple of seconds — while keyed connections still wait for the real decrypt (a stale hint can only ever make startup slower, never strip auth). The startup checks also now count the route prober's successful health probe as "hermes online" instead of waiting for the client-based probe to repeat the same check.
|
||||
|
||||
- **The startup reveal can no longer flash the "Connect Standard Hermes" card.** The gate was releasing on the route prober's early health evidence while the chat screen renders its connect CTA from a stricter signal (client built + reachability verdict) that lands a few hundred milliseconds later — so the fade-out could briefly expose the disconnected card before the full chat snapped in. The gate's happy path and the "conversation" check line now key on the chat surface's own readiness signal, so what's revealed is exactly what was verified.
|
||||
|
||||
- **Startup checks visibly check, and the OS splash blends into the sphere.** The sphere's check lines now resolve strictly top-to-bottom, each holding a brief spinner beat before its ✓ lands — with the fast cold-start path everything could already be true before the sphere faded in, and an all-✓-at-once reveal read as "nothing was actually verified". The gate waits for the ticking to finish (~1.5 s) before showing chat. The system splash (which Android always draws first and can't be replaced) now uses the app's exact background color — the old splash was a visibly different navy — and its icon is properly transparent, so launch reads as one continuous dark screen that the sphere fades into.
|
||||
|
||||
- **The startup sphere is now the actual loading screen.** Cold starts used to flash a slideshow of half-ready states — the disconnected "connect" prompt, then the connected state, then the conversation, each revealing separately — because the splash gate released on the first health verdict (often a probe against the old route, moments before the resolver switched) and force-hid itself after 5.5 s no matter what. The sphere now holds until the app is presentable — server answering AND the last conversation restored — or until an unreachable verdict survives a settle window (then the normal UI takes over with its offline status), with a 12 s backstop. While it holds, terminal-style check lines narrate progress at the bottom (state restored · route · hermes online · conversation), so a longer wait reads as work instead of a hang.
|
||||
|
||||
- **Terminal and Settings headers gained back buttons.** Both are pushed destinations (reached from the Chat/Manage header chrome), but neither offered a way back except the system gesture; they now carry the same header back arrow as every other pushed screen. The footer status pill also hugs the bottom edge slightly tighter.
|
||||
|
||||
- **"Use now" no longer silently becomes a preference.** The Routes card's "Use now" is now a true one-time switch: it moves traffic immediately and holds only until the next disconnect, without touching the saved route preference. Making a route sticky is the explicit "Prefer this route" action in the row's ⋮ menu (now a toggle, with "Stop preferring" when set). The Current line says which mode picked the route — automatic, preferred, or "manual (until disconnect)" — and dedicated "Cancel manual switch" / "Stop preferring" actions undo each layer separately. Tailscale is intentionally not auto-preferred: automatic resolution already promotes it the moment the LAN route stops answering, and keeps the faster LAN path when you're home.
|
||||
|
||||
- **Manage loading and overview polish.** The cold-load skeleton is now one progress bar plus quiet content-shaped ghost cards — previously four stacked progress bars with fake narrative labels ("Checking dashboard session"…) that read like three different failures. The cryptic KPI glyphs (`ok / … / !`) are replaced by three cards: section count, a tone-colored dashboard state word (ready / sign-in / offline / error), and the server version (handy for confirming which host answered after a route handoff). The dashboard status banner is now two lines — state + identity with Sign out, then URL · route · checked time — so nothing truncates, and its duplicate "Connection" button is gone (the Connections tile sits directly below).
|
||||
|
||||
- **Manage names its dashboard target and explains per-route sign-in.** The Manage tab now shows exactly which dashboard URL it's talking to ("Dashboard: http://… · Tailscale route") above the content, and "Dashboard unavailable" errors name the URL that failed — the dashboard (`:9119`) is a separate server from the API (`:8642`), so "chat works" never proved Manage's target was reachable. When the resolver has moved Manage onto a different host (e.g. roamed to Tailscale), the sign-in card now explains that dashboard sign-ins are per host and a one-time sign-in on this route keeps both sessions — the same hint voice already had.
|
||||
|
||||
- **Remote access is discoverable, not an easter egg.** The standard setup form now shows a "Remote access — Tailscale URL (optional)" field in the main flow (previously buried under Advanced), with a hint when Tailscale is detected on the phone; the setup result card gains a "Remote" readiness line that calls out LAN-only connections; the "Hermes API unreachable" status now diagnoses the likely cause ("Away from the server's network? Add a Tailscale or public route") instead of just reporting; and the Connections card offers an "Add Tailscale route" shortcut when the phone is on Tailscale but the connection has no Tailscale route.
|
||||
|
||||
- **README + Play listing refresh.** Both rewritten around the standard-first story. The README quick start now mirrors the app's capability card (Chat / Manage / Voice / Remote / Relay), voice is no longer described as relay-only, Manage and remote access become headline features, the desktop CLI section is trimmed and clearly marked alpha (with its planned refocus into a remote "hands" connector), and the stale CI badge, broken in-page anchors, and version-pinned "What's new in v0.6.0" section are gone. The Play listing (`docs/play-store-listing.md`) gets an end-user-first short description, a quick-start beat, Manage/remote-access feature blocks, a corrected no-plugin voice story, and v0.8.1 release notes.
|
||||
- **Quality-of-life.** Quote-in-reply, share-conversation-as-Markdown, ambient mode as a long-press gesture, a floating status pill, decluttered Manage cards, back buttons on pushed screens, and a softer active-connection card.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **App-start UI freeze (frozen sphere) from Keystore lock contention.** Cold starts could freeze the UI for many seconds (logcat: `Skipped 1386 frames`, `Davey! duration=11596ms`): every `EncryptedDashboardCookieStore` eagerly built its Keystore-backed prefs in its constructor — a 1–4 s operation on StrongBox devices that serializes through a process-global Tink lock — and several code paths (Manage section loads, connection validation, the Manage pre-warm) each constructed their own instance, stacking multi-second lock holds that main-thread keystore users then queued behind. The store now builds lazily on first cookie access (always an I/O thread), all dashboard-surface consumers share one cached instance per connection, and the pre-warm uses a single client plus the shared store for its whole sweep instead of one of each per section.
|
||||
- **No "Connect to Hermes" flash on cold start.** The empty-state now distinguishes "still hydrating from disk" from "nothing configured" (`ConnectionStore.isHydrated` → `chatConnectState`), showing a quiet "Connecting to Hermes…" spinner until ready and the connect CTA only once hydration confirms no connection exists.
|
||||
|
||||
- **"Re-check" / "Use now" no longer fail silently.** When every saved route failed its probe, the user-triggered re-probe early-returned without publishing anything: the Routes card sat on "Current: Resolving" forever (showing the internal relay URL underneath, which read as "stuck on the internal route") with zero feedback. The probe now always publishes its outcome, the card states "No route reachable — using saved URL …" explicitly, and per-route rows show why each candidate failed. The old 100 ms post-probe delay — always shorter than a real resolve, leaving the follow-up health checks pointed at the stale route — is replaced by actually awaiting the resolve.
|
||||
- **In-app What's New renders cleanly** — parsed into a version subtitle, bold section headers, and real bullets instead of raw text with literal `*`.
|
||||
|
||||
- **Standard (no-Relay) connections now follow LAN ↔ Tailscale network changes.** The ADR 24 network-aware route switching only activated when a Relay socket was open: the connectivity callback registered inside `connect()` and bailed without a socket URL, so a standard connection that left home Wi-Fi kept probing the dead LAN route until the app was backgrounded and reopened. The callback now registers at construction and re-resolves routes (debounced) even with no socket — chat, Manage, and standard voice follow the resolved endpoint automatically.
|
||||
- **App-start UI freeze from Keystore lock contention.** The encrypted cookie store built its StrongBox-backed prefs eagerly in its constructor (1–4 s under a process-global lock) from several code paths at once; it now builds lazily on an I/O thread and is shared per connection.
|
||||
|
||||
- **Standard voice follows the resolved route.** The standard voice client and its availability probe targeted the connection's persisted dashboard URL instead of the resolver's active route, so voice stayed pinned to the LAN host (and gated off) while away from home even after chat had switched to Tailscale. Both now ride `effectiveDashboardUrl`.
|
||||
- **Standard connections now follow LAN↔Tailscale changes**, standard voice follows the resolved route (not the persisted URL), and a stale probe cache can no longer pin a dead route after a handoff or resume.
|
||||
|
||||
- **Stale probe cache can't pin a dead route.** App-resume and network-change revalidation now clear the endpoint resolver's probe cache, so a route that died moments ago can't win re-resolution for the remainder of its 60-second positive cache window. The periodic health check also escalates two consecutive unreachable probes into a full cache-cleared re-resolve — the safety net for handoffs Android never surfaces as connectivity changes (always-on VPN keeps "internet available" true throughout).
|
||||
|
||||
- **Editing URLs no longer wipes fallback routes.** Saving an API or Relay URL rebuilt the connection's route-candidate list from just the edited URL, silently dropping the setup wizard's Tailscale route (or extra endpoints from a pairing payload). Edits now merge: the touched route is rebuilt, stored extras are preserved verbatim.
|
||||
|
||||
- **Per-route sign-in is explained.** Dashboard sessions are cookie-based and per-host, so a Manage sign-in at home doesn't carry to the Tailscale host. When voice is gated on sign-in because the route moved, Voice Settings and the chat mic toast now say so ("sign in once in Manage on this route") instead of showing a bare sign-in nag that looks broken.
|
||||
|
||||
- **A network change can no longer resurrect a deliberately disconnected relay socket.** The route-switch path force-reconnected whenever the resolved winner differed from the last URL, even after an explicit Disconnect; socket actions are now gated on reconnect intent while route publication for HTTP surfaces continues.
|
||||
- **Editing a URL no longer wipes fallback routes** (edits merge with stored extras instead of rebuilding from the edited URL alone); **"Re-check" / "Use now" no longer fail silently** (the probe always publishes its outcome and per-route failure reasons); and a network change can no longer resurrect a deliberately disconnected relay socket.
|
||||
|
||||
## [0.8.1] - 2026-05-26
|
||||
|
||||
@@ -222,7 +174,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Desktop CLI alpha.14 — `Ctrl+A ?` chord re-displays the chord-help banner.** The attach-time banner scrolls off as soon as anything writes to the terminal, so users mid-session forgot the verb list and had to detach + re-attach (or guess). New `Ctrl+A ?` (and `Ctrl+A h` synonym) reprints the banner to stderr without leaving the session. Banner text refactored into a single `CHORD_HELP` constant so the attach-time print, the `?` chord, and the unknown-chord hint can't drift out of sync. Unknown-chord hint now also lists `?` as one of the known verbs.
|
||||
|
||||
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Bailey: *"This isn't cohesive — we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
|
||||
- **Desktop CLI alpha.13 — `Ctrl+A v` chord in `hermes-relay shell` for in-session paste.** Reported gap: *"...we have to exit hermes-relay shell to run `hermes-relay paste`. Can we leverage a tmux hook?"* Tmux runs on the Linux server with no path back to the Windows clipboard, so server-side hooks can't help — but the existing client-side chord state machine (`Ctrl+A .` detach, `Ctrl+A k` kill, `Ctrl+A Ctrl+A` literal) is the right place. Added `Ctrl+A v`: client reads its own clipboard image (same `captureClipboardImage()` path as the `/paste` REPL command), POSTs to `/clipboard/inbox` via the new shared `stageClipboardImageToInbox(url, token)` helper exported from `commands/paste.ts`, then types `/paste\r` into the PTY so the upstream Hermes TUI consumes it in the same flow the user would have typed by hand. Status line goes to stderr so it doesn't pollute the PTY stream: `[shell] pasted 1920×1080 (245 KB) → /paste`. Reentrancy guard prevents double-stage on a fast double-press. Banner help and chord doc-comment updated to list the new verb.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -232,9 +184,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
- **Android connection/profile state no longer leaks across switches.** Connection switches now clear the outgoing profile object immediately, load the destination connection's saved profile name only after that connection is active, and resolve it against the destination server's current profile list. The default local relay URL is now `ws://localhost:8767`, and auto-managed relay URLs are derived from the active API URL before reconnecting.
|
||||
|
||||
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** Bailey saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
|
||||
- **Desktop CLI alpha.12 — install scripts truncated the prerelease suffix in the "upgrading X → Y" line.** A user saw `existing install detected: 0.3.0-alpha.9 — upgrading to 0.3.` (literally truncated mid-token). Root cause: `normalize_pinned_version` (bash) and `Get-NormalizedPin` (PowerShell) stripped everything after the first `-`, including `-alpha.N`. Comment claimed this was "for comparison against the bare semver the binary reports" — but since alpha.4, the binary's `--version` reports the FULL semver (via the embedded `gen:version` constant), so the strip is no longer defensive, just lossy. Removed the suffix-strip from both normalizers; both now produce `0.3.0-alpha.11` from `desktop-v0.3.0-alpha.11`. The equality compare at line 138 still works because both sides include the prerelease tail.
|
||||
|
||||
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** Bailey on alpha.9 ran `hermes-relay update --check`, expected to see alpha.10, got "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
|
||||
- **Desktop CLI alpha.11 — `hermes-relay update` (and the install one-liners) saw the wrong "latest" release.** On alpha.9, `hermes-relay update --check` expected to see alpha.10 but reported "Up to date." Root cause: GitHub's `/repos/.../releases` API returns rows ordered by the release object's `created_at`, NOT by SemVer of the tag — and `created_at` shifts whenever the row is touched (re-tag, manual edit, asset replacement). When alpha.9's release row got touched after alpha.10 was tagged, the API listed alpha.9 first and all three of our resolvers blindly took `[0]`. Fix: pick the SemVer-max from all desktop-v* tags explicitly. (1) `desktop/src/updater.ts` — `desktop.reduce((max, r) => compareVersions(r.tag_name, max.tag_name) > 0 ? r : max)`. (2) `desktop/scripts/install.sh` — `sort -V | tail -1` (zero new deps; bash + sort is sufficient). (3) `desktop/scripts/install.ps1` — custom `Sort-Object` comparator that packs (Major, Minor, Patch, PrereleaseRank, PrereleaseNum) into a zero-padded sortable string with alpha=1, beta=2, rc=3, stable=999. Live-verified against the real API: all three now return `desktop-v0.3.0-alpha.10` instead of `alpha.9`.
|
||||
|
||||
- **Desktop CLI alpha.10 — `hermes-relay paste` always returned "No image on clipboard" on Windows even when an image was present.** Root cause: the PowerShell invocation in `captureClipboardWindows` (`src/chatAttach.ts`) was missing the `-STA` flag. `powershell.exe -Command` defaults to MTA (Multi-Threaded Apartment), and `[System.Windows.Forms.Clipboard]::GetImage()` only returns a valid image from STA threads — from MTA it silently returns null, indistinguishable from "no image present." Also affects the `chat` REPL's `/paste` command which routes through the same Windows code path. Fix: added `-STA` to the powershell args list (now `['-NoProfile', '-NonInteractive', '-STA', '-Command', ps]`). Live verification: empty clipboard returns null; a cyan 100×80 PNG placed via `[System.Windows.Forms.Clipboard]::SetImage` returns the expected 305-byte capture with correct dimensions. Affects `desktop-v0.3.0-alpha.7` through `desktop-v0.3.0-alpha.9`.
|
||||
|
||||
@@ -308,13 +260,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Changed
|
||||
|
||||
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of Bailey's double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
|
||||
- **Top-bar connection chip → inline switcher in the Agent sheet.** The app-wide `ConnectionChip` row that used to sit above every primary tab has been removed. Multi-connection switching now renders as a radio list inside the existing Agent sheet's Connection section (matching the visual pattern of the Profile and Personality sections above it), visible only when ≥2 connections are paired. Tapping a non-active connection fires `switchConnection` + a confirmation toast. Reasons: the chip duplicated the Agent sheet's Connection metadata, ate vertical space above every screen, and exposed the placeholder's `New connection…` label whenever an orphan existed (the root cause of the double-pair confusion). Dead code removed: the `ConnectionChip` import, the `connectionSheetVisible` state, the `ConnectionSwitcherSheet` render block at the bottom of `RelayApp`, and the `connectionChipVisible` / `activeConnection` vals. `ConnectionSwitcherSheet.kt` itself is kept for future programmatic callers.
|
||||
|
||||
### Added
|
||||
|
||||
- **Card-dispatch → server session sync** (completes ADR 26). Every [HermesCardDispatch] now carries a `syncedToServer` idempotency flag; on the next chat send, `CardDispatchSyncBuilder` synthesizes unsynced dispatches into OpenAI-format `assistant`+`tool` pairs under a namespaced synthetic tool name `hermes_card_action` and splices them into the request body alongside the existing voice-intent synthetic messages. `ChatHandler.markCardDispatchesSynced` commits the flag after the API client accepts the request — same post-handoff timing as voice intents, so a thrown request-building exception leaves both streams retryable. Guarantees the LLM sees prior card interactions ("you approved the `Run shell command?` card") across server restarts and reconnects, including `open_url` dispatches that never go through `sendMessage`. Unit-tested under `CardDispatchSyncBuilderTest` (pure-function JVM tests, no Android deps).
|
||||
- **Rich cards in chat via `CARD:{json}` inline markers** (ADR 26). Assistant messages can now surface structured Material 3 cards — skill results, approval prompts, link previews, calendar entries, weather — emitted as a single-line `CARD:{...}` alongside prose text. Follows the same streaming-endpoint-agnostic marker recipe as `MEDIA:`, so it works unchanged on `/v1/runs`, `/api/sessions/{id}/chat/stream`, and `/v1/chat/completions`. New `HermesCard` data class (`@Serializable`, `ignoreUnknownKeys=true` so newer agent schemas don't crash older phone builds) carries `title` / `subtitle` / `body` (markdown) / `fields` / `actions` / `footer` / `accent` (`info`/`success`/`warning`/`danger`). Built-in types: `skill_result`, `approval_request`, `link_preview`, `calendar_event`, `weather`; unknown types render via a generic fallback. `approval_request` intentionally mirrors Slack's exec-approval pattern (Allow / Deny with primary/danger button styles) so upstream Phase B adapter parity is a translation exercise, not a data-model rethink. Action dispatch (`send_text` default, `slash_command`, `open_url`) routes through `ChatViewModel.dispatchCardAction`, which stamps a `HermesCardDispatch` on the owning message before forwarding so the card collapses into a "Chose: X" confirmation even if the side effect fails. Renderer is `HermesCardBubble.kt` — accent stripe + Icon + Title/Subtitle + markdown body + fields table + FlowRow of action buttons. Cards render between the assistant's prose and any attachments in `MessageBubble`.
|
||||
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (Bailey's call: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
|
||||
- **CI test jobs advisory on `dev`, strict on `main`.** Both `.github/workflows/ci-android.yml` (`test`) and `.github/workflows/ci-server.yml` (`unit-tests`) now carry `continue-on-error: ${{ github.ref != 'refs/heads/main' && github.base_ref != 'main' }}` — tests still run on every dev push/PR and surface annotations and reports, but they no longer red-gate the merge. Lint stays strict on both branches (deliberate: lint debt should still block). The release-merge PR from `dev` → `main` flips tests back to strict, so nothing sneaks through to a tagged release.
|
||||
- **MorphingSphere on the docs site.** New `SphereMark.vue` component (in `user-docs/.vitepress/theme/components/`) renders a 58×34 sphere directly above the "Install in 30 seconds" block — mounted in the `home-hero-after` slot alongside `InstallSection` for a hero → sphere → install stack. Imports `preview/web/sphere.js` directly so `MorphingSphereCore.kt` remains the single source of truth across app / preview / docs. The cursor reactivity is **eye-only** — the sphere body stays anchored while the bright-spot gaze tracks the pointer (no canvas translate / body bounce). Gaze composition: **scroll-tracking is the always-on baseline** — the eye anchors to the Install section's top edge (via `.install-section` DOM query), not to the viewport center. `installGap = installRect.top − viewportH` is the runway until install enters view; as it shrinks below 50 % viewport-height, `scrollVy` ramps linearly to 1, so by the time install's top crosses into the viewport the eye is already looking straight down at it. Before that runway, the eye sits forward (`scrollVy = 0`). **Cursor-tracking is a soft overlay** — inside a rectangular detection band (full viewport width × container height, linear falloff over 1.0 × container height past the top/bottom edges) the cursor's unit-vector direction crossfades into the scroll target via `cursorWeight`. The eye always has one coherent target — no mode switching, no fbm drift fighting the cursor at the band boundary, no eye-flip between modes. Palette retarget Idle ↔ Listening is gated on `cursorWeight` (0.2 / 0.5 hysteresis) so the sphere reads as *calmly watching* at the scroll baseline and *attentive* on direct hover. A tiny fbm wander (±0.07 on top of the target) keeps the eye breathing when both scroll and cursor are stationary. Fallback when the install element isn't on the page: viewport-center reference preserves the gaze-follows-scroll feel without the anchor. Pointer inputs pass through a per-frame EMA low-pass (180 ms direction / 280 ms proximity time constants) before any math runs — stops the per-event jitter from `pointermove`'s big discrete jumps; asin/acos inputs are capped at ±0.9 so we stay off the infinite-slope end of the inverse-trig curves. Canvas is square (`aspect-ratio: 1 / 1`, `clamp(280px, 48vw, 420px)`) so the sphere fills the frame at the algorithm's natural 0.60-envelope sizing — no dead space between the phone video and the Install block. Respects `prefers-reduced-motion` (zeroes the gaze blend so the eye stops tracking but the ambient animation continues), pauses drawing while scrolled off-screen via `IntersectionObserver`, and resizes via `ResizeObserver` on the container. SSR-safe without a `<ClientOnly>` wrapper — `sphere.js` has no side-effectful imports and all DOM access lives inside `onMounted`, which Vue 3 never runs on the server.
|
||||
- **`SphereFrame` gaze-bias fields in `MorphingSphereCore.kt` (mirrored in `sphere.js`).** New `lightAngleBiasX`, `lightAngleBiasY`, `lightAngleBlend` (all default 0f / 0) let callers aim the sphere's bright spot at a specific direction without touching the sphere body. The light-angle computation blends between the natural `t * lightSpeedX + noise` rotation (`blend = 0`) and the caller-supplied bias (`blend = 1`). Defaults preserve byte-identical behavior for every existing caller — Android `MorphingSphere.kt` composable, the parity test, and the JS parity harness all stay green because they never set the new fields. First consumer: `SphereMark.vue` on the docs site, which uses the bias to make the sphere's eye track the reader's cursor without bouncing the canvas.
|
||||
- **`SphereFrame.shadowStrength`** (mirrored in `sphere.js`, default 0f / 0). Darkens `distBrightness` on the hemisphere facing away from the light, scaling it by `(1 − shadowStrength · (1 − directionalLight))` — the lit side is untouched, the shadow side dims proportionally. At 0 the legacy uniform "pearl" shading is preserved byte-for-byte. Docs-site `SphereMark.vue` uses 0.6 so the eye reads clearly against the unlit half of the sphere; Android composable doesn't set it and stays on legacy shading.
|
||||
@@ -691,7 +643,7 @@ sees the toggle, never installs the wake lock, and never invokes
|
||||
|
||||
### Added — Voice intent → server session sync (v0.4.1 fast-follow)
|
||||
|
||||
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per Bailey's 2026-04-14 on-device repro).
|
||||
- **Voice actions now reach the server-side LLM's session memory.** Previously, phone-local voice intents (`open Chrome`, `text Sam saying hi`, etc.) ran in-process via `BridgeCommandHandler.handleLocalCommand` and appended local-only trace bubbles to the chat scroll. The Hermes API server's session never learned about them, so a follow-up text question like "did that work?" hit the LLM with no context and returned hallucinated answers (per a 2026-04-14 on-device repro).
|
||||
- **Implementation.** Each phone-local voice intent now records a structured `VoiceIntentTrace` (tool name, JSON args, success, JSON result envelope) on the post-dispatch chat-trace bubble it produces. `VoiceIntentSyncBuilder` walks the chat history before each `POST /v1/runs` / `POST /api/sessions/{id}/chat/stream` call and synthesizes OpenAI-format `assistant` (with `tool_calls`) + `tool` (with `tool_call_id`) message pairs from any unsynced traces. The synthesized array rides under the existing payload's new `messages` field — additive, ignored by older servers, picked up by anything OpenAI Chat Completions–shaped. Idempotency: traces flip to `syncedToServer=true` the moment the API client takes ownership of the request, so subsequent turns don't re-emit them.
|
||||
- **Zero server changes.** Frontend-only, no hermes-agent edits needed.
|
||||
- **Files.** `data/ChatMessage.kt` (new `voiceIntent: VoiceIntentTrace?` field), `voice/VoiceIntentSyncBuilder.kt` (pure-function builder + helpers), `network/HermesApiClient.kt` (optional `voiceIntentMessages` parameter on both stream methods), `viewmodel/ChatViewModel.kt` (build + sync + flag flip in `startStream`), `viewmodel/VoiceViewModel.kt` (extended dispatch callback wires the structured trace into the chat-trace bubble), `voice/VoiceBridgeIntentHandler.kt` (new `androidToolName` + `androidToolArgsJson` on `IntentResult.Handled`), sideload `VoiceBridgeIntentHandlerImpl.kt` populates them per intent, sideload + googlePlay `VoiceBridgeIntentFactory.kt` typealias updates. Tests in `test/voice/VoiceIntentSyncBuilderTest.kt` (12 cases — empty input, single success, failure with error_code, idempotency, chronological order, prefix gate, blank-args gate, call-id pairing, helpers) and `test/network/handlers/ChatHandlerTest.kt` (4 new cases for trace storage + `markVoiceIntentsSynced`).
|
||||
|
||||
@@ -65,7 +65,7 @@ The Android client probes per-endpoint capability via `HermesApiClient.probeCapa
|
||||
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and **`POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **standard (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
|
||||
|
||||
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`; Android Chat still uses the API-server bearer path until a dashboard `/api/ws` chat adapter is wired. Android Manage may consume this dashboard surface directly, but relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
|
||||
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`; Android Chat still uses the API-server bearer path until a dashboard `/api/ws` chat adapter is wired. Note the event-richness gap: `/api/ws` is backed by `tui_gateway/server.py` (what hermes-desktop + the Ink TUI speak) and is the only upstream surface with **live** `reasoning.delta`/`thinking.delta` streaming; the api_server SSE paths emit reasoning only post-hoc (`reasoning.available` → `tool.progress` with `tool_name:"_thinking"`, ≤500 chars; full text in `run.completed.messages[].reasoning`). Android Manage may consume this dashboard surface directly, but relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
|
||||
|
||||
**Tool call rendering paths:**
|
||||
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
|
||||
@@ -73,6 +73,7 @@ Current upstream supports two auth modes on this surface. Loopback dashboards st
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
|
||||
|
||||
## Key Instructions
|
||||
- **Standard path = vanilla upstream only.** The default (no-plugin) connection path — chat via the API server, standard voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
|
||||
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
|
||||
- **Bootstrap maintenance:** Retire `hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
|
||||
@@ -129,6 +130,17 @@ hermes-android/
|
||||
- **DEVLOG.md** — update at end of each work session with what was done, what's next, blockers
|
||||
- **CLAUDE.md hygiene:** Key Files entries must stay one line — implementation detail belongs in the file or `docs/`. Run `/revise-claude-md` after feature-heavy sessions to trim drift.
|
||||
|
||||
### Public-repo writing hygiene
|
||||
|
||||
This is a **public, distributed repo** — every committed file (CHANGELOG, DEVLOG, README, docs, release notes) is public-facing. Write accordingly:
|
||||
|
||||
- **No personal names** in prose — attribute impersonally ("a user reported", "observed"). Author identity lives in git history + the signing cert, not the changelog.
|
||||
- **No private infrastructure** — real server hostnames/IPs, internal deployment names, `~/SYSTEM.md` contents. (Generic example IPs like `192.168.1.100` in setup docs are fine.)
|
||||
- **No AI/assistant process self-narration** — no "I should have…", no course-correction confessionals. State the technical conclusion, not the path to it.
|
||||
- **No internal jargon / fork-branch plumbing** in user-facing notes — keep *what changed*, drop *where we staged it*.
|
||||
- **CHANGELOG** uses Keep-a-Changelog grouping (Added / Changed / Fixed). Detail may accumulate during iteration, but at **release-prep the version block is condensed to crisp public bullets** (1–2 lines each) — deep "how we debugged it" stays in commits/DEVLOG. See [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution".
|
||||
- **DEVLOG.md** is a committed, factual engineering log — what changed, why, and verification — depersonalized and third-person, not a diary.
|
||||
|
||||
### Code Style — Android (Kotlin)
|
||||
- **Jetpack Compose** — no XML layouts. Material 3 / Material You.
|
||||
- **kotlinx.serialization** — not Gson. Type-safe, faster.
|
||||
@@ -170,14 +182,22 @@ hermes-android/
|
||||
|------|-----|
|
||||
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
|
||||
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
|
||||
| `AGENTS.md` | Tool usage patterns for the `android_*` toolset |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp |
|
||||
| `AGENTS.md` | Universal agent entry point — points here + the non-negotiables (standard-path, commits, writing hygiene) |
|
||||
| `docs/mcp-tooling.md` | MCP server setup — android-tools-mcp + mobile-mcp; `android_*` tool usage patterns |
|
||||
| **App — Core** | |
|
||||
| `ui/RelayApp.kt` | Main scaffold — bottom nav, Compose navigation |
|
||||
| `viewmodel/ChatViewModel.kt` | Chat orchestration — send, stream, cancel, slash commands |
|
||||
| `viewmodel/ConnectionViewModel.kt` | Dual connection model (API + relay); `resolveStreamingEndpoint()`; derived `relayUiState` flow + `markPaired` hook stamp the active Connection |
|
||||
| `viewmodel/RelayUiState.kt` | Shared sealed state for the relay row — 5 cases + `asBadgeState()` / `statusText()` extensions; 5s grace window before Stale |
|
||||
| `network/HermesApiClient.kt` | Direct HTTP/SSE — `sendRunStream()`, `sendChatStream()`, `probeCapabilities()` |
|
||||
| `network/GatewayChatClient.kt` | Gateway chat transport — JSON-RPC over dashboard `/api/ws` (tui_gateway); live `reasoning.delta`; fresh ws-ticket per connect; per-turn SSE fallback via `onPreflightFailure`; `prewarm()` (connect+resume off the send path); `setKeepAliveInBackground()` suppresses the 120s idle-close |
|
||||
| `network/GatewayKeepAliveService.kt` | Opt-in `specialUse` foreground service (BOTH flavors; declared in main manifest; Play needs a Console FGS declaration) holding the process up so the gateway socket survives background/Doze; driven by ConnectionViewModel from the `KEY_GATEWAY_KEEP_ALIVE` toggle; stops on task-removal |
|
||||
| `data/GatewayKeepAlivePrefs.kt` | Shared `KEY_GATEWAY_KEEP_ALIVE` pref key + `Context.setGatewayKeepAlive()` — used by ConnectionViewModel (StateFlow/setter) and the FGS Stop action |
|
||||
| `network/GatewayEventMapper.kt` | Pure-JVM gateway event→callback mapping for one turn; unknown event types silently ignored; tui_gateway usage-key translation |
|
||||
| `network/GatewayModels.kt` | `GatewayAvailability`, `ActiveTurnHandle`, `GatewayTurnCallbacks` (all members REQUIRED — forces dispatchOn main-thread wrap), `GatewayAsk`, `GatewaySubagentEvent`, `resolveStreamingEndpointPreference()` |
|
||||
| `ui/components/ChatInputBar.kt` | Redesigned input bar — pill field, one trailing slot morphing Send/Voice/Stop/Steer/Queue, no slash button (long-press + opens palette) |
|
||||
| `ui/components/SubagentLane.kt` | Per-taskIndex subagent progress lane — guide rail, compact tool rows, auto-collapse |
|
||||
| `notifications/TurnCompleteNotifier.kt` | Turn-complete local notification when backgrounded — channel `chat_turn_complete`, cancel on resume, settings-gated |
|
||||
| `network/ConnectionManager.kt` | WSS to relay with auto-reconnect; rebuilds OkHttpClient with fresh CertPinner on connect |
|
||||
| `network/ChannelMultiplexer.kt` | Envelope routing by channel; `sendNotification()` for notification outbound |
|
||||
| `network/handlers/ChatHandler.kt` | Chat message state, streaming events, tool annotation parser |
|
||||
@@ -216,12 +236,16 @@ hermes-android/
|
||||
| `user-docs/.vitepress/theme/components/SphereMark.vue` | Docs-site sphere embed — imports `preview/web/sphere.js` directly; autonomous fbm drift + pointer-proximity gaze/state blend; `<ClientOnly>` + `IntersectionObserver` + `prefers-reduced-motion` aware |
|
||||
| **App — Media + Notifications** | |
|
||||
| `util/MediaCacheWriter.kt` | `cacheDir/hermes-media/` LRU writer; returns FileProvider URIs |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic |
|
||||
| `util/MediaSaver.kt` | Save/share/open for chat media — MediaStore scoped-storage save (Pictures/Download `Hermes-Relay`, no perms on API 29+; pre-Q → share sheet); FileProvider share staging; remote-byte fetch; magic-byte image-MIME sniff for correct extensions |
|
||||
| `ui/components/ChatImageViewer.kt` | Full-screen image viewer — pinch-zoom/pan (`detectTransformGestures`), double-tap 1×/2.5×, Share/Save/Close; `ChatImageViewerSource` decouples Coil-model/bitmap display from a suspend `bytesProvider` so Save keeps original bytes |
|
||||
| `ui/components/InboundAttachmentCard.kt` | Discord-style attachment card for images/video/audio/pdf/text/generic; image tap → ChatImageViewer, file card long-press → Open/Share/Save menu |
|
||||
| `ui/components/ChatImageContent.kt` | Parses `` out of assistant content; remote http(s) → Coil (tap → ChatImageViewer), server-local/failed → inline "can't render" notice with the path |
|
||||
| `data/HermesCard.kt` | `CARD:{json}` envelope (ADR 26) — type/accent/fields/actions; kotlinx.serialization |
|
||||
| `ui/components/HermesCardBubble.kt` | Rich-card renderer — accent stripe + FlowRow actions + dispatch stamp collapse |
|
||||
| `viewmodel/CardDispatchSyncBuilder.kt` | Twin of VoiceIntentSyncBuilder — synthesizes card dispatches as `hermes_card_action` OpenAI pairs for session memory |
|
||||
| `notifications/HermesNotificationCompanion.kt` | NotificationListenerService; cold-start buffer (50); forwards via ChannelMultiplexer |
|
||||
| `util/RelayErrorClassifier.kt` | `classifyError(Throwable, context) → HumanError`; used by Voice/Chat/Connection |
|
||||
| `util/TurnLatencyTracer.kt` | One `TurnLatency` INFO line per chat turn — `warm/cold` + `connect/session/submit/ttfe/ttft/done@…ms`; gateway + 3 SSE paths use it for desktop-comparable latency diagnosis; durations only |
|
||||
| **Relay — Server** | |
|
||||
| `plugin/relay/server.py` | Canonical relay — WSS + HTTP routes; bridge, media, voice, session, pairing handlers. `handle_pairing_mint` mirrors `pair.py:762` — top-level = API server, `relay.{url,code}` nested |
|
||||
| `plugin/relay/auth.py` | PairingManager, SessionManager, RateLimiter; `math.inf` for never-expire |
|
||||
|
||||
@@ -96,6 +96,16 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`,
|
||||
|
||||
Release-prep commits (version bump, changelog promotion) land on `dev` first, then a surface-specific release PR merges `dev` → `main` with `--no-ff`. Tags are cut from `main` after the merge: `android-vX.Y.Z`, `server-vX.Y.Z`, or `desktop-vX.Y.Z`. See [RELEASE.md](RELEASE.md) for the full release process.
|
||||
|
||||
## Changelog & writing conventions
|
||||
|
||||
This is a **public repo** — `CHANGELOG.md`, `DEVLOG.md`, the README, and everything under `docs/` ship publicly. Keep them clean:
|
||||
|
||||
- **`CHANGELOG.md`** follows [Keep a Changelog](https://keepachangelog.com/) (Added / Changed / Fixed). Append your change to the `## [Unreleased]` block in the PR. Entries can carry detail while they accumulate, but at release-prep the version block is **condensed to crisp public bullets** (1–2 lines each) — the deep "how we debugged it" narrative belongs in commit messages and `DEVLOG.md`, not the public changelog.
|
||||
- **`DEVLOG.md`** is a factual engineering log — what changed, why, and how it was verified. Keep it depersonalized and third-person; it's a record, not a diary.
|
||||
- **No non-public wording anywhere committed:** no personal names (attribute impersonally — identity lives in git history), no real server hostnames/IPs or internal deployment names, no AI/assistant process self-narration, no fork/branch plumbing in user-facing notes. Generic example IPs in setup docs are fine.
|
||||
|
||||
Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/play-store-listing.md`) are theme-framed and user-facing; see [RELEASE.md](RELEASE.md) §2 "Scrub for public distribution" for the full checklist.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<p align="center">
|
||||
<img src="assets/logo.svg" alt="Hermes-Relay" width="120">
|
||||
<img src="assets/play-store-feature-1024x500.png" alt="Hermes-Relay — your Hermes agent, in your pocket" width="800">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Hermes-Relay</h1>
|
||||
<p align="center">
|
||||
<strong>Runs on your machine. Lives on your devices.</strong><br>
|
||||
A native Android companion for your <a href="https://github.com/NousResearch/hermes-agent">Hermes agent</a> — streaming chat, hands-free voice,
|
||||
and full agent management. Plus a single-binary CLI that gives the agent hands on any machine you pair.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Your self-hosted Hermes agent, native on your phone.</strong><br>
|
||||
Chat, voice, and full agent management over your own infrastructure —<br>
|
||||
plus an experimental desktop CLI that gives the agent hands on your computer.
|
||||
<a href="https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="MIT"></a>
|
||||
<a href="https://developer.android.com"><img src="https://img.shields.io/badge/Surface%201-Android-green.svg" alt="Android"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/Surface%202-Desktop%20CLI%20%28alpha%29-orange.svg" alt="Desktop CLI (alpha)"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Android-8.0%2B-3DDC84.svg?logo=android&logoColor=white" alt="Android 8.0+"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml"><img src="https://github.com/Codename-11/hermes-relay/actions/workflows/ci-android.yml/badge.svg" alt="Android CI"></a>
|
||||
<a href="https://developer.android.com/about/versions/oreo"><img src="https://img.shields.io/badge/Min%20SDK-26-brightgreen.svg" alt="Min SDK 26"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/releases"><img src="https://img.shields.io/github/v/release/Codename-11/hermes-relay?filter=android-v*&label=release&color=8B5CF6" alt="Latest release"></a>
|
||||
<a href="https://github.com/Codename-11/hermes-relay/tree/main/desktop"><img src="https://img.shields.io/badge/CLI-alpha-orange.svg" alt="CLI (alpha)"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -25,74 +27,76 @@
|
||||
<a href="https://hermes-agent.nousresearch.com">Hermes Agent</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<video src="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo.mp4" poster="https://github.com/Codename-11/hermes-relay/raw/main/assets/chat_demo_poster.jpg" autoplay loop muted playsinline width="280"></video>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Two surfaces, one pair
|
||||
## What it is
|
||||
|
||||
| Surface | What | Status |
|
||||
|---------|------|--------|
|
||||
| **[Android app](#quick-start-android)** | Native phone client — streaming chat, hands-free voice, full agent management (models, keys, skills, profiles), and on sideload builds the agent can read your screen and act on it. | Available — Google Play (Internal testing) + sideload APK |
|
||||
| **[Desktop CLI](#desktop-cli-alpha)** | The agent reaching back to **your machine** — local tool routing (files, terminal, screenshots, clipboard) plus a remote shell to the host. | **Alpha** — `desktop-v*` releases, expect heavy changes |
|
||||
Hermes-Relay puts your [Hermes agent](https://github.com/NousResearch/hermes-agent) on the devices you actually carry. The brain stays on your own machine — Hermes-Relay is how you reach it.
|
||||
|
||||
Both share the same WSS relay and credentials store. **Pair once from either, both work.**
|
||||
- **📱 Android app** — streaming chat, hands-free voice, and the full Hermes dashboard (models, keys, skills, profiles), rebuilt native. On sideload builds, the agent can read your screen and act on it.
|
||||
- **⌨️ Hermes-Relay CLI** *(alpha)* — a single binary that gives the agent **hands on any machine you pair**: files, terminal, search, screenshots — consent-gated.
|
||||
|
||||
---
|
||||
A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**. Add the optional relay only when you want terminal, phone control, or the CLI's tools. **Pair once from either surface; both work.**
|
||||
|
||||
## Quick Start (Android)
|
||||
|
||||
Install → connect → talk, in about two minutes. A vanilla [hermes-agent](https://github.com/NousResearch/hermes-agent) install is enough — chat, management, and voice need **no plugin**.
|
||||
Install → connect → talk, in about two minutes.
|
||||
|
||||
### 1. Install the app
|
||||
### 1 · Install the app
|
||||
|
||||
- **Google Play** — coming soon (currently on Internal testing)
|
||||
- **APK** — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Full walkthrough — integrity verification, signing fingerprint, what's in each build — in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
- **Google Play** *(easiest — auto-updates)* — [**install from Google Play**](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay). Chat, voice, Manage, terminal/TUI, media, notifications, and relay sessions.
|
||||
- **APK** *(full phone-control feature set)* — download the file ending in **`-sideload-release.apk`** from the newest `android-v*` release on [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases) and open it (allow your browser to install unknown apps the first time). Integrity verification, signing fingerprint, and per-build details are in the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk).
|
||||
|
||||
Sideload builds check GitHub for new releases and show a one-tap update banner when you're behind; Play builds update through the Play Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
Sideload builds check GitHub for updates and show a one-tap banner when you're behind; Play builds update through the Store. See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks) for the capability matrix.
|
||||
|
||||
### 2. Have Hermes running
|
||||
### 2 · Have Hermes running
|
||||
|
||||
Run upstream Hermes with its API server and dashboard enabled:
|
||||
The app needs your Hermes **API server enabled and reachable from your phone**, plus an **API key** — the token the app sends to authenticate Chat (pick any value you like). Installing Hermes and choosing a provider is standard Hermes setup; the [full walkthrough](https://codename-11.github.io/hermes-relay/guide/getting-started) covers Windows, the dashboard for **Manage**, LAN scan, and QR setup.
|
||||
|
||||
```bash
|
||||
hermes setup --portal
|
||||
hermes setup --portal # install / log in / pick a provider — skip if already done
|
||||
|
||||
mkdir -p ~/.hermes
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)"
|
||||
API_SERVER_KEY="$(openssl rand -hex 32)" # strong random key — or substitute your own memorable value
|
||||
cat >> ~/.hermes/.env <<EOF
|
||||
API_SERVER_ENABLED=true
|
||||
API_SERVER_HOST=0.0.0.0
|
||||
API_SERVER_PORT=8642
|
||||
API_SERVER_KEY=$API_SERVER_KEY
|
||||
EOF
|
||||
chmod 600 ~/.hermes/.env
|
||||
|
||||
echo "Android API URL: http://<this-computer-ip>:8642"
|
||||
echo "Android API key: $API_SERVER_KEY"
|
||||
echo "Android API URL: http://<this-computer-ip>:8642 key: $API_SERVER_KEY"
|
||||
hermes gateway
|
||||
```
|
||||
|
||||
Windows commands, dashboard auth notes, and upstream links: [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
`API_SERVER_ENABLED` turns the API server on; `API_SERVER_HOST=0.0.0.0` makes it reachable on your LAN (the default is localhost-only); `API_SERVER_KEY` is the bearer token the app sends — **your choice of value**.
|
||||
|
||||
### 3. Connect and talk
|
||||
> **Heads up on `0.0.0.0`:** that exposes the API to every device on your network — fine on a trusted home LAN, but off it keep the key set and front it with Tailscale or an HTTPS reverse proxy ([Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access)) rather than exposing it directly. You don't have to type the key on your phone — **Scan for Hermes on LAN**, or have your agent make a setup QR (below). For **Manage** (skills, models, keys), also run the Hermes dashboard — see [Getting Started](https://codename-11.github.io/hermes-relay/guide/getting-started).
|
||||
|
||||
Open the app, choose **Standard Hermes**, and enter your server's address and API key. The wizard probes everything and finishes with a capability card:
|
||||
### 3 · Connect and talk
|
||||
|
||||
Open the app and pick how to connect — any of:
|
||||
|
||||
- **Standard Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
|
||||
- **Standard Hermes** → type the address (`http://<host>:8642`) and key by hand.
|
||||
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>"}`) and scan it.
|
||||
|
||||
The wizard probes everything and finishes with a capability card:
|
||||
|
||||
| Line | What it means |
|
||||
|---|---|
|
||||
|------|---------------|
|
||||
| **Chat** | API server reachable — you can talk |
|
||||
| **Manage** | Dashboard found — models, keys, skills, profiles from the phone |
|
||||
| **Voice** | Speech ready via your server (or one Manage sign-in away) |
|
||||
| **Remote** | Fallback route configured — keeps working away from home |
|
||||
| **Relay** | Optional power tools — fine to leave unpaired |
|
||||
|
||||
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session also unlocks voice. That's the whole standard setup.
|
||||
If your dashboard requires sign-in, do it once under the **Manage** tab — the same session unlocks voice. That's the whole standard setup.
|
||||
|
||||
**Going places?** Put your server's Tailscale URL in the setup form's "Remote access" field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
|
||||
> **Going places?** Put your server's Tailscale URL in the setup form's *Remote access* field (or add a route any time under **Settings → Connections → Routes**). The app uses LAN at home and switches routes automatically when you leave. See [Remote access](https://codename-11.github.io/hermes-relay/guide/remote-access).
|
||||
|
||||
### 4. Optional: install Relay for power tools
|
||||
### 4 · Optional: install Relay for power tools
|
||||
|
||||
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
|
||||
|
||||
@@ -102,204 +106,198 @@ hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
The installer clones to `~/.hermes/hermes-relay/`, registers the plugin/skill paths, and can install a systemd user service. Scan the QR from the phone's Connections screen; if you can't scan, use `hermes pair --register-code ABCD12` with the manual code from Android **Settings → Connections → Advanced**. (`/hermes-relay-pair` and the dashed `hermes-pair` shim remain for chat-surface and older builds.)
|
||||
The installer clones to `~/.hermes/hermes-relay/`, registers the plugin/skill paths, and can install a systemd user service. Scan the QR from the phone's Connections screen — or use `hermes pair --register-code ABCD12` with the manual code from Android **Settings → Connections → Advanced**.
|
||||
|
||||
- **Updating:** `hermes-relay-update` — idempotent; or re-run the install one-liner.
|
||||
- **Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` — reverses every step, never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
|
||||
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a "Relay" tab (paired devices, bridge activity, media tokens) appears in the web UI.
|
||||
- **Update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
|
||||
- **Uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — reverses every step, never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
|
||||
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a **Relay** tab (paired devices, bridge activity, media tokens) appears in the web UI.
|
||||
|
||||
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+ on the server · macOS / Linux / Windows for the desktop CLI.
|
||||
**Requirements:** Android 8.0+ (SDK 26) · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+ with Python 3.11+ on the server.
|
||||
|
||||
## Desktop CLI (alpha)
|
||||
## Screenshots
|
||||
|
||||
> **Alpha — expect heavy changes.** With [hermes-desktop](https://hermes-agent.nousresearch.com) now covering chat and management on the desktop, this surface is being refocused into a pure remote **"hands" connector**: the agent reaching back through the relay to run tools on your machine (files, terminal, screenshots, clipboard, editor). The chat and shell features that overlap hermes-desktop will be removed in a future release. Binaries are unsigned during the experimental phase — SmartScreen/Gatekeeper warnings are expected.
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/01_startup.png" alt="Cold start" width="100%"><br><sub><b>Cold start</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/02_chat.png" alt="Streaming chat" width="100%"><br><sub><b>Streaming chat</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/03_voice.png" alt="Hands-free voice" width="100%"><br><sub><b>Hands-free voice</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/04_sessions.png" alt="Session history" width="100%"><br><sub><b>Session history</b></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/05_commands.png" alt="Command palette" width="100%"><br><sub><b>Command palette</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/06_manage.png" alt="Manage your agent" width="100%"><br><sub><b>Manage your agent</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/07_connections.png" alt="Connections and routes" width="100%"><br><sub><b>Connections & routes</b></sub></td>
|
||||
<td align="center" width="25%"><img src="assets/screenshots/08_settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
The agent's brain stays on the host; the CLI lets it call `desktop_read_file`, `desktop_terminal`, `desktop_search_files`, `desktop_screenshot`, `desktop_clipboard_*`, `desktop_open_in_editor`, and more **on your machine** over the same WSS relay — with a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch. No Node required; installs are self-contained native binaries.
|
||||
<p align="center"><sub>▶ <a href="https://codename-11.github.io/hermes-relay/guide/getting-started.html#see-it-working">Watch the demo</a> on the docs site</sub></p>
|
||||
|
||||
**Install** (Windows PowerShell / macOS / Linux):
|
||||
## Features
|
||||
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — direct SSE to the Hermes API server with live markdown, tool-call cards, session history, a searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing.
|
||||
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage keys (write-only, masked, rate-limited reveal), create and edit profiles including `SOUL.md`, and browse/install/update skills. One dashboard sign-in covers it all.
|
||||
- **Hands-free voice** — talk on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice and an opt-in provider-native Realtime Agent with background task handoff.
|
||||
- **Works away from home** — add a Tailscale or public URL and the app roams automatically (LAN at home, fallback elsewhere). An unreachable server gets a diagnosis, not just a red dot.
|
||||
- **Multi-Connection + profiles** — pair multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay a profile's model + `SOUL.md` per chat.
|
||||
- **Phone control (bridge)** — with Relay paired, the agent reads the screen and acts: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros. Guarded by per-app blocklist (banking/2FA blocked by default), destructive-verb confirmation, idle auto-disable, and a full activity log.
|
||||
- **Notification companion** — opt-in access so the agent can triage, summarize, and route incoming notifications.
|
||||
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL.
|
||||
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts.
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free intents like *"text Sam I'll be 10 minutes late."* See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
|
||||
|
||||
## Hands on any machine — the Hermes-Relay CLI <sub>(alpha)</sub>
|
||||
|
||||
> **Alpha · Windows today** (macOS / Linux coming soon). A single self-contained binary — no Node required. Binaries are unsigned during the experimental phase, so SmartScreen / Gatekeeper warnings are expected.
|
||||
|
||||
The agent's brain stays on the host; the CLI lets it call tools **on your machine** over the same WSS relay — `read_file`, `write_file`, `terminal`, `search_files`, `screenshot`, `clipboard`, `open_in_editor`, and more — behind a one-time consent gate, interactive diff approval for patches, and a `--no-tools` kill-switch.
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
hermes-relay daemon # headless tool router — agent reaches you anytime
|
||||
hermes-relay # interactive Hermes TUI in tmux (legacy, being refocused)
|
||||
hermes-relay update # self-update via GitHub Releases
|
||||
```
|
||||
|
||||
- **Docs:** [Desktop guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Release track:** tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases?q=desktop)
|
||||
It pairs against the **same relay and credential store** as the Android app — pair once from either, both work. Tagged on a separate `desktop-v*` [release track](https://github.com/Codename-11/hermes-relay/releases?q=desktop).
|
||||
|
||||
- **Docs:** [CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **AI-agent setup recipe:** `/hermes-relay-desktop-setup`
|
||||
|
||||
## Features
|
||||
|
||||
### Android
|
||||
|
||||
- **Streaming chat** — direct SSE to the Hermes API Server with real-time markdown rendering, session history, tool-call visualization, searchable command palette, file attachments, quote-in-reply, conversation share, and send-while-streaming queuing
|
||||
- **Manage your agent** — the full Hermes dashboard, native: switch models from your provider catalog, manage provider keys (write-only, masked, server-rate-limited reveal), create and edit agent profiles including `SOUL.md`, and browse, install, and update skills from the hub. One dashboard sign-in covers it all
|
||||
- **Voice mode** — talk hands-free on a vanilla install: speech rides your server's configured providers, unlocked by the same Manage sign-in. Relay-paired setups add per-profile voice providers and an opt-in provider-native Realtime Agent with background task handoff
|
||||
- **Works away from home** — add your server's Tailscale or public URL and the app roams automatically: LAN at home, fallback elsewhere. Routes are editable per connection, and an unreachable server gets a diagnosis ("away from the server's network? add a route"), not just a red dot
|
||||
- **Multi-Connection + profiles** — pair with multiple Hermes servers (home + work, dev + prod) and switch in one tap; overlay an agent profile's model + `SOUL.md` per chat
|
||||
- **Phone control (bridge)** — with the Relay plugin paired, the agent reads the screen and acts on it: tap, type, swipe, scroll, screenshots, clipboard, media keys, batched macros, and event-driven waits. Guarded by safety rails: per-app blocklist (banking/payments/2FA default-blocked), destructive-verb confirmation, idle auto-disable, full activity log
|
||||
- **Notification companion** — opt-in notification access so the agent can triage, summarize, and route incoming notifications
|
||||
- **Security & pairing** — QR pairing, Android Keystore session storage (StrongBox-preferred), TOFU cert pinning, per-channel time-bound grants, user-chosen session TTL
|
||||
- **Stats for Nerds** — local-only analytics: TTFT, token usage, stream health, peak-time charts
|
||||
|
||||
> Sideload builds add direct SMS, contact search, one-tap dialing, and location awareness — handy for fully hands-free voice intents like "text Sam I'll be 10 minutes late". See [Release tracks](https://codename-11.github.io/hermes-relay/guide/release-tracks).
|
||||
|
||||
### Desktop CLI
|
||||
|
||||
- **Local tool routing** — `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` / `_clipboard_*` / `_screenshot` / `_open_in_editor` run on your machine; agent-proposed patches render as colored diffs with interactive approval
|
||||
- **Daemon mode** — headless tool router; the agent can reach you with no shell open
|
||||
- **Multi-endpoint pairing, reconnect-on-drop, TOFU cert pinning** — same model as the Android app
|
||||
- **Self-update** — `hermes-relay update` verifies SHA256 and atomic-swaps the binary
|
||||
|
||||
## Install with an AI agent
|
||||
|
||||
If an AI assistant (Claude, GPT, etc.) manages your server, paste this block into its chat and it will fetch the canonical setup recipe and walk you through install, pairing, and troubleshooting:
|
||||
|
||||
```text
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a desktop CLI + a Python plugin for the Hermes AI agent platform.
|
||||
|
||||
Read the canonical setup recipe before acting:
|
||||
https://raw.githubusercontent.com/Codename-11/hermes-relay/main/skills/devops/hermes-relay-self-setup/SKILL.md
|
||||
|
||||
Then guide me through:
|
||||
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
|
||||
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Connecting my phone by Standard Hermes API URL/key first, then optionally pairing Relay via the plugin-provided `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the `hermes-relay` desktop CLI (binary one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh` or `irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (desktop CLI)
|
||||
|
||||
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
|
||||
```
|
||||
|
||||
Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `/hermes-relay-self-setup` from any chat for re-setup or "is everything wired correctly?" checks.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (HTTP) --> Hermes Dashboard (:9119) [manage + standard voice — cookie sign-in]
|
||||
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
|
||||
Desktop CLI (WSS) --> Relay (:8767) [desktop tools, tui, terminal]
|
||||
CLI (WSS) --> Relay (:8767) [machine tools, tui, terminal]
|
||||
```
|
||||
|
||||
Chat connects directly to the Hermes API Server with the API key — the same pattern used by Open WebUI and other Hermes frontends. The Manage tab and standard voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla install needs no plugin for either. The optional relay on `:8767` adds the power surfaces — terminal, bridge phone control, media handoff, desktop tools, and relay-side voice providers (preferred automatically when paired). One QR can configure API, dashboard, and relay routes without merging their auth models.
|
||||
Chat connects **directly** to the Hermes API server with the API key — the same pattern Open WebUI and other Hermes frontends use. Manage and standard voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla install needs no plugin for either. The optional relay on `:8767` adds the power surfaces — terminal, bridge phone control, media handoff, machine tools, and relay-side voice (preferred automatically when paired). One QR can configure API, dashboard, and relay routes without merging their auth models.
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, both surfaces, features, configuration — start here** |
|
||||
| **[User Guide](https://codename-11.github.io/hermes-relay/)** | **Quick start, features, configuration — start here** |
|
||||
| [Android](https://codename-11.github.io/hermes-relay/guide/) | Android install + setup + features |
|
||||
| [Desktop CLI](https://codename-11.github.io/hermes-relay/desktop/) | Desktop CLI guide — pairing, subcommands, local tool routing |
|
||||
| [Hermes-Relay CLI](https://codename-11.github.io/hermes-relay/desktop/) | Pairing, subcommands, local tool routing |
|
||||
| [Architecture](https://codename-11.github.io/hermes-relay/architecture/) | How the system works under the hood |
|
||||
| [API Reference](https://codename-11.github.io/hermes-relay/reference/api.html) | Hermes API endpoints used by both surfaces |
|
||||
| [Specification](docs/spec.md) | Full spec — protocol, UI, phases, dependencies |
|
||||
| [Architecture Decisions](docs/decisions.md) | ADRs — framework, channels, auth, terminal |
|
||||
| [Upstream Integration Sync](docs/upstream-integration-sync.md) | Supported Hermes extension points vs server-owned compatibility layers |
|
||||
| [Changelog](CHANGELOG.md) | Release history (Android `android-v*`, Server `server-v*`, Desktop `desktop-v*`) |
|
||||
| [Changelog](CHANGELOG.md) | Release history (`android-v*`, `server-v*`, `desktop-v*`) |
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary><b>Install with an AI agent</b> — paste-ready prompt for Claude / GPT</summary>
|
||||
|
||||
<br>
|
||||
|
||||
If an AI assistant manages your server, paste this block into its chat and it will fetch the canonical setup recipe and walk you through install, pairing, and troubleshooting:
|
||||
|
||||
```text
|
||||
You are helping me install and maintain Hermes-Relay (https://github.com/Codename-11/hermes-relay) — a native Android client + a CLI + a Python plugin for the Hermes AI agent platform.
|
||||
|
||||
Read the canonical setup recipe before acting:
|
||||
https://raw.githubusercontent.com/Codename-11/hermes-relay/main/skills/devops/hermes-relay-self-setup/SKILL.md
|
||||
|
||||
Then guide me through:
|
||||
- Verifying hermes-agent is already installed (it's a prerequisite — Hermes-Relay is a plugin, not standalone)
|
||||
- Running the server-plugin install one-liner: `curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash`
|
||||
- Connecting my phone by Standard Hermes API URL/key first, then optionally pairing Relay via `hermes pair` or `/hermes-relay-pair` for power tools; OR pairing my laptop via the Hermes-Relay CLI (`irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex` on Windows, then `hermes-relay pair --remote ws://<host>:8767`)
|
||||
- Verifying with `hermes-status` (server) or `hermes-relay doctor` (CLI)
|
||||
|
||||
Always confirm before running shell commands. Never restart hermes-gateway without asking. If any step fails, consult the Troubleshooting section in the SKILL.md and ask me for the exact error.
|
||||
```
|
||||
|
||||
Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `/hermes-relay-self-setup` from any chat for re-setup or "is everything wired correctly?" checks.
|
||||
|
||||
</details>
|
||||
|
||||
## Development
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **File > Open** the repo root in Android Studio
|
||||
2. Wait for Gradle sync
|
||||
3. **Run** (Shift+F10) to deploy to emulator or device
|
||||
|
||||
### Dev Scripts
|
||||
|
||||
```bash
|
||||
# Android: open the repo root in Android Studio, wait for Gradle sync, Run (Shift+F10).
|
||||
scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat release # Build signed release APK
|
||||
scripts/dev.bat bundle # Build release AAB for Google Play
|
||||
scripts/dev.bat run # Build + install + launch + logcat
|
||||
scripts/dev.bat test # Run unit tests
|
||||
scripts/dev.bat version # Show current version
|
||||
scripts/dev.bat relay # Start Server (dev, no TLS)
|
||||
```
|
||||
|
||||
### Repository Structure
|
||||
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── desktop/ # Desktop CLI thin-client (@hermes-relay/cli — TS + Bun-compiled binary)
|
||||
├── relay_server/ # WSS Server (Python + aiohttp; thin shim → plugin/relay)
|
||||
├── plugin/ # Hermes agent plugin
|
||||
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, desktop tools)
|
||||
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
|
||||
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
|
||||
├── skills/ # Hermes agent skills
|
||||
│ └── devops/
|
||||
│ ├── hermes-relay-pair/ # /hermes-relay-pair slash-command skill
|
||||
│ ├── hermes-relay-self-setup/ # AI-agent setup recipe (Android + desktop)
|
||||
│ └── hermes-relay-desktop-setup/ # AI-agent recipe specifically for the desktop CLI
|
||||
├── user-docs/ # VitePress documentation site (Android + desktop sections)
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines (ci-android / ci-server / ci-desktop)
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
scripts/dev.bat relay # Start the relay server (dev, no TLS)
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Desktop CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Server** | Python 3.11+, aiohttp |
|
||||
| **Android app** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Hermes-Relay CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Server / plugin** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization (Android) |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (desktop) |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, APK artifact, desktop binaries per platform) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
|
||||
| **CI/CD** | GitHub Actions — lint, build, test, APK artifact, CLI binaries per platform |
|
||||
| **Min SDK** | 26 (Android 8.0) · Target SDK 35 |
|
||||
|
||||
### Server (optional — bridge, terminal, TUI, media, and relay voice routes)
|
||||
<details>
|
||||
<summary><b>Repository structure</b></summary>
|
||||
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── desktop/ # Hermes-Relay CLI thin-client (TS + Bun-compiled binary)
|
||||
├── relay_server/ # WSS server (Python + aiohttp; thin shim → plugin/relay)
|
||||
├── plugin/ # Hermes agent plugin
|
||||
│ ├── relay/ # - canonical relay (server.py, channels/, media, voice, machine tools)
|
||||
│ ├── tools/ # - android_* bridge + desktop_* tool handlers
|
||||
│ └── pair.py # - QR pairing CLI + multi-endpoint payload builder
|
||||
├── skills/devops/ # Hermes agent skills (pairing, self-setup, CLI setup recipes)
|
||||
├── user-docs/ # VitePress documentation site
|
||||
├── docs/ # Spec, decisions, security
|
||||
├── scripts/ # Dev helper scripts
|
||||
├── .github/workflows/ # CI + release pipelines (ci-android / ci-server / ci-desktop)
|
||||
└── gradle/ # Wrapper (8.13) + version catalog
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Running the server / plugin from a clone</b></summary>
|
||||
|
||||
<br>
|
||||
|
||||
End users should install via the [one-liner](#4--optional-install-relay-for-power-tools) above. For local development:
|
||||
|
||||
```bash
|
||||
hermes relay start --no-ssl # if you installed the plugin
|
||||
# or from a repo checkout:
|
||||
python -m plugin.relay --no-ssl
|
||||
```
|
||||
python -m plugin.relay --no-ssl # or from a repo checkout
|
||||
|
||||
Or with Docker:
|
||||
|
||||
```bash
|
||||
# Docker:
|
||||
docker build -t hermes-relay relay_server/ && docker run -d --network host --name hermes-relay hermes-relay
|
||||
```
|
||||
|
||||
See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
|
||||
### Hermes Plugin (for contributors)
|
||||
|
||||
End users should install via the [one-liner](#4-optional-install-relay-for-power-tools) above. For local development from a clone:
|
||||
|
||||
```bash
|
||||
cp -r plugin ~/.hermes/plugins/hermes-relay
|
||||
# Or symlink for live edits:
|
||||
# Live-edit the plugin against a local Hermes:
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run the plugin-provided `hermes pair` to verify pairing. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. `/hermes-relay-pair` and the dashed `hermes-pair` shim remain available for chat-surface and older-build compatibility.
|
||||
Then restart hermes and run `hermes pair` to verify. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
|
||||
## Hermes Agent
|
||||
</details>
|
||||
|
||||
## Built for Hermes Agent
|
||||
|
||||
Hermes-Relay is built for [Hermes Agent](https://github.com/NousResearch/hermes-agent) — an open-source AI agent platform by [Nous Research](https://nousresearch.com). See the [Hermes Agent docs](https://hermes-agent.nousresearch.com) for server setup, gateway configuration, and plugin development.
|
||||
|
||||
## Found a bug? Let us know!
|
||||
## Found a bug? Let us know
|
||||
|
||||
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line "this didn't work on my Pixel 7" / "the alpha.14 Windows binary segfaults on my Surface" is genuinely useful.
|
||||
This is an indie project and every report helps shape where it goes next. If something feels off, broken, or just weird — [open an issue](https://github.com/Codename-11/hermes-relay/issues/new). We read every one, and even a one-line *"this didn't work on my Pixel 7"* is genuinely useful.
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
@@ -374,7 +374,34 @@ the new app version and a higher `appVersionCode`.
|
||||
(v0.4.0 shipped with 0.1.0 content until caught post-release).
|
||||
- `docs/play-store-listing.md` — Play Store listing copy. Update
|
||||
the version reference and the "Release Notes" section that gets
|
||||
pasted into the Play Console "What's new" field.
|
||||
pasted into the Play Console "What's new" field. Keep the Play
|
||||
"What's new" within **500 characters** and framed around the
|
||||
release's themes, not a feature dump.
|
||||
|
||||
#### Scrub for public distribution
|
||||
|
||||
This is a **public repo** and these four files are user-facing. Before
|
||||
promoting the `[Unreleased]` block and writing the notes, scrub the
|
||||
versioned CHANGELOG block and all three release-notes artifacts for
|
||||
wording that shouldn't ship publicly. The CHANGELOG accumulates in a
|
||||
dev-log voice during the iteration phase — release-prep is where it
|
||||
becomes public copy. Check for and remove/rewrite:
|
||||
|
||||
- **Personal names / quoted asides** — `git grep -niE "bailey|: \"" CHANGELOG.md`
|
||||
on the new block. Attribute fixes impersonally ("a user reported"),
|
||||
not by name. (Author identity already lives in git + the signing cert.)
|
||||
- **Private infrastructure** — server hostnames/IPs, `~/SYSTEM.md`,
|
||||
internal deployment names, anything that should stay in the operator's
|
||||
environment and not the repo. `grep -niE "192\.168|10\.0\.|hermes-host|SYSTEM\.md"`.
|
||||
(Example IPs like `192.168.1.100` in install docs are fine.)
|
||||
- **Fork / branch plumbing + internal nicknames** — references to private
|
||||
fork branches, rollout channels, or in-team incident nicknames read as
|
||||
internal. Keep the *what changed*, drop the *where we staged it*.
|
||||
- **Personal example data** — genericize sample profile/agent names to
|
||||
neutral placeholders so the copy doesn't expose a specific setup.
|
||||
|
||||
The goal is that someone who has never seen the repo can read the block
|
||||
and the release notes and learn only what the software does.
|
||||
|
||||
### 3. Build and verify locally
|
||||
|
||||
|
||||
@@ -1,44 +1,66 @@
|
||||
# Unreleased
|
||||
# Hermes-Relay-Android v1.0.0
|
||||
|
||||
## Changed
|
||||
**Release Date:** June 14, 2026
|
||||
**Since v0.8.1:** The 1.0 milestone — a rechromed app, a first-class standard (no-plugin) path, live-thinking gateway chat, and a broad polish pass.
|
||||
|
||||
- Android now defaults to a standard Hermes layout with **Chat**, **Manage**, and **Settings** in bottom navigation. Terminal and Bridge remain available under **Settings → Power tools** and through existing routes.
|
||||
- Added a native **Manage** surface backed by the Hermes dashboard/admin API for Skills, Cron, MCP servers/catalog, Profiles, Models, and Config. It supports dashboard sign-in, common management actions, cron run details, and read-only profile SOUL details without requiring relay pairing.
|
||||
- Relay-only features now show a consistent **Requires pairing** / **Pair to unlock** gate when the active connection is not paired.
|
||||
- Connections now model API auth, dashboard auth, and relay pairing separately. Dashboard URLs derive from the API host on port `9119` by default.
|
||||
|
||||
---
|
||||
|
||||
# Hermes-Relay-Android v0.8.1
|
||||
|
||||
**Release Date:** May 26, 2026
|
||||
**Since v0.8.0:** A focused patch fixing a voice-mode crash. No new features.
|
||||
|
||||
v0.8.1 is a patch release. If you don't use voice mode with barge-in enabled, v0.8.0 is unaffected — but updating is still recommended.
|
||||
v1.0.0 is the first stable release. The headline is that a **plain, unmodified Hermes agent is now enough**: chat, Manage, and voice all work against vanilla upstream with no relay plugin. The relay plugin is now purely additive (phone control, terminal, notification companion, extra voice engines).
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
v0.8.1 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
v1.0.0 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| Google Play | `hermes-relay-0.8.1-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, wake locks, or unattended phone control. |
|
||||
| sideload | `hermes-relay-0.8.1-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-0.8.1-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-0.8.1-sideload-release.aab` | Parity/testing artifact. |
|
||||
| Google Play | `hermes-relay-1.0.0-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.0.0-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.0.0-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.0.0-sideload-release.aab` | Parity/testing artifact. |
|
||||
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
## Highlights
|
||||
|
||||
### Voice mode crash with barge-in on legacy TTS playback
|
||||
### Standard path is first-class — no plugin required
|
||||
|
||||
Starting voice mode with **barge-in enabled** while the relay served audio over the legacy `/voice/synthesize` path crashed the app the instant the agent began speaking — the first word or two played, then the app died with `Player is accessed on the wrong thread`.
|
||||
Chat, Manage, and voice now work against an unmodified upstream Hermes agent. Chat streams over the API server; Manage and voice use the Hermes dashboard with a single sign-in. The relay plugin stays optional and only adds power tools.
|
||||
|
||||
The barge-in listener reads the audio session id from a background thread to attach the echo canceller, but Media3's `ExoPlayer` is thread-confined and throws when its `audioSessionId` getter is read off the main thread. `VoicePlayer.audioSessionId` is now backed by a thread-safe cache populated from main-thread playback callbacks, so it's safe to read from any thread.
|
||||
### Gateway chat transport with live thinking
|
||||
|
||||
This only affected the **opt-in** barge-in feature on the legacy text-to-speech path; the provider-native Realtime Agent and Voice Output paths were never affected.
|
||||
Chat can now ride the upstream dashboard `/api/ws` gateway (the same surface the official hermes-desktop client speaks). It's the only vanilla-upstream path that streams reasoning **live**, so the Thinking block and sphere light up *during* generation instead of after. "Auto" prefers the gateway when the dashboard is reachable and Manage is signed in, and falls back to the SSE endpoints per turn on any failure.
|
||||
|
||||
- **Warm-start + keep-alive.** The app pre-warms the gateway on foreground so the first token lands fast (the cold session-setup cost moves off the send path). An opt-in **Keep connected in background** toggle (both flavors) holds the connection open via a foreground service so a long-backgrounded conversation resumes instantly.
|
||||
- **Attachments at desktop parity.** Images, PDFs, and any other file upload natively over the gateway (`image.attach_bytes` / `pdf.attach` / `file.attach`). Turns that fall back to an endpoint that can't carry a file now post a visible notice instead of dropping it silently.
|
||||
- **Steering, edit & resend, subagent lanes.** Send mid-turn to inject guidance into the running turn; edit your own messages to rewind and regenerate; watch per-task subagent lanes stream under the bubble; a context-window meter warns as the window fills.
|
||||
- **Turn-complete notifications** when the app is backgrounded.
|
||||
|
||||
### Manage parity with the desktop dashboard
|
||||
|
||||
The Manage tab now does what the desktop dashboard does: change models from the full provider catalog, manage provider keys (write-only, masked, reveal), create/edit profiles and SOUL.md, and browse/install/update skills. Manage data is cached to disk so a cold launch renders instantly.
|
||||
|
||||
### Per-conversation agent profiles
|
||||
|
||||
Switch the whole agent — model, persona (SOUL), and skills — from the chat header. The selection is **ephemeral and per-conversation** (bound to the session, like the official desktop): it never changes your server's default agent for other clients. The session drawer scopes to the active profile, opening one of its chats loads that profile's history, and the right agent is restored on cold start.
|
||||
|
||||
### Redesigned chat input + seamless connection UX
|
||||
|
||||
A cleaner Telegram-style input bar (pill field, one morphing Send/Voice/Stop/Steer/Queue button, no slash button). Network route handoffs (LAN↔Tailscale) and reconnects no longer repaint or reload the chat, and connection/update status now slide down as in-theme toasts over the content instead of pushing the UI around.
|
||||
|
||||
### Voice
|
||||
|
||||
The provider-native Realtime Agent keeps one session open across turns (follow-ups retain context), and long Hermes runs are promoted to tracked background tasks so the conversation stays responsive and the answer is spoken when it's ready.
|
||||
|
||||
### Docs + branding
|
||||
|
||||
The documentation site was rechromed to the app's cockpit theme and repositioned around the two-path story (just connect → give it hands), with a reworked Android getting-started funnel and a Google Play badge.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade notes
|
||||
|
||||
- **Google Play submission:** the opt-in keep-alive feature adds a `FOREGROUND_SERVICE_SPECIAL_USE` service. Complete the Play Console **Foreground service permissions** declaration for `specialUse` at submission (see `docs/play-store-listing.md`).
|
||||
- **PDF attachments** over the gateway require `poppler-utils` (`pdftoppm`) on the Hermes host; without it, PDF attach reports an error and the message still sends as text.
|
||||
- `appVersionCode` is **12**.
|
||||
|
||||
@@ -230,6 +230,10 @@ dependencies {
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer.code)
|
||||
|
||||
// Coil 3 — async image loading for generated images in chat
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
// QR Code scanning (ML Kit + CameraX)
|
||||
implementation(libs.mlkit.barcode)
|
||||
implementation(libs.camera.core)
|
||||
|
||||
@@ -6,6 +6,19 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<!-- Turn-complete chat notification (TurnCompleteNotifier) — runtime-requested
|
||||
on API 33+ from the Chat Settings toggle. Lives in main (not just the
|
||||
sideload overlay) so the googlePlay flavor can notify too. -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Opt-in "Keep connected in background" (GatewayKeepAliveService). In main
|
||||
(not the sideload overlay) so the googlePlay flavor ships it too — the
|
||||
Home-Assistant-class persistent-connection use case Play permits. The
|
||||
specialUse type requires a one-time Play Console foreground-service
|
||||
declaration at submission. (Also already present in the sideload overlay
|
||||
for the device-control bridge service; the merger dedups.) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
@@ -54,6 +67,20 @@
|
||||
</service>
|
||||
<!-- === END PHASE3-notif-listener === -->
|
||||
|
||||
<!-- Opt-in "Keep connected in background" — holds the gateway chat
|
||||
socket open while backgrounded. In main so BOTH flavors ship it
|
||||
(Home-Assistant-class persistent connection). Off by default; only
|
||||
runs while the user has explicitly enabled the toggle. specialUse
|
||||
needs a Play Console foreground-service declaration at submission. -->
|
||||
<service
|
||||
android:name=".network.GatewayKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Keeps the user's chat connection to their Hermes agent open while the app is backgrounded, only when the user has explicitly enabled 'Keep connected in background'." />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
v0.8.1 - Voice mode crash fix
|
||||
v1.0.0 - The 1.0 release
|
||||
|
||||
Standard path
|
||||
* Chat, Manage, and voice now work on a plain Hermes agent — no relay
|
||||
plugin required. The plugin is optional and only adds power tools.
|
||||
|
||||
Chat
|
||||
* New gateway transport streams the agent's reasoning live, so the
|
||||
Thinking block fills in during generation instead of after.
|
||||
* Warm-start + opt-in "Keep connected in background" make returning to a
|
||||
conversation fast.
|
||||
* Attachments at desktop parity: images, PDFs, and files upload over the
|
||||
gateway. If a connection can't carry a file, you'll see a notice
|
||||
instead of a silent drop.
|
||||
* Steer a running turn, edit & resend your messages, watch subagent
|
||||
lanes, and a context-window meter — plus turn-complete notifications.
|
||||
* Tap an image to open it full-screen (pinch to zoom); save or share
|
||||
images and other attachments.
|
||||
* Redesigned input bar: pill field, one morphing Send/Voice/Stop button.
|
||||
|
||||
Profiles
|
||||
* Switch the whole agent — model, persona, and skills — per conversation.
|
||||
The drawer scopes to the active profile, and switching is ephemeral: it
|
||||
never changes your server's default agent.
|
||||
|
||||
Manage
|
||||
* Models, provider keys, profiles + SOUL.md, and a skills hub — parity
|
||||
with the desktop dashboard. Cached for instant cold-launch.
|
||||
|
||||
Voice
|
||||
* Fixed a crash that could hit voice mode when barge-in was enabled on the
|
||||
legacy text-to-speech path — the agent's first words no longer cut off
|
||||
into a crash. Barge-in is opt-in; the Realtime Agent and Voice Output
|
||||
paths were never affected.
|
||||
* Realtime Agent keeps one session across turns; long runs continue in
|
||||
the background and are spoken when ready.
|
||||
|
||||
Polish
|
||||
* Seamless LAN/Tailscale handoffs (no chat reload), slide-down status
|
||||
toasts, and a broad round of fixes.
|
||||
|
||||
@@ -4,12 +4,29 @@ import android.app.Application
|
||||
import android.os.Build
|
||||
import androidx.compose.ui.ComposeUiFlags
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import coil3.ImageLoader
|
||||
import coil3.PlatformContext
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.AppAnalytics
|
||||
import com.hermesandroid.relay.power.WakeLockManager
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
|
||||
class HermesRelayApp : Application() {
|
||||
class HermesRelayApp : Application(), SingletonImageLoader.Factory {
|
||||
|
||||
/**
|
||||
* Coil's singleton image loader for the whole app. Registering the OkHttp
|
||||
* network fetcher EXPLICITLY guarantees `http(s)` image URLs (e.g. a
|
||||
* generated-image link in a chat reply) load, rather than relying on
|
||||
* artifact auto-registration. Crossfade for a clean fade-in.
|
||||
*/
|
||||
override fun newImageLoader(context: PlatformContext): ImageLoader =
|
||||
ImageLoader.Builder(context)
|
||||
.components { add(OkHttpNetworkFetcherFactory()) }
|
||||
.crossfade(true)
|
||||
.build()
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
override fun attachBaseContext(base: android.content.Context?) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.hermesandroid.relay.accessibility.ScreenCaptureRequester
|
||||
import com.hermesandroid.relay.bridge.BridgeForegroundService
|
||||
import com.hermesandroid.relay.bridge.UnattendedAccessManager
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.notifications.TurnCompleteNotifier
|
||||
import com.hermesandroid.relay.ui.RelayApp
|
||||
import com.hermesandroid.relay.util.ComposeArrWorkaround
|
||||
import com.hermesandroid.relay.util.NavRouteRequest
|
||||
@@ -37,7 +38,7 @@ class MainActivity : ComponentActivity() {
|
||||
// We do NOT call MediaProjectionHolder directly from here. On Android
|
||||
// 14+, getMediaProjection() must run from inside a foreground service
|
||||
// that has already called startForeground(type=mediaProjection), and
|
||||
// that startForeground call must happen AFTER consent. So we hand the
|
||||
// that startForeground call must happen AFT consent. So we hand the
|
||||
// result off to BridgeForegroundService, which:
|
||||
// 1. Upgrades its FGS type to SPECIAL_USE | MEDIA_PROJECTION
|
||||
// 2. Calls MediaProjectionHolder.acceptGrantInsideForegroundService
|
||||
@@ -142,6 +143,9 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Returning to the app clears the one-slot "Hermes finished
|
||||
// responding" notification — the chat surface is the answer.
|
||||
TurnCompleteNotifier.cancel(this)
|
||||
// v0.4.1 — register this activity as the host for
|
||||
// KeyguardManager.requestDismissKeyguard. Cleared in onPause so
|
||||
// we don't leak the Activity past its lifecycle. The unattended-
|
||||
|
||||
@@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
|
||||
*
|
||||
* # Master enable / disable
|
||||
*
|
||||
* The Android system toggle in `Settings → Accessibility → Hermes Relay` is
|
||||
* The Android system toggle in `Settings → Accessibility → Hermes-Relay` is
|
||||
* the hard switch — if it's off we never receive events. On top of that the
|
||||
* user can flip a soft master in Settings (`bridge_master_enabled`); when
|
||||
* that's false we still run (Android requires it to stay connected) but we
|
||||
|
||||
@@ -11,17 +11,26 @@ package com.hermesandroid.relay.data
|
||||
object AgentDisplay {
|
||||
const val SERVER_DEFAULT_PROFILE_KEY: String = "__server_default__"
|
||||
|
||||
// Only an EXPLICIT pick drives the effective profile. We deliberately do
|
||||
// NOT fall back to the advertised "default" profile: a dashboard profile's
|
||||
// description is a verbose SOUL summary ("Builds and maintains…"), and
|
||||
// resolving it here replaced the clean agent name (the personality, e.g.
|
||||
// "Victor") with that summary in the header. With no explicit pick, the
|
||||
// name comes from the personality. ([profiles] kept for call-site symmetry.)
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun effectiveProfile(
|
||||
selectedProfile: Profile?,
|
||||
profiles: List<Profile>,
|
||||
): Profile? = selectedProfile
|
||||
?: profiles.firstOrNull { it.name.equals("default", ignoreCase = true) }
|
||||
|
||||
// The NAME goes in the name slot. A profile's description is a SOUL summary
|
||||
// ("Builds and maintains…"), far too verbose for the agent-name label, so
|
||||
// the profile name wins; description is only a last resort when name is blank.
|
||||
fun profileDisplayName(profile: Profile?): String? {
|
||||
if (profile == null) return null
|
||||
return when {
|
||||
profile.description.isNotBlank() -> profile.description.trim()
|
||||
profile.name.isNotBlank() -> titleCase(profile.name.trim())
|
||||
profile.description.isNotBlank() -> profile.description.trim()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +238,27 @@ data class ToolCall(
|
||||
val provenance: String? = null,
|
||||
// Duration tracking
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
val completedAt: Long? = null
|
||||
val completedAt: Long? = null,
|
||||
/**
|
||||
* Gateway `tool.generating` pre-start phase — the model is still
|
||||
* streaming this tool's arguments. Cleared (flipped false) when the
|
||||
* matching `tool.start` arrives and the call begins executing. Renders
|
||||
* as the quiet "preparing" state in ToolProgressCard / CompactToolCall
|
||||
* rather than the active running spinner.
|
||||
*/
|
||||
val isGenerating: Boolean = false,
|
||||
/**
|
||||
* Subagent lane index from gateway `subagent.*` events (`task_index`).
|
||||
* Null = top-level tool call, rendered exactly as before. Non-null
|
||||
* calls are grouped per index into a SubagentLane under the bubble.
|
||||
*/
|
||||
val taskIndex: Int? = null,
|
||||
/**
|
||||
* Human label for the owning subagent lane — the `subagent.start`
|
||||
* goal truncated to 60 chars. Carried on each child call so the lane
|
||||
* header can render without a separate lane registry.
|
||||
*/
|
||||
val taskLabel: String? = null
|
||||
)
|
||||
|
||||
enum class MessageRole {
|
||||
|
||||
@@ -100,6 +100,17 @@ class ConnectionStore private constructor(
|
||||
private val _activeConnectionId = MutableStateFlow<String?>(null)
|
||||
val activeConnectionId: StateFlow<String?> = _activeConnectionId.asStateFlow()
|
||||
|
||||
/**
|
||||
* Flips to `true` once the initial DataStore hydrate completes (success OR
|
||||
* failure). Until then [connections] / [activeConnection] hold their empty
|
||||
* seed values, which are indistinguishable from a genuinely empty store.
|
||||
* Consumers that must not mistake "still loading" for "nothing configured"
|
||||
* — e.g. the chat empty-state, which would otherwise flash a "Connect to
|
||||
* Hermes" CTA on every cold start — gate on this instead of on emptiness.
|
||||
*/
|
||||
private val _isHydrated = MutableStateFlow(false)
|
||||
val isHydrated: StateFlow<Boolean> = _isHydrated.asStateFlow()
|
||||
|
||||
/**
|
||||
* Derived: the active connection, or null when the active ID is missing
|
||||
* or points to a deleted connection. Recomputes every time either
|
||||
@@ -144,6 +155,11 @@ class ConnectionStore private constructor(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Initial hydrate failed: ${e.message}")
|
||||
} finally {
|
||||
// Mark hydration done even on failure — a failed read still
|
||||
// means "we now know the store's state is empty", so the UI
|
||||
// should stop showing the neutral loading gate.
|
||||
_isHydrated.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
|
||||
/**
|
||||
* Single source of truth for the opt-in "keep the gateway chat connection
|
||||
* alive in the background" preference. Off by default.
|
||||
*
|
||||
* Shared by [com.hermesandroid.relay.viewmodel.ConnectionViewModel] (the
|
||||
* StateFlow + setter that drive the foreground service and the client's
|
||||
* no-background-close flag) and
|
||||
* [com.hermesandroid.relay.network.GatewayKeepAliveService]'s Stop notification
|
||||
* action, so both read/write the same key.
|
||||
*/
|
||||
val KEY_GATEWAY_KEEP_ALIVE = booleanPreferencesKey("gateway_keep_alive_background")
|
||||
|
||||
/** Persist the keep-alive preference. Used by the FGS Stop action. */
|
||||
suspend fun Context.setGatewayKeepAlive(enabled: Boolean) {
|
||||
relayDataStore.edit { it[KEY_GATEWAY_KEEP_ALIVE] = enabled }
|
||||
}
|
||||
@@ -53,8 +53,22 @@ data class HermesCard(
|
||||
* which action (if any) has been dispatched, so the same card reloaded
|
||||
* from session history doesn't re-prompt. Falls back to the card's
|
||||
* position in the message when null.
|
||||
*
|
||||
* For the gateway ask types this is the ask's `request_id` (or
|
||||
* `approval-<sid>-<ts>` for approval, which has no request id) — the
|
||||
* dispatch tracker keys answer-once semantics off it.
|
||||
*/
|
||||
val id: String? = null,
|
||||
/**
|
||||
* Interactive input slot rendered between [fields] and [actions] —
|
||||
* the answer surface for the gateway ask cards (`ask.clarify` choice
|
||||
* chips + free text, `ask.secret` masked field, `ask.sudo`
|
||||
* hold-to-confirm). Null for every plain card. Submissions flow
|
||||
* through the renderer's `onInputSubmit(cardKey, value)` callback and
|
||||
* collapse the card via the same [HermesCardDispatch] list as button
|
||||
* actions.
|
||||
*/
|
||||
val input: HermesCardInput? = null,
|
||||
) {
|
||||
object BuiltInTypes {
|
||||
const val SKILL_RESULT = "skill_result"
|
||||
@@ -62,6 +76,14 @@ data class HermesCard(
|
||||
const val LINK_PREVIEW = "link_preview"
|
||||
const val CALENDAR_EVENT = "calendar_event"
|
||||
const val WEATHER = "weather"
|
||||
|
||||
// Gateway interactive asks (desktop-parity wave). Locally built
|
||||
// from clarify/approval/sudo/secret request events — never parsed
|
||||
// out of the text stream.
|
||||
const val ASK_APPROVAL = "ask.approval"
|
||||
const val ASK_CLARIFY = "ask.clarify"
|
||||
const val ASK_SUDO = "ask.sudo"
|
||||
const val ASK_SECRET = "ask.secret"
|
||||
}
|
||||
|
||||
object Accents {
|
||||
@@ -72,6 +94,67 @@ data class HermesCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive input slot on a [HermesCard]. The flags compose rather than
|
||||
* branch — a sudo ask can be `masked + holdToConfirm` (password field whose
|
||||
* submit is the 650ms press-fill button), while clarify is
|
||||
* `choices + allowFreeText` and secret is `masked` alone.
|
||||
*
|
||||
* Security contract: when [masked] is true the submitted value is a secret.
|
||||
* It must never be echoed into chat content, logged, or synced via
|
||||
* CardDispatchSyncBuilder — record [SECRET_PROVIDED_STAMP] as the dispatch's
|
||||
* actionValue instead of the real value. The renderer masks the collapse
|
||||
* stamp for masked inputs regardless, but the dispatch record itself is
|
||||
* persisted and synced, so the caller must not put the secret there.
|
||||
*/
|
||||
@Serializable
|
||||
data class HermesCardInput(
|
||||
/**
|
||||
* Input kind — one of [Kinds]. Drives which composite the renderer
|
||||
* builds; unknown kinds degrade to a plain free-text field so newer
|
||||
* asks still get an answer surface.
|
||||
*/
|
||||
val kind: String,
|
||||
/** Quick-answer chips (clarify). Empty = no chip row. */
|
||||
val choices: List<String> = emptyList(),
|
||||
/** Render the inline free-text mini field under the chips. */
|
||||
val allowFreeText: Boolean = false,
|
||||
/** Password-style field: masked glyphs + reveal toggle (secret/sudo). */
|
||||
val masked: Boolean = false,
|
||||
/** Submit is a 650ms hold-to-confirm press-fill instead of a tap (sudo). */
|
||||
val holdToConfirm: Boolean = false,
|
||||
/**
|
||||
* Wall-clock expiry for timed asks (sudo 120s, clarify/secret 300s).
|
||||
* The renderer shows a countdown footer (Amber under 30s) and
|
||||
* self-collapses to "Expired — not granted" past it. Null = no timeout
|
||||
* (approval is session-scoped).
|
||||
*/
|
||||
val expiresAtMillis: Long? = null,
|
||||
) {
|
||||
object Kinds {
|
||||
const val CHOICE = "choice"
|
||||
const val TEXT = "text"
|
||||
const val SECRET = "secret"
|
||||
const val CONFIRM = "confirm"
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Sentinel recorded as [HermesCardDispatch.actionValue] when a
|
||||
* [masked] input is submitted. The real secret value goes only to
|
||||
* the ask-respond RPC — never into the dispatch record, chat
|
||||
* content, or session sync.
|
||||
*/
|
||||
const val SECRET_PROVIDED_STAMP = "secret-provided"
|
||||
|
||||
/**
|
||||
* Value submitted by a bare hold-to-confirm (no text field) — the
|
||||
* sudo/approval "yes" that carries no payload of its own.
|
||||
*/
|
||||
const val CONFIRM_VALUE = "confirm"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A label/value row inside a card. [value] is rendered as markdown so the
|
||||
* agent can embed emphasis, inline code, or links.
|
||||
@@ -117,6 +200,16 @@ data class HermesCardAction(
|
||||
const val SEND_TEXT = "send_text"
|
||||
const val SLASH_COMMAND = "slash_command"
|
||||
const val OPEN_URL = "open_url"
|
||||
|
||||
/**
|
||||
* Ask-card answer: dispatch [value] straight to the gateway
|
||||
* ask-respond RPC (clarify/sudo/secret/approval.respond), never
|
||||
* as chat text. Dispatches in this mode are EXCLUDED from
|
||||
* [com.hermesandroid.relay.viewmodel.CardDispatchSyncBuilder] —
|
||||
* the server already absorbed the answer through the blocking
|
||||
* ask, and for secrets the value must not enter session memory.
|
||||
*/
|
||||
const val SUBMIT_ASK = "submit_ask"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,62 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Per-connection, per-Hermes-profile last active chat session.
|
||||
* Which chat transport created (and can resume) a stored session.
|
||||
*
|
||||
* The two chat transports do NOT share session storage, so their ids are not
|
||||
* interchangeable on a non-default profile:
|
||||
* - [GATEWAY] — the `/api/ws` tui_gateway path. `session.create`/`session.resume`
|
||||
* bind the profile's own HERMES_HOME, so sessions live in that profile's
|
||||
* `state.db`. Ids look like `YYYYMMDD_HHMMSS_<hex>`.
|
||||
* - [SSE] — the api_server chat path (`/api/sessions/.../chat/stream`,
|
||||
* `/v1/runs`). The api_server has no per-request profile scoping; it always
|
||||
* persists to its launch `state.db`. Ids look like `api_<unixsecs>_<hex>`.
|
||||
*
|
||||
* Resuming an [SSE] id over the [GATEWAY] (which opens the profile DB) — or vice
|
||||
* versa — fails with "session not found" and silently forks a new session. So
|
||||
* each transport gets its own persisted slot, and a stored id is only ever
|
||||
* restored for the transport that can actually resume it.
|
||||
*/
|
||||
enum class SessionTransport(val key: String) {
|
||||
GATEWAY("gw"),
|
||||
SSE("sse");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Bucket a stored session id by the subsystem that created it — the
|
||||
* id's namespace is the server's own ground truth about which transport
|
||||
* can resume it, more reliable than re-deriving the resolved endpoint
|
||||
* (a turn can fall back from gateway to SSE per-turn).
|
||||
*/
|
||||
fun forSessionId(sessionId: String): SessionTransport =
|
||||
if (sessionId.startsWith("api_")) SSE else GATEWAY
|
||||
|
||||
/**
|
||||
* Bucket a resolved streaming endpoint. Only `"gateway"` resumes from
|
||||
* the per-profile DB; every SSE-family member (`"sessions"` /
|
||||
* `"completions"` / `"runs"`) rides the api_server's launch DB.
|
||||
*/
|
||||
fun forEndpoint(resolvedEndpoint: String): SessionTransport =
|
||||
if (resolvedEndpoint == "gateway") GATEWAY else SSE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-connection, per-Hermes-profile, per-transport last active chat session.
|
||||
*
|
||||
* This is intentionally separate from [ProfileSelectionStore]. Selection says
|
||||
* which agent is active; this store says which chat session belongs to that
|
||||
* agent on that connection. Null profile name is the explicit Server default
|
||||
* context.
|
||||
*
|
||||
* **Transport dimension (v1.0.0).** The slot is keyed by [SessionTransport] too,
|
||||
* because a gateway session and an api_server (SSE) session are stored in
|
||||
* different databases and cannot be cross-resumed on a non-default profile.
|
||||
* Keying by transport keeps the two from clobbering one slot and guarantees a
|
||||
* restored id is always resumable by the transport asking for it. The key shape
|
||||
* changed in this release, so pre-existing (untransported) slots are not read —
|
||||
* a one-time drop of the "last session" pointer that also clears the exact stale
|
||||
* cross-transport ids that caused mid-conversation forks.
|
||||
*/
|
||||
class ProfileSessionStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
@@ -25,11 +75,18 @@ class ProfileSessionStore(
|
||||
companion object {
|
||||
private const val PREFIX = "profile_session__"
|
||||
|
||||
private fun keyName(connectionId: String, profileName: String?): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
|
||||
private fun keyName(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
): String =
|
||||
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}__${transport.key}"
|
||||
|
||||
private fun keyFor(connectionId: String, profileName: String?) =
|
||||
stringPreferencesKey(keyName(connectionId, profileName))
|
||||
private fun keyFor(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
) = stringPreferencesKey(keyName(connectionId, profileName, transport))
|
||||
|
||||
private fun connectionPrefix(connectionId: String): String =
|
||||
"$PREFIX${connectionId}__"
|
||||
@@ -38,10 +95,11 @@ class ProfileSessionStore(
|
||||
suspend fun setSessionId(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
sessionId: String?,
|
||||
) {
|
||||
dataStore.edit { prefs ->
|
||||
val key = keyFor(connectionId, profileName)
|
||||
val key = keyFor(connectionId, profileName, transport)
|
||||
if (sessionId.isNullOrBlank()) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
@@ -50,8 +108,12 @@ class ProfileSessionStore(
|
||||
}
|
||||
}
|
||||
|
||||
fun sessionIdFlow(connectionId: String, profileName: String?): Flow<String?> {
|
||||
val key = keyFor(connectionId, profileName)
|
||||
fun sessionIdFlow(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
): Flow<String?> {
|
||||
val key = keyFor(connectionId, profileName, transport)
|
||||
return dataStore.data.map { prefs -> prefs[key] }
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,18 @@ class ConnectionManager(
|
||||
@Volatile
|
||||
private var networkResolveJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
/** Deferred reaction to a network loss — cancelled if a network returns within the grace. */
|
||||
private var networkLossJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
/**
|
||||
* Set when a network loss outlives [NETWORK_LOSS_GRACE_MS] — only then may
|
||||
* a re-resolve switch DOWN to a lower-priority endpoint. Prevents a
|
||||
* transient probe miss (Wi-Fi settling) from switching routes and
|
||||
* cancelling an in-flight turn. Cleared once a resolution is published.
|
||||
*/
|
||||
@Volatile
|
||||
private var sustainedLossDeclared = false
|
||||
|
||||
init {
|
||||
// Register at construction, not on first connect(). Standard
|
||||
// (no-Relay) connections never open the WSS socket, but their HTTP
|
||||
@@ -214,6 +226,15 @@ class ConnectionManager(
|
||||
// enough to coalesce the onAvailable burst of a handoff, short
|
||||
// enough that a route swap still feels immediate.
|
||||
private const val NETWORK_RESOLVE_DEBOUNCE_MS = 300L
|
||||
|
||||
/**
|
||||
* Grace before reacting to a network loss. A transient blip (Wi-Fi
|
||||
* power-save/roam, a brief drop, the OS swapping radios) recovers
|
||||
* within this window and must NOT mark the active endpoint unreachable
|
||||
* or switch routes — doing so rebuilds the chat client and cancels an
|
||||
* in-flight turn. Only a loss sustained past the grace switches.
|
||||
*/
|
||||
private const val NETWORK_LOSS_GRACE_MS = 6_000L
|
||||
// Matches plugin.relay.auth._BLOCK_SECONDS (5 min). If we see 429
|
||||
// on the WSS upgrade, we're IP-banned server-side — retrying at
|
||||
// our normal 1-30s cadence re-fills the ban bucket and keeps us
|
||||
@@ -540,6 +561,23 @@ class ConnectionManager(
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
// Endpoint hysteresis: a transient blip can make the active
|
||||
// (higher-priority) endpoint's health probe miss, so the resolver
|
||||
// falls through to a LOWER-priority fallback. Switching on that
|
||||
// transient miss rebuilds the chat client and CANCELS an in-flight
|
||||
// turn. Don't switch DOWN in priority unless a sustained loss was
|
||||
// actually declared (the onLost grace elapsed). Same/upgrade
|
||||
// winners always publish.
|
||||
val active = _activeEndpoint.value
|
||||
if (active != null && resolved.priority > active.priority && !sustainedLossDeclared) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"re-resolve picked lower-priority ${resolved.role}(p${resolved.priority}) over " +
|
||||
"active ${active.role}(p${active.priority}) not confirmed dead — keeping active",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
sustainedLossDeclared = false
|
||||
_activeEndpoint.value = resolved
|
||||
if (current == null) return@launch
|
||||
// After an explicit disconnect() the route still publishes above
|
||||
@@ -569,15 +607,33 @@ class ConnectionManager(
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.i(TAG, "network onAvailable — re-evaluating endpoint")
|
||||
// A network returned — cancel any pending loss reaction: the
|
||||
// drop was transient, so don't switch routes / rebuild the chat
|
||||
// client / cancel an in-flight turn. Re-resolve to pick the best
|
||||
// route (usually the same one); the rebuild only fires if the
|
||||
// URL actually moved.
|
||||
networkLossJob?.cancel()
|
||||
endpointResolver?.clearCache()
|
||||
scheduleNetworkReResolve("Network change — switching endpoint")
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
Log.i(TAG, "network onLost — marking active endpoint unreachable and resolving fallback")
|
||||
endpointResolver?.clearCache()
|
||||
markActiveEndpointUnreachable("network lost")
|
||||
scheduleNetworkReResolve("Network lost — switching endpoint")
|
||||
// Defer the reaction: a transient blip recovers within the grace
|
||||
// (onAvailable cancels this job). Reacting immediately — marking
|
||||
// the active endpoint unreachable + re-resolving to a fallback —
|
||||
// switches routes mid-blip, which rebuilds the chat client and
|
||||
// CANCELS the in-flight turn. The gateway client already handles
|
||||
// its own socket reconnect across the blip.
|
||||
Log.i(TAG, "network onLost — deferring fallback re-resolve by ${NETWORK_LOSS_GRACE_MS}ms")
|
||||
networkLossJob?.cancel()
|
||||
networkLossJob = scope.launch {
|
||||
delay(NETWORK_LOSS_GRACE_MS)
|
||||
Log.i(TAG, "network loss sustained past grace — marking active endpoint unreachable and resolving fallback")
|
||||
sustainedLossDeclared = true
|
||||
endpointResolver?.clearCache()
|
||||
markActiveEndpointUnreachable("network lost (sustained)")
|
||||
scheduleNetworkReResolve("Network lost — switching endpoint")
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -594,6 +650,8 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
private fun unregisterNetworkCallback() {
|
||||
networkLossJob?.cancel()
|
||||
networkLossJob = null
|
||||
val ctx = context ?: return
|
||||
val cb = networkCallback ?: return
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.content.Context
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.models.MessageItem
|
||||
import com.hermesandroid.relay.network.models.MessageListResponse
|
||||
import com.hermesandroid.relay.network.models.SessionItem
|
||||
import com.hermesandroid.relay.network.models.SessionListResponse
|
||||
import com.hermesandroid.relay.auth.KeystoreTokenStore
|
||||
import com.hermesandroid.relay.auth.LegacyEncryptedPrefsTokenStore
|
||||
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||
@@ -372,6 +377,88 @@ class DashboardApiClient(
|
||||
suspend fun deleteProfile(name: String): Result<JsonObject> =
|
||||
deleteJsonObject("/api/profiles/${pathSegment(name)}")
|
||||
|
||||
/**
|
||||
* List the host's Hermes agent profiles (`GET /api/profiles`) — the same
|
||||
* profiles the Manage tab and the official desktop expose — mapped into the
|
||||
* shared [Profile] type so the chat agent sheet can offer them even on a
|
||||
* dashboard-only (non-relay) connection, where the relay's `auth.ok`
|
||||
* profile list is empty. Tolerates the array (`{profiles:[…]}` / `{items:[…]}`)
|
||||
* and the object-map (`{profiles:{name:{…}}}`) shapes; an item missing a
|
||||
* required field is skipped, not fatal.
|
||||
*/
|
||||
suspend fun listProfiles(): Result<List<Profile>> =
|
||||
getJsonObject("/api/profiles").mapCatching { root -> parseProfiles(root) }
|
||||
|
||||
/**
|
||||
* List a profile's chat sessions via the dashboard `GET /api/sessions?profile=`.
|
||||
*
|
||||
* This is the per-profile scoping the official desktop sidebar uses: upstream
|
||||
* (`web_server.py` `_open_session_db_for_profile`) opens THAT profile's own
|
||||
* `state.db` directly. The gateway `session.list` RPC can't do this — it reads
|
||||
* one process-global DB bound to the launch profile, so over a single socket it
|
||||
* always returns the launch profile's sessions regardless of the active profile.
|
||||
*
|
||||
* [profile] null/blank → the launch (default) profile's DB (param omitted). The
|
||||
* returned ids are the same stored-session ids the gateway `session.resume`
|
||||
* reads, so list-here / resume-on-gateway stays consistent. `min_messages=1`
|
||||
* drops empty draft rows; `order=recent` keeps live conversations on top.
|
||||
*/
|
||||
suspend fun listSessions(profile: String? = null, limit: Int = 50): Result<List<SessionItem>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val query = buildList {
|
||||
add("limit=${limit.coerceIn(1, 200)}")
|
||||
add("order=recent")
|
||||
add("min_messages=1")
|
||||
val name = profile?.trim().orEmpty()
|
||||
if (name.isNotBlank()) add("profile=${pathSegment(name)}")
|
||||
}.joinToString(prefix = "?", separator = "&")
|
||||
getJson("/api/sessions$query").mapCatching { root ->
|
||||
val parsed = json.decodeFromJsonElement(SessionListResponse.serializer(), root)
|
||||
parsed.sessions ?: parsed.items ?: parsed.data ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A session's message history, scoped to its owning profile via the dashboard
|
||||
* `GET /api/sessions/{id}/messages?profile=`. Required twin of [listSessions]:
|
||||
* a non-default profile's sessions live in that profile's own `state.db`, so
|
||||
* loading their transcript through the api_server (one shared DB, no profile)
|
||||
* returns nothing. [profile] null/blank → the launch profile's DB. Decodes the
|
||||
* upstream `{session_id, messages:[…]}` envelope into the shared [MessageItem].
|
||||
*/
|
||||
suspend fun getSessionMessages(
|
||||
sessionId: String,
|
||||
profile: String? = null,
|
||||
): Result<List<MessageItem>> = withContext(Dispatchers.IO) {
|
||||
val name = profile?.trim().orEmpty()
|
||||
val query = if (name.isNotBlank()) "?profile=${pathSegment(name)}" else ""
|
||||
getJson("/api/sessions/${pathSegment(sessionId)}/messages$query").mapCatching { root ->
|
||||
val parsed = json.decodeFromJsonElement(MessageListResponse.serializer(), root)
|
||||
parsed.messages ?: parsed.data ?: parsed.items ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseProfiles(root: JsonObject): List<Profile> {
|
||||
fun decode(element: JsonElement, nameOverride: String?): Profile? = runCatching {
|
||||
val obj = element as? JsonObject ?: return null
|
||||
// Profile requires name + model; inject the map key as name and an
|
||||
// empty model when the server omits them so a sparse row still maps.
|
||||
val patched = buildJsonObject {
|
||||
obj.forEach { (k, v) -> put(k, v) }
|
||||
if (obj["name"] == null && !nameOverride.isNullOrBlank()) put("name", nameOverride)
|
||||
if (obj["model"] == null) put("model", "")
|
||||
}
|
||||
json.decodeFromJsonElement(Profile.serializer(), patched)
|
||||
}.getOrNull()
|
||||
|
||||
(root["profiles"] as? JsonArray)?.let { arr -> return arr.mapNotNull { decode(it, null) } }
|
||||
(root["items"] as? JsonArray)?.let { arr -> return arr.mapNotNull { decode(it, null) } }
|
||||
(root["profiles"] as? JsonObject)?.let { map ->
|
||||
return map.entries.mapNotNull { (name, value) -> decode(value, name) }
|
||||
}
|
||||
return root.entries.mapNotNull { (name, value) -> decode(value, name) }
|
||||
}
|
||||
|
||||
suspend fun loginPassword(
|
||||
provider: String = "basic",
|
||||
username: String,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
|
||||
/**
|
||||
* Maps tui_gateway events for ONE chat turn onto [GatewayTurnCallbacks].
|
||||
*
|
||||
* Pure JVM (no Android deps) so the whole mapping table is unit-testable.
|
||||
* The caller (GatewayChatClient) filters events by live session id and
|
||||
* invokes [onEvent] in arrival order; this class owns per-turn state
|
||||
* (backfill guards, synthetic tool ids, turn-end detection).
|
||||
*
|
||||
* Forward-compat contract: unknown event types MUST be ignored — upstream
|
||||
* adds event types freely and old clients are expected to skip them. That is
|
||||
* why dispatch is a manual `when (type)` over [JsonObject] rather than a
|
||||
* sealed polymorphic hierarchy (which throws on unknown discriminators).
|
||||
*/
|
||||
class GatewayEventMapper(private val callbacks: GatewayTurnCallbacks) {
|
||||
|
||||
/** True once `message.complete` or `error` has been seen — the turn is over. */
|
||||
var turnEnded: Boolean = false
|
||||
private set
|
||||
|
||||
private var sawMessageStart = false
|
||||
private var sawTextDelta = false
|
||||
private var sawThinkingDelta = false
|
||||
private var syntheticToolCounter = 0
|
||||
|
||||
/**
|
||||
* `tool.complete` events match their `tool.start` by `tool_id`; when a
|
||||
* server omits the id we synthesize one per start and match completes by
|
||||
* tool name, FIFO.
|
||||
*/
|
||||
private val openSyntheticIdsByName = mutableMapOf<String, ArrayDeque<String>>()
|
||||
|
||||
/**
|
||||
* `tool.generating` pre-registrations awaiting their `tool.start`, per
|
||||
* name FIFO — the start adopts the pre-minted id (unless the server
|
||||
* supplied a real `tool_id`) so the "preparing" placeholder and the
|
||||
* running card stay one ToolCall.
|
||||
*/
|
||||
private val generatingIdsByName = mutableMapOf<String, ArrayDeque<String>>()
|
||||
|
||||
fun onEvent(type: String, payload: JsonObject?) {
|
||||
if (turnEnded) return
|
||||
when (type) {
|
||||
"reasoning.delta", "thinking.delta" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty()) {
|
||||
sawThinkingDelta = true
|
||||
callbacks.onThinkingDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
// Post-hoc reasoning (providers that don't stream it) — only
|
||||
// useful when nothing streamed live.
|
||||
"reasoning.available" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty() && !sawThinkingDelta) {
|
||||
sawThinkingDelta = true
|
||||
callbacks.onThinkingDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
"message.delta" -> {
|
||||
val text = payload.string("text")
|
||||
if (!text.isNullOrEmpty()) {
|
||||
sawTextDelta = true
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
}
|
||||
|
||||
"message.start" -> {
|
||||
// Gateway has no server-side message id (placeholder UUID
|
||||
// stays). A second start inside one turn means a new
|
||||
// assistant message began — close out the previous one.
|
||||
if (sawMessageStart) callbacks.onTurnComplete()
|
||||
sawMessageStart = true
|
||||
}
|
||||
|
||||
"tool.generating" -> {
|
||||
// `{name?}` with NO tool_id — the model is still streaming
|
||||
// this tool's arguments.
|
||||
val name = payload.string("name")
|
||||
if (name != null) {
|
||||
generatingIdsByName.getOrPut(name) { ArrayDeque() }
|
||||
.addLast("gateway-tool-$name-${syntheticToolCounter++}")
|
||||
}
|
||||
callbacks.onToolGenerating(name)
|
||||
}
|
||||
|
||||
"tool.start" -> {
|
||||
val name = payload.string("name") ?: "unknown"
|
||||
// A pending generating placeholder for this name is adopted
|
||||
// (consumed FIFO) whether or not the server sent a real id.
|
||||
val adopted = generatingIdsByName[name]?.removeFirstOrNull()
|
||||
val serverId = payload.string("tool_id")
|
||||
val toolId = when {
|
||||
serverId != null -> serverId
|
||||
adopted != null -> adopted.also {
|
||||
openSyntheticIdsByName.getOrPut(name) { ArrayDeque() }.addLast(it)
|
||||
}
|
||||
else -> syntheticToolId(name)
|
||||
}
|
||||
callbacks.onToolCallStart(toolId, name)
|
||||
}
|
||||
|
||||
"tool.complete" -> {
|
||||
val name = payload.string("name") ?: "unknown"
|
||||
val toolId = payload.string("tool_id")
|
||||
?: openSyntheticIdsByName[name]?.removeFirstOrNull()
|
||||
?: return
|
||||
val error = payload.string("error")
|
||||
if (!error.isNullOrEmpty()) {
|
||||
callbacks.onToolCallFailed(toolId, error)
|
||||
} else {
|
||||
callbacks.onToolCallDone(toolId, payload.string("summary"))
|
||||
}
|
||||
}
|
||||
|
||||
"message.complete" -> {
|
||||
// Non-streaming servers (or error turns) deliver everything
|
||||
// here; backfill whatever never streamed.
|
||||
val text = payload.string("text")
|
||||
if (!sawTextDelta && !text.isNullOrEmpty()) {
|
||||
callbacks.onTextDelta(text)
|
||||
}
|
||||
val reasoning = payload.string("reasoning")
|
||||
if (!sawThinkingDelta && !reasoning.isNullOrEmpty()) {
|
||||
callbacks.onThinkingDelta(reasoning)
|
||||
}
|
||||
callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject))
|
||||
turnEnded = true
|
||||
callbacks.onComplete()
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
turnEnded = true
|
||||
callbacks.onError(payload.string("message") ?: "Gateway error")
|
||||
}
|
||||
|
||||
"subagent.start", "subagent.thinking", "subagent.tool",
|
||||
"subagent.progress", "subagent.complete",
|
||||
-> {
|
||||
val phase = when (type) {
|
||||
"subagent.start" -> GatewaySubagentEvent.Phase.START
|
||||
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
|
||||
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
|
||||
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
|
||||
else -> GatewaySubagentEvent.Phase.COMPLETE
|
||||
}
|
||||
callbacks.onSubagentEvent(
|
||||
GatewaySubagentEvent(
|
||||
phase = phase,
|
||||
taskIndex = payload.int("task_index") ?: 0,
|
||||
taskCount = payload.int("task_count") ?: 1,
|
||||
goal = payload.string("goal") ?: "",
|
||||
status = payload.string("status"),
|
||||
summary = payload.string("summary"),
|
||||
toolName = payload.string("tool_name"),
|
||||
// subagent.tool sets tool_preview AND mirrors it into
|
||||
// text; thinking/progress carry text only.
|
||||
preview = payload.string("tool_preview") ?: payload.string("text"),
|
||||
durationSeconds = payload.double("duration_seconds"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
"clarify.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.CLARIFY,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("question") ?: "The agent needs clarification",
|
||||
choices = (payload?.get("choices") as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
timeoutSeconds = CLARIFY_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
"approval.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.APPROVAL,
|
||||
// Upstream approvals correlate per-SESSION, never
|
||||
// per-request — a stray request_id must not be adopted.
|
||||
requestId = null,
|
||||
text = listOfNotNull(payload.string("command"), payload.string("description"))
|
||||
.joinToString(" — ")
|
||||
.ifBlank { "a command approval" },
|
||||
timeoutSeconds = 0,
|
||||
),
|
||||
)
|
||||
|
||||
"sudo.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SUDO,
|
||||
requestId = payload.string("request_id"),
|
||||
// Payload carries request_id ONLY — no command to show.
|
||||
text = "Elevated permissions requested",
|
||||
timeoutSeconds = SUDO_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
"secret.request" -> callbacks.onInteractionRequest(
|
||||
GatewayAsk(
|
||||
kind = GatewayAsk.Kind.SECRET,
|
||||
requestId = payload.string("request_id"),
|
||||
text = payload.string("prompt") ?: "The agent needs a secret value",
|
||||
envVar = payload.string("env_var"),
|
||||
timeoutSeconds = SECRET_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
// Known-but-unrendered (notification.show, status.update, …) and
|
||||
// unknown types alike: ignore.
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun syntheticToolId(name: String): String {
|
||||
val id = "gateway-tool-$name-${syntheticToolCounter++}"
|
||||
openSyntheticIdsByName.getOrPut(name) { ArrayDeque() }.addLast(id)
|
||||
return id
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* `message.complete.usage` uses tui_gateway's own key names
|
||||
* (`input`/`output`/`total`, with `prompt`/`completion` as the raw
|
||||
* counterparts — see upstream `_get_usage()`), NOT the
|
||||
* `input_tokens`/`prompt_tokens` schemes [UsageInfo] decodes from the
|
||||
* SSE paths. Translate explicitly. Values are session-cumulative.
|
||||
*
|
||||
* The context-window block (`context_used`/`context_max`/
|
||||
* `context_percent`) exists only when the server's context compressor
|
||||
* is active — absent fields stay null and the meter stays hidden.
|
||||
*/
|
||||
fun parseGatewayUsage(usage: JsonObject?): UsageInfo? {
|
||||
if (usage == null) return null
|
||||
val input = usage.int("input") ?: usage.int("prompt")
|
||||
val output = usage.int("output") ?: usage.int("completion")
|
||||
val total = usage.int("total")
|
||||
val contextUsed = usage.int("context_used")
|
||||
val contextMax = usage.int("context_max")
|
||||
val contextPercent = usage.int("context_percent")
|
||||
if (input == null && output == null && total == null &&
|
||||
contextUsed == null && contextMax == null && contextPercent == null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return UsageInfo(
|
||||
inputTokens = input,
|
||||
outputTokens = output,
|
||||
totalTokens = total,
|
||||
contextUsed = contextUsed,
|
||||
contextMax = contextMax,
|
||||
contextPercent = contextPercent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upstream `_block()` timeouts per ask kind (server.py) — the blocked thread
|
||||
// resolves to "" when these elapse. Approval has none (session-scoped).
|
||||
private const val CLARIFY_TIMEOUT_SECONDS = 300
|
||||
private const val SUDO_TIMEOUT_SECONDS = 120
|
||||
private const val SECRET_TIMEOUT_SECONDS = 300
|
||||
|
||||
private fun JsonObject?.string(key: String): String? =
|
||||
(this?.get(key) as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun JsonObject?.int(key: String): Int? =
|
||||
(this?.get(key) as? JsonPrimitive)?.intOrNull
|
||||
|
||||
private fun JsonObject?.double(key: String): Double? =
|
||||
(this?.get(key) as? JsonPrimitive)?.doubleOrNull
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.setGatewayKeepAlive
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Opt-in foreground service that keeps the app process alive so the gateway
|
||||
* chat WebSocket (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
|
||||
* [GatewayChatClient]) survives Android's background-freeze / Doze — i.e.
|
||||
* "keep connected in the background".
|
||||
*
|
||||
* # Both flavors (Play declaration required)
|
||||
*
|
||||
* Declared in the MAIN manifest (unlike the device-control
|
||||
* [com.hermesandroid.relay.bridge.BridgeForegroundService], which is sideload
|
||||
* only), so googlePlay ships it too — the Home-Assistant-class persistent-
|
||||
* connection use case Google Play permits. The `specialUse` type is honest for
|
||||
* an always-on connection (`dataSync` is force-stopped after a 6h/day cap on
|
||||
* SDK 35) but requires a one-time Play Console foreground-service declaration
|
||||
* at submission. Off by default; only runs while the user enables the toggle.
|
||||
*
|
||||
* # It does NOT own the socket
|
||||
*
|
||||
* The service's only job is to hold the process in the foreground. The socket
|
||||
* stays open because [GatewayChatClient.setKeepAliveInBackground] stops its
|
||||
* idle-close timer while the toggle is on. On task removal (user swipes the app
|
||||
* away) the ViewModel + socket die with the process, so the service stops
|
||||
* itself rather than leave a notification that lies about being connected.
|
||||
*
|
||||
* # Android 15 watchdog
|
||||
*
|
||||
* On target SDK 35 any intent to a service that declares a foregroundServiceType
|
||||
* must call `startForeground` within 5s — so [onStartCommand] always does that
|
||||
* first, before branching on the action. Shutdown goes through [stop]
|
||||
* (`stopService`) to bypass [onStartCommand] entirely.
|
||||
*/
|
||||
class GatewayKeepAliveService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "GatewayKeepAliveSvc"
|
||||
const val CHANNEL_ID = "gateway_keepalive"
|
||||
private const val CHANNEL_NAME = "Background connection"
|
||||
const val NOTIFICATION_ID = 4713
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context.applicationContext, GatewayKeepAliveService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.applicationContext.startForegroundService(intent)
|
||||
} else {
|
||||
context.applicationContext.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
// stopService() bypasses onStartCommand, so a "please shut down"
|
||||
// never trips the Android 15 foreground-start watchdog.
|
||||
context.applicationContext.stopService(
|
||||
Intent(context.applicationContext, GatewayKeepAliveService::class.java),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForegroundNotification()
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "ACTION_STOP → user dismissed background connection")
|
||||
// Flip the pref off so ConnectionViewModel's collector won't
|
||||
// restart us on the next foreground.
|
||||
scope.launch { runCatching { applicationContext.setGatewayKeepAlive(false) } }
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
// The socket lives in the ViewModel, which dies when the task is
|
||||
// removed — keeping the notification would be a lie. Stop cleanly.
|
||||
Log.i(TAG, "onTaskRemoved → app swiped away; stopping keep-alive")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
// The service + specialUse type + FOREGROUND_SERVICE_SPECIAL_USE permission
|
||||
// are all declared in the main manifest (both flavors), so the type is
|
||||
// satisfied. Suppress retained defensively — lint's ForegroundServiceType
|
||||
// check is finicky about correlating the runtime type arg with the manifest.
|
||||
@SuppressLint("ForegroundServiceType")
|
||||
private fun startForegroundNotification() {
|
||||
ensureChannel()
|
||||
val notification = buildNotification()
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "startForeground failed — stopping keep-alive", t)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): android.app.Notification {
|
||||
val tapIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val tapPending = PendingIntent.getActivity(this, 0, tapIntent, pendingFlags)
|
||||
|
||||
val stopIntent = Intent(this, GatewayKeepAliveService::class.java).setAction(ACTION_STOP)
|
||||
val stopPending = PendingIntent.getService(this, 1, stopIntent, pendingFlags)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes stays connected")
|
||||
.setContentText("Keeping your chat connection warm in the background.")
|
||||
.setContentIntent(tapPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.addAction(0, "Disconnect", stopPending)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description =
|
||||
"Persistent indicator while Hermes keeps your chat connection open in the background."
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
|
||||
/**
|
||||
* Shared types for the Gateway chat transport — upstream hermes-agent's
|
||||
* `tui_gateway` JSON-RPC-over-WebSocket surface at the dashboard's `/api/ws`.
|
||||
*
|
||||
* This is the same wire protocol the official hermes-desktop client and the
|
||||
* Ink TUI speak (reference shapes vendored in `desktop/src/gatewayTypes.ts`).
|
||||
* It is the only upstream surface that streams reasoning live
|
||||
* (`reasoning.delta` / `thinking.delta`) — the api_server SSE paths only
|
||||
* deliver reasoning after generation completes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Why the Gateway chat transport is or isn't usable right now. Mirrors
|
||||
* [com.hermesandroid.relay.viewmodel.StandardVoiceAvailability] — both ride
|
||||
* the dashboard surface and share the same probe — minus the audio-route
|
||||
* requirement (`/api/ws` ships with every embedded-chat dashboard build).
|
||||
*/
|
||||
enum class GatewayAvailability {
|
||||
/** No probe has completed yet (startup, connection switch). */
|
||||
Unknown,
|
||||
|
||||
/** Dashboard reachable and authenticated (or auth not required). */
|
||||
Ready,
|
||||
|
||||
/** Dashboard reachable and gated, but no signed-in session — Manage sign-in unlocks it. */
|
||||
SignInRequired,
|
||||
|
||||
/** Dashboard URL configured but `/api/status` did not answer. */
|
||||
Unreachable,
|
||||
|
||||
/**
|
||||
* Runtime sticky downgrade: the WS upgrade or ticket mint was rejected in
|
||||
* a way that says this server build has no usable `/api/ws` (404 on the
|
||||
* route, dashboard build predating the embedded chat). Cleared on
|
||||
* connection switch / fresh probe cycle.
|
||||
*/
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/** Lifecycle of the gateway WebSocket, exposed for diagnostics. */
|
||||
enum class GatewayConnectionState {
|
||||
Idle,
|
||||
MintingTicket,
|
||||
Connecting,
|
||||
AwaitingReady,
|
||||
Ready,
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming-endpoint resolution with the gateway tier — pure so the matrix
|
||||
* is unit-testable without an AndroidViewModel. ConnectionViewModel
|
||||
* delegates here with its live state.
|
||||
*
|
||||
* Manual picks pass through untouched (ChatViewModel handles per-turn
|
||||
* fallback when a "gateway" pick can't serve a send); "auto" prefers the
|
||||
* gateway only when the dashboard probe says [GatewayAvailability.Ready],
|
||||
* otherwise it falls back to the capability-preferred SSE endpoint.
|
||||
*/
|
||||
fun resolveStreamingEndpointPreference(
|
||||
preference: String,
|
||||
gateway: GatewayAvailability,
|
||||
capabilities: ServerCapabilities,
|
||||
): String = when (preference) {
|
||||
"sessions", "completions", "runs", "gateway" -> preference
|
||||
else -> if (gateway == GatewayAvailability.Ready) {
|
||||
"gateway"
|
||||
} else {
|
||||
capabilities.preferredChatEndpoint()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellable handle for one in-flight chat turn, regardless of transport.
|
||||
* SSE turns wrap their [okhttp3.sse.EventSource]; gateway turns wrap a
|
||||
* `session.interrupt` dispatch. Replaces the raw `EventSource?` field in
|
||||
* ChatViewModel so both transports share the cancel/teardown sites.
|
||||
*/
|
||||
fun interface ActiveTurnHandle {
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* One server-side interactive ask. The agent thread upstream is BLOCKED
|
||||
* until the matching respond RPC arrives, the ask times out (resolves to ""
|
||||
* server-side), or the turn is cancelled (`session.interrupt` force-releases
|
||||
* pending asks and force-denies approvals). Built by [GatewayEventMapper]
|
||||
* from the four `*.request` events; answered via the
|
||||
* [GatewayChatClient] `respond*` helpers.
|
||||
*/
|
||||
data class GatewayAsk(
|
||||
val kind: Kind,
|
||||
/**
|
||||
* Correlates the answer with the blocked server thread. Null ONLY for
|
||||
* [Kind.APPROVAL] — upstream approvals correlate per-session, not
|
||||
* per-request (`approval.respond` carries `session_id` instead).
|
||||
*/
|
||||
val requestId: String?,
|
||||
/** Question / command / prompt — whatever the ask wants the user to read. */
|
||||
val text: String,
|
||||
/** Clarify-only: server-suggested answers. */
|
||||
val choices: List<String>? = null,
|
||||
/** Secret-only: the env var the value will be stored under. */
|
||||
val envVar: String? = null,
|
||||
/**
|
||||
* Upstream blocking timeout (clarify/secret 300s, sudo 120s). 0 means no
|
||||
* countdown — approvals are session-scoped and never expire on their own.
|
||||
*/
|
||||
val timeoutSeconds: Int,
|
||||
) {
|
||||
enum class Kind { CLARIFY, APPROVAL, SUDO, SECRET }
|
||||
}
|
||||
|
||||
/**
|
||||
* One `subagent.*` lifecycle event, emitted on the PARENT session. Lifecycle
|
||||
* per task: START → (THINKING | TOOL | PROGRESS)* → COMPLETE. Field
|
||||
* availability varies by phase — [toolName]/[preview] ride TOOL,
|
||||
* [status]/[summary]/[durationSeconds] ride COMPLETE — and older emitters
|
||||
* omit everything beyond the three defaults-bearing fields.
|
||||
*/
|
||||
data class GatewaySubagentEvent(
|
||||
val phase: Phase,
|
||||
val taskIndex: Int,
|
||||
val taskCount: Int,
|
||||
val goal: String,
|
||||
val status: String? = null,
|
||||
val summary: String? = null,
|
||||
val toolName: String? = null,
|
||||
val preview: String? = null,
|
||||
val durationSeconds: Double? = null,
|
||||
) {
|
||||
enum class Phase { START, THINKING, TOOL, PROGRESS, COMPLETE }
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider from the gateway `model.options` RPC — the curated, authenticated
|
||||
* provider/model list the upstream desktop + TUI model picker uses (NOT the
|
||||
* api_server `/v1/models`, which collapses to a single generic agent alias).
|
||||
*/
|
||||
data class GatewayModelProvider(
|
||||
val name: String,
|
||||
val slug: String,
|
||||
val models: List<String>,
|
||||
val isCurrent: Boolean,
|
||||
val warning: String?,
|
||||
)
|
||||
|
||||
/** Result of the gateway `model.options` RPC. */
|
||||
data class GatewayModelOptions(
|
||||
val providers: List<GatewayModelProvider>,
|
||||
val currentModel: String,
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Callback set for one gateway turn. Shapes intentionally mirror the SSE
|
||||
* callback lambdas in ChatViewModel.startStream() so the gateway branch can
|
||||
* forward to the exact same ChatHandler mutations.
|
||||
*
|
||||
* Every member is a REQUIRED constructor param on purpose: GatewayChatClient
|
||||
* `dispatchOn` must wrap each one onto the main thread, and a defaulted
|
||||
* member would compile unwrapped — running on the OkHttp reader thread.
|
||||
*/
|
||||
class GatewayTurnCallbacks(
|
||||
/** Stored (DB) session id — fired on session create/rotate so the drawer + persistence stay correct. */
|
||||
val onSessionId: (String) -> Unit,
|
||||
val onTextDelta: (String) -> Unit,
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
|
||||
val onToolCallFailed: (toolCallId: String, errorMsg: String?) -> Unit,
|
||||
val onTurnComplete: () -> Unit,
|
||||
val onComplete: () -> Unit,
|
||||
val onUsage: (UsageInfo?) -> Unit,
|
||||
val onError: (String) -> Unit,
|
||||
/**
|
||||
* `tool.generating` — the model is still writing this tool's arguments.
|
||||
* Carries the tool name when upstream sent one. The next `tool.start`
|
||||
* for the same name adopts the "preparing" placeholder (per name, FIFO).
|
||||
*/
|
||||
val onToolGenerating: (toolName: String?) -> Unit,
|
||||
/** `subagent.*` lifecycle on the parent session — feeds the subagent lanes. */
|
||||
val onSubagentEvent: (GatewaySubagentEvent) -> Unit,
|
||||
/**
|
||||
* Server-side interactive ask (clarify/approval/sudo/secret) that blocks
|
||||
* the turn until answered via the matching respond RPC or the turn is
|
||||
* cancelled.
|
||||
*/
|
||||
val onInteractionRequest: (GatewayAsk) -> Unit,
|
||||
)
|
||||
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.network.models.SessionResponse
|
||||
import com.hermesandroid.relay.network.models.SkillInfo
|
||||
import com.hermesandroid.relay.network.models.SkillListResponse
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
import com.hermesandroid.relay.util.TurnLatencyTracer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
@@ -416,6 +417,32 @@ class HermesApiClient(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// --- Available models ---
|
||||
|
||||
/**
|
||||
* Available model ids from `GET /v1/models` (OpenAI-compatible:
|
||||
* `{"object":"list","data":[{"id":"…"}]}`). Backs the in-chat model
|
||||
* picker. Returns ids in server order; empty on any failure (the picker
|
||||
* then offers only "Server default").
|
||||
*/
|
||||
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = authRequest("$baseUrl/v1/models").get().build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) return@withContext emptyList()
|
||||
val body = response.body?.string() ?: return@withContext emptyList()
|
||||
val data = (json.parseToJsonElement(body) as? JsonObject)
|
||||
?.get("data") as? JsonArray ?: return@withContext emptyList()
|
||||
data.mapNotNull {
|
||||
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch models: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server personalities ---
|
||||
|
||||
/**
|
||||
@@ -556,6 +583,8 @@ class HermesApiClient(
|
||||
.build()
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("sessions")
|
||||
|
||||
// Notify caller of the session ID being used
|
||||
mainHandler.post { onSessionId(sessionId) }
|
||||
@@ -567,6 +596,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
@@ -577,6 +607,14 @@ class HermesApiClient(
|
||||
try {
|
||||
val event = json.decodeFromString<HermesSseEvent>(data)
|
||||
|
||||
// First visible streamed token (reasoning OR text) — the
|
||||
// metric that exposes SSE's reasoning dead-air vs gateway.
|
||||
if (!event.delta.isNullOrEmpty() || !event.thinkingDelta.isNullOrEmpty() ||
|
||||
!event.thinking.isNullOrEmpty()
|
||||
) {
|
||||
tracer.mark("ttft")
|
||||
}
|
||||
|
||||
// Check for usage data on ANY event before type resolution
|
||||
// (OpenAI-format chunks have no type/event field but may carry usage)
|
||||
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
|
||||
@@ -721,6 +759,7 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
@@ -734,6 +773,7 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
@@ -796,6 +836,8 @@ class HermesApiClient(
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val messageStarted = AtomicBoolean(false)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("completions")
|
||||
|
||||
val listener = object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
@@ -804,6 +846,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
@@ -832,12 +875,14 @@ class HermesApiClient(
|
||||
|
||||
openAiReasoningDelta(event)?.let { reasoning ->
|
||||
if (reasoning.isNotEmpty()) {
|
||||
tracer.mark("ttft")
|
||||
mainHandler.post { onThinkingDelta(reasoning) }
|
||||
}
|
||||
}
|
||||
|
||||
openAiTextDelta(event)?.let { delta ->
|
||||
if (delta.isNotEmpty()) {
|
||||
tracer.mark("ttft")
|
||||
mainHandler.post { onTextDelta(delta) }
|
||||
}
|
||||
}
|
||||
@@ -856,6 +901,7 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
@@ -869,6 +915,7 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
@@ -977,6 +1024,8 @@ class HermesApiClient(
|
||||
.build()
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
val tracer = TurnLatencyTracer("runs")
|
||||
|
||||
val listener = object : EventSourceListener() {
|
||||
override fun onEvent(
|
||||
@@ -985,6 +1034,7 @@ class HermesApiClient(
|
||||
type: String?,
|
||||
data: String
|
||||
) {
|
||||
tracer.mark("ttfe")
|
||||
if (data == "[DONE]") {
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
@@ -995,6 +1045,14 @@ class HermesApiClient(
|
||||
try {
|
||||
val event = json.decodeFromString<HermesSseEvent>(data)
|
||||
|
||||
// First visible streamed token (reasoning OR text) — the
|
||||
// metric that exposes SSE's reasoning dead-air vs gateway.
|
||||
if (!event.delta.isNullOrEmpty() || !event.thinkingDelta.isNullOrEmpty() ||
|
||||
!event.thinking.isNullOrEmpty()
|
||||
) {
|
||||
tracer.mark("ttft")
|
||||
}
|
||||
|
||||
// Check for usage data before type resolution (catches OpenAI-format chunks)
|
||||
if (event.usage != null && (event.usage.resolvedInputTokens != null || event.usage.resolvedOutputTokens != null)) {
|
||||
mainHandler.post { onUsage(event.usage) }
|
||||
@@ -1055,6 +1113,7 @@ class HermesApiClient(
|
||||
"reasoning.available" -> {
|
||||
val reasoningText = event.text
|
||||
if (!reasoningText.isNullOrEmpty()) {
|
||||
tracer.mark("ttft")
|
||||
mainHandler.post { onThinkingDelta(reasoningText) }
|
||||
}
|
||||
}
|
||||
@@ -1141,6 +1200,7 @@ class HermesApiClient(
|
||||
t: Throwable?,
|
||||
response: Response?
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
@@ -1154,6 +1214,7 @@ class HermesApiClient(
|
||||
}
|
||||
|
||||
override fun onClosed(eventSource: EventSource) {
|
||||
tracer.done()
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
mainHandler.post { onComplete() }
|
||||
}
|
||||
|
||||
@@ -538,7 +538,7 @@ class BridgeCommandHandler(
|
||||
put(
|
||||
"error",
|
||||
"Device Control is not included in the Google Play build " +
|
||||
"of Hermes Relay. This build keeps Hermes Bridge Core " +
|
||||
"of Hermes-Relay. This build keeps Hermes Bridge Core " +
|
||||
"features such as chat, voice, terminal, media, " +
|
||||
"notifications, and relay status, but it does not " +
|
||||
"ship AccessibilityService, screen reading, taps, " +
|
||||
@@ -562,7 +562,7 @@ class BridgeCommandHandler(
|
||||
"Hermes accessibility service is not enabled. " +
|
||||
"The phone IS paired and connected — this is " +
|
||||
"NOT a pairing problem. The user must enable " +
|
||||
"the Hermes Relay accessibility service in " +
|
||||
"the Hermes-Relay accessibility service in " +
|
||||
"Android Settings > Accessibility > " +
|
||||
"Installed services before the bridge can " +
|
||||
"dispatch phone-control commands.",
|
||||
@@ -570,7 +570,7 @@ class BridgeCommandHandler(
|
||||
put("error_code", "service_unavailable")
|
||||
put(
|
||||
"required_action",
|
||||
"User enables Hermes Relay in Android Accessibility Settings",
|
||||
"User enables Hermes-Relay in Android Accessibility Settings",
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -813,7 +813,7 @@ class BridgeCommandHandler(
|
||||
}
|
||||
|
||||
// === PHASE3-return-to-hermes ===
|
||||
// Bring the Hermes Relay app back to foreground. Used by the
|
||||
// Bring the Hermes-Relay app back to foreground. Used by the
|
||||
// server-side agent as the final step of any multi-app task
|
||||
// (e.g. after driving Messages to send an SMS) so the user
|
||||
// sees the agent's reply in-context without manually switching
|
||||
@@ -1219,7 +1219,7 @@ class BridgeCommandHandler(
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "android_location is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
|
||||
put("error", "android_location is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
@@ -1234,7 +1234,7 @@ class BridgeCommandHandler(
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "android_search_contacts is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
|
||||
put("error", "android_search_contacts is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
@@ -1258,7 +1258,7 @@ class BridgeCommandHandler(
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "android_call auto-dial is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
|
||||
put("error", "android_call auto-dial is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
@@ -1312,7 +1312,7 @@ class BridgeCommandHandler(
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "android_send_sms is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
|
||||
put("error", "android_send_sms is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
@@ -1370,7 +1370,7 @@ class BridgeCommandHandler(
|
||||
respond(
|
||||
requestId, 403,
|
||||
buildJsonObject {
|
||||
put("error", "$path is only available on the sideload flavor of Hermes Relay. This build is googlePlay.")
|
||||
put("error", "$path is only available on the sideload flavor of Hermes-Relay. This build is googlePlay.")
|
||||
put("error_code", "sideload_only")
|
||||
put("flavor", "googlePlay")
|
||||
}
|
||||
@@ -1411,9 +1411,9 @@ class BridgeCommandHandler(
|
||||
val target = if (to.isBlank()) "the selected recipient" else to
|
||||
"Send MMS compose to $target with $attachmentCount attachment(s)?"
|
||||
} else if (attachmentCount > 0) {
|
||||
"Share $attachmentCount attachment(s) from Hermes Relay?"
|
||||
"Share $attachmentCount attachment(s) from Hermes-Relay?"
|
||||
} else {
|
||||
"Share text from Hermes Relay?"
|
||||
"Share text from Hermes-Relay?"
|
||||
}
|
||||
val allowed = safetyManager.awaitConfirmation(path, confirmText)
|
||||
if (!allowed) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.network.GatewaySubagentEvent
|
||||
import com.hermesandroid.relay.network.models.MessageItem
|
||||
import com.hermesandroid.relay.network.models.SessionItem
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -33,7 +34,7 @@ class ChatHandler {
|
||||
private const val TAG = "ChatHandler"
|
||||
|
||||
/** Maximum number of messages kept in memory per session. Oldest are trimmed. */
|
||||
private const val MAX_MESSAGES = 500
|
||||
internal const val MAX_MESSAGES = 500
|
||||
|
||||
// Tool annotation patterns embedded as text markers by Hermes.
|
||||
//
|
||||
@@ -200,6 +201,58 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a SYSTEM-role notice bubble (e.g. a gateway interactive ask the
|
||||
* phone can't answer). SYSTEM role keeps it out of the voice TTS observer
|
||||
* and renders with the muted system styling in MessageBubble.
|
||||
*/
|
||||
fun addSystemNotice(text: String) {
|
||||
_messages.update { list ->
|
||||
val notice = ChatMessage(
|
||||
id = "system-notice-${java.util.UUID.randomUUID()}",
|
||||
role = MessageRole.SYSTEM,
|
||||
content = text,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
(list + notice).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an assistant message that carries ONLY a gateway ask card
|
||||
* (clarify / approval / sudo / secret). Local-only — the server never
|
||||
* stores the ask as a message, so [loadMessageHistory] preserves the
|
||||
* `ask-` id prefix the same way it preserves voice-intent traces.
|
||||
* Idempotent on [messageId] so a re-emitted ask never duplicates.
|
||||
*/
|
||||
fun appendAskCardMessage(messageId: String, card: HermesCard) {
|
||||
_messages.update { list ->
|
||||
if (list.any { it.id == messageId }) return@update list
|
||||
val msg = ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
cards = listOf(card),
|
||||
agentName = activeAgentName,
|
||||
)
|
||||
(list + msg).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit-and-regenerate local truncation: drop [messageId] and everything
|
||||
* after it. The gateway performs the authoritative truncation via
|
||||
* `truncate_before_user_ordinal`; this keeps the visible list consistent
|
||||
* until the post-turn history reload reconciles any divergence.
|
||||
*/
|
||||
fun truncateMessagesFrom(messageId: String) {
|
||||
_messages.update { list ->
|
||||
val idx = list.indexOfFirst { it.id == messageId }
|
||||
if (idx < 0) list else list.take(idx)
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceMessageContent(messageId: String, content: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { message ->
|
||||
@@ -594,6 +647,7 @@ class ChatHandler {
|
||||
activeAnnotationTools.clear()
|
||||
cardLineBuffer.clear()
|
||||
dispatchedCardMarkers.clear()
|
||||
subagentLabels.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -735,6 +789,14 @@ class ChatHandler {
|
||||
toolCalls = toolCalls,
|
||||
cards = extractedCards,
|
||||
agentName = if (role == MessageRole.ASSISTANT) activeAgentName else null,
|
||||
// Server persists per-message reasoning — restore it so the
|
||||
// Thought-process block survives returning to the chat
|
||||
// instead of existing only for the live turn.
|
||||
thinkingContent = if (role == MessageRole.ASSISTANT) {
|
||||
item.resolvedReasoning?.trim() ?: ""
|
||||
} else {
|
||||
""
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -755,8 +817,15 @@ class ChatHandler {
|
||||
// sync (so these traces reach the LLM's session memory too) is
|
||||
// still a v0.4.1 follow-up, but preserving them client-side is
|
||||
// enough to fix the disappearing-scrollback bug today.
|
||||
// Gateway-local bubbles ride the same preservation: steered text
|
||||
// (id "steer-…") lives inside a server-side tool result, never as a
|
||||
// user message, and ask cards (id "ask-…") are built from gateway
|
||||
// events that have no server-side message at all — a wholesale
|
||||
// reload would silently erase both.
|
||||
val preservedVoiceTraces = _messages.value.filter {
|
||||
it.id.startsWith("voice-intent-")
|
||||
it.id.startsWith("voice-intent-") ||
|
||||
it.id.startsWith("steer-") ||
|
||||
it.id.startsWith("ask-")
|
||||
}
|
||||
val merged = if (preservedVoiceTraces.isEmpty()) {
|
||||
loaded
|
||||
@@ -933,7 +1002,7 @@ class ChatHandler {
|
||||
* Update sessions list from API response.
|
||||
*/
|
||||
fun updateSessions(items: List<SessionItem>) {
|
||||
_sessions.value = items.map { item ->
|
||||
val mapped = items.map { item ->
|
||||
// If > 1e12, already in milliseconds; otherwise convert from seconds
|
||||
val ts = item.startedAt ?: 0.0
|
||||
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
|
||||
@@ -945,6 +1014,20 @@ class ChatHandler {
|
||||
updatedAt = timestampMs
|
||||
)
|
||||
}
|
||||
// Preserve the active session's optimistic row when the server list
|
||||
// doesn't include it yet: a freshly created chat has 0 messages and the
|
||||
// drawer's `min_messages=1` query filters it out until its first turn
|
||||
// persists. Keeping the local row (its title/preview is already set)
|
||||
// stops a new chat from vanishing from the drawer between creation and
|
||||
// the first message. Once it has messages the server returns it and the
|
||||
// id-match below replaces the optimistic copy.
|
||||
val activeId = _currentSessionId.value
|
||||
val pending = if (activeId != null && mapped.none { it.sessionId == activeId }) {
|
||||
_sessions.value.firstOrNull { it.sessionId == activeId }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
_sessions.value = if (pending != null) listOf(pending) + mapped else mapped
|
||||
}
|
||||
|
||||
fun clearSessions() {
|
||||
@@ -1616,26 +1699,29 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallStart(
|
||||
messageId: String,
|
||||
toolCallId: String,
|
||||
toolName: String,
|
||||
runId: String? = null,
|
||||
provenance: String? = null,
|
||||
) {
|
||||
_isStreaming.value = true
|
||||
/** Monotonic suffix for synthetic generating / subagent ToolCall ids. */
|
||||
private var syntheticToolSeq = 0
|
||||
|
||||
val toolCall = ToolCall(
|
||||
id = toolCallId,
|
||||
name = toolName,
|
||||
/**
|
||||
* Gateway `tool.generating` — the model is still streaming this tool's
|
||||
* arguments. Appends a quiet "preparing" placeholder ToolCall; the
|
||||
* matching `tool.start` (same name, FIFO — see [onToolCallStart])
|
||||
* adopts it so the preparing card and the running card stay one entry.
|
||||
* Nameless events get a blank-name placeholder that the next start
|
||||
* adopts as a fallback; any never-adopted placeholders are swept in
|
||||
* [onStreamComplete].
|
||||
*/
|
||||
fun onToolGenerating(messageId: String, toolName: String?) {
|
||||
_isStreaming.value = true
|
||||
val placeholder = ToolCall(
|
||||
id = "generating-${toolName ?: "tool"}-${syntheticToolSeq++}",
|
||||
name = toolName ?: "",
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false,
|
||||
runId = runId,
|
||||
provenance = provenance,
|
||||
isGenerating = true,
|
||||
)
|
||||
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
@@ -1643,7 +1729,7 @@ class ChatHandler {
|
||||
if (target != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
msg.copy(toolCalls = msg.toolCalls + toolCall)
|
||||
msg.copy(toolCalls = msg.toolCalls + placeholder)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
@@ -1655,13 +1741,214 @@ class ChatHandler {
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
toolCalls = listOf(toolCall),
|
||||
toolCalls = listOf(placeholder),
|
||||
agentName = activeAgentName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallStart(
|
||||
messageId: String,
|
||||
toolCallId: String,
|
||||
toolName: String,
|
||||
runId: String? = null,
|
||||
provenance: String? = null,
|
||||
) {
|
||||
_isStreaming.value = true
|
||||
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
// Adopt a pending "preparing" placeholder for this name
|
||||
// (or, failing that, the oldest nameless one) so the
|
||||
// generating card flips to running in place instead of a
|
||||
// second card appearing.
|
||||
val genIdx = target.toolCalls
|
||||
.indexOfFirst { it.isGenerating && !it.isComplete && it.name == toolName }
|
||||
.takeIf { it >= 0 }
|
||||
?: target.toolCalls
|
||||
.indexOfFirst { it.isGenerating && !it.isComplete && it.name.isEmpty() }
|
||||
.takeIf { it >= 0 }
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
if (genIdx != null) {
|
||||
val calls = msg.toolCalls.toMutableList()
|
||||
calls[genIdx] = calls[genIdx].copy(
|
||||
id = toolCallId,
|
||||
name = toolName,
|
||||
isGenerating = false,
|
||||
// Execution starts now — preparing time isn't runtime.
|
||||
startedAt = System.currentTimeMillis(),
|
||||
runId = runId ?: calls[genIdx].runId,
|
||||
provenance = provenance ?: calls[genIdx].provenance,
|
||||
)
|
||||
msg.copy(toolCalls = calls)
|
||||
} else {
|
||||
msg.copy(
|
||||
toolCalls = msg.toolCalls + ToolCall(
|
||||
id = toolCallId,
|
||||
name = toolName,
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false,
|
||||
runId = runId,
|
||||
provenance = provenance,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
messages + ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
toolCalls = listOf(
|
||||
ToolCall(
|
||||
id = toolCallId,
|
||||
name = toolName,
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false,
|
||||
runId = runId,
|
||||
provenance = provenance,
|
||||
),
|
||||
),
|
||||
agentName = activeAgentName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gateway subagent lanes ---
|
||||
|
||||
/**
|
||||
* Lane labels by task index, captured from `subagent.start` (goal
|
||||
* truncated to 60 chars) and stamped onto every child ToolCall so
|
||||
* [com.hermesandroid.relay.ui.components.SubagentLane] can render its
|
||||
* header without a separate registry. Per-run state — cleared on
|
||||
* [onStreamComplete] / [clearMessages].
|
||||
*/
|
||||
private val subagentLabels = mutableMapOf<Int, String>()
|
||||
|
||||
/**
|
||||
* Apply one gateway `subagent.*` lifecycle event to the streaming
|
||||
* message's tool calls. Mutation model mirrors the upstream child
|
||||
* mirror: a TOOL event closes the lane's open tool then starts the new
|
||||
* one; COMPLETE closes whatever is still open and (for a lane that
|
||||
* never surfaced a tool) appends a single completed summary entry so
|
||||
* the lane is visible in history. THINKING/PROGRESS carry preview text
|
||||
* the lanes don't render — ignored.
|
||||
*/
|
||||
fun onSubagentEvent(messageId: String, event: GatewaySubagentEvent) {
|
||||
val label = event.goal.trim().take(60).ifBlank { null }
|
||||
when (event.phase) {
|
||||
GatewaySubagentEvent.Phase.START -> {
|
||||
if (label != null) subagentLabels[event.taskIndex] = label
|
||||
}
|
||||
|
||||
GatewaySubagentEvent.Phase.TOOL -> {
|
||||
_isStreaming.value = true
|
||||
val laneLabel = subagentLabels[event.taskIndex] ?: label
|
||||
val newCall = ToolCall(
|
||||
id = "subagent-${event.taskIndex}-${syntheticToolSeq++}",
|
||||
name = event.toolName?.takeIf { it.isNotBlank() } ?: "tool",
|
||||
args = event.preview,
|
||||
result = null,
|
||||
success = null,
|
||||
isComplete = false,
|
||||
taskIndex = event.taskIndex,
|
||||
taskLabel = laneLabel,
|
||||
)
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
val closed = msg.toolCalls.map { call ->
|
||||
if (call.taskIndex == event.taskIndex && !call.isComplete) {
|
||||
call.copy(
|
||||
success = true,
|
||||
isComplete = true,
|
||||
completedAt = System.currentTimeMillis(),
|
||||
)
|
||||
} else {
|
||||
call
|
||||
}
|
||||
}
|
||||
msg.copy(toolCalls = closed + newCall)
|
||||
}
|
||||
} else {
|
||||
messages + ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
toolCalls = listOf(newCall),
|
||||
agentName = activeAgentName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GatewaySubagentEvent.Phase.COMPLETE -> {
|
||||
// "interrupted" lanes never finished — not a success either.
|
||||
val failed = event.status == "failed" || event.status == "interrupted"
|
||||
val laneLabel = subagentLabels.remove(event.taskIndex) ?: label
|
||||
val summaryId = "subagent-${event.taskIndex}-${syntheticToolSeq++}"
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
val hasLaneCalls = msg.toolCalls.any { it.taskIndex == event.taskIndex }
|
||||
val closed = msg.toolCalls.map { call ->
|
||||
if (call.taskIndex == event.taskIndex && !call.isComplete) {
|
||||
call.copy(
|
||||
success = !failed,
|
||||
isComplete = true,
|
||||
result = event.summary ?: call.result,
|
||||
error = if (failed) (event.summary ?: event.status) else call.error,
|
||||
completedAt = System.currentTimeMillis(),
|
||||
)
|
||||
} else {
|
||||
call
|
||||
}
|
||||
}
|
||||
val withSummary = if (hasLaneCalls) {
|
||||
closed
|
||||
} else {
|
||||
closed + ToolCall(
|
||||
id = summaryId,
|
||||
name = laneLabel ?: "subagent",
|
||||
args = null,
|
||||
result = event.summary,
|
||||
success = !failed,
|
||||
isComplete = true,
|
||||
error = if (failed) (event.summary ?: event.status) else null,
|
||||
completedAt = System.currentTimeMillis(),
|
||||
taskIndex = event.taskIndex,
|
||||
taskLabel = laneLabel,
|
||||
)
|
||||
}
|
||||
msg.copy(toolCalls = withSummary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GatewaySubagentEvent.Phase.THINKING,
|
||||
GatewaySubagentEvent.Phase.PROGRESS,
|
||||
-> Unit
|
||||
}
|
||||
}
|
||||
|
||||
fun onToolCallComplete(
|
||||
messageId: String,
|
||||
toolCallId: String,
|
||||
@@ -1818,6 +2105,19 @@ class ChatHandler {
|
||||
// Finalize media markers unconditionally
|
||||
finalizeMediaMarkers(messageId)
|
||||
finalizeCardMarkers(messageId)
|
||||
|
||||
// Sweep "preparing" placeholders whose tool.start never arrived —
|
||||
// they never executed and would otherwise breathe forever.
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.toolCalls.none { it.isGenerating && !it.isComplete }) {
|
||||
msg
|
||||
} else {
|
||||
msg.copy(toolCalls = msg.toolCalls.filterNot { it.isGenerating && !it.isComplete })
|
||||
}
|
||||
}
|
||||
}
|
||||
subagentLabels.clear()
|
||||
}
|
||||
|
||||
fun onStreamError(message: String) {
|
||||
@@ -1831,6 +2131,19 @@ class ChatHandler {
|
||||
} else msg
|
||||
}
|
||||
}
|
||||
|
||||
// Same sweep as onStreamComplete — error/watchdog/transport-failure
|
||||
// turns must not leave "preparing" placeholders breathing forever.
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.toolCalls.none { it.isGenerating && !it.isComplete }) {
|
||||
msg
|
||||
} else {
|
||||
msg.copy(toolCalls = msg.toolCalls.filterNot { it.isGenerating && !it.isComplete })
|
||||
}
|
||||
}
|
||||
}
|
||||
subagentLabels.clear()
|
||||
}
|
||||
|
||||
fun onThinkingDelta(messageId: String, delta: String) {
|
||||
|
||||
@@ -147,8 +147,19 @@ data class MessageItem(
|
||||
@Serializable(with = FlexibleIdSerializer::class)
|
||||
val toolCallId: String? = null,
|
||||
val timestamp: Double? = null,
|
||||
@SerialName("finish_reason") val finishReason: String? = null
|
||||
@SerialName("finish_reason") val finishReason: String? = null,
|
||||
// Reasoning persisted with the assistant message (upstream serializes
|
||||
// both names; reasoning is the canonical one). Restored into
|
||||
// ChatMessage.thinkingContent so the Thought-process block survives a
|
||||
// return to the chat instead of existing only for the live turn.
|
||||
val reasoning: String? = null,
|
||||
@SerialName("reasoning_content") val reasoningContent: String? = null,
|
||||
) {
|
||||
/** Reasoning text under whichever field name the server used. */
|
||||
val resolvedReasoning: String?
|
||||
get() = reasoning?.takeIf { it.isNotBlank() }
|
||||
?: reasoningContent?.takeIf { it.isNotBlank() }
|
||||
|
||||
/** Extract content as plain text string. Handles both string and array-of-parts formats. */
|
||||
val contentText: String?
|
||||
get() = when (content) {
|
||||
@@ -276,7 +287,13 @@ data class UsageInfo(
|
||||
@SerialName("completion_tokens") val completionTokens: Int? = null,
|
||||
// Cache tokens
|
||||
@SerialName("cache_creation_input_tokens") val cacheCreationInputTokens: Int? = null,
|
||||
@SerialName("cache_read_input_tokens") val cacheReadInputTokens: Int? = null
|
||||
@SerialName("cache_read_input_tokens") val cacheReadInputTokens: Int? = null,
|
||||
// Gateway context-window block (session-cumulative; present only when the
|
||||
// server's context compressor is active — upstream _get_usage()). Render
|
||||
// context UI only when contextMax is non-null.
|
||||
@SerialName("context_used") val contextUsed: Int? = null,
|
||||
@SerialName("context_max") val contextMax: Int? = null,
|
||||
@SerialName("context_percent") val contextPercent: Int? = null
|
||||
) {
|
||||
/** Resolved input tokens — prefers Hermes naming, falls back to OpenAI. */
|
||||
val resolvedInputTokens: Int? get() = inputTokens ?: promptTokens
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.hermesandroid.relay.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* One-shot "Hermes finished responding" notification — posted from
|
||||
* ChatViewModel's turn-complete path when the app is backgrounded, never
|
||||
* for cancelled streams or errors. Structural twin of
|
||||
* [com.hermesandroid.relay.bridge.AutoDisableWorker]'s notification half:
|
||||
* same channel-ensure, permission-gate, and tap-intent anatomy.
|
||||
*
|
||||
* One stable slot ([NOTIFICATION_ID]) — chat is one stream, so the latest
|
||||
* turn replaces any prior notification rather than stacking noise. The
|
||||
* caller cancels it via [cancel] when the user returns to the app
|
||||
* (MainActivity onResume).
|
||||
*
|
||||
* Tap routes through the existing cross-layer deep-link path: the intent
|
||||
* carries [MainActivity.EXTRA_NAV_ROUTE] → MainActivity pumps it onto
|
||||
* NavRouteRequest → RelayApp's collector navigates.
|
||||
*/
|
||||
object TurnCompleteNotifier {
|
||||
|
||||
private const val TAG = "TurnCompleteNotifier"
|
||||
private const val CHANNEL_ID = "chat_turn_complete"
|
||||
private const val CHANNEL_NAME = "Hermes replies"
|
||||
const val NOTIFICATION_ID = 3822
|
||||
|
||||
/**
|
||||
* Compose nav route for the Chat tab. Hardcoded on purpose to avoid
|
||||
* pulling the ui.RelayApp graph into the notifications classpath — if
|
||||
* Screen.Chat.route() changes in RelayApp.kt, change it here too.
|
||||
* (Same convention as BridgeForegroundService's settings deep-link.)
|
||||
*/
|
||||
private const val CHAT_ROUTE = "chat"
|
||||
|
||||
/**
|
||||
* Post (or replace) the turn-complete notification.
|
||||
*
|
||||
* @param agentName Display name for the title; blank falls back to
|
||||
* "Hermes".
|
||||
* @param responseText Final assistant text — collapsed line is the
|
||||
* first 120 chars, BigTextStyle expands to 400.
|
||||
* @param toolCount Number of tool calls the turn ran; 0 hides the
|
||||
* subText line.
|
||||
* @param durationSeconds Wall-clock turn duration for the subText
|
||||
* ("3 tools · 42s"); null renders the count alone.
|
||||
*/
|
||||
// Lint can't trace through [hasPostNotificationsPermission] to see that
|
||||
// we early-return when the runtime grant isn't held, and the notify()
|
||||
// call is also wrapped in runCatching to swallow SecurityException as
|
||||
// a belt-and-braces. Suppress here rather than inlining the check —
|
||||
// the helper exists so the same gate can grow more conditions later
|
||||
// without each call site re-implementing it. Both IDs are needed:
|
||||
// `NotificationPermission` is the notify()-specific check (POST_NOTIFICATIONS
|
||||
// on API 33+); `MissingPermission` is the generic fallback.
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
fun notifyTurnComplete(
|
||||
context: Context,
|
||||
agentName: String?,
|
||||
responseText: String,
|
||||
toolCount: Int = 0,
|
||||
durationSeconds: Long? = null,
|
||||
) {
|
||||
ensureChannel(context)
|
||||
if (!hasPostNotificationsPermission(context)) {
|
||||
Log.i(TAG, "POST_NOTIFICATIONS not granted — skipping turn-complete notification")
|
||||
return
|
||||
}
|
||||
|
||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(MainActivity.EXTRA_NAV_ROUTE, CHAT_ROUTE)
|
||||
}
|
||||
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val tapPending = PendingIntent.getActivity(context, 0, tapIntent, pendingFlags)
|
||||
|
||||
val title = agentName?.takeIf { it.isNotBlank() } ?: "Hermes"
|
||||
val collapsed = responseText.take(120)
|
||||
val expanded = responseText.take(400)
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(collapsed)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(expanded))
|
||||
.setContentIntent(tapPending)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
|
||||
if (toolCount > 0) {
|
||||
val tools = "$toolCount tool${if (toolCount == 1) "" else "s"}"
|
||||
builder.setSubText(
|
||||
durationSeconds?.let { "$tools · ${it}s" } ?: tools
|
||||
)
|
||||
}
|
||||
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build())
|
||||
}.onFailure { Log.w(TAG, "notifyTurnComplete: notify failed", it) }
|
||||
}
|
||||
|
||||
/** Clear the slot — call from MainActivity.onResume so returning to the app dismisses it. */
|
||||
fun cancel(context: Context) {
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
|
||||
}.onFailure { Log.w(TAG, "cancel: failed", it) }
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
val existing = nm.getNotificationChannel(CHANNEL_ID)
|
||||
if (existing != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Notifies when Hermes finishes responding while the app is in the background."
|
||||
// Unlike bridge_auto_disable, a reply badge is desirable.
|
||||
setShowBadge(true)
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun hasPostNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -19,6 +21,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -64,7 +67,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.ConnectionStatusBanner
|
||||
import com.hermesandroid.relay.ui.components.ConnectionStatusToast
|
||||
import com.hermesandroid.relay.ui.components.ConnectionSwitcherSheet
|
||||
import com.hermesandroid.relay.ui.components.PowerFeatureGateScreen
|
||||
import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
|
||||
@@ -113,6 +116,9 @@ import com.hermesandroid.relay.ui.screens.prewarmDashboardManage
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.RelayProfileInspectorClient
|
||||
import com.hermesandroid.relay.network.AutoVoiceAudioClient
|
||||
import com.hermesandroid.relay.network.DynamicDashboardCookieJar
|
||||
@@ -538,46 +544,86 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
chatViewModel.observeConnectionSwitches(connectionViewModel.connectionSwitchEvents)
|
||||
}
|
||||
|
||||
LaunchedEffect(chatApiClient) {
|
||||
chatApiClient?.let { client ->
|
||||
chatViewModel.initialize(client, connectionViewModel.chatHandler)
|
||||
chatViewModel.updateApiClient(client)
|
||||
|
||||
// Wire inbound-media dependencies. Safe to call on every reinit —
|
||||
// idempotent rewire of the ChatHandler callbacks.
|
||||
chatViewModel.initializeMedia(
|
||||
context = mediaContext,
|
||||
relayHttpClient = connectionViewModel.relayHttpClient,
|
||||
mediaSettingsRepo = connectionViewModel.mediaSettingsRepo,
|
||||
mediaCacheWriter = connectionViewModel.mediaCacheWriter
|
||||
)
|
||||
|
||||
// Agent-profile pick provider (Pass 2). Lambda reads the latest
|
||||
// StateFlow value on every send, so ChatViewModel never needs a
|
||||
// direct reference to ConnectionViewModel. Safe to rewire on
|
||||
// every API-client swap — the lambda captures the long-lived
|
||||
// VM, not the (per-connection) apiClient.
|
||||
chatViewModel.setSelectedProfileProvider {
|
||||
connectionViewModel.selectedProfile.value
|
||||
}
|
||||
chatViewModel.setEffectiveProfileProvider {
|
||||
AgentDisplay.effectiveProfile(
|
||||
selectedProfile = connectionViewModel.selectedProfile.value,
|
||||
profiles = connectionViewModel.agentProfiles.value,
|
||||
)
|
||||
}
|
||||
|
||||
// Wire session persistence callback
|
||||
chatViewModel.onSessionChanged = { sessionId ->
|
||||
connectionViewModel.saveLastSessionId(sessionId)
|
||||
}
|
||||
// Mirror chat streaming state so a mid-turn route change defers its
|
||||
// client rebuild instead of cancelling the live turn (the gateway
|
||||
// socket rides the blip via its own reconnect).
|
||||
launch {
|
||||
chatViewModel.isStreaming.collect { connectionViewModel.setChatStreaming(it) }
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(chatApiClient, activeConnectionId, selectedProfile?.name, lastSessionId) {
|
||||
if (chatApiClient == null) return@LaunchedEffect
|
||||
LaunchedEffect(chatApiClient) {
|
||||
val client = chatApiClient ?: return@LaunchedEffect
|
||||
val handler = connectionViewModel.chatHandler
|
||||
// A route handoff / reconnect rebuilds the API client (new instance)
|
||||
// while the chat is unchanged — the bound handler is the same. Take the
|
||||
// cheap path: swap the client reference only, no re-init. This is what
|
||||
// keeps the chat surface from repainting/reloading on a LAN↔Tailscale
|
||||
// switch or a reconnect. A genuine re-bind (different handler) falls
|
||||
// through to the full one-time wiring below.
|
||||
if (chatViewModel.boundHandler === handler) {
|
||||
chatViewModel.updateApiClient(client)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
chatViewModel.initialize(client, handler)
|
||||
|
||||
// Wire inbound-media dependencies. Idempotent rewire of the
|
||||
// ChatHandler callbacks.
|
||||
chatViewModel.initializeMedia(
|
||||
context = mediaContext,
|
||||
relayHttpClient = connectionViewModel.relayHttpClient,
|
||||
mediaSettingsRepo = connectionViewModel.mediaSettingsRepo,
|
||||
mediaCacheWriter = connectionViewModel.mediaCacheWriter
|
||||
)
|
||||
|
||||
// Agent-profile pick provider (Pass 2). Lambda reads the latest
|
||||
// StateFlow value on every send, so ChatViewModel never needs a
|
||||
// direct reference to ConnectionViewModel. The lambda captures the
|
||||
// long-lived VM, not the (per-connection) apiClient.
|
||||
chatViewModel.setSelectedProfileProvider {
|
||||
connectionViewModel.selectedProfile.value
|
||||
}
|
||||
chatViewModel.setEffectiveProfileProvider {
|
||||
AgentDisplay.effectiveProfile(
|
||||
selectedProfile = connectionViewModel.selectedProfile.value,
|
||||
profiles = connectionViewModel.agentProfiles.value,
|
||||
)
|
||||
}
|
||||
|
||||
// Drawer session list, scoped to the active profile on gateway
|
||||
// connections (dashboard `/api/sessions?profile=`). Returns null off the
|
||||
// dashboard surface so refreshSessions() falls back to the shared list.
|
||||
chatViewModel.setProfileSessionLister {
|
||||
connectionViewModel.listProfileScopedSessions()
|
||||
}
|
||||
// …and load a tapped session's transcript from that same profile's DB.
|
||||
chatViewModel.setProfileMessageLoader { sessionId ->
|
||||
connectionViewModel.loadProfileScopedMessages(sessionId)
|
||||
}
|
||||
|
||||
// Wire session persistence callback
|
||||
chatViewModel.onSessionChanged = { sessionId ->
|
||||
connectionViewModel.saveLastSessionId(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload sessions / switch profile context only on a SEMANTIC change
|
||||
// (connection, profile, or restored session) — NOT on every API-client
|
||||
// instance swap. Keying on a readiness flag instead of the client instance
|
||||
// means a route handoff (which churns the client) no longer triggers a
|
||||
// refreshSessions() that would flash/reload the chat. `switchProfileContext`
|
||||
// already no-ops when the context key + session are unchanged.
|
||||
val chatClientReady = chatApiClient != null
|
||||
LaunchedEffect(chatClientReady, activeConnectionId, selectedProfile?.name, lastSessionId) {
|
||||
if (!chatClientReady) return@LaunchedEffect
|
||||
// Coalesce the rapid lastSessionId null→value churn a profile switch
|
||||
// produces: selectProfile() nulls lastSessionId, then the persisted
|
||||
// per-profile session resolves a tick later. This effect re-fires on that
|
||||
// change, cancelling the delay below before it commits — so we skip
|
||||
// painting the intermediate empty draft and land straight on the resolved
|
||||
// session (or a genuine fresh draft when the profile has no history).
|
||||
delay(160)
|
||||
chatViewModel.switchProfileContext(
|
||||
contextKey = AgentDisplay.profileContextKey(
|
||||
connectionId = activeConnectionId,
|
||||
@@ -617,6 +663,14 @@ fun RelayApp() {
|
||||
}
|
||||
// === END PHASE3-status ===
|
||||
|
||||
// Mirror the "Notify when Hermes finishes" setting into ChatViewModel —
|
||||
// same pattern as appContextSettings; the VM reads the plain field at
|
||||
// turn-complete time instead of holding a ConnectionViewModel reference.
|
||||
val notifyTurnComplete by connectionViewModel.notifyTurnComplete.collectAsState()
|
||||
LaunchedEffect(notifyTurnComplete) {
|
||||
chatViewModel.notifyOnTurnComplete = notifyTurnComplete
|
||||
}
|
||||
|
||||
// Sync tool annotation parsing toggle to ChatHandler
|
||||
val parseAnnotations by connectionViewModel.parseToolAnnotations.collectAsState()
|
||||
LaunchedEffect(parseAnnotations) {
|
||||
@@ -626,11 +680,24 @@ fun RelayApp() {
|
||||
// Sync streaming endpoint preference to chat. Resolves "auto" against the
|
||||
// current server capabilities so vanilla upstream + bootstrap-injected
|
||||
// sessions API picks /v1/chat/completions for portable SSE chat while
|
||||
// still using /api/sessions/* for browse/rename/delete.
|
||||
// still using /api/sessions/* for browse/rename/delete. Gateway
|
||||
// availability is a key so a Manage sign-in mid-session re-resolves
|
||||
// "auto" to the gateway transport (live thinking) without an app restart.
|
||||
val streamingEndpoint by connectionViewModel.streamingEndpoint.collectAsState()
|
||||
val serverCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
LaunchedEffect(streamingEndpoint, serverCapabilities) {
|
||||
chatViewModel.streamingEndpoint = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
// The resolved API route is a key so a mid-turn route switch (LAN→Tailscale)
|
||||
// re-runs activeGatewayChatClient(), which RETARGETS the in-flight gateway
|
||||
// client to follow the new dashboard route instead of stranding the turn on
|
||||
// the dead one.
|
||||
val effectiveApiUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
|
||||
LaunchedEffect(streamingEndpoint, serverCapabilities, gatewayAvailability, effectiveApiUrl) {
|
||||
val resolved = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
|
||||
chatViewModel.streamingEndpoint = resolved
|
||||
chatViewModel.sseFallbackEndpoint = connectionViewModel.resolveSseStreamingEndpoint()
|
||||
chatViewModel.updateGatewayClient(
|
||||
if (resolved == "gateway") connectionViewModel.activeGatewayChatClient() else null,
|
||||
)
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
@@ -898,6 +965,30 @@ fun RelayApp() {
|
||||
startupGateMinElapsed &&
|
||||
startupConnectionResolved
|
||||
) {
|
||||
// When the 12s backstop (not readiness, not a settled error)
|
||||
// is what opened the gate, leave a diagnostic naming the
|
||||
// conditions still unmet — the demo-video session measured
|
||||
// 6–28s launch variance against the same LAN server and had
|
||||
// no way to see why from the device.
|
||||
val happyPathReady =
|
||||
chatReady && initialChatSettled && startupNarrationComplete
|
||||
if (
|
||||
hasStartupConnection &&
|
||||
!happyPathReady &&
|
||||
!startupUnreachableSettled &&
|
||||
startupGateTimedOut
|
||||
) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Api,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Startup gate released by timeout",
|
||||
detail = "chatReady=$chatReady " +
|
||||
"historySettled=$initialChatSettled " +
|
||||
"narration=$startupNarrationStage/${startupCheckTargets.size} " +
|
||||
"health=$apiHealth " +
|
||||
"route=${activeEndpoint?.role ?: "unresolved"}",
|
||||
)
|
||||
}
|
||||
startupGateReleased = true
|
||||
}
|
||||
}
|
||||
@@ -1006,8 +1097,21 @@ fun RelayApp() {
|
||||
!isOnboarding &&
|
||||
!showStartupSphere &&
|
||||
!voiceUiState.voiceMode
|
||||
val showConnectionStatusBanner =
|
||||
// Sideload-only update availability (UpdateViewModel short-circuits on
|
||||
// googlePlay). Hoisted to the outer scope so the update toast can render
|
||||
// in the floating Box overlay below alongside the connection toast.
|
||||
val updateBannerState by updateViewModel.bannerState.collectAsState()
|
||||
val availableUpdate = (updateBannerState as? UpdateCheckResult.Available)?.update
|
||||
|
||||
// Content-identity key so a swipe-up dismiss sticks for THIS status but
|
||||
// a genuinely new status (different title/tone/phase) re-shows.
|
||||
var dismissedStatusKey by remember { mutableStateOf<String?>(null) }
|
||||
val currentStatusKey = globalConnectionStatus?.let {
|
||||
"${it.title}|${it.tone}|${it.active}|${it.success}|${it.route}"
|
||||
}
|
||||
val showConnectionStatusToast =
|
||||
globalConnectionStatus != null &&
|
||||
currentStatusKey != dismissedStatusKey &&
|
||||
!isOnboarding &&
|
||||
!showStartupSphere &&
|
||||
!voiceUiState.voiceMode
|
||||
@@ -1064,36 +1168,10 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
|
||||
// Sideload-only update banner. UpdateViewModel short-circuits on
|
||||
// googlePlay so this block is effectively dead on that flavor.
|
||||
// bannerState hides the banner for versions the user has
|
||||
// dismissed, re-appearing automatically on a newer release.
|
||||
val updateBannerState by updateViewModel.bannerState.collectAsState()
|
||||
val availableUpdate = (updateBannerState as? UpdateCheckResult.Available)?.update
|
||||
AnimatedVisibility(
|
||||
visible = availableUpdate != null && !isOnboarding,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(200)),
|
||||
) {
|
||||
availableUpdate?.let { upd ->
|
||||
UpdateBanner(
|
||||
update = upd,
|
||||
onDismiss = { updateViewModel.dismiss(upd.latestVersion) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showConnectionStatusBanner,
|
||||
enter = fadeIn(tween(160)),
|
||||
exit = fadeOut(tween(180)),
|
||||
) {
|
||||
ConnectionStatusBanner(
|
||||
status = globalConnectionStatus,
|
||||
includeStatusBarPadding = !showUnattendedBanner && availableUpdate == null,
|
||||
onClick = onConnectionStatusBannerClick,
|
||||
)
|
||||
}
|
||||
// The update banner AND the connection-status indicator now render as
|
||||
// floating overlay TOASTS in the Box below (see the top-overlay Column
|
||||
// after the Scaffold), so they slide down OVER the content instead of
|
||||
// taking layout space — no UI resize/cut on update / handoff / reconnect.
|
||||
|
||||
// (The app-wide ConnectionChip row that used to live here has been
|
||||
// removed. Multi-connection switching is now reachable from the
|
||||
@@ -1124,7 +1202,10 @@ fun RelayApp() {
|
||||
// would otherwise double-pad and render too far down.
|
||||
// Consume the inset here so the Scaffold tree treats
|
||||
// the top edge as already handled.
|
||||
if (showUnattendedBanner || showConnectionStatusBanner || connectionChipVisible) {
|
||||
// The connection-status toast is now a floating overlay and
|
||||
// doesn't occupy space above the Scaffold, so it no longer
|
||||
// participates in the top-inset accounting.
|
||||
if (showUnattendedBanner || connectionChipVisible) {
|
||||
Modifier.consumeWindowInsets(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
@@ -1931,6 +2012,45 @@ fun RelayApp() {
|
||||
}
|
||||
} // end Column (wraps banner + Scaffold)
|
||||
|
||||
// Floating overlay toasts (update + connection status). Rendered in the
|
||||
// Box, stacked top-down in one status-bar-padded Column so they slide
|
||||
// down OVER the content without resizing it — no UI cut/resize on update
|
||||
// / handoff / reconnect. Both gated off during onboarding / startup
|
||||
// sphere / voice mode. The Column self-pads the status bar once; the
|
||||
// children don't (so two stacked toasts don't double-pad).
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.statusBars),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = availableUpdate != null && !isOnboarding &&
|
||||
!showStartupSphere && !voiceUiState.voiceMode,
|
||||
enter = slideInVertically(tween(220)) { -it } + fadeIn(tween(180)),
|
||||
exit = slideOutVertically(tween(200)) { -it } + fadeOut(tween(160)),
|
||||
) {
|
||||
availableUpdate?.let { upd ->
|
||||
UpdateBanner(
|
||||
update = upd,
|
||||
onDismiss = { updateViewModel.dismiss(upd.latestVersion) },
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = showConnectionStatusToast,
|
||||
enter = slideInVertically(tween(220)) { -it } + fadeIn(tween(180)),
|
||||
exit = slideOutVertically(tween(200)) { -it } + fadeOut(tween(160)),
|
||||
) {
|
||||
ConnectionStatusToast(
|
||||
status = globalConnectionStatus,
|
||||
includeStatusBarPadding = false,
|
||||
onClick = onConnectionStatusBannerClick,
|
||||
onDismiss = { dismissedStatusKey = currentStatusKey },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// (The ConnectionSwitcherSheet modal that used to live here was
|
||||
// driven by the removed top-bar ConnectionChip. Switching is now
|
||||
// inline in AgentInfoSheet's Connection section — see
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BrokenImage
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
|
||||
/**
|
||||
* One markdown image reference (``) pulled out of an assistant
|
||||
* message so it can be rendered as a real image (or a graceful inline notice)
|
||||
* instead of the empty/blank element the markdown renderer produces for it.
|
||||
*/
|
||||
data class ChatInlineImage(val alt: String, val src: String)
|
||||
|
||||
// `` and ``. src = first non-space, non-`)` run.
|
||||
private val MARKDOWN_IMAGE_REGEX = Regex("""!\[([^\]]*)]\(([^)\s]+)[^)]*\)""")
|
||||
|
||||
/**
|
||||
* Split assistant [content] into (markdown body without image links, parsed
|
||||
* images). The `![...]()` token is removed from the body so it doesn't render
|
||||
* as a broken/empty element; surrounding prose is preserved.
|
||||
*/
|
||||
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
|
||||
if (!content.contains("![")) return content to emptyList()
|
||||
val images = mutableListOf<ChatInlineImage>()
|
||||
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
|
||||
images += ChatInlineImage(alt = m.groupValues[1].trim(), src = m.groupValues[2].trim())
|
||||
""
|
||||
}
|
||||
if (images.isEmpty()) return content to emptyList()
|
||||
// Collapse the blank lines the removal can leave behind.
|
||||
return stripped.replace(Regex("\n{3,}"), "\n\n").trim() to images
|
||||
}
|
||||
|
||||
private fun ChatInlineImage.isRemote(): Boolean {
|
||||
val s = src.lowercase()
|
||||
return s.startsWith("http://") || s.startsWith("https://")
|
||||
}
|
||||
|
||||
/**
|
||||
* Render generated/inline images for an assistant bubble. Remote `http(s)`
|
||||
* URLs load via Coil with loading/error states; anything else (a server-local
|
||||
* file path, `file://`, a relative path) degrades to an inline notice that
|
||||
* explains WHY it can't be shown rather than rendering blank.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatInlineImages(
|
||||
images: List<ChatInlineImage>,
|
||||
modifier: Modifier = Modifier,
|
||||
maxWidth: Dp = 280.dp,
|
||||
) {
|
||||
if (images.isEmpty()) return
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
images.forEach { image ->
|
||||
if (image.isRemote()) {
|
||||
RemoteChatImage(image, maxWidth)
|
||||
} else {
|
||||
UnrenderableImageNotice(image)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
|
||||
var viewerOpen by remember { mutableStateOf(false) }
|
||||
if (viewerOpen) {
|
||||
ChatImageViewer(
|
||||
source = ChatImageViewerSource.Coil(
|
||||
model = image.src,
|
||||
displayName = image.alt.ifBlank { "image" },
|
||||
mime = "image/*",
|
||||
bytesProvider = { MediaSaver.fetchRemoteBytes(image.src).first },
|
||||
),
|
||||
onDismiss = { viewerOpen = false },
|
||||
)
|
||||
}
|
||||
SubcomposeAsyncImage(
|
||||
model = image.src,
|
||||
contentDescription = image.alt.ifBlank { "Generated image" },
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = 360.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { viewerOpen = true },
|
||||
) {
|
||||
val state by painter.state.collectAsState()
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
|
||||
is AsyncImagePainter.State.Loading -> Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.height(120.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
// Error / Empty — couldn't load. Offer to open it externally.
|
||||
else -> UnrenderableImageNotice(image, reason = "Couldn't load this image.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnrenderableImageNotice(
|
||||
image: ChatInlineImage,
|
||||
reason: String = "This image is on the server and can't be shown here.",
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val remote = image.isRemote()
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.then(
|
||||
if (remote) {
|
||||
Modifier.clickable {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, image.src.toUri()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (remote) Icons.Filled.BrokenImage else Icons.Outlined.Image,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = image.alt.ifBlank { "Image" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = if (remote) "Tap to open · ${image.src}" else "$reason\n${image.src}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* What the [ChatImageViewer] displays and how it obtains bytes for Save/Share.
|
||||
*
|
||||
* Display and byte-acquisition are decoupled on purpose: a remote image
|
||||
* displays straight from its URL via Coil but fetches bytes over HTTP, while an
|
||||
* inbound attachment displays from an already-decoded [ImageBitmap] but reads
|
||||
* its original bytes back from the cached `content://` URI — so Save preserves
|
||||
* the real file, not a re-encode.
|
||||
*/
|
||||
sealed interface ChatImageViewerSource {
|
||||
val displayName: String
|
||||
val mime: String
|
||||
|
||||
/** Returns the bytes to save/share, or null if they can't be obtained. */
|
||||
val bytesProvider: suspend () -> ByteArray?
|
||||
|
||||
/** Display via Coil from any Coil-native model (URL string, content:// Uri). */
|
||||
data class Coil(
|
||||
val model: Any,
|
||||
override val displayName: String,
|
||||
override val mime: String,
|
||||
override val bytesProvider: suspend () -> ByteArray?,
|
||||
) : ChatImageViewerSource
|
||||
|
||||
/** Display from an already-decoded bitmap (outbound / cached attachments). */
|
||||
data class Bitmap(
|
||||
val bitmap: ImageBitmap,
|
||||
override val displayName: String,
|
||||
override val mime: String,
|
||||
override val bytesProvider: suspend () -> ByteArray?,
|
||||
) : ChatImageViewerSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen image viewer dialog: pinch-to-zoom + pan (double-tap to toggle
|
||||
* 1×/2.5×), with overlaid Share / Save / Close controls. Save lands in
|
||||
* `Pictures/Hermes-Relay` on Android 10+; on older versions (or any failure
|
||||
* path) it falls back to the share sheet via [MediaSaver].
|
||||
*/
|
||||
@Composable
|
||||
fun ChatImageViewer(
|
||||
source: ChatImageViewerSource,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val gestureModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
scale = (scale * zoom).coerceIn(1f, 6f)
|
||||
offset = if (scale > 1f) offset + pan else Offset.Zero
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onDoubleTap = {
|
||||
if (scale > 1f) {
|
||||
scale = 1f
|
||||
offset = Offset.Zero
|
||||
} else {
|
||||
scale = 2.5f
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = offset.x
|
||||
translationY = offset.y
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.94f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when (source) {
|
||||
is ChatImageViewerSource.Coil -> AsyncImage(
|
||||
model = source.model,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
|
||||
is ChatImageViewerSource.Bitmap -> Image(
|
||||
bitmap = source.bitmap,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
|
||||
// Control bar — top-right, inset past the status bar / notch.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
busy = false
|
||||
if (bytes == null) {
|
||||
toast(context, "Couldn't load this image")
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = "Share")
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
toast(context, "Couldn't load this image")
|
||||
return@launch
|
||||
}
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
busy = false
|
||||
toast(context, "Saved to ${result.location}")
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, "Save failed: ${result.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = "Save")
|
||||
}
|
||||
IconButton(onClick = onDismiss, colors = tint) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(context: android.content.Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.purpleGlow
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* What the single trailing slot of the input bar renders. The caller
|
||||
* derives the state — the bar never widens, it morphs:
|
||||
*
|
||||
* ```
|
||||
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
|
||||
* !isStreaming -> VOICE // GraphicEq, primary
|
||||
* isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle
|
||||
* canSteer (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
|
||||
* else -> QUEUE // Send glyph + clock badge, tertiary
|
||||
* ```
|
||||
*/
|
||||
enum class ChatInputTrailing { SEND, VOICE, STOP, STEER, QUEUE }
|
||||
|
||||
/**
|
||||
* The minimal Telegram-style chat input bar — 3 elements, one trailing
|
||||
* button. Replaces ChatScreen's Row of attach / slash / OutlinedTextField /
|
||||
* Stop / smart-swap.
|
||||
*
|
||||
* - "+" tap = file picker ([onAttach]); long-press = CommandPalette
|
||||
* ([onLongPressAttach]) — the app's quiet-gesture idiom. The dedicated
|
||||
* slash button is gone; typing "/" still surfaces InlineAutocomplete.
|
||||
* - Pill [BasicTextField] (surfaceContainerHigh, hairline border, grows
|
||||
* to 5 lines) instead of OutlinedTextField chrome.
|
||||
* - ONE trailing slot morphing through [ChatInputTrailing] with
|
||||
* [AnimatedContent] — Stop stops being a separate slot so the bar never
|
||||
* widens during streaming.
|
||||
* - Char counter as a tiny mono overline above the bar (Amber, Danger at
|
||||
* the limit) only when length > [charLimit] - 200 — supportingText
|
||||
* reflows the bar, the overline doesn't.
|
||||
* - [caption] renders a single relayMetadataStyle line above the bar
|
||||
* (steer/queue hinting during streaming-with-text); Cyan when the slot
|
||||
* is STEER, muted otherwise. Null collapses the row.
|
||||
* - Voice: GraphicEq glyph ("voice session", not "record"); when
|
||||
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
|
||||
* ("needs setup" reads intentional, not broken) and the tap still goes
|
||||
* to [onVoice] for the route-specific toast. [showVoiceHint] one-shot
|
||||
* floats the "Live voice conversation" pill above the button for ~3s
|
||||
* (DataStore flag owned by the caller, consumed via [onVoiceHintShown]).
|
||||
* - [purpleGlow] on the trailing button (dark theme only) when it is an
|
||||
* enabled SEND — the bar's one flourish, exactly as before.
|
||||
*
|
||||
* [onSend] fires for SEND, STEER, and QUEUE — the caller already encoded
|
||||
* the meaning in the state it passed; [onVoice]/[onStop] for theirs.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ChatInputBar(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
trailing: ChatInputTrailing,
|
||||
onSend: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
onAttach: () -> Unit,
|
||||
onLongPressAttach: () -> Unit,
|
||||
charLimit: Int,
|
||||
caption: String?,
|
||||
voiceReady: Boolean,
|
||||
showVoiceHint: Boolean,
|
||||
onVoiceHintShown: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
// Keep the last caption around so the AnimatedVisibility exit doesn't
|
||||
// flash an empty line while collapsing.
|
||||
var lastCaption by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(caption) {
|
||||
if (caption != null) lastCaption = caption
|
||||
}
|
||||
|
||||
// One-shot voice hint. Consumed-flag locally so flipping the DataStore
|
||||
// flag (via onVoiceHintShown) can't restart-cancel the visible window;
|
||||
// the hide timer is keyed on visibility alone so trailing-state morphs
|
||||
// mid-delay don't strand the pill.
|
||||
var hintVisible by remember { mutableStateOf(false) }
|
||||
var hintConsumed by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(showVoiceHint, trailing) {
|
||||
if (showVoiceHint && !hintConsumed && trailing == ChatInputTrailing.VOICE) {
|
||||
hintConsumed = true
|
||||
hintVisible = true
|
||||
onVoiceHintShown()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(hintVisible) {
|
||||
if (hintVisible) {
|
||||
delay(3_000)
|
||||
hintVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Caption row — steer/queue hinting, single line, no buttons.
|
||||
AnimatedVisibility(visible = caption != null) {
|
||||
Text(
|
||||
text = caption ?: lastCaption.orEmpty(),
|
||||
style = relayMetadataStyle(),
|
||||
color = if (trailing == ChatInputTrailing.STEER) {
|
||||
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.9f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Voice hint pill — floats above the trailing button.
|
||||
AnimatedVisibility(
|
||||
visible = hintVisible,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(end = 12.dp, bottom = 2.dp),
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Text(
|
||||
text = "Live voice conversation",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f))
|
||||
.padding(horizontal = 12.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Char counter overline — same near-limit threshold as before, but
|
||||
// it no longer reflows the bar.
|
||||
if (value.length > charLimit - 200) {
|
||||
Text(
|
||||
text = "${value.length}/$charLimit",
|
||||
style = relayMetadataStyle(),
|
||||
color = if (value.length >= charLimit) RelayRefresh.Danger else RelayRefresh.Amber,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(end = 16.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
// "+" — tap attaches, long-press opens the command palette.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 2.dp)
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.combinedClickable(
|
||||
onClick = onAttach,
|
||||
onLongClick = onLongPressAttach,
|
||||
onLongClickLabel = "Browse commands",
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = "Attach file — hold for commands",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Pill field
|
||||
Surface(
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = { if (it.length <= charLimit) onValueChange(it) },
|
||||
maxLines = 5,
|
||||
enabled = enabled,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
decorationBox = { inner ->
|
||||
Box(Modifier.padding(horizontal = 16.dp, vertical = 11.dp)) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = RelayRefresh.Dim,
|
||||
)
|
||||
}
|
||||
inner()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Trailing slot
|
||||
val glow = trailing == ChatInputTrailing.SEND && enabled && isDarkTheme
|
||||
Box(
|
||||
modifier = if (glow) {
|
||||
Modifier.purpleGlow(radius = 24.dp, alpha = 0.35f, isDarkTheme = true)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = trailing,
|
||||
transitionSpec = {
|
||||
(fadeIn(tween(150)) + scaleIn(initialScale = 0.8f))
|
||||
.togetherWith(fadeOut(tween(100)))
|
||||
},
|
||||
label = "chatInputTrailing",
|
||||
) { state ->
|
||||
when (state) {
|
||||
ChatInputTrailing.SEND -> IconButton(
|
||||
onClick = onSend,
|
||||
enabled = enabled,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send message",
|
||||
tint = if (enabled) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
ChatInputTrailing.VOICE -> Box {
|
||||
IconButton(onClick = onVoice) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.GraphicEq,
|
||||
contentDescription = if (voiceReady) "Start voice conversation"
|
||||
else "Voice conversation — setup needed",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
// "Needs setup" badge — full-alpha button + Amber
|
||||
// dot instead of a half-dimmed broken-looking mic.
|
||||
if (!voiceReady) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 8.dp, end = 8.dp)
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(RelayRefresh.Amber),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChatInputTrailing.STOP -> IconButton(onClick = onStop) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.border(1.dp, MaterialTheme.colorScheme.error, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = "Stop streaming",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChatInputTrailing.STEER -> IconButton(
|
||||
onClick = onSend,
|
||||
enabled = enabled,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Steer the response",
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
|
||||
ChatInputTrailing.QUEUE -> IconButton(
|
||||
onClick = onSend,
|
||||
enabled = enabled,
|
||||
) {
|
||||
Box {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Queue message",
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Schedule,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 5.dp, y = (-3).dp)
|
||||
.size(10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,13 +47,30 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A slash command entry — built-in, personality, or server skill.
|
||||
* A slash command entry — built-in, personality, server skill, or (on the
|
||||
* gateway transport) a server-catalog command from `commands.catalog`.
|
||||
*/
|
||||
data class SlashCommand(
|
||||
val command: String,
|
||||
val description: String,
|
||||
val category: String = "built-in"
|
||||
)
|
||||
val category: String = "built-in",
|
||||
/**
|
||||
* Where the entry came from — [SOURCE_SERVER] for gateway
|
||||
* `commands.catalog` entries, null for client-defined sources.
|
||||
* Used by the merge in ChatScreen's `allCommands` to dedupe by name
|
||||
* with the server description winning, and by the send path to route
|
||||
* server-only commands through `slash.exec` / `command.dispatch`
|
||||
* instead of plain text.
|
||||
*/
|
||||
val source: String? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val SOURCE_SERVER = "server"
|
||||
|
||||
/** Palette category for server-catalog commands without one. */
|
||||
const val CATEGORY_SERVER = "server"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen command palette as a bottom sheet.
|
||||
@@ -73,7 +90,10 @@ fun CommandPalette(
|
||||
|
||||
// Get unique categories in a logical order
|
||||
val categories = remember(commands) {
|
||||
val priorityOrder = listOf("session", "configuration", "info", "personality")
|
||||
val priorityOrder = listOf(
|
||||
"session", "configuration", "info", "personality",
|
||||
SlashCommand.CATEGORY_SERVER,
|
||||
)
|
||||
commands.map { it.category }.distinct().sortedWith(
|
||||
compareBy<String> {
|
||||
val idx = priorityOrder.indexOf(it)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -10,6 +15,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -28,9 +34,11 @@ fun CompactToolCall(
|
||||
toolCall: ToolCall,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isPreparing = toolCall.isGenerating && !toolCall.isComplete
|
||||
val statusText = when {
|
||||
toolCall.isComplete && toolCall.success == true -> "completed"
|
||||
toolCall.isComplete && toolCall.success == false -> "failed"
|
||||
isPreparing -> "preparing"
|
||||
else -> "running"
|
||||
}
|
||||
|
||||
@@ -70,6 +78,24 @@ fun CompactToolCall(
|
||||
|
||||
// Status indicator
|
||||
when {
|
||||
// tool.generating parity with ToolProgressCard's "preparing"
|
||||
// state — MoreHoriz in Muted with the same alpha breathe; the
|
||||
// Cyan spinner stays reserved for actually-executing tools.
|
||||
isPreparing -> {
|
||||
val breathe = rememberInfiniteTransition(label = "compactToolGenerating")
|
||||
val alpha = breathe.animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 0.9f,
|
||||
animationSpec = infiniteRepeatable(tween(900), repeatMode = RepeatMode.Reverse),
|
||||
label = "compactToolGeneratingAlpha",
|
||||
).value
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreHoriz,
|
||||
contentDescription = "Preparing",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)
|
||||
)
|
||||
}
|
||||
!toolCall.isComplete -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(10.dp),
|
||||
|
||||
@@ -37,6 +37,11 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionHandoffStatus
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStatusSnapshot
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStatusTone
|
||||
@@ -206,6 +211,185 @@ fun ConnectionStatusBanner(
|
||||
}
|
||||
}
|
||||
|
||||
private const val SWIPE_DISMISS_THRESHOLD_PX = 80f
|
||||
|
||||
/**
|
||||
* Floating, in-theme connection status **toast** for connection switches,
|
||||
* network handoffs, and disconnects.
|
||||
*
|
||||
* Unlike [ConnectionStatusBanner] (edge-to-edge, takes layout space above the
|
||||
* Scaffold and so resizes the content), this is meant to be rendered as a
|
||||
* top-aligned overlay inside a `Box` — it slides down OVER the UI without
|
||||
* shifting it. Pair it with `AnimatedVisibility(enter = slideInVertically{-it})`
|
||||
* at the call site.
|
||||
*
|
||||
* - Spinner while [ConnectionStatusSnapshot.active] (handoff / loading).
|
||||
* - [onClick] acts on it (reconnect / open the relevant screen).
|
||||
* - [onDismiss] is wired to a swipe-up; the host suppresses re-show until the
|
||||
* status content changes.
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionStatusToast(
|
||||
status: ConnectionStatusSnapshot?,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = true,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
) {
|
||||
val current = status ?: return
|
||||
val containerColor = when {
|
||||
current.tone == ConnectionStatusTone.Error -> MaterialTheme.colorScheme.errorContainer
|
||||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.82f)
|
||||
current.success -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
current.active -> MaterialTheme.colorScheme.secondaryContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
val contentColor = when {
|
||||
current.tone == ConnectionStatusTone.Error ||
|
||||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.onErrorContainer
|
||||
current.success -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
current.active -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
// Reset the swipe accumulator whenever a new status arrives.
|
||||
val dragAccum = remember(current.updatedAtMs) { mutableFloatStateOf(0f) }
|
||||
val swipeModifier = if (onDismiss != null) {
|
||||
Modifier.pointerInput(onDismiss) {
|
||||
detectVerticalDragGestures(
|
||||
onDragEnd = {
|
||||
if (dragAccum.floatValue < -SWIPE_DISMISS_THRESHOLD_PX) onDismiss()
|
||||
dragAccum.floatValue = 0f
|
||||
},
|
||||
onVerticalDrag = { change, dy ->
|
||||
if (dy < 0f) {
|
||||
dragAccum.floatValue += dy
|
||||
change.consume()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
shadowElevation = 8.dp,
|
||||
tonalElevation = 2.dp,
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.then(swipeModifier)
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 40.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when {
|
||||
current.active -> CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = contentColor,
|
||||
)
|
||||
current.success -> Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
current.tone == ConnectionStatusTone.Warning ||
|
||||
current.tone == ConnectionStatusTone.Error -> Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Filled.Sync,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = current.title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
current.route?.takeIf { it.isNotBlank() }?.let { route ->
|
||||
Text(
|
||||
text = route,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
val outputLines = current.entries
|
||||
.takeLast(2)
|
||||
.mapNotNull { entry ->
|
||||
val label = entry.label.trim().takeIf { it.isNotBlank() }
|
||||
val detail = entry.detail?.trim()?.takeIf { it.isNotBlank() }
|
||||
when {
|
||||
label != null && detail != null -> "$label: $detail"
|
||||
label != null -> label
|
||||
detail != null -> detail
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
outputLines.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
current.actionLabel?.takeIf { it.isNotBlank() }?.let { label ->
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.86f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PulsingSyncIcon(color: androidx.compose.ui.graphics.Color) {
|
||||
val infinite = rememberInfiniteTransition(label = "connection-handoff-pulse")
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -39,6 +40,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -58,6 +60,7 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
@@ -637,6 +640,16 @@ fun AgentInfoSheet(
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val personalityNames by chatViewModel.personalityNames.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
val availableModels by chatViewModel.availableModels.collectAsState()
|
||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
|
||||
// Pull the gateway's curated provider/model list (model.options) when the
|
||||
// sheet opens — the real switchable models, grouped by provider.
|
||||
LaunchedEffect(Unit) { chatViewModel.refreshModelOptions() }
|
||||
// Pull the host's agent profiles from the dashboard so they appear in the
|
||||
// Profile picker even on a dashboard-only (non-relay) connection.
|
||||
LaunchedEffect(Unit) { connectionViewModel.refreshDashboardProfiles() }
|
||||
|
||||
// Connection summary state.
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
@@ -739,11 +752,11 @@ fun AgentInfoSheet(
|
||||
val apparentActiveProfile = agentProfiles
|
||||
.firstOrNull { it.gatewayRunning }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SectionLabel(
|
||||
title = "Profile",
|
||||
hint = "Host-side Hermes contexts",
|
||||
)
|
||||
CollapsiblePickerSection(
|
||||
title = "Profile",
|
||||
hint = "Host-side Hermes contexts",
|
||||
currentValue = AgentDisplay.profileDisplayName(selectedProfile) ?: "Server default",
|
||||
) {
|
||||
|
||||
val defaultDotColor = serverDefaultProfile?.let { profile ->
|
||||
if (profile.gatewayRunning) {
|
||||
@@ -811,6 +824,9 @@ fun AgentInfoSheet(
|
||||
onSelect = {
|
||||
if (selectedProfile != null) {
|
||||
connectionViewModel.selectProfile(null)
|
||||
// Gateway turns carry no per-request profile —
|
||||
// hot-swap the live session server-side too.
|
||||
chatViewModel.activateGatewayProfile(null)
|
||||
toast("Using Server default")
|
||||
}
|
||||
},
|
||||
@@ -849,62 +865,50 @@ fun AgentInfoSheet(
|
||||
// to the capitalised profile name when description
|
||||
// is blank.
|
||||
val hasDescription = profile.description.isNotBlank()
|
||||
val primaryLabel = if (hasDescription) {
|
||||
profile.description
|
||||
} else {
|
||||
profile.name.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
// Headline is the profile NAME (easy to scan); the
|
||||
// description + model ride one subtitle line.
|
||||
val primaryLabel = profile.name.replaceFirstChar { it.uppercase() }
|
||||
// Secondary line: `modelname • Running` / `• Idle`.
|
||||
// The dot itself carries the same information via
|
||||
// colour; the text label is the a11y-visible
|
||||
// complement so a screen reader user also gets
|
||||
// the status without relying on colour.
|
||||
val secondaryLine = profile.model + runningLabel
|
||||
val secondaryLine = listOfNotNull(
|
||||
profile.description.takeIf { hasDescription },
|
||||
profile.model.takeIf { it.isNotBlank() },
|
||||
).joinToString(" · ").takeIf { it.isNotBlank() }
|
||||
// Tertiary caption: the profile identifier (when
|
||||
// we promoted the description to primary) plus an
|
||||
// "active on server" hint when this row matches
|
||||
// the apparent default.
|
||||
val tertiaryLine = buildString {
|
||||
if (hasDescription) {
|
||||
append("profile: ")
|
||||
append(profile.name)
|
||||
}
|
||||
if (profile.hasIsolatedApi) {
|
||||
if (isNotEmpty()) append(" \u2022 ")
|
||||
append("isolated API")
|
||||
} else {
|
||||
if (isNotEmpty()) append(" \u2022 ")
|
||||
append("compatibility overlay")
|
||||
}
|
||||
if (isApparentActive && selectedProfile == null) {
|
||||
if (isNotEmpty()) append(" \u2022 ")
|
||||
append("This is the server's active profile")
|
||||
}
|
||||
}.takeIf { it.isNotBlank() }
|
||||
val tertiaryLine: String? = null
|
||||
ProfileRadioRow(
|
||||
primary = primaryLabel,
|
||||
secondary = secondaryLine,
|
||||
tertiary = tertiaryLine,
|
||||
// Cleaner card: drop the verbose "profile: … ·
|
||||
// compatibility overlay · active" caption now that
|
||||
// the name is the headline.
|
||||
tertiary = null,
|
||||
selected = selectedProfile?.name == profile.name,
|
||||
enabled = !isStreaming,
|
||||
contentAlpha = 1f,
|
||||
leadingDotColor = dotColor,
|
||||
leadingDotContentDescription = dotA11y,
|
||||
secondaryTrailing = if (profile.hasSoul || profile.skillCount > 0) {
|
||||
secondaryTrailing = if (
|
||||
profile.gatewayRunning || profile.hasSoul || profile.skillCount > 0
|
||||
) {
|
||||
{
|
||||
ProfileMetadataBadge(
|
||||
text = if (profile.hasIsolatedApi) "API" else "Overlay",
|
||||
background = if (profile.hasIsolatedApi) {
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor = if (profile.hasIsolatedApi) {
|
||||
MaterialTheme.colorScheme.onTertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
// Prominent status chip — the running/active
|
||||
// profile, so the dropped "· Running" text
|
||||
// doesn't cost status visibility (the green
|
||||
// leading dot still reinforces it).
|
||||
if (profile.gatewayRunning) {
|
||||
ProfileMetadataBadge(
|
||||
text = "Active",
|
||||
background = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
if (profile.skillCount > 0) {
|
||||
ProfileMetadataBadge(
|
||||
text = "${profile.skillCount} skills",
|
||||
@@ -924,6 +928,10 @@ fun AgentInfoSheet(
|
||||
onSelect = {
|
||||
if (selectedProfile?.name != profile.name) {
|
||||
connectionViewModel.selectProfile(profile)
|
||||
// Gateway turns carry no per-request profile;
|
||||
// hot-swap the live session server-side so the
|
||||
// agent (SOUL+model+skills) changes in place.
|
||||
chatViewModel.activateGatewayProfile(profile)
|
||||
val display = primaryLabel
|
||||
val suffix = if (profile.hasIsolatedApi) {
|
||||
" — profile API active"
|
||||
@@ -971,14 +979,12 @@ fun AgentInfoSheet(
|
||||
// row is still tappable because the user may want to queue the
|
||||
// choice for after they clear the profile. No alpha on the entire
|
||||
// Column because the section header would look broken.
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
CollapsiblePickerSection(
|
||||
title = "Personality",
|
||||
hint = "System-prompt preset on this agent",
|
||||
currentValue = AgentDisplay.personalityLabel(selectedPersonality, defaultPersonality),
|
||||
modifier = Modifier.alpha(if (profileOverridesPersonality) 0.55f else 1f),
|
||||
) {
|
||||
SectionLabel(
|
||||
title = "Personality",
|
||||
hint = "System-prompt preset on this agent",
|
||||
)
|
||||
|
||||
// Default row — maps to selectedPersonality == "default" which
|
||||
// the VM resolves to whatever server-side personality is
|
||||
@@ -1038,6 +1044,88 @@ fun AgentInfoSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Model section (host-side provider model) ----
|
||||
// Switches the model for THIS session. On the gateway this fires a
|
||||
// `/model` dispatch (the rich model-info card lands in chat); on SSE
|
||||
// the pick rides the next request body. Hidden when the server
|
||||
// advertises no models. Locked mid-turn — the gateway rejects a
|
||||
// switch while a turn runs, and SSE would race the in-flight request.
|
||||
// SSE fallback model list — /v1/models plus the configured profiles'
|
||||
// models (used only when the gateway model.options groups aren't
|
||||
// available, e.g. on an SSE transport).
|
||||
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
|
||||
(availableModels +
|
||||
agentProfiles.mapNotNull { it.model?.takeIf { m -> m.isNotBlank() } } +
|
||||
listOfNotNull(selectedModelOverride))
|
||||
.distinct()
|
||||
}
|
||||
if (modelProviders.isNotEmpty() || sseModelOptions.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
CollapsiblePickerSection(
|
||||
title = "Model",
|
||||
hint = "Provider model for this session",
|
||||
currentValue = selectedModelOverride ?: "Server default",
|
||||
) {
|
||||
ProfileRadioRow(
|
||||
primary = "Server default",
|
||||
secondary = serverModelName.takeIf { it.isNotBlank() },
|
||||
selected = selectedModelOverride == null,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedModelOverride != null) {
|
||||
chatViewModel.selectModel(null)
|
||||
toast("Using server default model")
|
||||
}
|
||||
},
|
||||
)
|
||||
if (modelProviders.isNotEmpty()) {
|
||||
// Gateway: the curated provider→model groups the desktop
|
||||
// picker uses (grok / kimi / gpt-5.5 …). Each provider's
|
||||
// models are grouped under its name; the switch carries
|
||||
// `--provider <slug>`.
|
||||
modelProviders.forEach { provider ->
|
||||
if (provider.models.isNotEmpty()) {
|
||||
Text(
|
||||
text = provider.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 4.dp),
|
||||
)
|
||||
provider.models.forEach { model ->
|
||||
ProfileRadioRow(
|
||||
primary = model,
|
||||
secondary = null,
|
||||
selected = selectedModelOverride == model,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedModelOverride != model) {
|
||||
chatViewModel.selectModel(model, provider.slug)
|
||||
toast("Model: $model")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sseModelOptions.forEach { model ->
|
||||
ProfileRadioRow(
|
||||
primary = model,
|
||||
secondary = null,
|
||||
selected = selectedModelOverride == model,
|
||||
enabled = !isStreaming,
|
||||
onSelect = {
|
||||
if (selectedModelOverride != model) {
|
||||
chatViewModel.selectModel(model)
|
||||
toast("Model: $model")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// ---- Session + stats section ----
|
||||
@@ -1259,6 +1347,62 @@ private fun SectionLabel(title: String, hint: String?) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A space-saving picker section: a tappable header (the [SectionLabel] plus the
|
||||
* current value and a chevron) that collapses its option rows by default and
|
||||
* expands them on tap — a dropdown for the agent sheet's Profile / Personality
|
||||
* / Model lists so the sheet doesn't render every option at once. Selecting an
|
||||
* option (inside [content]) updates [currentValue] in the header; callers may
|
||||
* collapse on select by toggling their own state if desired, but leaving it
|
||||
* open lets the user see the new selection land.
|
||||
*/
|
||||
@Composable
|
||||
private fun CollapsiblePickerSection(
|
||||
title: String,
|
||||
hint: String?,
|
||||
currentValue: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(vertical = 6.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
SectionLabel(title = title, hint = hint)
|
||||
}
|
||||
if (!expanded && currentValue.isNotBlank()) {
|
||||
Text(
|
||||
text = currentValue,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 150.dp)
|
||||
.padding(end = 8.dp),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = if (expanded) "Collapse $title" else "Expand $title",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (expanded) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Radio-style row used by both Profile and Personality sections. Whole row
|
||||
* is a tap target (and selectable() for a11y). Disabled when [enabled] is
|
||||
|
||||
@@ -1838,7 +1838,7 @@ private fun ManualEntryStep(
|
||||
singleLine = true,
|
||||
isError = relayError != null,
|
||||
supportingText = {
|
||||
Text(relayError ?: "Hermes Relay — Terminal, Bridge, relay sessions, and grants")
|
||||
Text(relayError ?: "Hermes-Relay — Terminal, Bridge, relay sessions, and grants")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -1938,7 +1938,7 @@ private fun ShowCodeStep(
|
||||
singleLine = true,
|
||||
isError = relayError != null,
|
||||
supportingText = {
|
||||
Text(relayError ?: "Hermes Relay — Terminal, Bridge, relay sessions, and grants")
|
||||
Text(relayError ?: "Hermes-Relay — Terminal, Bridge, relay sessions, and grants")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Ambient context-window meter — a 2dp hairline strip seated at the seam
|
||||
* between the TopAppBar and RelayModeStrip. The Telegram answer: zero-tap,
|
||||
* invisible until it matters.
|
||||
*
|
||||
* Silent below 50% usage (composes to nothing — no reserved height, the
|
||||
* seam simply stays a seam). From 50% the fill tracks
|
||||
* [usedFraction] through Relay → Amber (≥75%) → Danger (≥90%), the same
|
||||
* caution ladder the sudo countdown and voice badge use. Amber/Danger are
|
||||
* deliberate direct RelayRefresh reads — identical in both schemes.
|
||||
*
|
||||
* Render only when the gateway usage carries a context_max (the compressor
|
||||
* is present); pass null otherwise and the strip vanishes.
|
||||
*/
|
||||
@Composable
|
||||
fun ContextMeterBar(usedFraction: Float?, modifier: Modifier = Modifier) {
|
||||
if (usedFraction == null || usedFraction < 0.5f) return
|
||||
|
||||
val fill by animateFloatAsState(
|
||||
targetValue = usedFraction.coerceIn(0f, 1f),
|
||||
animationSpec = tween(600),
|
||||
label = "ctxFill",
|
||||
)
|
||||
val color = when {
|
||||
fill >= 0.9f -> RelayRefresh.Danger
|
||||
fill >= 0.75f -> RelayRefresh.Amber
|
||||
else -> RelayRefresh.Relay.copy(alpha = 0.8f)
|
||||
}
|
||||
val percent = (usedFraction.coerceIn(0f, 1f) * 100).roundToInt()
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant)
|
||||
.semantics { contentDescription = "Context $percent% used" },
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(fill)
|
||||
.fillMaxHeight()
|
||||
.background(color),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,13 @@ package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -20,37 +25,65 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.CalendarToday
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.HourglassBottom
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.filled.WbSunny
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.HermesCardDispatch
|
||||
import com.hermesandroid.relay.data.HermesCardField
|
||||
import com.hermesandroid.relay.data.HermesCardInput
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Inline rich-card render for a [HermesCard] extracted from an assistant
|
||||
@@ -69,11 +102,23 @@ import com.hermesandroid.relay.data.HermesCardField
|
||||
* fields + actions — so a newer agent emitting a type the phone build
|
||||
* doesn't recognize still gets a coherent card, not an empty bubble.
|
||||
*
|
||||
* Action dispatch is fully delegated to [onActionTap]. The bubble is
|
||||
* stateless w.r.t. dispatch tracking — it reads [dispatches] (from the
|
||||
* owning [com.hermesandroid.relay.data.ChatMessage.cardDispatches]) and
|
||||
* renders a confirmation row instead of the action buttons once the user
|
||||
* has chosen.
|
||||
* Action dispatch is fully delegated to [onActionTap]; input-slot
|
||||
* submissions (ask cards — clarify answer, secret value, sudo confirm)
|
||||
* are delegated to [onInputSubmit] the same way. The bubble is stateless
|
||||
* w.r.t. dispatch tracking — it reads [dispatches] (from the owning
|
||||
* [com.hermesandroid.relay.data.ChatMessage.cardDispatches]) and renders
|
||||
* a confirmation row instead of the answer surfaces once the user has
|
||||
* chosen.
|
||||
*
|
||||
* Timed asks ([HermesCardInput.expiresAtMillis]) tick a countdown footer
|
||||
* (Amber under 30s) and self-collapse to a muted "Expired — not granted"
|
||||
* stamp past expiry — history reload past the deadline lands directly in
|
||||
* the collapsed state, so a dead ask never re-prompts.
|
||||
*
|
||||
* Secrets: when [HermesCardInput.masked] is set, the submitted value goes
|
||||
* only through [onInputSubmit]; the collapse stamp renders masked dots and
|
||||
* the caller must record [HermesCardInput.SECRET_PROVIDED_STAMP] as the
|
||||
* dispatch value, never the secret itself.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -82,6 +127,7 @@ fun HermesCardBubble(
|
||||
cardKey: String,
|
||||
dispatches: List<HermesCardDispatch>,
|
||||
onActionTap: (cardKey: String, action: HermesCardAction) -> Unit,
|
||||
onInputSubmit: (cardKey: String, value: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
maxWidth: Dp = 280.dp,
|
||||
) {
|
||||
@@ -89,6 +135,21 @@ fun HermesCardBubble(
|
||||
val typeIcon = iconForType(card.type)
|
||||
val alreadyChosen = dispatches.firstOrNull { it.cardKey == cardKey }
|
||||
|
||||
// Expiry clock for timed asks. Ticks once a second while the deadline
|
||||
// is ahead; freezes after. Keyed on the deadline so a re-used card id
|
||||
// with a fresh expiry restarts the loop.
|
||||
val expiresAt = card.input?.expiresAtMillis
|
||||
var nowMillis by remember(expiresAt) { mutableLongStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(expiresAt) {
|
||||
if (expiresAt == null) return@LaunchedEffect
|
||||
while (System.currentTimeMillis() < expiresAt) {
|
||||
nowMillis = System.currentTimeMillis()
|
||||
delay(1_000)
|
||||
}
|
||||
nowMillis = System.currentTimeMillis()
|
||||
}
|
||||
val expired = expiresAt != null && nowMillis >= expiresAt && alreadyChosen == null
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.widthIn(max = maxWidth)
|
||||
@@ -178,26 +239,66 @@ fun HermesCardBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Actions OR dispatch confirmation
|
||||
if (card.actions.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
if (alreadyChosen != null) {
|
||||
val chosen = card.actions.firstOrNull {
|
||||
// Input slot + actions OR a single dispatch/expiry stamp.
|
||||
// A card with an input slot owns ONE stamp for the whole
|
||||
// card — answering via chip, field, hold-confirm, or an
|
||||
// action button all collapse the same way.
|
||||
val input = card.input
|
||||
when {
|
||||
alreadyChosen != null -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
val chosenAction = card.actions.firstOrNull {
|
||||
it.value == alreadyChosen.actionValue
|
||||
}
|
||||
ChoseRow(chosen?.label ?: alreadyChosen.actionValue)
|
||||
} else {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
card.actions.forEach { action ->
|
||||
ActionButton(
|
||||
action = action,
|
||||
onClick = { onActionTap(cardKey, action) },
|
||||
)
|
||||
when {
|
||||
chosenAction != null -> ChoseRow("Chose: ${chosenAction.label}")
|
||||
input?.masked == true -> ChoseRow("Secret provided · ••••")
|
||||
input != null -> ChoseRow("Answered: ${alreadyChosen.actionValue}")
|
||||
else -> ChoseRow("Chose: ${alreadyChosen.actionValue}")
|
||||
}
|
||||
}
|
||||
expired -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
ChoseRow(
|
||||
text = "Expired — not granted",
|
||||
icon = Icons.Filled.HourglassBottom,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
if (input != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
CardInputSlot(
|
||||
input = input,
|
||||
onSubmit = { value -> onInputSubmit(cardKey, value) },
|
||||
)
|
||||
}
|
||||
if (card.actions.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
card.actions.forEach { action ->
|
||||
ActionButton(
|
||||
action = action,
|
||||
onClick = { onActionTap(cardKey, action) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Countdown footer for timed asks — Amber when the
|
||||
// deadline is inside 30s.
|
||||
if (expiresAt != null) {
|
||||
val remainingSec = ((expiresAt - nowMillis) / 1000).coerceAtLeast(0)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "expires in %d:%02d".format(remainingSec / 60, remainingSec % 60),
|
||||
style = relayMetadataStyle(),
|
||||
color = if (remainingSec < 30) RelayRefresh.Amber
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +351,11 @@ private fun looksMonospaceWorthy(value: String): Boolean {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoseRow(label: String) {
|
||||
private fun ChoseRow(
|
||||
text: String,
|
||||
icon: ImageVector = Icons.Filled.Check,
|
||||
iconTint: Color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -259,20 +364,264 @@ private fun ChoseRow(label: String) {
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Chose: $label",
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The interactive answer surface for ask cards, composed from the
|
||||
* [HermesCardInput] flags rather than the card type:
|
||||
* - [HermesCardInput.choices] → AssistChip row, one tap dispatches.
|
||||
* - [HermesCardInput.allowFreeText] → [InlineAnswerField] mini pill +
|
||||
* 18dp send affordance.
|
||||
* - [HermesCardInput.masked] → password-style OutlinedTextField with a
|
||||
* reveal toggle and the "Not stored in chat history" assurance line.
|
||||
* - [HermesCardInput.holdToConfirm] → [HoldToConfirmButton] replaces the
|
||||
* plain submit (sudo). With [HermesCardInput.masked] it submits the
|
||||
* typed value; bare, it submits [HermesCardInput.CONFIRM_VALUE].
|
||||
*
|
||||
* Unknown kinds degrade to the free-text field so a newer ask still gets
|
||||
* an answer surface.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun CardInputSlot(
|
||||
input: HermesCardInput,
|
||||
onSubmit: (String) -> Unit,
|
||||
) {
|
||||
// Deliberately remember, not rememberSaveable — a typed secret must
|
||||
// never be written into the saved-instance-state Bundle.
|
||||
var answerText by remember { mutableStateOf("") }
|
||||
var reveal by remember { mutableStateOf(false) }
|
||||
|
||||
val showFreeText = !input.masked && (
|
||||
input.allowFreeText ||
|
||||
input.kind == HermesCardInput.Kinds.TEXT ||
|
||||
// Unknown-kind fallback: with no other surface, still offer text.
|
||||
(input.choices.isEmpty() && !input.holdToConfirm &&
|
||||
input.kind != HermesCardInput.Kinds.CONFIRM)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Choice chips
|
||||
if (input.choices.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
input.choices.forEach { choice ->
|
||||
AssistChip(
|
||||
onClick = { onSubmit(choice) },
|
||||
label = {
|
||||
Text(choice, style = MaterialTheme.typography.labelMedium)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Masked secret field
|
||||
if (input.masked) {
|
||||
if (input.choices.isNotEmpty()) Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = answerText,
|
||||
onValueChange = { answerText = it },
|
||||
singleLine = true,
|
||||
visualTransformation = if (reveal) VisualTransformation.None
|
||||
else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { reveal = !reveal }) {
|
||||
Icon(
|
||||
imageVector = if (reveal) Icons.Filled.VisibilityOff
|
||||
else Icons.Filled.Visibility,
|
||||
contentDescription = if (reveal) "Hide value" else "Reveal value",
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Not stored in chat history",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
|
||||
// Free-text mini field (clarify)
|
||||
if (showFreeText) {
|
||||
if (input.choices.isNotEmpty()) Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
InlineAnswerField(
|
||||
value = answerText,
|
||||
onValueChange = { answerText = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = { onSubmit(answerText.trim()) },
|
||||
enabled = answerText.isNotBlank(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send answer",
|
||||
tint = if (answerText.isNotBlank()) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit affordance for masked / hold-to-confirm inputs
|
||||
when {
|
||||
input.holdToConfirm -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
HoldToConfirmButton(
|
||||
label = "Hold to confirm",
|
||||
enabled = !input.masked || answerText.isNotEmpty(),
|
||||
onConfirmed = {
|
||||
onSubmit(
|
||||
if (input.masked) answerText
|
||||
else HermesCardInput.CONFIRM_VALUE,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
input.masked -> {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Button(
|
||||
onClick = { onSubmit(answerText) },
|
||||
enabled = answerText.isNotEmpty(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) { Text("Submit", style = MaterialTheme.typography.labelMedium) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini pill answer field — Navy3 surface, hairline border, bodyMedium,
|
||||
* single line growing to 3. The in-card sibling of the chat input pill.
|
||||
*/
|
||||
@Composable
|
||||
private fun InlineAnswerField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = "Type an answer…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
maxLines = 3,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sudo confirm hold duration — long enough to defeat a drive-by tap. */
|
||||
private const val HOLD_TO_CONFIRM_MS = 650
|
||||
|
||||
/**
|
||||
* Error-container button whose fill completes over [HOLD_TO_CONFIRM_MS] of
|
||||
* sustained press (pointerInput onPress racing tryAwaitRelease). Releasing
|
||||
* early snaps the fill back; holding to completion fires [onConfirmed]
|
||||
* exactly once at the moment the fill lands.
|
||||
*/
|
||||
@Composable
|
||||
private fun HoldToConfirmButton(
|
||||
label: String,
|
||||
enabled: Boolean,
|
||||
onConfirmed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val fill = remember { Animatable(0f) }
|
||||
val errorColor = MaterialTheme.colorScheme.error
|
||||
val shape = RoundedCornerShape(20.dp)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.clip(shape)
|
||||
.background(errorColor.copy(alpha = if (enabled) 0.18f else 0.08f))
|
||||
.border(1.dp, errorColor.copy(alpha = if (enabled) 0.6f else 0.25f), shape)
|
||||
.pointerInput(enabled) {
|
||||
if (!enabled) return@pointerInput
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
coroutineScope {
|
||||
val ramp = launch {
|
||||
fill.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(HOLD_TO_CONFIRM_MS, easing = LinearEasing),
|
||||
)
|
||||
onConfirmed()
|
||||
}
|
||||
tryAwaitRelease()
|
||||
ramp.cancel()
|
||||
}
|
||||
fill.snapTo(0f)
|
||||
},
|
||||
)
|
||||
}
|
||||
.semantics { contentDescription = "$label — press and hold" },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Press-fill layer grows left → right under the label.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.fillMaxHeight()
|
||||
.fillMaxWidth(fill.value.coerceIn(0f, 1f))
|
||||
.background(errorColor.copy(alpha = 0.45f)),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (enabled) errorColor else errorColor.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButton(
|
||||
action: HermesCardAction,
|
||||
@@ -320,6 +669,10 @@ private fun iconForType(type: String): ImageVector? = when (type) {
|
||||
HermesCard.BuiltInTypes.CALENDAR_EVENT -> Icons.Filled.CalendarToday
|
||||
HermesCard.BuiltInTypes.WEATHER -> Icons.Filled.WbSunny
|
||||
HermesCard.BuiltInTypes.SKILL_RESULT -> Icons.Filled.AutoAwesome
|
||||
HermesCard.BuiltInTypes.ASK_APPROVAL -> Icons.Filled.Shield
|
||||
HermesCard.BuiltInTypes.ASK_SUDO -> Icons.Filled.Shield
|
||||
HermesCard.BuiltInTypes.ASK_CLARIFY -> Icons.Filled.AutoAwesome
|
||||
HermesCard.BuiltInTypes.ASK_SECRET -> Icons.Filled.Lock
|
||||
else -> Icons.Filled.AutoAwesome
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -13,11 +17,17 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -31,7 +41,11 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
import com.hermesandroid.relay.data.AttachmentState
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Discord-style inline render for any attachment — outbound (user-authored)
|
||||
@@ -213,12 +227,25 @@ private fun ImageRender(
|
||||
}
|
||||
|
||||
if (bitmap != null) {
|
||||
var viewerOpen by remember { mutableStateOf(false) }
|
||||
if (viewerOpen) {
|
||||
ChatImageViewer(
|
||||
source = ChatImageViewerSource.Bitmap(
|
||||
bitmap = bitmap,
|
||||
displayName = attachment.fileName ?: "image",
|
||||
mime = attachment.contentType.ifBlank { "image/*" },
|
||||
bytesProvider = { attachmentBytes(context, attachment) },
|
||||
),
|
||||
onDismiss = { viewerOpen = false },
|
||||
)
|
||||
}
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = attachment.fileName,
|
||||
modifier = modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { viewerOpen = true },
|
||||
contentScale = ContentScale.FillWidth
|
||||
)
|
||||
} else {
|
||||
@@ -232,6 +259,7 @@ private fun ImageRender(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FileCardRender(
|
||||
attachment: Attachment,
|
||||
@@ -239,32 +267,30 @@ private fun FileCardRender(
|
||||
maxWidth: Dp
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val (emoji, typeLabel) = emojiAndLabelFor(attachment.renderMode, attachment.contentType)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val openExternal = {
|
||||
val uriStr = attachment.cachedUri
|
||||
if (!uriStr.isNullOrBlank()) {
|
||||
MediaSaver.open(context, Uri.parse(uriStr), attachment.contentType)
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.clickable {
|
||||
val uriStr = attachment.cachedUri
|
||||
if (!uriStr.isNullOrBlank()) {
|
||||
try {
|
||||
val uri = Uri.parse(uriStr)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, attachment.contentType)
|
||||
addFlags(
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
// No viewer installed or malformed URI — silently ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tap opens externally (unchanged); long-press surfaces the
|
||||
// Open / Share / Save menu — only when there are bytes to act on.
|
||||
.combinedClickable(
|
||||
onClick = openExternal,
|
||||
onLongClick = { if (!attachment.cachedUri.isNullOrBlank()) menuExpanded = true },
|
||||
)
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -298,9 +324,80 @@ private fun FileCardRender(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Open") },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
openExternal()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Share") },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
scope.launch {
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
attachmentToast(context, "Couldn't read this file")
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Save to device") },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
scope.launch {
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
attachmentToast(context, "Couldn't read this file")
|
||||
return@launch
|
||||
}
|
||||
when (val result = MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
attachmentToast(context, "Saved to ${result.location}")
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
attachmentToast(context, "Save failed: ${result.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} // end Box
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Original bytes behind an attachment — read from the cached `content://` URI
|
||||
* when present (inbound), else base64-decoded from the inline content
|
||||
* (outbound). Off the main thread; null when neither source is available.
|
||||
*/
|
||||
private suspend fun attachmentBytes(context: Context, attachment: Attachment): ByteArray? {
|
||||
val uriStr = attachment.cachedUri
|
||||
return when {
|
||||
!uriStr.isNullOrBlank() -> MediaSaver.readUriBytes(context, Uri.parse(uriStr))
|
||||
attachment.content.isNotBlank() -> withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
android.util.Base64.decode(attachment.content, android.util.Base64.DEFAULT)
|
||||
}.getOrNull()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachmentToast(context: Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun emojiAndLabelFor(
|
||||
mode: AttachmentRenderMode,
|
||||
contentType: String
|
||||
|
||||
@@ -85,7 +85,20 @@ fun MessageBubble(
|
||||
* card collapses) and forwards the action value per its mode.
|
||||
* Defaults to no-op so legacy callers / tests don't have to wire it.
|
||||
*/
|
||||
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit = { _, _, _ -> }
|
||||
onCardAction: (messageId: String, cardKey: String, action: HermesCardAction) -> Unit = { _, _, _ -> },
|
||||
/**
|
||||
* Invoked when the user submits a card's interactive input slot (the
|
||||
* gateway ask cards — clarify answer, secret value, sudo confirm).
|
||||
* Routed to [com.hermesandroid.relay.viewmodel.ChatViewModel.answerAsk]
|
||||
* by ChatScreen; defaults to no-op for legacy callers.
|
||||
*/
|
||||
onCardInput: (messageId: String, cardKey: String, value: String) -> Unit = { _, _, _ -> },
|
||||
/**
|
||||
* "Edit & resend" entry in the USER-bubble long-press menu — gateway
|
||||
* transport only (the only path that supports rewinding the server
|
||||
* conversation). Null hides the entry.
|
||||
*/
|
||||
onEditMessage: ((ChatMessage) -> Unit)? = null,
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
@@ -141,6 +154,18 @@ fun MessageBubble(
|
||||
val a11yDescription = "${message.role.name.lowercase()} message: ${message.content.take(100)}"
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
// Pull generated/inline image links (``) out of assistant
|
||||
// content so they render as real images (remote URLs via Coil) or a
|
||||
// graceful inline notice — not the blank element the markdown renderer
|
||||
// emits for an image link. User/system bubbles keep their raw content.
|
||||
val (markdownBody, inlineImages) = remember(message.content, isUser, isSystem) {
|
||||
if (isUser || isSystem) {
|
||||
message.content to emptyList()
|
||||
} else {
|
||||
extractChatInlineImages(message.content)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = alignment
|
||||
@@ -174,6 +199,7 @@ fun MessageBubble(
|
||||
ThinkingBlock(
|
||||
thinkingContent = message.thinkingContent,
|
||||
isStreaming = message.isThinkingStreaming,
|
||||
timestamp = message.timestamp,
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.padding(bottom = 4.dp)
|
||||
@@ -187,6 +213,21 @@ fun MessageBubble(
|
||||
// is rendered as a separate Box so it hugs the bubble's left edge
|
||||
// regardless of content height (tall bubbles with multi-line
|
||||
// markdown stretch the bar via fillMaxHeight + IntrinsicSize).
|
||||
//
|
||||
// Suppress an otherwise-empty assistant bubble: a message that
|
||||
// carries only thinking and/or tool calls (both rendered OUTSIDE
|
||||
// this Surface — the ThinkingBlock above, the tool pills as separate
|
||||
// rows) would otherwise paint a bare timestamp-only chip between the
|
||||
// Thought-process block and the tool pill. Keep the bubble while
|
||||
// streaming (StreamingDots is the live "working" indicator) and
|
||||
// whenever there are cards/attachments to render inside it.
|
||||
val showBubble = isUser || isSystem ||
|
||||
message.content.isNotBlank() ||
|
||||
message.isStreaming ||
|
||||
message.cards.isNotEmpty() ||
|
||||
message.attachments.isNotEmpty() ||
|
||||
inlineImages.isNotEmpty()
|
||||
if (showBubble) {
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = maxBubbleWidth),
|
||||
verticalAlignment = Alignment.Top,
|
||||
@@ -205,7 +246,8 @@ fun MessageBubble(
|
||||
// wired; with copy as the only action it stays a direct copy so the
|
||||
// one-action case doesn't pay a menu tap.
|
||||
var showMessageActions by remember { mutableStateOf(false) }
|
||||
if (onQuoteMessage != null) {
|
||||
val showEditAction = onEditMessage != null && isUser
|
||||
if (onQuoteMessage != null || showEditAction) {
|
||||
DropdownMenu(
|
||||
expanded = showMessageActions,
|
||||
onDismissRequest = { showMessageActions = false },
|
||||
@@ -217,13 +259,24 @@ fun MessageBubble(
|
||||
onCopyMessage(message.content)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Quote in reply") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onQuoteMessage(message.content)
|
||||
},
|
||||
)
|
||||
if (onQuoteMessage != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Quote in reply") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onQuoteMessage(message.content)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showEditAction) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Edit & resend") },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onEditMessage(message)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
@@ -242,7 +295,7 @@ fun MessageBubble(
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = {
|
||||
if (onQuoteMessage != null) {
|
||||
if (onQuoteMessage != null || showEditAction) {
|
||||
showMessageActions = true
|
||||
} else {
|
||||
onCopyMessage(message.content)
|
||||
@@ -261,16 +314,30 @@ fun MessageBubble(
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
// Markdown for assistant messages
|
||||
if (message.content.isNotEmpty()) {
|
||||
// Markdown for assistant messages (image links stripped
|
||||
// out — rendered separately below).
|
||||
if (markdownBody.isNotEmpty()) {
|
||||
MarkdownContent(
|
||||
content = message.content,
|
||||
content = markdownBody,
|
||||
textColor = textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline generated images (assistant only) — rendered OUTSIDE
|
||||
// the SelectionContainer (they're not selectable text). Remote
|
||||
// http(s) URLs load via Coil; server-local paths and load
|
||||
// failures degrade to a notice that says why, instead of a
|
||||
// blank space.
|
||||
if (!isUser && !isSystem && inlineImages.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
ChatInlineImages(
|
||||
images = inlineImages,
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
)
|
||||
}
|
||||
|
||||
// Rich cards — rendered between the markdown body and
|
||||
// attachments so the reading order stays: narration → card
|
||||
// → attached file. Each card gets a stable key built from
|
||||
@@ -288,6 +355,9 @@ fun MessageBubble(
|
||||
onActionTap = { key, action ->
|
||||
onCardAction(message.id, key, action)
|
||||
},
|
||||
onInputSubmit = { key, value ->
|
||||
onCardInput(message.id, key, value)
|
||||
},
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
@@ -340,6 +410,7 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
} // end Row (bubble + optional leading accent bar)
|
||||
} // end if (showBubble)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -21,6 +22,7 @@ import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -58,6 +60,7 @@ fun SessionDrawerContent(
|
||||
currentSessionId: String?,
|
||||
scopeTitle: String = "Sessions",
|
||||
scopeSubtitle: String? = null,
|
||||
isLoading: Boolean = false,
|
||||
onNewChat: () -> Unit,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onDeleteSession: (String) -> Unit,
|
||||
@@ -157,7 +160,27 @@ fun SessionDrawerContent(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
if (visibleSessions.isEmpty()) {
|
||||
if (isLoading && sessions.isEmpty()) {
|
||||
// First load (or a profile switch) — show a quiet spinner instead of
|
||||
// flashing "No sessions yet" before the list arrives.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
text = "Loading sessions…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else if (visibleSessions.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
|
||||
/**
|
||||
* One subagent lane — the grouped render for every [ToolCall] sharing a
|
||||
* non-null [ToolCall.taskIndex], shown after top-level tool cards under the
|
||||
* streaming bubble. Grouping is a pure derivation in the caller
|
||||
* (`message.toolCalls.groupBy { it.taskIndex }`); the null group renders
|
||||
* exactly as before (flat ToolProgressCard / CompactToolCall).
|
||||
*
|
||||
* Anatomy mirrors the ToolProgressCard header (icon, 6dp gap, labelMedium
|
||||
* title, weight spacer, mono meta, expand chevron) with a 2dp guide rail
|
||||
* down the leading edge — tertiary while any child runs, hairline once done.
|
||||
* Children are ALWAYS [CompactToolCall] regardless of the toolDisplay pref:
|
||||
* full cards nested in a lane are visual noise on a phone width.
|
||||
*
|
||||
* Collapse behavior copies ToolProgressCard's auto-collapse: expanded while
|
||||
* any child is running, folds to the one-line summary header when the last
|
||||
* child completes ([LaunchedEffect] keyed on allComplete). A failed child
|
||||
* stamps the folded header with Close in the error tint instead of Check.
|
||||
*
|
||||
* Max depth is 1 — nested subagent-of-subagent calls arrive flattened into
|
||||
* their parent lane by the mapper (taskLabel prefixed "parent / child"), so
|
||||
* this component never indents twice.
|
||||
*/
|
||||
@Composable
|
||||
fun SubagentLane(
|
||||
taskIndex: Int,
|
||||
calls: List<ToolCall>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val anyRunning = calls.any { !it.isComplete }
|
||||
val allComplete = calls.isNotEmpty() && calls.all { it.isComplete }
|
||||
val anyFailed = calls.any { it.isComplete && it.success == false }
|
||||
val runningCount = calls.count { !it.isComplete }
|
||||
|
||||
val laneLabel = calls.firstNotNullOfOrNull { call ->
|
||||
call.taskLabel?.takeIf { it.isNotBlank() }
|
||||
} ?: "Agent ${taskIndex + 1}"
|
||||
|
||||
// Lane duration: first child start → last child completion.
|
||||
val duration = run {
|
||||
val startedAt = calls.minOfOrNull { it.startedAt }
|
||||
val completedAt = calls.mapNotNull { it.completedAt }.maxOrNull()
|
||||
if (allComplete && startedAt != null && completedAt != null && completedAt >= startedAt) {
|
||||
String.format("%.1fs", (completedAt - startedAt) / 1000.0)
|
||||
} else null
|
||||
}
|
||||
|
||||
val toolCountLabel = "${calls.size} tool${if (calls.size == 1) "" else "s"}"
|
||||
val statusMeta = when {
|
||||
anyRunning -> "$runningCount running"
|
||||
duration != null -> "$toolCountLabel · $duration"
|
||||
else -> toolCountLabel
|
||||
}
|
||||
|
||||
var expanded by remember { mutableStateOf(!allComplete) }
|
||||
|
||||
// Auto-collapse when the last child completes — same pattern as
|
||||
// ToolProgressCard's LaunchedEffect(toolCall.isComplete).
|
||||
LaunchedEffect(allComplete) {
|
||||
if (allComplete) expanded = false
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp)
|
||||
.height(IntrinsicSize.Min)
|
||||
.semantics {
|
||||
contentDescription = "Subagent $laneLabel, $statusMeta" +
|
||||
if (anyFailed) ", failed" else ""
|
||||
},
|
||||
) {
|
||||
// Guide rail
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(2.dp)
|
||||
.fillMaxHeight()
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (anyRunning) MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f)
|
||||
else RelayRefresh.Line
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Lane header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.AccountTree,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
Text(
|
||||
text = laneLabel,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = statusMeta,
|
||||
style = relayMetadataStyle(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (allComplete) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Icon(
|
||||
imageVector = if (anyFailed) Icons.Filled.Close else Icons.Filled.Check,
|
||||
contentDescription = if (anyFailed) "Failed" else "Completed",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = if (anyFailed) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
calls.forEach { call ->
|
||||
CompactToolCall(toolCall = call)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,17 @@ import androidx.compose.ui.unit.dp
|
||||
fun ThinkingBlock(
|
||||
thinkingContent: String,
|
||||
isStreaming: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
/** Message timestamp shown right-aligned in the header (null hides it). */
|
||||
timestamp: Long? = null,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(isStreaming) }
|
||||
val timeLabel = timestamp?.let {
|
||||
remember(it) {
|
||||
java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault())
|
||||
.format(java.util.Date(it))
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
@@ -66,6 +74,14 @@ fun ThinkingBlock(
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (!isStreaming && timeLabel != null) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse" else "Expand",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -22,6 +28,7 @@ import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.HourglassTop
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.TouchApp
|
||||
@@ -43,15 +50,32 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
|
||||
@Composable
|
||||
fun ToolProgressCard(
|
||||
toolCall: ToolCall,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* Fallback wall-clock for the right-aligned header time when the call
|
||||
* has no completion stamp (history-restored calls) — usually the parent
|
||||
* message timestamp. Null hides the time.
|
||||
*/
|
||||
messageTimestamp: Long? = null,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(!toolCall.isComplete) }
|
||||
// Preparing (tool.generating) starts collapsed — the args preview line
|
||||
// under the header is the whole story until tool.start lands.
|
||||
var expanded by remember { mutableStateOf(!toolCall.isComplete && !toolCall.isGenerating) }
|
||||
val isPreparing = toolCall.isGenerating && !toolCall.isComplete
|
||||
val timeMillis = toolCall.completedAt ?: messageTimestamp
|
||||
val timeLabel = timeMillis?.takeIf { toolCall.isComplete }?.let {
|
||||
remember(it) {
|
||||
java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault())
|
||||
.format(java.util.Date(it))
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-collapse when tool completes
|
||||
LaunchedEffect(toolCall.isComplete) {
|
||||
@@ -68,6 +92,13 @@ fun ToolProgressCard(
|
||||
statusIcon = Icons.Filled.Close
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
// Args still streaming — "preparing" must read as LESS active than
|
||||
// running: Muted instead of tertiary (Cyan stays reserved for
|
||||
// actually-executing tools).
|
||||
isPreparing -> {
|
||||
statusIcon = Icons.Filled.MoreHoriz
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
else -> {
|
||||
statusIcon = Icons.Filled.HourglassTop
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
@@ -78,9 +109,22 @@ fun ToolProgressCard(
|
||||
val statusText = when {
|
||||
toolCall.isComplete && toolCall.success == true -> "completed"
|
||||
toolCall.isComplete && toolCall.success == false -> "failed"
|
||||
isPreparing -> "preparing"
|
||||
else -> "running"
|
||||
}
|
||||
|
||||
// Slow alpha breathe on the tool icon while preparing — an indeterminate
|
||||
// bar promises imminent work; a breathe says "being written".
|
||||
val toolIconAlpha = if (isPreparing) {
|
||||
val breathe = rememberInfiniteTransition(label = "toolGenerating")
|
||||
breathe.animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 0.9f,
|
||||
animationSpec = infiniteRepeatable(tween(900), repeatMode = RepeatMode.Reverse),
|
||||
label = "toolGeneratingAlpha",
|
||||
).value
|
||||
} else 1f
|
||||
|
||||
val duration = if (toolCall.completedAt != null && toolCall.completedAt >= toolCall.startedAt) {
|
||||
val seconds = (toolCall.completedAt - toolCall.startedAt) / 1000.0
|
||||
String.format("%.1fs", seconds)
|
||||
@@ -109,35 +153,41 @@ fun ToolProgressCard(
|
||||
imageVector = toolIcon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = toolIconAlpha)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
|
||||
// Tool name
|
||||
// Tool name — tool.generating may arrive nameless
|
||||
Text(
|
||||
text = toolCall.name,
|
||||
text = if (isPreparing) toolCall.name.ifBlank { "Preparing tool…" } else toolCall.name,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (isPreparing) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else androidx.compose.ui.graphics.Color.Unspecified,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Duration
|
||||
if (duration != null) {
|
||||
// Duration + completion time ("3.1s · 5:32 PM")
|
||||
val metaLabel = listOfNotNull(duration, timeLabel).joinToString(" · ")
|
||||
if (metaLabel.isNotEmpty()) {
|
||||
Text(
|
||||
text = duration,
|
||||
text = metaLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
}
|
||||
|
||||
// Status icon
|
||||
Icon(
|
||||
imageVector = statusIcon,
|
||||
contentDescription = statusText,
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
// Status icon — crossfade so MoreHoriz→HourglassTop on
|
||||
// tool.start reads as a state change, not a card swap.
|
||||
Crossfade(targetState = statusIcon, label = "toolStatusIcon") { icon ->
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = statusText,
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
@@ -150,8 +200,21 @@ fun ToolProgressCard(
|
||||
)
|
||||
}
|
||||
|
||||
// Progress bar while running
|
||||
if (!toolCall.isComplete) {
|
||||
// One-line faded mono args preview while preparing — partial
|
||||
// JSON grows per delta; no expand needed, the card stays folded.
|
||||
if (isPreparing && !toolCall.args.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = compactToolDetail(toolCall.args, 80),
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
// Progress bar while running (not while args are still streaming)
|
||||
if (!toolCall.isComplete && !isPreparing) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.SystemUpdate
|
||||
@@ -21,7 +24,7 @@ import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
@@ -48,14 +51,22 @@ fun UpdateBanner(
|
||||
update: AvailableUpdate,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.shadow(8.dp, RoundedCornerShape(14.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(14.dp))
|
||||
.padding(start = 12.dp, end = 6.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
|
||||
@@ -1134,6 +1134,7 @@ private fun CompactTranscriptRow(
|
||||
ThinkingBlock(
|
||||
thinkingContent = message.thinkingContent,
|
||||
isStreaming = message.isThinkingStreaming,
|
||||
timestamp = message.timestamp,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -13,30 +17,80 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* "What's New" sheet, shown automatically on a version bump (RelayApp) and from
|
||||
* the About screen. Parses [whats_new.txt]'s tiny markup — a version line,
|
||||
* blank-separated sections with a plain-text header, `*` bullets with indented
|
||||
* continuation lines — into styled Compose instead of pasting the raw text
|
||||
* (which showed literal `*` and gave headers no emphasis).
|
||||
*/
|
||||
@Composable
|
||||
fun WhatsNewDialog(
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val changelogText = remember { loadWhatsNew(context) }
|
||||
val notes = remember { parseWhatsNew(loadWhatsNew(context)) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("What's New") },
|
||||
title = {
|
||||
Column {
|
||||
Text("What's New")
|
||||
notes.version?.let { version ->
|
||||
Text(
|
||||
text = version,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = changelogText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Default
|
||||
),
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 400.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
)
|
||||
.heightIn(max = 420.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (notes.groups.isEmpty()) {
|
||||
Text(
|
||||
text = notes.fallback ?: "No release notes available.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
notes.groups.forEachIndexed { index, group ->
|
||||
group.header?.let { header ->
|
||||
Text(
|
||||
text = header,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = if (index == 0) 0.dp else 6.dp),
|
||||
)
|
||||
}
|
||||
group.bullets.forEach { bullet ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "•",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = bullet,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
@@ -46,6 +100,74 @@ fun WhatsNewDialog(
|
||||
)
|
||||
}
|
||||
|
||||
/** One section: an optional header plus its bullets. */
|
||||
private data class WhatsNewGroup(val header: String?, val bullets: List<String>)
|
||||
|
||||
/** Parsed release notes: the leading version line plus styled sections. */
|
||||
private data class WhatsNewNotes(
|
||||
val version: String?,
|
||||
val groups: List<WhatsNewGroup>,
|
||||
val fallback: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Classify each line of the [whats_new.txt] format:
|
||||
* - line 0 (non-bullet) → version subtitle (`-` upgraded to an em dash),
|
||||
* - blank line → ends the current bullet,
|
||||
* - `* ` prefix → a new bullet,
|
||||
* - leading whitespace → continuation appended to the current bullet,
|
||||
* - anything else → a section header.
|
||||
*/
|
||||
private fun parseWhatsNew(raw: String): WhatsNewNotes {
|
||||
if (raw.isBlank()) return WhatsNewNotes(null, emptyList(), raw.ifBlank { null })
|
||||
|
||||
val lines = raw.lines()
|
||||
var version: String? = null
|
||||
val groups = mutableListOf<WhatsNewGroup>()
|
||||
var currentHeader: String? = null
|
||||
val currentBullets = mutableListOf<String>()
|
||||
val pending = StringBuilder()
|
||||
|
||||
fun flushBullet() {
|
||||
if (pending.isNotEmpty()) {
|
||||
currentBullets += pending.toString().trim()
|
||||
pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun flushGroup() {
|
||||
flushBullet()
|
||||
if (currentHeader != null || currentBullets.isNotEmpty()) {
|
||||
groups += WhatsNewGroup(currentHeader, currentBullets.toList())
|
||||
}
|
||||
currentHeader = null
|
||||
currentBullets.clear()
|
||||
}
|
||||
|
||||
lines.forEachIndexed { index, line ->
|
||||
val trimmed = line.trim()
|
||||
when {
|
||||
index == 0 && trimmed.isNotEmpty() && !trimmed.startsWith("*") ->
|
||||
version = trimmed.replace(" - ", " — ")
|
||||
trimmed.isEmpty() -> flushBullet()
|
||||
trimmed.startsWith("* ") -> {
|
||||
flushBullet()
|
||||
pending.append(trimmed.removePrefix("* "))
|
||||
}
|
||||
line.startsWith(" ") || line.startsWith("\t") -> {
|
||||
if (pending.isNotEmpty()) pending.append(' ')
|
||||
pending.append(trimmed)
|
||||
}
|
||||
else -> {
|
||||
flushGroup()
|
||||
currentHeader = trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
flushGroup()
|
||||
return WhatsNewNotes(version, groups, fallback = null)
|
||||
}
|
||||
|
||||
private fun loadWhatsNew(context: Context): String {
|
||||
return try {
|
||||
context.assets.open("whats_new.txt").bufferedReader().readText()
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -35,14 +34,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
@@ -57,7 +54,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -91,7 +87,6 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.purpleGlow
|
||||
import com.hermesandroid.relay.ui.theme.radialNavyBackground
|
||||
import com.hermesandroid.relay.network.ChatMode
|
||||
import com.hermesandroid.relay.network.RelayVoiceClient
|
||||
@@ -119,18 +114,22 @@ import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.ChatInputBar
|
||||
import com.hermesandroid.relay.ui.components.ChatInputTrailing
|
||||
import com.hermesandroid.relay.ui.components.CommandPalette
|
||||
import com.hermesandroid.relay.ui.components.ConnectionStatusBadge
|
||||
import com.hermesandroid.relay.ui.components.CommandRow
|
||||
import com.hermesandroid.relay.ui.components.CompactToolCall
|
||||
import com.hermesandroid.relay.ui.components.ContextMeterBar
|
||||
import com.hermesandroid.relay.ui.components.InlineAutocomplete
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
@@ -140,14 +139,21 @@ import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.SphereState
|
||||
import com.hermesandroid.relay.ui.components.SessionDrawerContent
|
||||
import com.hermesandroid.relay.ui.components.SlashCommand
|
||||
import com.hermesandroid.relay.ui.components.StreamingDots
|
||||
import com.hermesandroid.relay.ui.components.SubagentLane
|
||||
import com.hermesandroid.relay.ui.components.ToolProgressCard
|
||||
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayGridTexture
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import kotlin.math.roundToInt
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ChatConnectState
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
import com.hermesandroid.relay.voice.VoiceOverlayHost
|
||||
@@ -266,6 +272,7 @@ fun ChatScreen(
|
||||
var voiceOutputConfig by remember { mutableStateOf<VoiceOutputConfig?>(null) }
|
||||
var realtimeAgentConfig by remember { mutableStateOf<RealtimeVoiceConfig?>(null) }
|
||||
val chatReady by connectionViewModel.chatReady.collectAsState()
|
||||
val chatConnectState by connectionViewModel.chatConnectState.collectAsState()
|
||||
// Stable voice can use the standard Hermes dashboard audio routes or the
|
||||
// optional Relay voice routes. Gate the mic on either route being usable;
|
||||
// availability picks the actionable toast when neither is.
|
||||
@@ -279,6 +286,7 @@ fun ChatScreen(
|
||||
val sessions by chatViewModel.sessions.collectAsState()
|
||||
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
|
||||
val isLoadingHistory by chatViewModel.isLoadingHistory.collectAsState()
|
||||
val isLoadingSessions by chatViewModel.isLoadingSessions.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
val personalityNames by chatViewModel.personalityNames.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
@@ -303,6 +311,39 @@ fun ChatScreen(
|
||||
val maxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
|
||||
val charLimit by connectionViewModel.maxMessageLength.collectAsState()
|
||||
|
||||
// === Gateway desktop-parity state ===
|
||||
val serverCommands by chatViewModel.serverCommands.collectAsState()
|
||||
val contextUsage by chatViewModel.contextUsage.collectAsState()
|
||||
val steerableTurn by chatViewModel.steerableTurn.collectAsState()
|
||||
val steerNotice by chatViewModel.steerNotice.collectAsState()
|
||||
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
|
||||
// Whether the NEXT turn would ride the gateway transport — gates the
|
||||
// "Edit & resend" menu entry (conversation rewind needs the gateway).
|
||||
// Per-turn steerability comes from [steerableTurn], which also covers
|
||||
// the preflight-SSE-fallback window.
|
||||
val streamingEndpointPref by connectionViewModel.streamingEndpoint.collectAsState()
|
||||
val chatServerCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val chatGatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val isGatewayTransport = remember(
|
||||
streamingEndpointPref, chatServerCapabilities, chatGatewayAvailability,
|
||||
) {
|
||||
connectionViewModel.resolveStreamingEndpoint(streamingEndpointPref) == "gateway"
|
||||
}
|
||||
|
||||
// Pre-warm the gateway (connect + resume the current session) whenever the
|
||||
// chat surface is visible, the app is foregrounded, and the gateway is the
|
||||
// resolved transport — so the first send is warm (tens of ms to first
|
||||
// token) instead of paying the cold connect + session.resume on the send
|
||||
// path. Best-effort / idempotent; re-fires on return-to-foreground.
|
||||
val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState()
|
||||
LaunchedEffect(isGatewayTransport, appForeground) {
|
||||
if (isGatewayTransport && appForeground) chatViewModel.prewarmGateway()
|
||||
}
|
||||
|
||||
// Edit-and-resend mode: long-press a user bubble → "Edit & resend"
|
||||
// prefills the input; submit rewinds the conversation from that message.
|
||||
var editingMessage by remember { mutableStateOf<ChatMessage?>(null) }
|
||||
|
||||
// Animation settings
|
||||
val animationEnabled by connectionViewModel.animationEnabled.collectAsState()
|
||||
val animationBehindChat by connectionViewModel.animationBehindChat.collectAsState()
|
||||
@@ -349,6 +390,14 @@ fun ChatScreen(
|
||||
var showCommandPalette by remember { mutableStateOf(false) }
|
||||
var showAgentInfo by remember { mutableStateOf(false) }
|
||||
|
||||
// Server command dispatch can ask the composer to prefill (e.g. /undo).
|
||||
LaunchedEffect(chatViewModel) {
|
||||
chatViewModel.composerPrefill.collect { text ->
|
||||
editingMessage = null
|
||||
inputText = text.take(charLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings → Active Agent deep-link: when the nav arg says "open the
|
||||
// sheet", flip `showAgentInfo` on and call [onAgentSheetArgConsumed] so
|
||||
// the host clears the arg. Keyed on [openAgentSheetOnEntry] so the
|
||||
@@ -537,8 +586,22 @@ fun ChatScreen(
|
||||
// visibleItemsInfo (which has to account for header/footer spacers and
|
||||
// the StreamingDots indicator). This is also the trigger that resumes
|
||||
// auto-follow after the user scrolls back down manually.
|
||||
// "At bottom" with a small slop (~1.5 lines of text) rather than the exact
|
||||
// `!canScrollForward`. A burst of streaming content — or a sub-frame layout
|
||||
// gap before the auto-follow re-pins — can momentarily make the list
|
||||
// scrollable-forward; the strict check would read that as "user scrolled
|
||||
// away" and drop the follow. The slop keeps the Telegram-style follow
|
||||
// sticky through streaming jitter while still flipping to "scrolled away"
|
||||
// on a real read-up gesture.
|
||||
val atBottomSlopPx = 140
|
||||
val isAtBottom by remember {
|
||||
derivedStateOf { !listState.canScrollForward }
|
||||
derivedStateOf {
|
||||
val layout = listState.layoutInfo
|
||||
val last = layout.visibleItemsInfo.lastOrNull()
|
||||
?: return@derivedStateOf true
|
||||
last.index == layout.totalItemsCount - 1 &&
|
||||
(last.offset + last.size) - layout.viewportEndOffset <= atBottomSlopPx
|
||||
}
|
||||
}
|
||||
|
||||
// True when the user has scrolled up (away from the bottom). The
|
||||
@@ -568,8 +631,9 @@ fun ChatScreen(
|
||||
derivedStateOf { messages.isNotEmpty() && !isAtBottom }
|
||||
}
|
||||
|
||||
// Build all commands dynamically: built-in + personalities + server skills
|
||||
val allCommands by remember(availableSkills, personalityNames) {
|
||||
// Build all commands dynamically: built-in + personalities + server
|
||||
// skills + (gateway) the server's commands.catalog
|
||||
val allCommands by remember(availableSkills, personalityNames, serverCommands) {
|
||||
derivedStateOf {
|
||||
// Built-in hermes gateway commands (from hermes_cli/commands.py)
|
||||
// Only includes commands available via gateway (not cli_only)
|
||||
@@ -626,7 +690,26 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
builtIn + personalities + skills
|
||||
val base = builtIn + personalities + skills
|
||||
if (serverCommands.isEmpty()) {
|
||||
base
|
||||
} else {
|
||||
// Merge the gateway catalog as a 4th source: dedupe by
|
||||
// command name with the server description winning;
|
||||
// server-only commands append under their catalog category
|
||||
// (or the palette's "server" bucket).
|
||||
val serverByName = serverCommands.associateBy { it.command.lowercase() }
|
||||
val merged = base.map { cmd ->
|
||||
serverByName[cmd.command.lowercase()]?.let { server ->
|
||||
cmd.copy(
|
||||
description = server.description,
|
||||
source = SlashCommand.SOURCE_SERVER,
|
||||
)
|
||||
} ?: cmd
|
||||
}
|
||||
val baseNames = base.map { it.command.lowercase() }.toSet()
|
||||
merged + serverCommands.filter { it.command.lowercase() !in baseNames }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,6 +737,16 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Opening the drawer re-syncs the list — so a session created on another
|
||||
// device (or one whose optimistic row was dropped on a profile switch)
|
||||
// shows up without a manual reload. Cheap dashboard read; the optimistic
|
||||
// row for the active session is preserved by ChatHandler.updateSessions.
|
||||
LaunchedEffect(drawerState.isOpen) {
|
||||
if (drawerState.isOpen && chatReady) {
|
||||
chatViewModel.refreshSessions()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom while streaming.
|
||||
//
|
||||
// Bugs the previous versions had:
|
||||
@@ -739,8 +832,21 @@ fun ChatScreen(
|
||||
val isListRebuild = prev != null
|
||||
&& snapshot.messageCount - prev.messageCount > 1
|
||||
|
||||
if (isListRebuild) {
|
||||
// Instant — no animation, no conflict with animateItem.
|
||||
// Growth of the bubble we're already following (thinking /
|
||||
// content / tool cards on the same message) arrives at token
|
||||
// frequency on the gateway transport. animateScrollToItem
|
||||
// per delta is a cancel/restart storm — each collectLatest
|
||||
// cancellation strands the viewport mid-animation (showing
|
||||
// earlier content) before the next one yanks it back:
|
||||
// visible stutter when parked at the bottom during long
|
||||
// reasoning. Pin instantly instead; reserve the animation
|
||||
// for the discrete new-bubble event.
|
||||
val isSameTurnGrowth = prev != null
|
||||
&& snapshot.messageCount == prev.messageCount
|
||||
|
||||
if (isListRebuild || isSameTurnGrowth) {
|
||||
// Instant — no animation, no conflict with animateItem,
|
||||
// no pile-up at delta frequency.
|
||||
listState.scrollToItem(lastIndex, Int.MAX_VALUE)
|
||||
} else {
|
||||
// scrollOffset = Int.MAX_VALUE → Compose clamps to
|
||||
@@ -831,6 +937,7 @@ fun ChatScreen(
|
||||
currentSessionId = currentSessionId,
|
||||
scopeTitle = drawerTitle,
|
||||
scopeSubtitle = drawerSubtitle,
|
||||
isLoading = isLoadingSessions,
|
||||
onNewChat = {
|
||||
chatViewModel.createNewChat()
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -879,15 +986,6 @@ fun ChatScreen(
|
||||
else -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
|
||||
// Customization cue — true when the user has overridden
|
||||
// the server defaults via either picker. Drives the 2dp
|
||||
// accent ring on the avatar (subtle, at-a-glance signal
|
||||
// that "you've customized this agent"). Personality
|
||||
// defaults to the sentinel "default" string — any other
|
||||
// value means explicit pick.
|
||||
val customized = selectedProfile != null ||
|
||||
selectedPersonality != "default"
|
||||
|
||||
// Single-line subtitle: when we have a model name we show
|
||||
// `model · personality` so the user sees both dimensions
|
||||
// at once. Before the server config lands (or while
|
||||
@@ -903,11 +1001,23 @@ fun ChatScreen(
|
||||
val modelName = effectiveProfile?.model
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: serverModelName
|
||||
// Subtext: a NON-default personality shown BEFORE the model
|
||||
// (e.g. "Catgirl \u00B7 gpt-5.5"); the default personality is
|
||||
// implied, so it's just the model. Falls back to the
|
||||
// personality label when there's no model name yet.
|
||||
val nonDefaultPersonality = selectedPersonality
|
||||
.takeIf {
|
||||
it.isNotBlank() &&
|
||||
it != "default" &&
|
||||
!it.equals(defaultPersonality, ignoreCase = true)
|
||||
}
|
||||
?.replaceFirstChar { it.uppercase() }
|
||||
val subtitleText = when {
|
||||
!apiReachable -> statusText
|
||||
modelName.isNotBlank() ->
|
||||
"$modelName \u00B7 $personalityLabel"
|
||||
else -> personalityLabel
|
||||
else -> listOfNotNull(
|
||||
nonDefaultPersonality,
|
||||
modelName.takeIf { it.isNotBlank() },
|
||||
).joinToString(" \u00B7 ").ifBlank { personalityLabel }
|
||||
}
|
||||
val subtitleColor = if (apiReachable) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
@@ -920,34 +1030,15 @@ fun ChatScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.clickable { showAgentInfo = true }
|
||||
) {
|
||||
// Avatar — 40dp, optional 2dp primary-color accent ring
|
||||
// when the user has overridden any of the agent
|
||||
// defaults. Ring sits OUTSIDE the avatar circle; the
|
||||
// inner Surface is downsized by ring width so the
|
||||
// overall footprint stays at 40dp.
|
||||
val ringWidth = if (customized) 2.dp else 0.dp
|
||||
val innerSize = 40.dp - (ringWidth * 2)
|
||||
// Avatar — a plain 40dp circle whose letter swaps to the
|
||||
// active agent (profile or personality). No overlay ring:
|
||||
// the letter itself is the indicator.
|
||||
Box(modifier = Modifier.size(40.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.then(
|
||||
if (customized) {
|
||||
Modifier.border(
|
||||
width = ringWidth,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = CircleShape,
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.padding(ringWidth),
|
||||
contentAlignment = Alignment.Center,
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(innerSize),
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
// Cross-fade the letter when the
|
||||
// effective agent (profile or personality)
|
||||
// changes so the avatar feels alive on a
|
||||
@@ -971,7 +1062,6 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ConnectionStatusBadge(
|
||||
isConnected = apiReachable,
|
||||
isConnecting = isConnecting,
|
||||
@@ -990,8 +1080,28 @@ fun ChatScreen(
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
// Context escalation: ≥85% the subtitle gains a
|
||||
// " · NN% ctx" suffix in the caution ladder color
|
||||
// (Amber, Danger past 90%). The ambient strip
|
||||
// below the app bar covers the 50–85% range.
|
||||
val ctxFraction = contextUsage
|
||||
val subtitleAnnotated = buildAnnotatedString {
|
||||
append(subtitleText)
|
||||
if (apiReachable && ctxFraction != null && ctxFraction >= 0.85f) {
|
||||
val ctxColor = if (ctxFraction >= 0.9f) {
|
||||
RelayRefresh.Danger
|
||||
} else {
|
||||
RelayRefresh.Amber
|
||||
}
|
||||
withStyle(SpanStyle(color = ctxColor)) {
|
||||
append(
|
||||
" · ${(ctxFraction * 100).roundToInt()}% ctx"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = subtitleText,
|
||||
text = subtitleAnnotated,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = subtitleColor,
|
||||
maxLines = 1,
|
||||
@@ -1057,6 +1167,9 @@ fun ChatScreen(
|
||||
containerColor = RelayRefresh.Background.copy(alpha = 0.96f)
|
||||
)
|
||||
)
|
||||
// Ambient context-window meter — 2dp strip at the seam between
|
||||
// the app bar and the mode strip; composes to nothing below 50%.
|
||||
ContextMeterBar(usedFraction = contextUsage)
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Chat,
|
||||
onModeSelected = { mode ->
|
||||
@@ -1200,60 +1313,114 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (chatReady) "Start a conversation" else "Connect to Hermes",
|
||||
text = when (chatConnectState) {
|
||||
// Name the agent when a profile is picked, so a
|
||||
// profile switch is legible in the thread itself
|
||||
// (not just the header) — the desktop's intro.
|
||||
ChatConnectState.Ready ->
|
||||
if (selectedProfile != null) "Chat with $agentDisplayName" else "Start a conversation"
|
||||
ChatConnectState.Connecting -> "Connecting to Hermes…"
|
||||
ChatConnectState.NeedsConnection -> "Connect to Hermes"
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
if (!chatReady) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.86f),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
// The selected agent's role/description — the rest of the
|
||||
// fresh-session intro, shown only when a profile is active.
|
||||
val profileBlurb = effectiveProfile?.description
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals(agentDisplayName, ignoreCase = true) }
|
||||
if (chatConnectState == ChatConnectState.Ready && profileBlurb != null) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = profileBlurb,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
when (chatConnectState) {
|
||||
// Hydration finished and there is genuinely nothing
|
||||
// configured — the only state that shows the CTA.
|
||||
ChatConnectState.NeedsConnection -> {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.86f),
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "Chat needs a Standard Hermes API connection.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = onNavigateToConnect,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Connect Standard Hermes")
|
||||
Text(
|
||||
text = "Chat needs a Standard Hermes API connection.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = onNavigateToConnect,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Connect Standard Hermes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Suggestion chips
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
suggestions.forEach { suggestion ->
|
||||
AssistChip(
|
||||
onClick = { inputText = suggestion },
|
||||
label = {
|
||||
Text(
|
||||
text = suggestion,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
labelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
// Cold-start hydration / an active connection still
|
||||
// coming up. Quiet spinner — never the connect CTA.
|
||||
// A low-emphasis "Manage connections" escape hatch
|
||||
// keeps a genuinely-stuck connection recoverable.
|
||||
ChatConnectState.Connecting -> {
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Text(
|
||||
text = "Getting things ready…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onNavigateToConnections) {
|
||||
Text("Manage connections")
|
||||
}
|
||||
}
|
||||
|
||||
ChatConnectState.Ready -> {
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Suggestion chips
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
suggestions.forEach { suggestion ->
|
||||
AssistChip(
|
||||
onClick = { inputText = suggestion },
|
||||
label = {
|
||||
Text(
|
||||
text = suggestion,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
},
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
labelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1303,10 +1470,12 @@ fun ChatScreen(
|
||||
val message = messages[index]
|
||||
|
||||
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
|
||||
// no attachments). Attachments keep the bubble alive for inbound media.
|
||||
// no attachments). Attachments keep the bubble alive for inbound media;
|
||||
// cards keep ask-card-only messages alive the same way.
|
||||
if (message.content.isBlank() &&
|
||||
message.toolCalls.isEmpty() &&
|
||||
message.attachments.isEmpty() &&
|
||||
message.cards.isEmpty() &&
|
||||
!message.isStreaming
|
||||
) return@items
|
||||
|
||||
@@ -1348,6 +1517,23 @@ fun ChatScreen(
|
||||
chatViewModel.dispatchCardAction(msgId, cardKey, action)
|
||||
}
|
||||
},
|
||||
onCardInput = { msgId, cardKey, value ->
|
||||
chatViewModel.answerAsk(msgId, cardKey, value)
|
||||
},
|
||||
onEditMessage = if (
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
message.role == MessageRole.USER &&
|
||||
!message.id.startsWith("voice-intent-") &&
|
||||
!message.id.startsWith("steer-")
|
||||
) {
|
||||
{ msg ->
|
||||
editingMessage = msg
|
||||
inputText = msg.content.take(charLimit)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onQuoteMessage = { text ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
val quoted = text.take(600)
|
||||
@@ -1383,28 +1569,60 @@ fun ChatScreen(
|
||||
}
|
||||
)
|
||||
|
||||
// Steered sends live inside a server-side tool
|
||||
// result, not a user message — flag the local
|
||||
// bubble so the scrollback explains itself.
|
||||
if (message.role == MessageRole.USER && message.id.startsWith("steer-")) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Text(
|
||||
text = "↳ steered",
|
||||
style = relayMetadataStyle(),
|
||||
color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.9f),
|
||||
modifier = Modifier.padding(top = 1.dp, end = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (toolDisplay != "off") {
|
||||
message.toolCalls.forEach { toolCall ->
|
||||
// Subagent children (taskIndex != null) group
|
||||
// into lanes after the top-level tool cards;
|
||||
// the null group renders exactly as before.
|
||||
val laneGroups = message.toolCalls.groupBy { it.taskIndex }
|
||||
laneGroups[null]?.forEach { toolCall ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
when (toolDisplay) {
|
||||
"compact" -> CompactToolCall(toolCall = toolCall)
|
||||
else -> ToolProgressCard(toolCall = toolCall)
|
||||
else -> ToolProgressCard(
|
||||
toolCall = toolCall,
|
||||
messageTimestamp = message.timestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
SubagentLane(
|
||||
taskIndex = taskIndex,
|
||||
calls = laneGroups.getValue(taskIndex),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isStreaming) {
|
||||
item {
|
||||
StreamingDots(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp, top = 4.dp)
|
||||
.animateItem()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(8.dp).animateItem()) }
|
||||
// NOTE: no standalone StreamingDots item here — the
|
||||
// streaming bubble already renders its own in-bubble
|
||||
// dots (MessageBubble), and a second indicator below
|
||||
// the bubble both read as a duplicate "typing" hint
|
||||
// and churned animateItem placement at the viewport
|
||||
// bottom on every delta (visible jitter at
|
||||
// gateway/token delta frequency). Same reason the
|
||||
// trailing spacer doesn't animateItem(): its position
|
||||
// shifts on every delta of the growing bubble above
|
||||
// it, and a constant 8dp gap gains nothing from
|
||||
// placement animation.
|
||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||
}
|
||||
|
||||
// Scroll-to-bottom FAB
|
||||
@@ -1567,149 +1785,128 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Input bar with character limit
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
// Attach file button
|
||||
IconButton(
|
||||
onClick = { filePickerLauncher.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
// Edit-and-resend mode chip — cancelable; submitting rewinds the
|
||||
// conversation from the edited message (gateway only).
|
||||
AnimatedVisibility(visible = editingMessage != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = "Attach file",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
imageVector = Icons.Filled.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
}
|
||||
|
||||
// Command palette button
|
||||
IconButton(
|
||||
onClick = { showCommandPalette = true },
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "/",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
text = "Editing — will rewind the conversation",
|
||||
style = relayMetadataStyle(),
|
||||
color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.9f),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = { if (it.length <= charLimit) inputText = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = {
|
||||
Text(if (isStreaming && inputText.isBlank()) "Queue a message..." else "Message...")
|
||||
},
|
||||
maxLines = 4,
|
||||
enabled = chatReady,
|
||||
supportingText = if (inputText.length > charLimit - 200) {
|
||||
{
|
||||
Text(
|
||||
"${inputText.length}/$charLimit",
|
||||
color = if (inputText.length >= charLimit) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
} else null
|
||||
)
|
||||
|
||||
// Stop button — visible during streaming
|
||||
AnimatedVisibility(visible = isStreaming) {
|
||||
IconButton(onClick = { chatViewModel.cancelStream() }) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
editingMessage = null
|
||||
inputText = ""
|
||||
},
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = "Stop streaming",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Cancel editing",
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing button — smart-swap between mic and send.
|
||||
// Empty input → Mic (taps into voice mode overlay).
|
||||
// Any typed text or attachment → Send (morph into send arrow,
|
||||
// queues during streaming). Stop button during streaming is a
|
||||
// separate IconButton above this one; both can coexist since
|
||||
// they have distinct semantics.
|
||||
val hasContent = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
val sendEnabled = hasContent && chatReady
|
||||
Box(
|
||||
modifier = if (sendEnabled && isDarkTheme && !isStreaming) {
|
||||
Modifier.purpleGlow(
|
||||
radius = 24.dp,
|
||||
alpha = 0.35f,
|
||||
isDarkTheme = true
|
||||
)
|
||||
} else Modifier
|
||||
) {
|
||||
if (hasContent) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
chatViewModel.sendMessage(inputText.ifBlank { "[attachment]" })
|
||||
inputText = ""
|
||||
},
|
||||
enabled = sendEnabled
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = if (isStreaming) "Queue message" else "Send message",
|
||||
tint = if (sendEnabled) {
|
||||
if (isStreaming) MaterialTheme.colorScheme.tertiary
|
||||
else MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (voiceReady) {
|
||||
requestVoiceMode()
|
||||
} else {
|
||||
android.widget.Toast.makeText(
|
||||
context,
|
||||
when (standardVoiceAvailability) {
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
|
||||
standardVoiceSignInRouteHint?.let { route ->
|
||||
"Voice needs a one-time sign-in on the $route route — open Manage"
|
||||
} ?: "Voice needs dashboard sign-in — open Manage to sign in"
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Unsupported ->
|
||||
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
|
||||
else ->
|
||||
"Voice needs a reachable Hermes dashboard or Relay voice route"
|
||||
},
|
||||
android.widget.Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Mic,
|
||||
contentDescription = if (voiceReady) {
|
||||
"Voice mode"
|
||||
} else {
|
||||
"Voice mode unavailable"
|
||||
},
|
||||
tint = if (voiceReady) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
.copy(alpha = 0.5f)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input bar — pill field with ONE trailing slot morphing
|
||||
// Send / Voice / Stop / Steer / Queue. "+" taps the file picker,
|
||||
// long-press opens the CommandPalette (the dedicated "/" button
|
||||
// is gone — typing "/" still surfaces InlineAutocomplete).
|
||||
val hasContent = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
val trailing = when {
|
||||
!isStreaming && hasContent -> ChatInputTrailing.SEND
|
||||
!isStreaming -> ChatInputTrailing.VOICE
|
||||
isStreaming && !hasContent -> ChatInputTrailing.STOP
|
||||
steerableTurn -> ChatInputTrailing.STEER
|
||||
else -> ChatInputTrailing.QUEUE
|
||||
}
|
||||
val inputCaption = when {
|
||||
isStreaming && hasContent && steerableTurn ->
|
||||
"↳ sends now — Hermes adjusts mid-turn"
|
||||
isStreaming && hasContent -> "↳ delivered after this turn finishes"
|
||||
isStreaming && steerNotice != null -> steerNotice
|
||||
else -> null
|
||||
}
|
||||
val inputPlaceholder = when {
|
||||
editingMessage != null -> "Edit your message…"
|
||||
isStreaming && steerableTurn -> "Steer the response…"
|
||||
isStreaming -> "Queue a message..."
|
||||
else -> "Message..."
|
||||
}
|
||||
ChatInputBar(
|
||||
value = inputText,
|
||||
onValueChange = { inputText = it },
|
||||
placeholder = inputPlaceholder,
|
||||
trailing = trailing,
|
||||
onSend = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
val editing = editingMessage
|
||||
if (editing != null) {
|
||||
// Only drop the edit state once the rewind actually
|
||||
// dispatched — a silent gate must not eat the text.
|
||||
if (chatViewModel.regenerateFromMessage(editing.id, inputText)) {
|
||||
editingMessage = null
|
||||
inputText = ""
|
||||
} else {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = "Can't edit right now — wait for the current turn to finish",
|
||||
duration = SnackbarDuration.Short,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
chatViewModel.sendMessage(inputText.ifBlank { "[attachment]" })
|
||||
inputText = ""
|
||||
}
|
||||
},
|
||||
onVoice = {
|
||||
if (voiceReady) {
|
||||
requestVoiceMode()
|
||||
} else {
|
||||
android.widget.Toast.makeText(
|
||||
context,
|
||||
when (standardVoiceAvailability) {
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
|
||||
standardVoiceSignInRouteHint?.let { route ->
|
||||
"Voice needs a one-time sign-in on the $route route — open Manage"
|
||||
} ?: "Voice needs dashboard sign-in — open Manage to sign in"
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Unsupported ->
|
||||
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
|
||||
else ->
|
||||
"Voice needs a reachable Hermes dashboard or Relay voice route"
|
||||
},
|
||||
android.widget.Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
onStop = { chatViewModel.cancelStream() },
|
||||
onAttach = { filePickerLauncher.launch(arrayOf("*/*")) },
|
||||
onLongPressAttach = { showCommandPalette = true },
|
||||
charLimit = charLimit,
|
||||
caption = inputCaption,
|
||||
voiceReady = voiceReady,
|
||||
showVoiceHint = !voiceHintSeen,
|
||||
onVoiceHintShown = { connectionViewModel.setVoiceHintSeen(true) },
|
||||
isDarkTheme = isDarkTheme,
|
||||
enabled = chatReady,
|
||||
)
|
||||
} // end Column
|
||||
|
||||
// Mic permission denied banner — title + body + Open Settings action.
|
||||
@@ -1802,7 +1999,7 @@ fun ChatScreen(
|
||||
voiceCompactMode = compact
|
||||
},
|
||||
// === v0.4.1 JIT permission-denied chip ===
|
||||
// Tap deep-links to Settings → Apps → Hermes Relay →
|
||||
// Tap deep-links to Settings → Apps → Hermes-Relay →
|
||||
// Permissions for the running package. Use BuildConfig
|
||||
// .APPLICATION_ID rather than a hard-coded string so both
|
||||
// the googlePlay and sideload flavors land on their own
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -42,7 +45,9 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.GatewayAvailability
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
@@ -165,6 +170,52 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Turn-complete notification toggle. First enable on
|
||||
// API 33+ runs the POST_NOTIFICATIONS request (the
|
||||
// BridgeScreen master-toggle precedent); if the user
|
||||
// denies, the notifier silently no-ops at post time.
|
||||
val notifyTurnComplete by connectionViewModel.notifyTurnComplete.collectAsState()
|
||||
val settingsContext = LocalContext.current
|
||||
val notifyPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { /* Notifier re-checks the grant at post time. */ }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Notify when Hermes finishes",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Post a notification when a reply completes while the app is in the background",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = notifyTurnComplete,
|
||||
onCheckedChange = { enabled ->
|
||||
connectionViewModel.setNotifyTurnComplete(enabled)
|
||||
if (enabled &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
settingsContext,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notifyPermissionLauncher.launch(
|
||||
android.Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Tool call display mode
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
@@ -373,11 +424,9 @@ fun ChatSettingsScreen(
|
||||
HorizontalDivider()
|
||||
|
||||
val serverCaps by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val resolvedStreamingEndpoint = if (streamingEndpoint == "auto") {
|
||||
serverCaps.preferredChatEndpoint()
|
||||
} else {
|
||||
streamingEndpoint
|
||||
}
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val resolvedStreamingEndpoint =
|
||||
connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
|
||||
|
||||
// Parse tool annotations toggle (text-stream endpoints only)
|
||||
val isTextAnnotationMode = resolvedStreamingEndpoint == "sessions" ||
|
||||
@@ -440,6 +489,8 @@ fun ChatSettingsScreen(
|
||||
"Auto: picks the best path based on what your server exposes. " +
|
||||
"Currently using: $resolvedStreamingEndpoint" +
|
||||
when {
|
||||
resolvedStreamingEndpoint == "gateway" ->
|
||||
" (live thinking via the dashboard WebSocket)"
|
||||
!serverCaps.sessionsChatStream && serverCaps.portable ->
|
||||
" (chat via /v1/chat/completions)"
|
||||
!serverCaps.sessionsChatStream && serverCaps.runs ->
|
||||
@@ -447,6 +498,9 @@ fun ChatSettingsScreen(
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
"gateway" -> "Gateway: live thinking + rich tool events over the " +
|
||||
"dashboard WebSocket (/api/ws) — what the desktop app uses. " +
|
||||
"Requires Manage sign-in; falls back to SSE per turn when unavailable."
|
||||
"sessions" -> "Sessions: Hermes-native /api/sessions/{id}/chat/stream."
|
||||
"completions" -> "Chat: OpenAI-compatible SSE via /v1/chat/completions."
|
||||
"runs" -> "Runs: use only when your server streams /v1/runs directly."
|
||||
@@ -457,9 +511,19 @@ fun ChatSettingsScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (gatewayAvailability == GatewayAvailability.SignInRequired &&
|
||||
(streamingEndpoint == "gateway" || streamingEndpoint == "auto")
|
||||
) {
|
||||
Text(
|
||||
text = "Sign in via the Manage tab to enable Gateway streaming " +
|
||||
"(live thinking).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
|
||||
val endpointOptions = listOf("auto", "sessions", "completions", "runs")
|
||||
val endpointLabels = listOf("Auto", "Sessions", "Chat", "Runs")
|
||||
val endpointOptions = listOf("auto", "gateway", "sessions", "completions", "runs")
|
||||
val endpointLabels = listOf("Auto", "Gateway", "Sessions", "Chat", "Runs")
|
||||
val selectedEndpointIndex = endpointOptions.indexOf(streamingEndpoint).coerceAtLeast(0)
|
||||
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -480,6 +544,36 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Keep connected in background — opt-in, both flavors.
|
||||
run {
|
||||
val gatewayKeepAlive by connectionViewModel.gatewayKeepAlive.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Keep connected in background",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Hold the chat connection open while the app is in the " +
|
||||
"background via a persistent notification, so replies stay " +
|
||||
"instant. Uses more battery; off by default.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = gatewayKeepAlive,
|
||||
onCheckedChange = { connectionViewModel.setGatewayKeepAlive(it) }
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
// Limits — expandable
|
||||
var limitsExpanded by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
|
||||
@@ -766,12 +766,24 @@ fun DashboardManagementScreen(
|
||||
}
|
||||
|
||||
pendingAction?.let { pending ->
|
||||
val isActivateProfile = pending.action.kind == DashboardActionKind.ActivateProfile
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingAction = null },
|
||||
title = { Text("${pending.action.label} ${pending.item.title}?") },
|
||||
title = {
|
||||
Text(
|
||||
if (isActivateProfile) "Make ${pending.item.title} the server default?"
|
||||
else "${pending.action.label} ${pending.item.title}?",
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = "This changes server-side dashboard state for ${pending.item.title}.",
|
||||
text = if (isActivateProfile) {
|
||||
"Sets ${pending.item.title} as the server's active agent for every " +
|
||||
"client — the persistent “hermes use” default. Switching agents in " +
|
||||
"chat is per-conversation and doesn't change this."
|
||||
} else {
|
||||
"This changes server-side dashboard state for ${pending.item.title}."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
@@ -781,7 +793,7 @@ fun DashboardManagementScreen(
|
||||
pendingAction = null
|
||||
runAction(pending.item, pending.action)
|
||||
},
|
||||
) { Text(pending.action.label) }
|
||||
) { Text(if (isActivateProfile) "Set default" else pending.action.label) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingAction = null }) {
|
||||
@@ -1154,6 +1166,12 @@ fun DashboardManagementScreen(
|
||||
)
|
||||
DashboardActionKind.EditProfileSoul ->
|
||||
openSoulEditor(item)
|
||||
// Always confirm: this flips the
|
||||
// server's persistent active agent for
|
||||
// every client, unlike the ephemeral
|
||||
// per-conversation switch in chat.
|
||||
DashboardActionKind.ActivateProfile ->
|
||||
pendingAction = PendingDashboardAction(item, action)
|
||||
else -> if (action.destructive) {
|
||||
pendingAction = PendingDashboardAction(item, action)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Save / share / open helpers for chat images and attachments — the byte
|
||||
* plumbing behind the image viewer's Save/Share controls and the attachment
|
||||
* card's long-press menu.
|
||||
*
|
||||
* Save destinations are deliberately permission-free:
|
||||
* - **Android 10+ (API 29, Scoped Storage):** [MediaStore] — images land in
|
||||
* `Pictures/Hermes-Relay`, other files in `Download/Hermes-Relay`. No
|
||||
* runtime permission required.
|
||||
* - **Android 9 and below:** the app declares no `WRITE_EXTERNAL_STORAGE`
|
||||
* permission (and shouldn't prompt for one for this niche path), so save
|
||||
* returns [SaveResult.UseShareInstead] and the caller routes to the system
|
||||
* share sheet — which offers "Save to Files"/Drive/etc.
|
||||
*
|
||||
* Sharing always works regardless of version: bytes are staged into the
|
||||
* FileProvider-backed `hermes-media/` cache (the same path `file_provider_paths`
|
||||
* already maps) and handed to `ACTION_SEND` as a `content://` URI, since you
|
||||
* cannot share a remote `http(s)` URL as a stream.
|
||||
*/
|
||||
object MediaSaver {
|
||||
|
||||
private const val SAVE_SUBDIR = "Hermes-Relay"
|
||||
private const val SHARE_CACHE_DIR = "hermes-media"
|
||||
|
||||
private val httpClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.callTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Outcome of a save attempt. */
|
||||
sealed interface SaveResult {
|
||||
/** Persisted; [location] is a human-readable folder for the toast. */
|
||||
data class Saved(val uri: Uri, val location: String) : SaveResult
|
||||
|
||||
/** Pre-Q with no storage permission — the caller should share instead. */
|
||||
data object UseShareInstead : SaveResult
|
||||
|
||||
data class Failed(val message: String) : SaveResult
|
||||
}
|
||||
|
||||
// --- Remote bytes -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GET [url] and return its bytes plus the best-effort `Content-Type`. Runs
|
||||
* on IO. Throws on a non-2xx response or empty body so callers can fall
|
||||
* back to a notice.
|
||||
*/
|
||||
suspend fun fetchRemoteBytes(url: String): Pair<ByteArray, String?> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
httpClient.newCall(request).execute().use { resp ->
|
||||
if (!resp.isSuccessful) error("HTTP ${resp.code}")
|
||||
val contentType = resp.header("Content-Type")?.substringBefore(';')?.trim()
|
||||
val body = resp.body.bytes()
|
||||
body to contentType
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the bytes behind a `content://` (or `file://`) [uri]. */
|
||||
suspend fun readUriBytes(context: Context, uri: Uri): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
// --- Save ---------------------------------------------------------------
|
||||
|
||||
suspend fun saveImage(
|
||||
context: Context,
|
||||
bytes: ByteArray,
|
||||
displayName: String?,
|
||||
mime: String,
|
||||
): SaveResult {
|
||||
// Prefer a magic-byte-sniffed type so the extension is correct even
|
||||
// when the caller only had a generic `image/*` guess (remote URLs).
|
||||
val effectiveMime = sniffImageMime(bytes) ?: mime.ifBlank { "image/png" }
|
||||
return saveTo(context, bytes, ensureNamed(displayName, effectiveMime), effectiveMime, isImage = true)
|
||||
}
|
||||
|
||||
suspend fun saveFile(
|
||||
context: Context,
|
||||
bytes: ByteArray,
|
||||
displayName: String?,
|
||||
mime: String,
|
||||
): SaveResult = saveTo(
|
||||
context,
|
||||
bytes,
|
||||
ensureNamed(displayName, mime),
|
||||
mime.ifBlank { "application/octet-stream" },
|
||||
isImage = false,
|
||||
)
|
||||
|
||||
private suspend fun saveTo(
|
||||
context: Context,
|
||||
bytes: ByteArray,
|
||||
fileName: String,
|
||||
mime: String,
|
||||
isImage: Boolean,
|
||||
): SaveResult = withContext(Dispatchers.IO) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
runCatching { saveViaMediaStore(context, bytes, fileName, mime, isImage) }
|
||||
.getOrElse { SaveResult.Failed(it.message ?: "save failed") }
|
||||
} else {
|
||||
// Pre-Q public-dir writes need WRITE_EXTERNAL_STORAGE, which we
|
||||
// don't request — route the caller to the share sheet instead.
|
||||
SaveResult.UseShareInstead
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun saveViaMediaStore(
|
||||
context: Context,
|
||||
bytes: ByteArray,
|
||||
fileName: String,
|
||||
mime: String,
|
||||
isImage: Boolean,
|
||||
): SaveResult {
|
||||
val resolver = context.contentResolver
|
||||
val collection = if (isImage) {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
} else {
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
val baseFolder = if (isImage) Environment.DIRECTORY_PICTURES else Environment.DIRECTORY_DOWNLOADS
|
||||
val relativePath = "$baseFolder/$SAVE_SUBDIR"
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||
if (mime.isNotBlank()) put(MediaStore.MediaColumns.MIME_TYPE, mime)
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(collection, values) ?: error("MediaStore insert returned null")
|
||||
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: error("openOutputStream returned null")
|
||||
resolver.update(uri, ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, null, null)
|
||||
return SaveResult.Saved(uri, relativePath)
|
||||
}
|
||||
|
||||
// --- Share / open -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stage [bytes] in the FileProvider cache; return a shareable `content://`
|
||||
* URI. When [mime] is blank or a wildcard (e.g. an `image/…` wildcard from
|
||||
* an inline image whose real type we only learn from the bytes), the type is
|
||||
* magic-byte-sniffed so the staged file gets a correct extension —
|
||||
* FileProvider derives the shared `content://` type from that extension, so
|
||||
* a wrong `.bin` would otherwise make the receiver see octet-stream.
|
||||
*/
|
||||
suspend fun stageForShare(context: Context, bytes: ByteArray, displayName: String?, mime: String): Uri =
|
||||
withContext(Dispatchers.IO) {
|
||||
val effectiveMime = if (mime.isBlank() || mime.endsWith("/*")) {
|
||||
sniffImageMime(bytes) ?: mime
|
||||
} else {
|
||||
mime
|
||||
}
|
||||
val dir = File(context.cacheDir, SHARE_CACHE_DIR).apply { if (!exists()) mkdirs() }
|
||||
val file = File(dir, ensureNamed(displayName, effectiveMime))
|
||||
file.writeBytes(bytes)
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
}
|
||||
|
||||
fun share(context: Context, uri: Uri, mime: String) {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = mime.ifBlank { "application/octet-stream" }
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val chooser = Intent.createChooser(send, "Share").apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
runCatching { context.startActivity(chooser) }
|
||||
}
|
||||
|
||||
fun open(context: Context, uri: Uri, mime: String) {
|
||||
val view = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mime.ifBlank { "*/*" })
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
runCatching { context.startActivity(view) }
|
||||
}
|
||||
|
||||
// --- Naming / sniffing --------------------------------------------------
|
||||
|
||||
/** Sanitize [base] and guarantee a file extension derived from [mime]. */
|
||||
fun ensureNamed(base: String?, mime: String): String {
|
||||
val cleaned = base
|
||||
?.substringAfterLast('/')
|
||||
?.substringAfterLast('\\')
|
||||
?.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
?.take(80)
|
||||
?.ifBlank { null }
|
||||
val stem = cleaned ?: "hermes-${System.currentTimeMillis()}"
|
||||
// Keep an existing, plausible extension; otherwise append the derived one.
|
||||
val hasExt = stem.substringAfterLast('.', "").let { it.isNotBlank() && it.length <= 5 }
|
||||
return if (hasExt) stem else "$stem.${extensionFor(mime)}"
|
||||
}
|
||||
|
||||
private fun extensionFor(mime: String): String = when (mime.lowercase().substringBefore(';').trim()) {
|
||||
"image/jpeg", "image/jpg" -> "jpg"
|
||||
"image/png" -> "png"
|
||||
"image/webp" -> "webp"
|
||||
"image/gif" -> "gif"
|
||||
"image/bmp" -> "bmp"
|
||||
"image/heic" -> "heic"
|
||||
"image/heif" -> "heif"
|
||||
"image/svg+xml" -> "svg"
|
||||
"video/mp4" -> "mp4"
|
||||
"audio/mpeg" -> "mp3"
|
||||
"application/pdf" -> "pdf"
|
||||
"text/plain" -> "txt"
|
||||
"application/json" -> "json"
|
||||
else -> "bin"
|
||||
}
|
||||
|
||||
/** Identify a common image type from its magic bytes; null if unknown. */
|
||||
private fun sniffImageMime(b: ByteArray): String? {
|
||||
if (b.size < 12) return null
|
||||
fun u(i: Int) = b[i].toInt() and 0xFF
|
||||
return when {
|
||||
u(0) == 0x89 && u(1) == 0x50 && u(2) == 0x4E && u(3) == 0x47 -> "image/png"
|
||||
u(0) == 0xFF && u(1) == 0xD8 && u(2) == 0xFF -> "image/jpeg"
|
||||
u(0) == 0x47 && u(1) == 0x49 && u(2) == 0x46 -> "image/gif"
|
||||
u(0) == 0x42 && u(1) == 0x4D -> "image/bmp"
|
||||
// RIFF....WEBP
|
||||
u(0) == 0x52 && u(1) == 0x49 && u(2) == 0x46 && u(3) == 0x46 &&
|
||||
u(8) == 0x57 && u(9) == 0x45 && u(10) == 0x42 && u(11) == 0x50 -> "image/webp"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,15 @@ fun buildPromptBlock(settings: AppContextSettings, snapshot: PhoneSnapshot): Str
|
||||
return lines.joinToString("\n\n")
|
||||
}
|
||||
|
||||
// Note: the gateway path intentionally carries NO phone-context preamble.
|
||||
// `prompt.submit` is bare text with no system-message slot, and prepending
|
||||
// the preamble to the user's text persists it into the session transcript
|
||||
// (ugly on history reload + visible from desktop). The gateway's only
|
||||
// call-time system overlay (`ephemeral_system_prompt`) is the personality
|
||||
// slot, so it can't carry phone context without clobbering the user's persona.
|
||||
// Phone context therefore rides ONLY the SSE `systemMessage` (invisible) and
|
||||
// the on-demand `android_phone_status` tool.
|
||||
|
||||
/**
|
||||
* Bridge-channel summary. Always emitted when `bridgeState` is on, even if
|
||||
* the bridge isn't bound — "Phone bridge: not installed" is itself useful
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Per-turn latency tracer for the chat transports.
|
||||
*
|
||||
* Stamps monotonic ([System.nanoTime]) marks across a single
|
||||
* send → first-token → done turn and emits ONE INFO line on [done] so the
|
||||
* gateway and SSE paths are directly comparable in logcat — the whole point
|
||||
* being to answer "where did the wait go?" against the official hermes-desktop
|
||||
* client, which always speaks the gateway.
|
||||
*
|
||||
* All marks are cumulative milliseconds since construction (t0 = the moment
|
||||
* the send began), tagged with `@` so adjacent marks can be diffed to read a
|
||||
* phase duration. Durations only — this NEVER logs message content.
|
||||
*
|
||||
* Gateway turns set `connect`/`session`/`submit` (the connection-establish
|
||||
* phases, skipped on a warm socket) plus `ttfe`/`ttft`. SSE turns are a single
|
||||
* POST, so they set only `ttfe`/`ttft` (the connection phases show as absent).
|
||||
*
|
||||
* - `ttfe` — time to first event (server acknowledged and started producing).
|
||||
* - `ttft` — time to first *visible* streamed token (reasoning OR text). This
|
||||
* is the perceptual "it finally responded" metric and the one that exposes
|
||||
* the SSE reasoning dead-air: on the gateway reasoning streams live so `ttft`
|
||||
* is small; on SSE the reasoning phase is invisible until done so `ttft`
|
||||
* balloons.
|
||||
*
|
||||
* Every mutator is idempotent on first-wins ([mark]) or single-shot ([done]),
|
||||
* so terminal paths can call [done] from more than one place safely.
|
||||
*/
|
||||
class TurnLatencyTracer(private val transport: String) {
|
||||
private val t0 = System.nanoTime()
|
||||
private val marks = LinkedHashMap<String, Long>()
|
||||
|
||||
@Volatile
|
||||
private var warmTag: String? = null
|
||||
|
||||
@Volatile
|
||||
private var finished = false
|
||||
|
||||
private fun nowMs(): Long = (System.nanoTime() - t0) / 1_000_000
|
||||
|
||||
/** Record whether the socket+session were already warm (gateway only). */
|
||||
@Synchronized
|
||||
fun warm(isWarm: Boolean) {
|
||||
warmTag = if (isWarm) "warm" else "cold"
|
||||
}
|
||||
|
||||
/** Record cumulative elapsed for [name]; first call wins (later calls ignored). */
|
||||
@Synchronized
|
||||
fun mark(name: String) {
|
||||
if (!marks.containsKey(name)) marks[name] = nowMs()
|
||||
}
|
||||
|
||||
/** Emit the consolidated timing line. Single-shot; safe to call from multiple terminals. */
|
||||
@Synchronized
|
||||
fun done(outcome: String = "") {
|
||||
if (finished) return
|
||||
finished = true
|
||||
val total = nowMs()
|
||||
val line = buildString {
|
||||
append("turn[").append(transport).append(']')
|
||||
warmTag?.let { append(' ').append(it) }
|
||||
marks.forEach { (k, v) -> append(' ').append(k).append('@').append(v).append("ms") }
|
||||
append(" done@").append(total).append("ms")
|
||||
if (outcome.isNotEmpty()) append(' ').append(outcome)
|
||||
}
|
||||
Log.i(TAG, line)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TurnLatency"
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,17 @@ object CardDispatchSyncBuilder {
|
||||
/** Prefix for synthetic tool-call IDs. Stable so tests can match. */
|
||||
internal const val CALL_ID_PREFIX = "call_carddispatch_"
|
||||
|
||||
/**
|
||||
* Gateway ask cards (clarify/approval/sudo/secret) are EXCLUDED from
|
||||
* sync entirely: the server already absorbed the answer through the
|
||||
* blocking ask RPC, and for secrets the value (even its sanitized
|
||||
* stamp) must not be replayed into session memory. Ask cards live only
|
||||
* on locally-built messages with this id prefix (see
|
||||
* ChatViewModel.presentInteractionAsk), and carry `ask.*` card types.
|
||||
*/
|
||||
internal const val ASK_MESSAGE_ID_PREFIX = "ask-"
|
||||
private const val ASK_CARD_TYPE_PREFIX = "ask."
|
||||
|
||||
/** Synthetic tool name. Intentionally namespaced so the upstream tool
|
||||
* dispatcher has no chance of mistaking it for a real executor. */
|
||||
internal const val TOOL_NAME = "hermes_card_action"
|
||||
@@ -90,6 +101,7 @@ object CardDispatchSyncBuilder {
|
||||
// audit records (regression caught by
|
||||
// CardDispatchSyncBuilderTest.buildSyntheticMessages_unknownCardKey_stillEmitsBareEnvelope).
|
||||
if (msg.cardDispatches.isEmpty()) continue
|
||||
if (msg.id.startsWith(ASK_MESSAGE_ID_PREFIX)) continue
|
||||
|
||||
// Index cards by their resolved cardKey so the dispatch
|
||||
// lookup is O(1). Mirrors the key formula in MessageBubble.kt
|
||||
@@ -101,6 +113,8 @@ object CardDispatchSyncBuilder {
|
||||
if (dispatch.syncedToServer) continue
|
||||
|
||||
val card = cardByKey[dispatch.cardKey]
|
||||
// Belt for ask cards that somehow live on a non-ask message.
|
||||
if (card?.type?.startsWith(ASK_CARD_TYPE_PREFIX) == true) continue
|
||||
// Dispatches whose card is gone from the message (trimmed
|
||||
// by the rolling MAX_MESSAGES buffer, for instance) still
|
||||
// get synced, just with less context — the key + value
|
||||
@@ -143,7 +157,8 @@ object CardDispatchSyncBuilder {
|
||||
|
||||
fun hasUnsynced(history: List<ChatMessage>): Boolean =
|
||||
history.any { msg ->
|
||||
msg.cardDispatches.any { !it.syncedToServer }
|
||||
!msg.id.startsWith(ASK_MESSAGE_ID_PREFIX) &&
|
||||
msg.cardDispatches.any { !it.syncedToServer }
|
||||
}
|
||||
|
||||
// === helpers ===
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProfileSessionStore
|
||||
import com.hermesandroid.relay.data.ProfileSelectionStore
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
@@ -38,12 +39,19 @@ import com.hermesandroid.relay.network.ChatMode
|
||||
import com.hermesandroid.relay.network.ConnectionManager
|
||||
import com.hermesandroid.relay.network.ConnectionState
|
||||
import com.hermesandroid.relay.network.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.models.MessageItem
|
||||
import com.hermesandroid.relay.network.models.SessionItem
|
||||
import com.hermesandroid.relay.network.DashboardAuthSession
|
||||
import com.hermesandroid.relay.network.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.DashboardStatus
|
||||
import com.hermesandroid.relay.network.EncryptedDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.EndpointResolver
|
||||
import com.hermesandroid.relay.network.GatewayAvailability
|
||||
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
|
||||
import com.hermesandroid.relay.network.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.GatewayKeepAliveService
|
||||
import com.hermesandroid.relay.network.HermesApiClient
|
||||
import com.hermesandroid.relay.network.resolveStreamingEndpointPreference
|
||||
import com.hermesandroid.relay.network.RouteProbeOutcome
|
||||
import com.hermesandroid.relay.network.ProfileApiUrlResolver
|
||||
import com.hermesandroid.relay.network.ServerCapabilities
|
||||
@@ -114,6 +122,23 @@ enum class StandardVoiceAvailability {
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/**
|
||||
* Coarse connection state for the chat empty-state, derived in
|
||||
* [ConnectionViewModel.chatConnectState]. Lets the UI hold a neutral
|
||||
* "Connecting…" placeholder during cold-start hydration instead of flashing
|
||||
* the "Connect to Hermes" CTA before we know whether anything is configured.
|
||||
*/
|
||||
enum class ChatConnectState {
|
||||
/** Store not hydrated yet, or an active connection is still coming up. */
|
||||
Connecting,
|
||||
|
||||
/** Chat client built and the API server is reachable. */
|
||||
Ready,
|
||||
|
||||
/** Hydration complete and no connection is configured — show the CTA. */
|
||||
NeedsConnection,
|
||||
}
|
||||
|
||||
class ConnectionViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
@@ -173,6 +198,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
// Chat scroll behavior
|
||||
private val KEY_SMOOTH_AUTO_SCROLL = booleanPreferencesKey("smooth_auto_scroll")
|
||||
|
||||
// Turn-complete notification ("Notify when Hermes finishes")
|
||||
private val KEY_NOTIFY_TURN_COMPLETE = booleanPreferencesKey("notify_turn_complete")
|
||||
|
||||
// One-shot "Live voice conversation" hint on the input bar's voice slot
|
||||
private val KEY_VOICE_HINT_SEEN = booleanPreferencesKey("voice_mode_hint_seen")
|
||||
}
|
||||
|
||||
// --- Core networking components ---
|
||||
@@ -533,6 +564,90 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
private val _standardAudioApiReachable = MutableStateFlow(false)
|
||||
val standardAudioApiReachable: StateFlow<Boolean> = _standardAudioApiReachable.asStateFlow()
|
||||
|
||||
/**
|
||||
* Gateway chat transport (tui_gateway over the dashboard's `/api/ws`)
|
||||
* availability. Piggybacks on [probeStandardVoice] — same surface, same
|
||||
* `/api/status` + `/api/auth/me` checks — minus the audio-route HEAD:
|
||||
* `/api/ws` ships with every embedded-chat dashboard build, so route
|
||||
* absence is only discovered (and made sticky) at WS-upgrade time via
|
||||
* [markGatewayUnsupported].
|
||||
*/
|
||||
private val _gatewayAvailability = MutableStateFlow(GatewayAvailability.Unknown)
|
||||
val gatewayAvailability: StateFlow<GatewayAvailability> = _gatewayAvailability.asStateFlow()
|
||||
|
||||
/**
|
||||
* Sticky downgrade fired by [GatewayChatClient] when the WS upgrade is
|
||||
* rejected outright (404/403 — dashboard build without `/api/ws`). Stops
|
||||
* auto-resolution from re-picking gateway until a connection switch
|
||||
* resets it.
|
||||
*/
|
||||
fun markGatewayUnsupported() {
|
||||
_gatewayAvailability.value = GatewayAvailability.Unsupported
|
||||
}
|
||||
|
||||
/** Probe-driven update that respects the sticky [markGatewayUnsupported] verdict. */
|
||||
private fun updateGatewayAvailability(probed: GatewayAvailability) {
|
||||
val current = _gatewayAvailability.value
|
||||
if (current == GatewayAvailability.Unsupported && probed == GatewayAvailability.Ready) return
|
||||
_gatewayAvailability.value = probed
|
||||
}
|
||||
|
||||
/** Cached gateway client, keyed by connection + resolved dashboard URL. */
|
||||
private var gatewayClientCache: Triple<String, String, GatewayChatClient>? = null
|
||||
|
||||
/**
|
||||
* Gateway chat client for the active connection — built lazily, rebuilt
|
||||
* when the connection or its resolved dashboard URL changes (LAN ↔
|
||||
* Tailscale handoff), sharing the Manage tab's encrypted cookie store so
|
||||
* a dashboard sign-in there authenticates chat here.
|
||||
*/
|
||||
@Synchronized
|
||||
fun activeGatewayChatClient(): GatewayChatClient? {
|
||||
val connectionId = connectionStore.activeConnectionId.value ?: return null
|
||||
val dashboardUrl = activeDashboardUrl() ?: return null
|
||||
gatewayClientCache?.let { (cachedConnection, cachedUrl, client) ->
|
||||
if (cachedConnection == connectionId && cachedUrl == dashboardUrl) return client
|
||||
// Same connection, the resolved dashboard URL moved (a LAN⇄Tailscale
|
||||
// route change) WHILE a turn is in flight: RETARGET the live client
|
||||
// to the new route so the turn FOLLOWS it (reconnect + keep the live
|
||||
// session id — the session is server-side and the same shared
|
||||
// gateway sits behind both routes), instead of tearing the client
|
||||
// down (which would call activeTurn.cancel()) or stranding the turn
|
||||
// on the dead route until the watchdog.
|
||||
if (cachedConnection == connectionId && client.hasActiveTurn()) {
|
||||
android.util.Log.i(
|
||||
"ConnectionViewModel",
|
||||
"gateway route changed mid-turn — retargeting active client to follow the route",
|
||||
)
|
||||
client.retarget(
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreFor(connectionId),
|
||||
),
|
||||
),
|
||||
)
|
||||
gatewayClientCache = Triple(connectionId, dashboardUrl, client)
|
||||
return client
|
||||
}
|
||||
}
|
||||
gatewayClientCache?.third?.shutdown()
|
||||
val client = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreFor(connectionId),
|
||||
),
|
||||
),
|
||||
onGatewayUnsupported = { markGatewayUnsupported() },
|
||||
)
|
||||
// Carry the current keep-alive preference onto the fresh client so a
|
||||
// connection/route switch doesn't lose the no-background-close flag.
|
||||
client.setKeepAliveInBackground(gatewayKeepAlive.value)
|
||||
gatewayClientCache = Triple(connectionId, dashboardUrl, client)
|
||||
return client
|
||||
}
|
||||
|
||||
/** Per-connection encrypted cookie stores, cached to avoid Keystore churn. */
|
||||
private val dashboardCookieStores =
|
||||
java.util.concurrent.ConcurrentHashMap<String, EncryptedDashboardCookieStore>()
|
||||
@@ -580,6 +695,34 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val chatReady: StateFlow<Boolean> = combine(_chatApiClient, _apiServerReachable) { client, reachable ->
|
||||
client != null && reachable
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
/**
|
||||
* Three-way gate for the chat empty-state, so a cold start doesn't flash
|
||||
* the loud "Connect to Hermes" CTA while DataStore is still hydrating.
|
||||
*
|
||||
* - [ChatConnectState.Ready] — chat client built + server reachable.
|
||||
* - [ChatConnectState.Connecting] — either the connection store hasn't
|
||||
* hydrated yet (we don't yet know if anything is configured), OR an
|
||||
* active connection exists but chat isn't reachable yet (cold connect /
|
||||
* route resolve). Show a quiet spinner, never the connect button.
|
||||
* - [ChatConnectState.NeedsConnection] — hydration finished and there is
|
||||
* genuinely no connection to use. The only state that shows the CTA.
|
||||
*
|
||||
* Seeds [ChatConnectState.Connecting] so the very first composed frame —
|
||||
* before any flow emits — is the neutral state, not the CTA.
|
||||
*/
|
||||
val chatConnectState: StateFlow<ChatConnectState> = combine(
|
||||
connectionStore.isHydrated,
|
||||
activeConnection,
|
||||
chatReady,
|
||||
) { hydrated, active, ready ->
|
||||
when {
|
||||
ready -> ChatConnectState.Ready
|
||||
!hydrated -> ChatConnectState.Connecting
|
||||
active != null -> ChatConnectState.Connecting
|
||||
else -> ChatConnectState.NeedsConnection
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, ChatConnectState.Connecting)
|
||||
// NOTE: [relayReady] / [voiceReady] are declared below the [_relayUrl]
|
||||
// MutableStateFlow,
|
||||
// further down this file, because Kotlin class-body initializers run
|
||||
@@ -604,6 +747,25 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
endpoint?.api?.url ?: savedUrl
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, DEFAULT_API_URL)
|
||||
|
||||
/**
|
||||
* Whether a chat turn is currently streaming — mirrored from
|
||||
* [com.hermesandroid.relay.viewmodel.ChatViewModel.isStreaming] by RelayApp.
|
||||
* While true, an [effectiveApiServerUrl] route change DEFERS its chat-client
|
||||
* rebuild: rebuilding mid-turn replaces the client and cancels the in-flight
|
||||
* turn, whereas the gateway socket rides a transient route blip via its own
|
||||
* reconnect (keeping the live session). The deferred rebuild applies once
|
||||
* the turn ends.
|
||||
*/
|
||||
private val _chatStreaming = MutableStateFlow(false)
|
||||
|
||||
fun setChatStreaming(streaming: Boolean) {
|
||||
_chatStreaming.value = streaming
|
||||
}
|
||||
|
||||
/** A route change arrived mid-turn and its chat-client rebuild was deferred. */
|
||||
@Volatile
|
||||
private var pendingApiClientRebuild = false
|
||||
|
||||
/**
|
||||
* Runtime route for relay HTTP calls and WSS-adjacent helpers. Relay
|
||||
* control still requires the paired relay session token; this only
|
||||
@@ -806,10 +968,91 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// the same reason as [authState] / [pairingCode] — after a connection
|
||||
// switch the underlying [AuthManager] instance is replaced and the
|
||||
// public flow needs to repoint at the new manager's backing state.
|
||||
/**
|
||||
* Host agent profiles loaded from the dashboard `GET /api/profiles` — the
|
||||
* same profiles Manage and the official desktop expose. Populates
|
||||
* [agentProfiles] on a dashboard-only (non-relay) connection, where the
|
||||
* relay's `auth.ok` profile list is empty. Refreshed by
|
||||
* [refreshDashboardProfiles] when the agent sheet opens.
|
||||
*/
|
||||
private val _dashboardProfiles = MutableStateFlow<List<Profile>>(emptyList())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val agentProfiles: StateFlow<List<Profile>> = _authManagerFlow
|
||||
.flatMapLatest { it.agentProfiles }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, authManager.agentProfiles.value)
|
||||
val agentProfiles: StateFlow<List<Profile>> = combine(
|
||||
_authManagerFlow.flatMapLatest { it.agentProfiles },
|
||||
_dashboardProfiles,
|
||||
) { relay, dashboard ->
|
||||
// Prefer the relay's list when it has entries (richer runtime metadata);
|
||||
// fall back to the dashboard list so a dashboard-only connection still
|
||||
// sees its server profiles in the chat picker.
|
||||
relay.ifEmpty { dashboard }
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, authManager.agentProfiles.value)
|
||||
|
||||
/**
|
||||
* Load the host's agent profiles from the dashboard `/api/profiles` into
|
||||
* [agentProfiles] (merged in the combine above). Lets the chat agent sheet
|
||||
* offer server profiles on a dashboard-only connection. Best-effort: leaves
|
||||
* the current list untouched on failure (e.g. dashboard not signed in).
|
||||
*/
|
||||
fun refreshDashboardProfiles() {
|
||||
val connectionId = connectionStore.activeConnectionId.value ?: return
|
||||
val dashboardUrl = activeDashboardUrl() ?: return
|
||||
viewModelScope.launch {
|
||||
DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreFor(connectionId),
|
||||
),
|
||||
).listProfiles().onSuccess { profiles ->
|
||||
_dashboardProfiles.value = profiles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACTIVE profile's chat sessions, scoped server-side via the dashboard
|
||||
* `GET /api/sessions?profile=` surface — upstream opens that profile's own
|
||||
* `state.db` directly, the same per-profile scoping the official desktop
|
||||
* sidebar uses. Returns `null` when there's no dashboard URL (an api_server-
|
||||
* only connection with no Manage session), so the caller falls back to the
|
||||
* shared api_server session list.
|
||||
*
|
||||
* The gateway `session.list` RPC can't substitute here: it reads one process-
|
||||
* global DB pinned to the launch profile, so it never re-scopes on a profile
|
||||
* switch. The default/`null` selection omits the param → the launch profile's
|
||||
* DB (the server's configured default), matching [selectProfile]'s semantics.
|
||||
*/
|
||||
suspend fun listProfileScopedSessions(limit: Int = 50): Result<List<SessionItem>>? {
|
||||
val connectionId = connectionStore.activeConnectionId.value ?: return null
|
||||
val dashboardUrl = activeDashboardUrl() ?: return null
|
||||
val profileName = AgentDisplay.profileRequestName(_selectedProfile.value?.name)
|
||||
return DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreFor(connectionId),
|
||||
),
|
||||
).listSessions(profile = profileName, limit = limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* A session's transcript, scoped to the active profile via the dashboard
|
||||
* `/api/sessions/{id}/messages?profile=`. The twin of [listProfileScopedSessions]:
|
||||
* once the drawer lists a non-default profile's sessions, opening one must read
|
||||
* that profile's own `state.db` (the api_server's shared DB has no such rows).
|
||||
* Returns `null` off the dashboard surface so the caller falls back to the
|
||||
* api_server transcript.
|
||||
*/
|
||||
suspend fun loadProfileScopedMessages(sessionId: String): Result<List<MessageItem>>? {
|
||||
val connectionId = connectionStore.activeConnectionId.value ?: return null
|
||||
val dashboardUrl = activeDashboardUrl() ?: return null
|
||||
val profileName = AgentDisplay.profileRequestName(_selectedProfile.value?.name)
|
||||
return DashboardApiClient(
|
||||
baseUrl = dashboardUrl,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreFor(connectionId),
|
||||
),
|
||||
).getSessionMessages(sessionId, profileName)
|
||||
}
|
||||
|
||||
/**
|
||||
* User's current profile pick for the chat send pipeline. `null` means
|
||||
@@ -900,16 +1143,42 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Which transport's session slot to restore right now — or `null` when the
|
||||
* decision is still pending (the gateway probe hasn't landed). A manual
|
||||
* streaming-endpoint override resolves immediately; under `"auto"` the slot
|
||||
* follows the gateway probe, and we deliberately DEFER while it's [Unknown]
|
||||
* rather than guess SSE — otherwise a gateway connection would momentarily
|
||||
* restore the wrong (or empty) slot before the probe confirms it. The
|
||||
* gateway-availability collector re-runs the restore once it settles.
|
||||
*/
|
||||
private fun activeSessionTransport(): SessionTransport? {
|
||||
val preference = streamingEndpoint.value
|
||||
if (preference != "auto") return SessionTransport.forEndpoint(preference)
|
||||
return when (_gatewayAvailability.value) {
|
||||
GatewayAvailability.Ready -> SessionTransport.GATEWAY
|
||||
GatewayAvailability.Unknown -> null
|
||||
else -> SessionTransport.SSE
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshLastSessionForProfile(
|
||||
connectionId: String?,
|
||||
profileName: String?,
|
||||
) {
|
||||
_lastSessionId.value = null
|
||||
if (connectionId == null) return
|
||||
// Defer until the active transport is known — restoring an id the
|
||||
// current transport can't resume is exactly what forks a session
|
||||
// mid-conversation on a non-default profile.
|
||||
val transport = activeSessionTransport() ?: return
|
||||
viewModelScope.launch {
|
||||
val profileScoped = profileSessionStore
|
||||
.sessionIdFlow(connectionId, profileName)
|
||||
.sessionIdFlow(connectionId, profileName, transport)
|
||||
.first()
|
||||
// Default profile shares the launch DB across both transports, so a
|
||||
// pre-transport (untransported) pointer is still resumable — surface
|
||||
// it as the fallback only for the server-default context.
|
||||
val legacyDefault = if (profileName == null) {
|
||||
getApplication<Application>().relayDataStore.data
|
||||
.first()[KEY_LAST_SESSION_ID]
|
||||
@@ -918,7 +1187,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
if (
|
||||
activeConnectionId.value == connectionId &&
|
||||
_selectedProfile.value?.name == profileName
|
||||
_selectedProfile.value?.name == profileName &&
|
||||
activeSessionTransport() == transport
|
||||
) {
|
||||
_lastSessionId.value = profileScoped ?: legacyDefault
|
||||
}
|
||||
@@ -1108,6 +1378,41 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
// --- Opt-in "keep gateway connected in background" (sideload) ---
|
||||
|
||||
/**
|
||||
* Off by default. When on, the gateway socket stays open in the background
|
||||
* (the process is held up by [GatewayKeepAliveService]) until the app is
|
||||
* killed, so replies stay instant instead of paying a cold rejoin.
|
||||
*/
|
||||
val gatewayKeepAlive: StateFlow<Boolean> = application.relayDataStore.data
|
||||
.map { it[KEY_GATEWAY_KEEP_ALIVE] ?: false }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
fun setGatewayKeepAlive(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_GATEWAY_KEEP_ALIVE] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Drive the keep-alive: flip the active client's no-background-close
|
||||
// flag and start/stop the foreground service. Both flavors — the
|
||||
// GatewayKeepAliveService is declared in the main manifest (Play permits
|
||||
// this Home-Assistant-class persistent-connection use case). Mirrors
|
||||
// BridgeViewModel's masterToggle → BridgeForegroundService driver.
|
||||
viewModelScope.launch {
|
||||
gatewayKeepAlive.collect { enabled ->
|
||||
gatewayClientCache?.third?.setKeepAliveInBackground(enabled)
|
||||
val ctx = getApplication<Application>()
|
||||
if (enabled) runCatching { GatewayKeepAliveService.start(ctx) }
|
||||
else runCatching { GatewayKeepAliveService.stop(ctx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user's `streamingEndpoint` preference to a concrete value
|
||||
* based on the latest capability probe. Returns a concrete endpoint
|
||||
@@ -1116,10 +1421,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* - "sessions" / "completions" / "runs" pass through unchanged (manual override wins).
|
||||
* - "auto" → reads `serverCapabilities.value.preferredChatEndpoint()`.
|
||||
*/
|
||||
fun resolveStreamingEndpoint(preference: String): String = when (preference) {
|
||||
"sessions", "completions", "runs" -> preference
|
||||
else -> _serverCapabilities.value.preferredChatEndpoint()
|
||||
}
|
||||
fun resolveStreamingEndpoint(preference: String): String =
|
||||
resolveStreamingEndpointPreference(
|
||||
preference = preference,
|
||||
gateway = _gatewayAvailability.value,
|
||||
capabilities = _serverCapabilities.value,
|
||||
)
|
||||
|
||||
/**
|
||||
* Capability-resolved SSE endpoint, ignoring the gateway tier — wired to
|
||||
* [ChatViewModel.sseFallbackEndpoint] for per-turn gateway fallbacks.
|
||||
*/
|
||||
fun resolveSseStreamingEndpoint(): String =
|
||||
_serverCapabilities.value.preferredChatEndpoint()
|
||||
|
||||
// Parse tool annotations from text markers toggle
|
||||
val parseToolAnnotations: StateFlow<Boolean> = application.relayDataStore.data
|
||||
@@ -1176,6 +1490,37 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
// Turn-complete notification (default ON). RelayApp mirrors this into
|
||||
// ChatViewModel.notifyOnTurnComplete; ChatSettingsScreen owns the toggle
|
||||
// + the POST_NOTIFICATIONS runtime request on first enable.
|
||||
val notifyTurnComplete: StateFlow<Boolean> = application.relayDataStore.data
|
||||
.map { it[KEY_NOTIFY_TURN_COMPLETE] ?: true }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setNotifyTurnComplete(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_NOTIFY_TURN_COMPLETE] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot voice hint on the input bar. Initial stateIn value is TRUE
|
||||
// (treated as already-seen) so returning users never get a flash of the
|
||||
// hint while DataStore hydrates; fresh installs flip to false once the
|
||||
// (absent) preference loads and the hint shows exactly once.
|
||||
val voiceHintSeen: StateFlow<Boolean> = application.relayDataStore.data
|
||||
.map { it[KEY_VOICE_HINT_SEEN] ?: false }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setVoiceHintSeen(seen: Boolean) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { prefs ->
|
||||
prefs[KEY_VOICE_HINT_SEEN] = seen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Max attachment size in MB (default 10)
|
||||
val maxAttachmentMb: StateFlow<Int> = application.relayDataStore.data
|
||||
.map { it[KEY_MAX_ATTACHMENT_MB] ?: 10 }
|
||||
@@ -2692,6 +3037,39 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
// Fast-retry burst on an Unreachable verdict. The periodic loop
|
||||
// above ticks every 30s — a single transient miss (cold-start race
|
||||
// with the route resolver, Wi-Fi still settling, mid-route-swap)
|
||||
// used to park "offline" for a full tick, which the startup gate
|
||||
// (and the demo-video camera) measured as a 6–28s wildly variable
|
||||
// launch against the same healthy LAN server. One bounded burst per
|
||||
// failure episode: three quick re-probes, re-armed only by a
|
||||
// Reachable verdict — a genuinely down server fails one burst and
|
||||
// settles back to the 30s cadence (where the 2-consecutive-failures
|
||||
// escalation still owns route re-resolution). StateFlow dedup means
|
||||
// repeat Unreachable verdicts can't re-trigger the burst.
|
||||
viewModelScope.launch {
|
||||
var burstArmed = true
|
||||
_apiServerHealth.collect { verdict ->
|
||||
when (verdict) {
|
||||
HealthStatus.Reachable -> burstArmed = true
|
||||
HealthStatus.Unreachable -> {
|
||||
if (!burstArmed) return@collect
|
||||
burstArmed = false
|
||||
for (retryDelayMs in listOf(2_500L, 5_000L, 7_500L)) {
|
||||
delay(retryDelayMs)
|
||||
if (_apiServerHealth.value != HealthStatus.Unreachable) {
|
||||
return@collect
|
||||
}
|
||||
if (_apiClient.value == null) return@collect
|
||||
probeApiHealth()
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic relay health check — same cadence. Only fires when a relay
|
||||
// URL is configured. Does NOT touch the WSS channel; this is a pure
|
||||
// /health probe via RelayHttpClient (3s timeout, no auth needed).
|
||||
@@ -2716,7 +3094,35 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
rebuildApiClient()
|
||||
if (_chatStreaming.value) {
|
||||
// Rebuilding the chat client mid-turn replaces it and
|
||||
// CANCELS the in-flight turn. The gateway socket rides a
|
||||
// transient route blip via its own reconnect (keeping the
|
||||
// live session), so defer the rebuild until the turn ends.
|
||||
pendingApiClientRebuild = true
|
||||
android.util.Log.i(
|
||||
"ConnectionViewModel",
|
||||
"route changed mid-turn — deferring chat client rebuild",
|
||||
)
|
||||
} else {
|
||||
rebuildApiClient()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a route change that was deferred because a turn was streaming.
|
||||
// (StateFlow already conflates/dedups, so no distinctUntilChanged.)
|
||||
viewModelScope.launch {
|
||||
_chatStreaming
|
||||
.collect { streaming ->
|
||||
if (!streaming && pendingApiClientRebuild) {
|
||||
pendingApiClientRebuild = false
|
||||
android.util.Log.i(
|
||||
"ConnectionViewModel",
|
||||
"turn ended — applying deferred chat client rebuild",
|
||||
)
|
||||
rebuildApiClient()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2769,6 +3175,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
_selectedProfile.value = null
|
||||
_pendingSelectedProfileConnectionId.value = null
|
||||
_pendingSelectedProfileName.value = null
|
||||
// Dashboard profile lists are per-connection — drop the old one so
|
||||
// the pending persisted name can't resolve against the previous
|
||||
// connection's profiles before the new connection's list arrives.
|
||||
_dashboardProfiles.value = emptyList()
|
||||
// Gateway state is per-connection: drop the sticky
|
||||
// Unsupported verdict and tear down the old socket so the
|
||||
// next probe/send evaluates the new connection fresh.
|
||||
_gatewayAvailability.value = GatewayAvailability.Unknown
|
||||
synchronized(this@ConnectionViewModel) {
|
||||
gatewayClientCache?.third?.shutdown()
|
||||
gatewayClientCache = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2795,6 +3213,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
rebuildApiClient()
|
||||
}
|
||||
rebuildChatApiClient()
|
||||
// Hydrate the host's agent profiles eagerly (not lazily on
|
||||
// agent-sheet open). On a dashboard/gateway connection the relay
|
||||
// `auth.ok` profile list is empty, so without this the persisted
|
||||
// profile selection can't resolve until the user opens the picker
|
||||
// — the header shows the default agent on cold start, then visibly
|
||||
// snaps to the real profile (and re-scopes the chat) the moment the
|
||||
// sheet fetches the list. Best-effort; the agentProfiles collector
|
||||
// resolves the pending name once the list lands.
|
||||
refreshDashboardProfiles()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2812,6 +3239,21 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cold-start restore timing: the gateway probe is async, so the first
|
||||
// refreshLastSessionForProfile at connection-activate can run while
|
||||
// availability is still Unknown — [activeSessionTransport] defers, leaving
|
||||
// no session restored. Re-run once the probe settles, but only when
|
||||
// nothing has been restored or started yet, so we never yank a session
|
||||
// the user is already in.
|
||||
viewModelScope.launch {
|
||||
_gatewayAvailability.collect { availability ->
|
||||
if (availability == GatewayAvailability.Unknown) return@collect
|
||||
if (_lastSessionId.value != null) return@collect
|
||||
val connectionId = activeConnectionId.value ?: return@collect
|
||||
refreshLastSessionForProfile(connectionId, _selectedProfile.value?.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Revalidation ----------------------------------------------------
|
||||
@@ -2913,6 +3355,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
if (connectionId == null || dashboardUrl.isNullOrBlank()) {
|
||||
_standardVoiceAvailability.value = StandardVoiceAvailability.Unknown
|
||||
_standardAudioApiReachable.value = false
|
||||
updateGatewayAvailability(GatewayAvailability.Unknown)
|
||||
return
|
||||
}
|
||||
val client = DashboardApiClient(
|
||||
@@ -2927,12 +3370,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
if (status == null) {
|
||||
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
|
||||
_standardAudioApiReachable.value = false
|
||||
updateGatewayAvailability(GatewayAvailability.Unreachable)
|
||||
recordDashboardStatusIfChanged(connectionId, status = null, session = null)
|
||||
return
|
||||
}
|
||||
val session = if (status.authRequired) client.currentSession().getOrNull() else null
|
||||
val authed = !status.authRequired || session?.authenticated == true
|
||||
recordDashboardStatusIfChanged(connectionId, status, session)
|
||||
// Gateway chat shares the voice probe's dashboard checks; it has
|
||||
// no audio-route requirement.
|
||||
updateGatewayAvailability(
|
||||
if (authed) GatewayAvailability.Ready else GatewayAvailability.SignInRequired,
|
||||
)
|
||||
val availability = when {
|
||||
!authed -> StandardVoiceAvailability.SignInRequired
|
||||
client.audioRoutesPresent() -> StandardVoiceAvailability.Ready
|
||||
@@ -4561,7 +5010,34 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val profileName = _selectedProfile.value?.name
|
||||
viewModelScope.launch {
|
||||
if (connectionId != null) {
|
||||
profileSessionStore.setSessionId(connectionId, profileName, sessionId)
|
||||
if (sessionId != null) {
|
||||
// Bucket by the id's own namespace: the prefix is the server's
|
||||
// ground truth about which transport can resume it, robust to a
|
||||
// turn that fell back from gateway to SSE.
|
||||
val transport = SessionTransport.forSessionId(sessionId)
|
||||
profileSessionStore.setSessionId(
|
||||
connectionId,
|
||||
profileName,
|
||||
transport,
|
||||
sessionId,
|
||||
)
|
||||
} else {
|
||||
// A null clears only the ACTIVE transport's slot, and only when
|
||||
// that transport is known. A null while the gateway probe is
|
||||
// still pending (transport == null) — or right after a switch,
|
||||
// when availability is reset to Unknown — is a deferred-restore
|
||||
// transient forwarded by switchProfileContext, NOT a user clear;
|
||||
// clearing then would wipe a still-valid session before the
|
||||
// availability collector restores it.
|
||||
activeSessionTransport()?.let { transport ->
|
||||
profileSessionStore.setSessionId(
|
||||
connectionId,
|
||||
profileName,
|
||||
transport,
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (profileName == null) {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
|
||||
@@ -4384,14 +4384,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
errorCode == "permission_missing_sms" -> buildString {
|
||||
append("**Send SMS — permission needed**")
|
||||
append('\n')
|
||||
append("Grant SMS permission in Settings › Apps › Hermes Relay › Permissions.")
|
||||
append("Grant SMS permission in Settings › Apps › Hermes-Relay › Permissions.")
|
||||
}
|
||||
errorCode == "permission_missing_contacts" -> buildString {
|
||||
append("**Send SMS — permission needed**")
|
||||
append('\n')
|
||||
append("Grant Contacts permission to look up '")
|
||||
append(contact ?: "?")
|
||||
append("' in Settings › Apps › Hermes Relay › Permissions.")
|
||||
append("' in Settings › Apps › Hermes-Relay › Permissions.")
|
||||
}
|
||||
errorCode == "service_missing" -> buildString {
|
||||
append("**Send SMS — bridge offline**")
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains the sideload Device Control bridge while the user has explicitly enabled agent control." />
|
||||
</service>
|
||||
|
||||
<!-- Note: GatewayKeepAliveService ("Keep connected in background") is
|
||||
declared in the MAIN manifest so both flavors ship it. -->
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -29,21 +29,24 @@ class AgentDisplayTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effectiveProfile_fallsBackToAdvertisedDefaultProfile() {
|
||||
fun effectiveProfile_isNullWithoutAnExplicitPick() {
|
||||
// No fallback to the advertised "default" profile — its verbose SOUL
|
||||
// summary must not replace the personality-derived agent name.
|
||||
val effective = AgentDisplay.effectiveProfile(
|
||||
selectedProfile = null,
|
||||
profiles = listOf(mizu, defaultProfile),
|
||||
)
|
||||
|
||||
assertEquals(defaultProfile, effective)
|
||||
assertEquals(null, effective)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun agentName_prefersProfileDescriptionThenProfileName() {
|
||||
fun agentName_usesProfileNameNotVerboseDescription() {
|
||||
// The name slot shows the NAME, even when a (verbose) description exists.
|
||||
assertEquals(
|
||||
"Mizu",
|
||||
AgentDisplay.agentName(
|
||||
profile = mizu,
|
||||
profile = mizu.copy(description = "Builds and maintains the codebase"),
|
||||
selectedPersonality = "friendly",
|
||||
defaultPersonality = "default-persona",
|
||||
connectionLabel = "Lab",
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.hermesandroid.relay.data
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.hermesandroid.relay.data.SessionTransport.GATEWAY
|
||||
import com.hermesandroid.relay.data.SessionTransport.SSE
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -46,48 +48,82 @@ class ProfileSessionStoreTest {
|
||||
|
||||
@Test
|
||||
fun setAndGet_defaultProfileSession() = runBlocking {
|
||||
store.setSessionId("conn-1", null, "session-default")
|
||||
store.setSessionId("conn-1", null, GATEWAY, "session-default")
|
||||
|
||||
assertEquals(
|
||||
"session-default",
|
||||
store.sessionIdFlow("conn-1", null).first(),
|
||||
store.sessionIdFlow("conn-1", null, GATEWAY).first(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileSessionsAreIndependentFromDefaultAndEachOther() = runBlocking {
|
||||
store.setSessionId("conn-1", null, "session-default")
|
||||
store.setSessionId("conn-1", "mizu", "session-mizu")
|
||||
store.setSessionId("conn-1", "coder", "session-coder")
|
||||
store.setSessionId("conn-2", "mizu", "session-other")
|
||||
store.setSessionId("conn-1", null, GATEWAY, "session-default")
|
||||
store.setSessionId("conn-1", "mizu", GATEWAY, "session-mizu")
|
||||
store.setSessionId("conn-1", "coder", GATEWAY, "session-coder")
|
||||
store.setSessionId("conn-2", "mizu", GATEWAY, "session-other")
|
||||
|
||||
assertEquals("session-default", store.sessionIdFlow("conn-1", null).first())
|
||||
assertEquals("session-mizu", store.sessionIdFlow("conn-1", "mizu").first())
|
||||
assertEquals("session-coder", store.sessionIdFlow("conn-1", "coder").first())
|
||||
assertEquals("session-other", store.sessionIdFlow("conn-2", "mizu").first())
|
||||
assertEquals("session-default", store.sessionIdFlow("conn-1", null, GATEWAY).first())
|
||||
assertEquals("session-mizu", store.sessionIdFlow("conn-1", "mizu", GATEWAY).first())
|
||||
assertEquals("session-coder", store.sessionIdFlow("conn-1", "coder", GATEWAY).first())
|
||||
assertEquals("session-other", store.sessionIdFlow("conn-2", "mizu", GATEWAY).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullSessionClearsOnlyThatProfileSlot() = runBlocking {
|
||||
store.setSessionId("conn-1", "mizu", "session-mizu")
|
||||
store.setSessionId("conn-1", "coder", "session-coder")
|
||||
fun gatewayAndSseSlotsAreIndependentForSameProfile() = runBlocking {
|
||||
// The core of the continuity fix: a profile's gateway session and its
|
||||
// api_server (SSE) session must not clobber one another.
|
||||
store.setSessionId("conn-1", "mizu", GATEWAY, "20260614_192846_4bf8d3")
|
||||
store.setSessionId("conn-1", "mizu", SSE, "api_1781479723_f60ca534")
|
||||
|
||||
store.setSessionId("conn-1", "mizu", null)
|
||||
|
||||
assertNull(store.sessionIdFlow("conn-1", "mizu").first())
|
||||
assertEquals("session-coder", store.sessionIdFlow("conn-1", "coder").first())
|
||||
assertEquals(
|
||||
"20260614_192846_4bf8d3",
|
||||
store.sessionIdFlow("conn-1", "mizu", GATEWAY).first(),
|
||||
)
|
||||
assertEquals(
|
||||
"api_1781479723_f60ca534",
|
||||
store.sessionIdFlow("conn-1", "mizu", SSE).first(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearConnectionRemovesAllProfilesForThatConnectionOnly() = runBlocking {
|
||||
store.setSessionId("conn-1", null, "session-default")
|
||||
store.setSessionId("conn-1", "mizu", "session-mizu")
|
||||
store.setSessionId("conn-2", "mizu", "session-other")
|
||||
fun nullSessionClearsOnlyThatTransportSlot() = runBlocking {
|
||||
store.setSessionId("conn-1", "mizu", GATEWAY, "session-gw")
|
||||
store.setSessionId("conn-1", "mizu", SSE, "session-sse")
|
||||
|
||||
store.setSessionId("conn-1", "mizu", GATEWAY, null)
|
||||
|
||||
assertNull(store.sessionIdFlow("conn-1", "mizu", GATEWAY).first())
|
||||
assertEquals("session-sse", store.sessionIdFlow("conn-1", "mizu", SSE).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearConnectionRemovesAllProfilesAndTransportsForThatConnectionOnly() = runBlocking {
|
||||
store.setSessionId("conn-1", null, GATEWAY, "session-default")
|
||||
store.setSessionId("conn-1", "mizu", GATEWAY, "session-mizu")
|
||||
store.setSessionId("conn-1", "mizu", SSE, "session-mizu-sse")
|
||||
store.setSessionId("conn-2", "mizu", GATEWAY, "session-other")
|
||||
|
||||
store.clearConnection("conn-1")
|
||||
|
||||
assertNull(store.sessionIdFlow("conn-1", null).first())
|
||||
assertNull(store.sessionIdFlow("conn-1", "mizu").first())
|
||||
assertEquals("session-other", store.sessionIdFlow("conn-2", "mizu").first())
|
||||
assertNull(store.sessionIdFlow("conn-1", null, GATEWAY).first())
|
||||
assertNull(store.sessionIdFlow("conn-1", "mizu", GATEWAY).first())
|
||||
assertNull(store.sessionIdFlow("conn-1", "mizu", SSE).first())
|
||||
assertEquals("session-other", store.sessionIdFlow("conn-2", "mizu", GATEWAY).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forSessionId_bucketsByNamespace() {
|
||||
// api_ ids are api_server (launch DB / SSE path); everything else is gateway.
|
||||
assertEquals(SSE, SessionTransport.forSessionId("api_1781479723_f60ca534"))
|
||||
assertEquals(GATEWAY, SessionTransport.forSessionId("20260614_192846_4bf8d3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forEndpoint_onlyGatewayMapsToGateway() {
|
||||
assertEquals(GATEWAY, SessionTransport.forEndpoint("gateway"))
|
||||
assertEquals(SSE, SessionTransport.forEndpoint("sessions"))
|
||||
assertEquals(SSE, SessionTransport.forEndpoint("completions"))
|
||||
assertEquals(SSE, SessionTransport.forEndpoint("runs"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,4 +428,122 @@ class DashboardApiClientTest {
|
||||
assertEquals("DELETE", delete.method)
|
||||
assertEquals("/api/profiles/old%20profile", delete.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listProfiles_parsesArrayShapeIntoProfiles() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{"profiles":[
|
||||
{"name":"default","model":"gpt-5.5","description":"Victor","gateway_running":true,"skill_count":3,"is_default":true},
|
||||
{"name":"mizu","model":"claude-opus-4-8","description":"Code assistant","gateway_running":false}
|
||||
]}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val profiles = client.listProfiles().getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals("/api/profiles", request.path)
|
||||
assertEquals(2, profiles.size)
|
||||
assertEquals("default", profiles[0].name)
|
||||
assertEquals("gpt-5.5", profiles[0].model)
|
||||
assertEquals("Victor", profiles[0].description)
|
||||
assertTrue(profiles[0].gatewayRunning)
|
||||
assertEquals(3, profiles[0].skillCount)
|
||||
assertEquals("mizu", profiles[1].name)
|
||||
assertEquals("claude-opus-4-8", profiles[1].model)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listProfiles_parsesObjectMapShapeWithInjectedName() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"profiles":{"default":{"model":"gpt-5.5"},"mizu":{"model":"claude-opus-4-8","description":"Coder"}}}"""),
|
||||
)
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val profiles = client.listProfiles().getOrThrow().sortedBy { it.name }
|
||||
|
||||
assertEquals(2, profiles.size)
|
||||
// The map key is injected as the profile name when the object omits it.
|
||||
assertEquals("default", profiles[0].name)
|
||||
assertEquals("mizu", profiles[1].name)
|
||||
assertEquals("Coder", profiles[1].description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listSessions_scopesToProfileAndParsesUpstreamEnvelope() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{"sessions":[
|
||||
{"id":"sess-a","title":"Refactor","model":"claude-opus-4-8","message_count":4,"started_at":1234.5,"source":"tui","profile":"mizu"},
|
||||
{"id":"sess-b","title":"Notes","message_count":2,"started_at":1200.0,"source":"tui","profile":"mizu"}
|
||||
],"total":2,"limit":50,"offset":0}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val sessions = client.listSessions(profile = "mizu").getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("GET", request.method)
|
||||
// Server-side per-profile scoping is the whole point — the request must
|
||||
// carry profile=mizu (the desktop's `_open_session_db_for_profile` path).
|
||||
val url = request.requestUrl!!
|
||||
assertEquals("/api/sessions", url.encodedPath)
|
||||
assertEquals("mizu", url.queryParameter("profile"))
|
||||
assertEquals("1", url.queryParameter("min_messages"))
|
||||
assertEquals(2, sessions.size)
|
||||
assertEquals("sess-a", sessions[0].id)
|
||||
assertEquals("Refactor", sessions[0].title)
|
||||
assertEquals("claude-opus-4-8", sessions[0].model)
|
||||
assertEquals(4, sessions[0].messageCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listSessions_omitsProfileParamForTheDefaultSelection() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"sessions":[],"total":0,"limit":50,"offset":0}"""),
|
||||
)
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.listSessions(profile = null).getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
// No profile → omit the param so upstream reads the launch (default) DB.
|
||||
assertEquals(null, request.requestUrl!!.queryParameter("profile"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSessionMessages_scopesToProfileAndParsesUpstreamEnvelope() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""{"session_id":"sess-a","messages":[
|
||||
{"id":"m1","role":"user","content":"hi"},
|
||||
{"id":"m2","role":"assistant","content":"hello"}
|
||||
]}""".trimIndent(),
|
||||
),
|
||||
)
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val messages = client.getSessionMessages("sess-a", profile = "mizu").getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
val url = request.requestUrl!!
|
||||
assertEquals("/api/sessions/sess-a/messages", url.encodedPath)
|
||||
assertEquals("mizu", url.queryParameter("profile"))
|
||||
assertEquals(2, messages.size)
|
||||
assertEquals("user", messages[0].role)
|
||||
assertEquals("assistant", messages[1].role)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* [GatewayChatClient] wire tests against a scripted fake tui_gateway:
|
||||
* MockWebServer serves POST /api/auth/ws-ticket and upgrades /api/ws,
|
||||
* auto-answering session/prompt RPCs like upstream does.
|
||||
*/
|
||||
class GatewayClientHarness(
|
||||
autoRespond: Boolean = true,
|
||||
) {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val server = MockWebServer()
|
||||
val ticketMints = AtomicInteger(0)
|
||||
val serverSockets = LinkedBlockingQueue<WebSocket>()
|
||||
private val allServerSockets = ConcurrentLinkedQueue<WebSocket>()
|
||||
val rpcLog = ConcurrentLinkedQueue<Pair<String, JsonObject>>()
|
||||
var failTicketMint = false
|
||||
var resumeFails = false
|
||||
|
||||
@Volatile
|
||||
var steerStatus = "queued"
|
||||
|
||||
/** Methods answered with JSON-RPC -32601 — exercises the legacy-name fallback. */
|
||||
val methodNotFound: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
private val wsListener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
serverSockets.add(webSocket)
|
||||
allServerSockets.add(webSocket)
|
||||
webSocket.send(eventFrame("gateway.ready", null, null))
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text) as JsonObject
|
||||
val method = (frame["method"] as? JsonPrimitive)?.contentOrNull ?: return
|
||||
val id = (frame["id"] as? JsonPrimitive)?.contentOrNull ?: return
|
||||
val params = frame["params"] as? JsonObject ?: JsonObject(emptyMap())
|
||||
rpcLog.add(method to params)
|
||||
if (!autoRespondEnabled) return
|
||||
if (method in methodNotFound) {
|
||||
webSocket.send(
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id.toLong())
|
||||
put("error", buildJsonObject {
|
||||
put("code", -32601)
|
||||
put("message", "Method not found: $method")
|
||||
})
|
||||
}.toString(),
|
||||
)
|
||||
return
|
||||
}
|
||||
val result: JsonObject? = when (method) {
|
||||
"session.create" -> buildJsonObject {
|
||||
put("session_id", "live-1")
|
||||
put("stored_session_id", "20260612_120000_abc123")
|
||||
}
|
||||
"session.resume" ->
|
||||
if (resumeFails) null
|
||||
else buildJsonObject { put("session_id", "live-resumed") }
|
||||
"prompt.submit" -> buildJsonObject { put("ok", true) }
|
||||
"session.interrupt" -> buildJsonObject { put("ok", true) }
|
||||
"session.steer" -> buildJsonObject {
|
||||
put("status", steerStatus)
|
||||
put("text", (params["text"] as? JsonPrimitive)?.contentOrNull ?: "")
|
||||
}
|
||||
"image.attach_bytes", "image.attach.bytes" -> buildJsonObject {
|
||||
put("attached", true)
|
||||
put("count", 1)
|
||||
}
|
||||
"pdf.attach" -> buildJsonObject {
|
||||
put("attached", true)
|
||||
put("pages", 1)
|
||||
}
|
||||
"file.attach" -> buildJsonObject {
|
||||
put("attached", true)
|
||||
put("ref_text", "@file:notes.txt")
|
||||
}
|
||||
"clarify.respond", "sudo.respond", "secret.respond" ->
|
||||
buildJsonObject { put("status", "ok") }
|
||||
"approval.respond" -> buildJsonObject { put("resolved", true) }
|
||||
"commands.catalog" -> buildJsonObject {
|
||||
put(
|
||||
"pairs",
|
||||
json.parseToJsonElement("""[["/help","Show help"],["/model","Pick model"]]"""),
|
||||
)
|
||||
}
|
||||
else -> JsonObject(emptyMap())
|
||||
}
|
||||
val reply = if (result != null) {
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id.toLong())
|
||||
put("result", result)
|
||||
}
|
||||
} else {
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id.toLong())
|
||||
put("error", buildJsonObject { put("message", "$method refused") })
|
||||
}
|
||||
}
|
||||
webSocket.send(reply.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private val autoRespondEnabled = autoRespond
|
||||
|
||||
init {
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path ?: ""
|
||||
return when {
|
||||
path.startsWith("/api/auth/ws-ticket") -> {
|
||||
ticketMints.incrementAndGet()
|
||||
if (failTicketMint) {
|
||||
MockResponse().setResponseCode(401).setBody("""{"error":"no session"}""")
|
||||
} else {
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"ticket":"tkt-${ticketMints.get()}","ttl_seconds":30}""")
|
||||
}
|
||||
}
|
||||
path.startsWith("/api/ws") -> MockResponse().withWebSocketUpgrade(wsListener)
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
server.start()
|
||||
}
|
||||
|
||||
fun eventFrame(type: String, payload: JsonObject?, sessionId: String?): String =
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("method", "event")
|
||||
put("params", buildJsonObject {
|
||||
put("type", type)
|
||||
if (payload != null) put("payload", payload)
|
||||
if (sessionId != null) put("session_id", sessionId)
|
||||
})
|
||||
}.toString()
|
||||
|
||||
fun awaitServerSocket(): WebSocket =
|
||||
serverSockets.poll(5, TimeUnit.SECONDS) ?: error("server socket never opened")
|
||||
|
||||
fun awaitRpc(method: String): JsonObject {
|
||||
val deadline = System.currentTimeMillis() + 5_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
rpcLog.firstOrNull { it.first == method }?.let { return it.second }
|
||||
Thread.sleep(20)
|
||||
}
|
||||
error("rpc $method never arrived; saw ${rpcLog.map { it.first }}")
|
||||
}
|
||||
|
||||
/** Waits until [method] has been seen at least [count] times; returns the params in arrival order. */
|
||||
fun awaitRpcCount(method: String, count: Int): List<JsonObject> {
|
||||
val deadline = System.currentTimeMillis() + 5_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val seen = rpcLog.filter { it.first == method }
|
||||
if (seen.size >= count) return seen.map { it.second }
|
||||
Thread.sleep(20)
|
||||
}
|
||||
error("rpc $method x$count never arrived; saw ${rpcLog.map { it.first }}")
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
// Close any still-open server-side sockets first — an upgraded WS
|
||||
// connection otherwise occupies a MockWebServer dispatcher thread
|
||||
// and shutdown() gives up waiting for its queue. close(), not
|
||||
// cancel(): mockwebserver's server-side RealWebSocket has no `call`
|
||||
// and cancel() NPEs on it.
|
||||
allServerSockets.forEach { runCatching { it.close(1001, "teardown") } }
|
||||
try {
|
||||
server.shutdown()
|
||||
} catch (e: Throwable) {
|
||||
// Known mockwebserver limitation: shutdown can give up waiting
|
||||
// when a WS upgrade was served this test. Behaviour is asserted
|
||||
// in test bodies; teardown noise must not fail the suite.
|
||||
println("MockWebServer shutdown tolerated: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GatewayChatClientTest {
|
||||
|
||||
private lateinit var harness: GatewayClientHarness
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var client: GatewayChatClient
|
||||
private var unsupportedMarked = false
|
||||
|
||||
private class Recorder {
|
||||
val textDeltas = ConcurrentLinkedQueue<String>()
|
||||
val thinkingDeltas = ConcurrentLinkedQueue<String>()
|
||||
val sessionIds = ConcurrentLinkedQueue<String>()
|
||||
val errors = ConcurrentLinkedQueue<String>()
|
||||
val interactions = ConcurrentLinkedQueue<GatewayAsk>()
|
||||
|
||||
// ConcurrentLinkedQueue rejects nulls — unnamed generating events store "".
|
||||
val toolGenerating = ConcurrentLinkedQueue<String>()
|
||||
val subagentEvents = ConcurrentLinkedQueue<GatewaySubagentEvent>()
|
||||
val usages = ConcurrentLinkedQueue<UsageInfo>()
|
||||
val completeLatch = CountDownLatch(1)
|
||||
val preflightFailures = ConcurrentLinkedQueue<String>()
|
||||
|
||||
val callbacks = GatewayTurnCallbacks(
|
||||
onSessionId = { sessionIds += it },
|
||||
onTextDelta = { textDeltas += it },
|
||||
onThinkingDelta = { thinkingDeltas += it },
|
||||
onToolCallStart = { _, _ -> },
|
||||
onToolCallDone = { _, _ -> },
|
||||
onToolCallFailed = { _, _ -> },
|
||||
onTurnComplete = { },
|
||||
onComplete = { completeLatch.countDown() },
|
||||
onUsage = { it?.let(usages::add) },
|
||||
onError = { errors += it; completeLatch.countDown() },
|
||||
onToolGenerating = { toolGenerating += it ?: "" },
|
||||
onSubagentEvent = { subagentEvents += it },
|
||||
onInteractionRequest = { interactions += it },
|
||||
)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
harness = GatewayClientHarness()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
unsupportedMarked = false
|
||||
client = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = harness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
onGatewayUnsupported = { unsupportedMarked = true },
|
||||
scope = scope,
|
||||
// Keep the mid-turn reconnect window short so `failed rejoin`
|
||||
// surfaces its error well within the test's await budget.
|
||||
midTurnRejoinWindowMs = 3_000L,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
client.shutdown()
|
||||
scope.cancel()
|
||||
harness.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `happy path - ticket, ready, create, submit, stream, complete`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = null,
|
||||
text = "hello",
|
||||
newSessionTitle = "hello",
|
||||
callbacks = r.callbacks,
|
||||
onPreflightFailure = { r.preflightFailures += it },
|
||||
)
|
||||
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
// Verify the create carried the title and the stored id was reported.
|
||||
val create = harness.awaitRpc("session.create")
|
||||
assertEquals("hello", (create["title"] as? JsonPrimitive)?.contentOrNull)
|
||||
|
||||
serverWs.send(harness.eventFrame("message.start", null, "live-1"))
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"reasoning.delta",
|
||||
buildJsonObject { put("text", "thinking hard") },
|
||||
"live-1",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame("message.delta", buildJsonObject { put("text", "Hi!") }, "live-1"),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject {
|
||||
put("text", "Hi!")
|
||||
put("status", "complete")
|
||||
put("usage", buildJsonObject { put("input", 5); put("output", 2); put("total", 7) })
|
||||
},
|
||||
"live-1",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue("turn never completed", r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("thinking hard"), r.thinkingDeltas.toList())
|
||||
assertEquals(listOf("Hi!"), r.textDeltas.toList())
|
||||
assertEquals(listOf("20260612_120000_abc123"), r.sessionIds.toList())
|
||||
assertEquals(5, r.usages.firstOrNull()?.resolvedInputTokens)
|
||||
assertTrue(r.errors.isEmpty())
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `foreign session events are dropped`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame("message.delta", buildJsonObject { put("text", "not yours") }, "someone-else"),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame("message.complete", buildJsonObject { put("text", "yours") }, "live-1"),
|
||||
)
|
||||
|
||||
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("yours"), r.textDeltas.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existing session id is resumed not recreated`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn("20260101_010101_aaaaaa", "again", null, r.callbacks) {
|
||||
r.preflightFailures += it
|
||||
}
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val resume = harness.awaitRpc("session.resume")
|
||||
assertEquals(
|
||||
"20260101_010101_aaaaaa",
|
||||
(resume["session_id"] as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.create" })
|
||||
// Resumed sessions keep their stored id — no onSessionId rotation.
|
||||
assertTrue(r.sessionIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed resume falls back to fresh create`() {
|
||||
harness.resumeFails = true
|
||||
val r = Recorder()
|
||||
client.sendTurn("api_123_dead", "hi", "hi", r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("session.create")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
val deadline = System.currentTimeMillis() + 5_000
|
||||
while (r.sessionIds.isEmpty() && System.currentTimeMillis() < deadline) Thread.sleep(20)
|
||||
assertEquals(listOf("20260612_120000_abc123"), r.sessionIds.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ticket mint failure triggers preflight fallback not error`() {
|
||||
harness.failTicketMint = true
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hello", null, r.callbacks) {
|
||||
r.preflightFailures += it
|
||||
r.completeLatch.countDown()
|
||||
}
|
||||
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(r.preflightFailures.isNotEmpty())
|
||||
assertTrue(r.errors.isEmpty())
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each connect attempt mints a fresh ticket`() {
|
||||
val r1 = Recorder()
|
||||
client.sendTurn(null, "one", null, r1.callbacks) { r1.preflightFailures += it }
|
||||
val ws1 = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
ws1.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "ok") }, "live-1"))
|
||||
assertTrue(r1.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(1, harness.ticketMints.get())
|
||||
|
||||
// Kill the socket server-side; next send must reconnect with a NEW ticket.
|
||||
ws1.close(1001, "server restart")
|
||||
Thread.sleep(200)
|
||||
|
||||
harness.rpcLog.clear()
|
||||
val r2 = Recorder()
|
||||
client.sendTurn(null, "two", null, r2.callbacks) { r2.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
// ≥2: the reconnect minted at least one fresh ticket (a retried
|
||||
// attempt may mint a third — what matters is no reuse).
|
||||
assertTrue("expected a fresh ticket on reconnect", harness.ticketMints.get() >= 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel sends session interrupt`() {
|
||||
val r = Recorder()
|
||||
val handle = client.sendTurn(null, "long task", null, r.callbacks) {
|
||||
r.preflightFailures += it
|
||||
}
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
handle.cancel()
|
||||
val interrupt = harness.awaitRpc("session.interrupt")
|
||||
assertEquals("live-1", (interrupt["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `socket loss mid-turn reconnects without resume and completes on the original session`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
|
||||
val ws1 = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
// Server connection dies mid-turn (Wi-Fi roam analogue). The client
|
||||
// must reconnect with a FRESH ticket but WITHOUT session.resume —
|
||||
// upstream resume would mint a new id + fresh agent and orphan the
|
||||
// running turn — then keep consuming the still-running turn's events,
|
||||
// which arrive tagged with the ORIGINAL live session id ("live-1")
|
||||
// on the shared gateway stream.
|
||||
harness.rpcLog.clear()
|
||||
ws1.close(1011, "server crashed")
|
||||
|
||||
val ws2 = harness.awaitServerSocket()
|
||||
assertTrue("reconnect must mint a fresh ticket", harness.ticketMints.get() >= 2)
|
||||
|
||||
ws2.send(
|
||||
harness.eventFrame("message.delta", buildJsonObject { put("text", "after rejoin") }, "live-1"),
|
||||
)
|
||||
ws2.send(
|
||||
harness.eventFrame("message.complete", buildJsonObject { put("text", "after rejoin") }, "live-1"),
|
||||
)
|
||||
|
||||
assertTrue("turn never completed after rejoin", r.completeLatch.await(10, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("after rejoin"), r.textDeltas.toList())
|
||||
assertTrue("rejoined turn must not error, got ${r.errors}", r.errors.isEmpty())
|
||||
// The fix's core invariant: a mid-turn rejoin must NEVER session.resume.
|
||||
assertTrue(
|
||||
"mid-turn rejoin must not call session.resume",
|
||||
harness.rpcLog.none { it.first == "session.resume" },
|
||||
)
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed rejoin surfaces stream error`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
// Reconnect is impossible (ticket mint rejected) — the turn must
|
||||
// surface a stream error rather than hang.
|
||||
harness.failTicketMint = true
|
||||
serverWs.close(1011, "server crashed")
|
||||
|
||||
assertTrue(r.completeLatch.await(10, TimeUnit.SECONDS))
|
||||
assertTrue("expected stream error, got ${r.errors}", r.errors.isNotEmpty())
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interactive ask surfaces as structured GatewayAsk`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "do something risky", null, r.callbacks) { r.preflightFailures += it }
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"approval.request",
|
||||
buildJsonObject { put("command", "rm -rf /tmp/x"); put("description", "cleanup") },
|
||||
"live-1",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame("message.complete", buildJsonObject { put("text", "done") }, "live-1"),
|
||||
)
|
||||
|
||||
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
val ask = r.interactions.single()
|
||||
assertEquals(GatewayAsk.Kind.APPROVAL, ask.kind)
|
||||
assertEquals(null, ask.requestId)
|
||||
assertEquals("rm -rf /tmp/x — cleanup", ask.text)
|
||||
}
|
||||
|
||||
// --- Steer ---
|
||||
|
||||
@Test
|
||||
fun `steer queued when the server accepts`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
harness.steerStatus = "queued"
|
||||
assertEquals(SteerResult.Queued, runBlocking { client.steer("focus on tests") })
|
||||
val steer = harness.awaitRpc("session.steer")
|
||||
assertEquals("live-1", (steer["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("focus on tests", (steer["text"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steer rejected propagates to the caller`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
harness.steerStatus = "rejected"
|
||||
assertEquals(SteerResult.Rejected, runBlocking { client.steer("too late") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steer with no live session fails without touching the wire`() {
|
||||
assertEquals(SteerResult.Failed, runBlocking { client.steer("nothing running") })
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.steer" })
|
||||
}
|
||||
|
||||
// --- Profile-bound sessions (upstream tui_gateway: session.create/resume
|
||||
// take a `profile` arg; a session's agent is built from it) ---
|
||||
|
||||
@Test
|
||||
fun `session create binds the selected profile`() {
|
||||
val r = Recorder()
|
||||
client.sessionProfileProvider = { "mizu" }
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val create = harness.awaitRpc("session.create")
|
||||
assertEquals("mizu", (create["profile"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session create omits profile when none is selected`() {
|
||||
val r = Recorder()
|
||||
// Default provider returns null → no profile bound (launch profile).
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val create = harness.awaitRpc("session.create")
|
||||
assertEquals(null, create["profile"])
|
||||
}
|
||||
|
||||
// --- Attachments (image / pdf / file routing) ---
|
||||
|
||||
@Test
|
||||
fun `image attachments upload between session establish and prompt submit`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = null,
|
||||
text = "describe this",
|
||||
newSessionTitle = null,
|
||||
callbacks = r.callbacks,
|
||||
attachments = listOf(GatewayAttachment(name = "shot.png", base64 = "aGVsbG8=", ext = "png", contentType = "image/png")),
|
||||
onPreflightFailure = { r.preflightFailures += it },
|
||||
)
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val methods = harness.rpcLog.map { it.first }
|
||||
val createIdx = methods.indexOf("session.create")
|
||||
val attachIdx = methods.indexOf("image.attach_bytes")
|
||||
val submitIdx = methods.indexOf("prompt.submit")
|
||||
assertTrue("expected create < attach < submit, got $methods", createIdx in 0 until attachIdx)
|
||||
assertTrue("expected attach before submit, got $methods", attachIdx < submitIdx)
|
||||
|
||||
val attach = harness.awaitRpc("image.attach_bytes")
|
||||
assertEquals("live-1", (attach["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("aGVsbG8=", (attach["content_base64"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("shot.png", (attach["filename"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("png", (attach["ext"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach falls back to the legacy name on method-not-found and remembers it`() {
|
||||
harness.methodNotFound.add("image.attach_bytes")
|
||||
val r1 = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = null,
|
||||
text = "one",
|
||||
newSessionTitle = null,
|
||||
callbacks = r1.callbacks,
|
||||
attachments = listOf(GatewayAttachment("a.png", "QQ==", "png", "image/png")),
|
||||
onPreflightFailure = { r1.preflightFailures += it },
|
||||
)
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val legacy = harness.awaitRpc("image.attach.bytes")
|
||||
assertEquals("live-1", (legacy["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("QQ==", (legacy["bytes_base64"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("png", (legacy["format"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("a.png", (legacy["filename_hint"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertTrue(r1.preflightFailures.isEmpty())
|
||||
|
||||
serverWs.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "ok") }, "live-1"))
|
||||
assertTrue(r1.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
|
||||
// Same socket: the second upload must go straight to the legacy name —
|
||||
// no second probe of the upstream name.
|
||||
val r2 = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = "20260612_120000_abc123",
|
||||
text = "two",
|
||||
newSessionTitle = null,
|
||||
callbacks = r2.callbacks,
|
||||
attachments = listOf(GatewayAttachment("b.png", "Qg==", "png", "image/png")),
|
||||
onPreflightFailure = { r2.preflightFailures += it },
|
||||
)
|
||||
harness.awaitRpcCount("image.attach.bytes", 2)
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "image.attach_bytes" })
|
||||
assertTrue(r2.preflightFailures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attach failing on both names surfaces as preflight fallback`() {
|
||||
harness.methodNotFound.add("image.attach_bytes")
|
||||
harness.methodNotFound.add("image.attach.bytes")
|
||||
val r = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = null,
|
||||
text = "img",
|
||||
newSessionTitle = null,
|
||||
callbacks = r.callbacks,
|
||||
attachments = listOf(GatewayAttachment("a.png", "QQ==", "png", "image/png")),
|
||||
onPreflightFailure = {
|
||||
r.preflightFailures += it
|
||||
r.completeLatch.countDown()
|
||||
},
|
||||
)
|
||||
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(r.preflightFailures.isNotEmpty())
|
||||
// Nothing started server-side — the prompt was never submitted.
|
||||
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
|
||||
assertTrue(r.errors.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pdf attachments route to pdf attach with content_base64`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = null,
|
||||
text = "summarize this",
|
||||
newSessionTitle = null,
|
||||
callbacks = r.callbacks,
|
||||
attachments = listOf(
|
||||
GatewayAttachment(name = "report.pdf", base64 = "JVBERi0=", ext = "pdf", contentType = "application/pdf"),
|
||||
),
|
||||
onPreflightFailure = { r.preflightFailures += it },
|
||||
)
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val attach = harness.awaitRpc("pdf.attach")
|
||||
assertEquals("live-1", (attach["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("JVBERi0=", (attach["content_base64"] as? JsonPrimitive)?.contentOrNull)
|
||||
// No image RPC should have fired for a PDF.
|
||||
assertTrue(harness.rpcLog.none { it.first == "image.attach_bytes" })
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-image non-pdf attachments route to file attach with a data url`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = null,
|
||||
text = "read this",
|
||||
newSessionTitle = null,
|
||||
callbacks = r.callbacks,
|
||||
attachments = listOf(
|
||||
GatewayAttachment(name = "notes.txt", base64 = "aGk=", ext = "txt", contentType = "text/plain"),
|
||||
),
|
||||
onPreflightFailure = { r.preflightFailures += it },
|
||||
)
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val attach = harness.awaitRpc("file.attach")
|
||||
assertEquals("live-1", (attach["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(
|
||||
"data:text/plain;base64,aGk=",
|
||||
(attach["data_url"] as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
assertEquals("notes.txt", (attach["name"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertTrue(harness.rpcLog.none { it.first == "image.attach_bytes" })
|
||||
assertTrue(harness.rpcLog.none { it.first == "pdf.attach" })
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
}
|
||||
|
||||
// --- Ask responders ---
|
||||
|
||||
@Test
|
||||
fun `clarify respond carries request id and answer`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
assertTrue(runBlocking { client.respondClarify("r1", "use a.txt") }.isSuccess)
|
||||
val respond = harness.awaitRpc("clarify.respond")
|
||||
assertEquals("r1", (respond["request_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("use a.txt", (respond["answer"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sudo and secret responds carry request id under their key names`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
assertTrue(runBlocking { client.respondSudo("r2", "hunter2") }.isSuccess)
|
||||
val sudo = harness.awaitRpc("sudo.respond")
|
||||
assertEquals("r2", (sudo["request_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("hunter2", (sudo["password"] as? JsonPrimitive)?.contentOrNull)
|
||||
|
||||
assertTrue(runBlocking { client.respondSecret("r3", "sk-123") }.isSuccess)
|
||||
val secret = harness.awaitRpc("secret.respond")
|
||||
assertEquals("r3", (secret["request_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("sk-123", (secret["value"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval respond targets the live session`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
assertTrue(runBlocking { client.respondApproval("approve") }.isSuccess)
|
||||
val respond = harness.awaitRpc("approval.respond")
|
||||
assertEquals("live-1", (respond["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("approve", (respond["choice"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(false, (respond["all"] as? JsonPrimitive)?.booleanOrNull)
|
||||
}
|
||||
|
||||
// --- Commands catalog ---
|
||||
|
||||
@Test
|
||||
fun `commands catalog is fetched once and cached per socket`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
|
||||
harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
val first = runBlocking { client.commandsCatalog() }
|
||||
assertTrue(first.isSuccess)
|
||||
assertTrue(first.getOrThrow().containsKey("pairs"))
|
||||
val second = runBlocking { client.commandsCatalog() }
|
||||
assertTrue(second.isSuccess)
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "commands.catalog" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commands catalog without a ready socket fails fast and mints no ticket`() {
|
||||
val result = runBlocking { client.commandsCatalog() }
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(0, harness.ticketMints.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commands catalog connects on demand only when asked`() {
|
||||
val result = runBlocking { client.commandsCatalog(connectIfNeeded = true) }
|
||||
assertTrue(result.isSuccess)
|
||||
assertTrue(harness.ticketMints.get() >= 1)
|
||||
}
|
||||
|
||||
// --- Edit & regenerate ---
|
||||
|
||||
@Test
|
||||
fun `truncate ordinal rides prompt submit`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(
|
||||
sessionId = null,
|
||||
text = "edited message",
|
||||
newSessionTitle = null,
|
||||
callbacks = r.callbacks,
|
||||
truncateBeforeUserOrdinal = 2,
|
||||
onPreflightFailure = { r.preflightFailures += it },
|
||||
)
|
||||
val submit = harness.awaitRpc("prompt.submit")
|
||||
assertEquals(2, (submit["truncate_before_user_ordinal"] as? JsonPrimitive)?.intOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncate ordinal is absent from plain sends`() {
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "plain", null, r.callbacks) { r.preflightFailures += it }
|
||||
val submit = harness.awaitRpc("prompt.submit")
|
||||
assertFalse(submit.containsKey("truncate_before_user_ordinal"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Resolution matrix for [resolveStreamingEndpointPreference] — the gateway
|
||||
* tier sits above the capability-preferred SSE endpoint, but only for "auto"
|
||||
* and only when the dashboard probe reports Ready.
|
||||
*/
|
||||
class GatewayEndpointResolutionTest {
|
||||
|
||||
private val fullCaps = ServerCapabilities(
|
||||
sessionsApi = true,
|
||||
sessionsChatStream = true,
|
||||
runs = true,
|
||||
portable = true,
|
||||
healthy = true,
|
||||
)
|
||||
|
||||
private val portableOnlyCaps = ServerCapabilities(
|
||||
sessionsApi = false,
|
||||
sessionsChatStream = false,
|
||||
runs = false,
|
||||
portable = true,
|
||||
healthy = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `auto prefers gateway when ready`() {
|
||||
assertEquals(
|
||||
"gateway",
|
||||
resolveStreamingEndpointPreference("auto", GatewayAvailability.Ready, fullCaps),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto falls back to capability preference for every non-ready state`() {
|
||||
listOf(
|
||||
GatewayAvailability.Unknown,
|
||||
GatewayAvailability.SignInRequired,
|
||||
GatewayAvailability.Unreachable,
|
||||
GatewayAvailability.Unsupported,
|
||||
).forEach { availability ->
|
||||
assertEquals(
|
||||
"expected SSE fallback for $availability",
|
||||
"sessions",
|
||||
resolveStreamingEndpointPreference("auto", availability, fullCaps),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto fallback respects capability ordering`() {
|
||||
assertEquals(
|
||||
"completions",
|
||||
resolveStreamingEndpointPreference(
|
||||
"auto",
|
||||
GatewayAvailability.SignInRequired,
|
||||
portableOnlyCaps,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual picks pass through regardless of gateway state`() {
|
||||
listOf("gateway", "sessions", "completions", "runs").forEach { pick ->
|
||||
assertEquals(
|
||||
pick,
|
||||
resolveStreamingEndpointPreference(pick, GatewayAvailability.Unreachable, fullCaps),
|
||||
)
|
||||
assertEquals(
|
||||
pick,
|
||||
resolveStreamingEndpointPreference(pick, GatewayAvailability.Ready, fullCaps),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package com.hermesandroid.relay.network
|
||||
|
||||
import com.hermesandroid.relay.network.models.UsageInfo
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Mapping-table tests for [GatewayEventMapper] — one fixture per row of the
|
||||
* event→callback contract, plus the backfill, synthetic-id, and
|
||||
* forward-compat (unknown event type) behaviours.
|
||||
*/
|
||||
class GatewayEventMapperTest {
|
||||
|
||||
private class Recorder {
|
||||
val textDeltas = mutableListOf<String>()
|
||||
val thinkingDeltas = mutableListOf<String>()
|
||||
val toolStarts = mutableListOf<Pair<String, String>>()
|
||||
val toolDones = mutableListOf<Pair<String, String?>>()
|
||||
val toolFails = mutableListOf<Pair<String, String?>>()
|
||||
val toolGenerating = mutableListOf<String?>()
|
||||
val subagentEvents = mutableListOf<GatewaySubagentEvent>()
|
||||
val interactions = mutableListOf<GatewayAsk>()
|
||||
val sessionIds = mutableListOf<String>()
|
||||
var turnCompletes = 0
|
||||
var completes = 0
|
||||
var usage: UsageInfo? = null
|
||||
var usageCalls = 0
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
val callbacks = GatewayTurnCallbacks(
|
||||
onSessionId = { sessionIds += it },
|
||||
onTextDelta = { textDeltas += it },
|
||||
onThinkingDelta = { thinkingDeltas += it },
|
||||
onToolCallStart = { id, name -> toolStarts += id to name },
|
||||
onToolCallDone = { id, preview -> toolDones += id to preview },
|
||||
onToolCallFailed = { id, err -> toolFails += id to err },
|
||||
onTurnComplete = { turnCompletes++ },
|
||||
onComplete = { completes++ },
|
||||
onUsage = { usage = it; usageCalls++ },
|
||||
onError = { errors += it },
|
||||
onToolGenerating = { toolGenerating += it },
|
||||
onSubagentEvent = { subagentEvents += it },
|
||||
onInteractionRequest = { interactions += it },
|
||||
)
|
||||
}
|
||||
|
||||
private fun obj(jsonText: String): JsonObject =
|
||||
Json.parseToJsonElement(jsonText) as JsonObject
|
||||
|
||||
private fun mapperWith(recorder: Recorder) = GatewayEventMapper(recorder.callbacks)
|
||||
|
||||
// --- The feature: live thinking ---
|
||||
|
||||
@Test
|
||||
fun `reasoning delta streams to onThinkingDelta`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("reasoning.delta", obj("""{"text":"pondering"}"""))
|
||||
mapper.onEvent("thinking.delta", obj("""{"text":" more"}"""))
|
||||
assertEquals(listOf("pondering", " more"), r.thinkingDeltas)
|
||||
assertFalse(mapper.turnEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reasoning available backfills only when nothing streamed`() {
|
||||
val streamed = Recorder()
|
||||
val m1 = mapperWith(streamed)
|
||||
m1.onEvent("reasoning.delta", obj("""{"text":"live"}"""))
|
||||
m1.onEvent("reasoning.available", obj("""{"text":"post-hoc"}"""))
|
||||
assertEquals(listOf("live"), streamed.thinkingDeltas)
|
||||
|
||||
val quiet = Recorder()
|
||||
val m2 = mapperWith(quiet)
|
||||
m2.onEvent("reasoning.available", obj("""{"text":"post-hoc"}"""))
|
||||
assertEquals(listOf("post-hoc"), quiet.thinkingDeltas)
|
||||
}
|
||||
|
||||
// --- Text + turn lifecycle ---
|
||||
|
||||
@Test
|
||||
fun `message delta streams text and complete ends turn`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("message.start", null)
|
||||
mapper.onEvent("message.delta", obj("""{"text":"Hel"}"""))
|
||||
mapper.onEvent("message.delta", obj("""{"text":"lo"}"""))
|
||||
mapper.onEvent("message.complete", obj("""{"text":"Hello","status":"complete"}"""))
|
||||
assertEquals(listOf("Hel", "lo"), r.textDeltas)
|
||||
assertEquals(1, r.completes)
|
||||
assertTrue(mapper.turnEnded)
|
||||
// Text already streamed — complete must NOT re-append it.
|
||||
assertEquals(2, r.textDeltas.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message complete backfills text and reasoning when nothing streamed`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"Full answer","reasoning":"the hidden why","status":"complete"}"""),
|
||||
)
|
||||
assertEquals(listOf("Full answer"), r.textDeltas)
|
||||
assertEquals(listOf("the hidden why"), r.thinkingDeltas)
|
||||
assertEquals(1, r.completes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `second message start signals turn boundary`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("message.start", null)
|
||||
assertEquals(0, r.turnCompletes)
|
||||
mapper.onEvent("message.start", null)
|
||||
assertEquals(1, r.turnCompletes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events after turn end are ignored`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("message.complete", obj("""{"text":"done"}"""))
|
||||
mapper.onEvent("message.delta", obj("""{"text":"straggler"}"""))
|
||||
mapper.onEvent("error", obj("""{"message":"late"}"""))
|
||||
assertEquals(listOf("done"), r.textDeltas)
|
||||
assertEquals(1, r.completes)
|
||||
assertTrue(r.errors.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error event surfaces message and ends turn`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("error", obj("""{"message":"model exploded"}"""))
|
||||
assertEquals(listOf("model exploded"), r.errors)
|
||||
assertTrue(mapper.turnEnded)
|
||||
assertEquals(0, r.completes)
|
||||
}
|
||||
|
||||
// --- Tools ---
|
||||
|
||||
@Test
|
||||
fun `tool start and complete route by tool_id`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("tool.start", obj("""{"tool_id":"t1","name":"execute_code"}"""))
|
||||
mapper.onEvent("tool.complete", obj("""{"tool_id":"t1","name":"execute_code","summary":"ran fine"}"""))
|
||||
assertEquals(listOf("t1" to "execute_code"), r.toolStarts)
|
||||
assertEquals(listOf("t1" to "ran fine"), r.toolDones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool complete with error routes to failed`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("tool.start", obj("""{"tool_id":"t2","name":"web_search"}"""))
|
||||
mapper.onEvent("tool.complete", obj("""{"tool_id":"t2","name":"web_search","error":"timeout"}"""))
|
||||
assertEquals(listOf("t2" to "timeout"), r.toolFails)
|
||||
assertTrue(r.toolDones.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing tool ids get synthesized and matched FIFO by name`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("tool.start", obj("""{"name":"read_file"}"""))
|
||||
mapper.onEvent("tool.start", obj("""{"name":"read_file"}"""))
|
||||
mapper.onEvent("tool.complete", obj("""{"name":"read_file","summary":"first"}"""))
|
||||
mapper.onEvent("tool.complete", obj("""{"name":"read_file","summary":"second"}"""))
|
||||
assertEquals(2, r.toolStarts.size)
|
||||
val (firstId, secondId) = r.toolStarts.map { it.first }
|
||||
assertEquals(listOf(firstId to "first", secondId to "second"), r.toolDones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool complete with no id and no open start is dropped`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("tool.complete", obj("""{"name":"mystery"}"""))
|
||||
assertTrue(r.toolDones.isEmpty())
|
||||
assertTrue(r.toolFails.isEmpty())
|
||||
}
|
||||
|
||||
// --- tool.generating (args still being written) ---
|
||||
|
||||
@Test
|
||||
fun `tool generating fires callback and its id is adopted through to complete`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("tool.generating", obj("""{"name":"write_file"}"""))
|
||||
assertEquals(listOf<String?>("write_file"), r.toolGenerating)
|
||||
assertTrue(r.toolStarts.isEmpty())
|
||||
|
||||
mapper.onEvent("tool.start", obj("""{"name":"write_file"}"""))
|
||||
mapper.onEvent("tool.complete", obj("""{"name":"write_file","summary":"wrote"}"""))
|
||||
val startId = r.toolStarts.single().first
|
||||
assertEquals(listOf(startId to "wrote"), r.toolDones)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generating pre-registrations are adopted FIFO per name`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("tool.generating", obj("""{"name":"read_file"}"""))
|
||||
mapper.onEvent("tool.generating", obj("""{"name":"read_file"}"""))
|
||||
mapper.onEvent("tool.start", obj("""{"name":"read_file"}"""))
|
||||
mapper.onEvent("tool.start", obj("""{"name":"read_file"}"""))
|
||||
assertEquals(2, r.toolStarts.size)
|
||||
assertEquals(2, r.toolStarts.map { it.first }.distinct().size)
|
||||
|
||||
mapper.onEvent("tool.complete", obj("""{"name":"read_file","summary":"first"}"""))
|
||||
mapper.onEvent("tool.complete", obj("""{"name":"read_file","summary":"second"}"""))
|
||||
// Completes match the started ids in start order — FIFO held end to end.
|
||||
assertEquals(r.toolStarts.map { it.first }, r.toolDones.map { it.first })
|
||||
assertEquals(listOf("first", "second"), r.toolDones.map { it.second })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server tool id wins over a pending generating pre-registration`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("tool.generating", obj("""{"name":"web_search"}"""))
|
||||
mapper.onEvent("tool.start", obj("""{"tool_id":"t9","name":"web_search"}"""))
|
||||
assertEquals(listOf("t9" to "web_search"), r.toolStarts)
|
||||
// The pre-registration was consumed — a later id-less start mints fresh.
|
||||
mapper.onEvent("tool.start", obj("""{"name":"web_search"}"""))
|
||||
assertTrue(r.toolStarts[1].first != "t9")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool generating without a name still fires the callback`() {
|
||||
val r = Recorder()
|
||||
mapperWith(r).onEvent("tool.generating", obj("""{}"""))
|
||||
assertEquals(listOf<String?>(null), r.toolGenerating)
|
||||
}
|
||||
|
||||
// --- Subagent lifecycle ---
|
||||
|
||||
@Test
|
||||
fun `subagent lifecycle maps phases and fields`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("subagent.start", obj("""{"goal":"research topic","task_index":1,"task_count":3}"""))
|
||||
mapper.onEvent("subagent.thinking", obj("""{"goal":"research topic","task_index":1,"task_count":3,"text":"hmm"}"""))
|
||||
mapper.onEvent(
|
||||
"subagent.tool",
|
||||
obj("""{"goal":"research topic","task_index":1,"task_count":3,"tool_name":"web_search","tool_preview":"searching docs","text":"searching docs"}"""),
|
||||
)
|
||||
mapper.onEvent("subagent.progress", obj("""{"goal":"research topic","task_index":1,"task_count":3,"text":"halfway"}"""))
|
||||
mapper.onEvent(
|
||||
"subagent.complete",
|
||||
obj("""{"goal":"research topic","task_index":1,"task_count":3,"status":"completed","summary":"found it","duration_seconds":12.5}"""),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewaySubagentEvent.Phase.START,
|
||||
GatewaySubagentEvent.Phase.THINKING,
|
||||
GatewaySubagentEvent.Phase.TOOL,
|
||||
GatewaySubagentEvent.Phase.PROGRESS,
|
||||
GatewaySubagentEvent.Phase.COMPLETE,
|
||||
),
|
||||
r.subagentEvents.map { it.phase },
|
||||
)
|
||||
val start = r.subagentEvents[0]
|
||||
assertEquals(1, start.taskIndex)
|
||||
assertEquals(3, start.taskCount)
|
||||
assertEquals("research topic", start.goal)
|
||||
assertEquals("hmm", r.subagentEvents[1].preview)
|
||||
val tool = r.subagentEvents[2]
|
||||
assertEquals("web_search", tool.toolName)
|
||||
assertEquals("searching docs", tool.preview)
|
||||
assertEquals("halfway", r.subagentEvents[3].preview)
|
||||
val complete = r.subagentEvents[4]
|
||||
assertEquals("completed", complete.status)
|
||||
assertEquals("found it", complete.summary)
|
||||
assertEquals(12.5, complete.durationSeconds!!, 0.001)
|
||||
assertFalse(mapper.turnEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subagent payload defaults when older emitters omit fields`() {
|
||||
val r = Recorder()
|
||||
mapperWith(r).onEvent("subagent.start", obj("""{}"""))
|
||||
val event = r.subagentEvents.single()
|
||||
assertEquals(0, event.taskIndex)
|
||||
assertEquals(1, event.taskCount)
|
||||
assertEquals("", event.goal)
|
||||
assertNull(event.status)
|
||||
assertNull(event.toolName)
|
||||
assertNull(event.durationSeconds)
|
||||
}
|
||||
|
||||
// --- Usage translation (tui_gateway key names, not SSE names) ---
|
||||
|
||||
@Test
|
||||
fun `gateway usage keys translate to UsageInfo`() {
|
||||
val usage = GatewayEventMapper.parseGatewayUsage(
|
||||
obj("""{"input":120,"output":45,"total":165,"model":"hermes-4"}"""),
|
||||
)
|
||||
assertEquals(120, usage?.resolvedInputTokens)
|
||||
assertEquals(45, usage?.resolvedOutputTokens)
|
||||
assertEquals(165, usage?.resolvedTotalTokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gateway usage falls back to prompt and completion keys`() {
|
||||
val usage = GatewayEventMapper.parseGatewayUsage(
|
||||
obj("""{"prompt":10,"completion":5}"""),
|
||||
)
|
||||
assertEquals(10, usage?.resolvedInputTokens)
|
||||
assertEquals(5, usage?.resolvedOutputTokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty or missing usage maps to null`() {
|
||||
assertNull(GatewayEventMapper.parseGatewayUsage(null))
|
||||
assertNull(GatewayEventMapper.parseGatewayUsage(obj("""{"model":"x"}""")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gateway usage lifts context window fields`() {
|
||||
val usage = GatewayEventMapper.parseGatewayUsage(
|
||||
obj("""{"input":120,"output":45,"total":165,"context_used":41200,"context_max":48000,"context_percent":86}"""),
|
||||
)
|
||||
assertEquals(41200, usage?.contextUsed)
|
||||
assertEquals(48000, usage?.contextMax)
|
||||
assertEquals(86, usage?.contextPercent)
|
||||
assertEquals(120, usage?.resolvedInputTokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `context fields stay null when the compressor block is absent`() {
|
||||
val usage = GatewayEventMapper.parseGatewayUsage(obj("""{"input":1,"output":1,"total":2}"""))
|
||||
assertNull(usage?.contextUsed)
|
||||
assertNull(usage?.contextMax)
|
||||
assertNull(usage?.contextPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message complete forwards usage`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent(
|
||||
"message.complete",
|
||||
obj("""{"text":"hi","usage":{"input":7,"output":3,"total":10}}"""),
|
||||
)
|
||||
assertEquals(7, r.usage?.resolvedInputTokens)
|
||||
assertEquals(3, r.usage?.resolvedOutputTokens)
|
||||
}
|
||||
|
||||
// --- Interactive asks (structured GatewayAsk) ---
|
||||
|
||||
@Test
|
||||
fun `clarify request maps to structured ask with request id and choices`() {
|
||||
val r = Recorder()
|
||||
mapperWith(r).onEvent(
|
||||
"clarify.request",
|
||||
obj("""{"request_id":"r1","question":"Which file?","choices":["a.txt","b.txt"]}"""),
|
||||
)
|
||||
val ask = r.interactions.single()
|
||||
assertEquals(GatewayAsk.Kind.CLARIFY, ask.kind)
|
||||
assertEquals("r1", ask.requestId)
|
||||
assertEquals("Which file?", ask.text)
|
||||
assertEquals(listOf("a.txt", "b.txt"), ask.choices)
|
||||
assertNull(ask.envVar)
|
||||
assertEquals(300, ask.timeoutSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clarify request without choices maps null choices`() {
|
||||
val r = Recorder()
|
||||
mapperWith(r).onEvent(
|
||||
"clarify.request",
|
||||
obj("""{"request_id":"r1","question":"Why?","choices":null}"""),
|
||||
)
|
||||
assertNull(r.interactions.single().choices)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval request has no request id by contract`() {
|
||||
val r = Recorder()
|
||||
mapperWith(r).onEvent(
|
||||
"approval.request",
|
||||
obj("""{"command":"rm -rf build","description":"clean the build tree"}"""),
|
||||
)
|
||||
val ask = r.interactions.single()
|
||||
assertEquals(GatewayAsk.Kind.APPROVAL, ask.kind)
|
||||
assertNull(ask.requestId)
|
||||
assertEquals("rm -rf build — clean the build tree", ask.text)
|
||||
// Session-scoped — no countdown.
|
||||
assertEquals(0, ask.timeoutSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval request ignores a stray request id`() {
|
||||
// Upstream approvals correlate per-session; even if some build sends
|
||||
// a request_id it must not be adopted (approval.respond has no slot for it).
|
||||
val r = Recorder()
|
||||
mapperWith(r).onEvent(
|
||||
"approval.request",
|
||||
obj("""{"command":"ls","request_id":"bogus"}"""),
|
||||
)
|
||||
assertNull(r.interactions.single().requestId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sudo and secret requests carry request ids and timeouts`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("sudo.request", obj("""{"request_id":"r2"}"""))
|
||||
mapper.onEvent("secret.request", obj("""{"request_id":"r3","env_var":"API_KEY","prompt":"Enter API key"}"""))
|
||||
assertEquals(2, r.interactions.size)
|
||||
|
||||
val sudo = r.interactions[0]
|
||||
assertEquals(GatewayAsk.Kind.SUDO, sudo.kind)
|
||||
assertEquals("r2", sudo.requestId)
|
||||
assertEquals(120, sudo.timeoutSeconds)
|
||||
|
||||
val secret = r.interactions[1]
|
||||
assertEquals(GatewayAsk.Kind.SECRET, secret.kind)
|
||||
assertEquals("r3", secret.requestId)
|
||||
assertEquals("Enter API key", secret.text)
|
||||
assertEquals("API_KEY", secret.envVar)
|
||||
assertEquals(300, secret.timeoutSeconds)
|
||||
}
|
||||
|
||||
// --- Forward compat ---
|
||||
|
||||
@Test
|
||||
fun `unknown event types are silently ignored`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent("hologram.delta", obj("""{"text":"future"}"""))
|
||||
mapper.onEvent("session.info", obj("""{"model":"x"}"""))
|
||||
mapper.onEvent("status.update", obj("""{"kind":"busy"}"""))
|
||||
mapper.onEvent("tool.progress", obj("""{"name":"write_file","preview":"..."}"""))
|
||||
assertTrue(r.textDeltas.isEmpty())
|
||||
assertTrue(r.thinkingDeltas.isEmpty())
|
||||
assertTrue(r.errors.isEmpty())
|
||||
assertTrue(r.subagentEvents.isEmpty())
|
||||
assertTrue(r.toolGenerating.isEmpty())
|
||||
assertFalse(mapper.turnEnded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null payloads do not crash`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
listOf(
|
||||
"reasoning.delta", "thinking.delta", "message.delta", "message.start",
|
||||
"message.complete", "error", "clarify.request", "approval.request",
|
||||
"sudo.request", "secret.request", "reasoning.available",
|
||||
"tool.generating", "subagent.start", "subagent.thinking",
|
||||
"subagent.tool", "subagent.progress", "subagent.complete",
|
||||
).forEach { type ->
|
||||
// message.complete/error end the turn; use a fresh mapper for each
|
||||
mapperWith(Recorder()).onEvent(type, null)
|
||||
}
|
||||
// tool events with null payload on a shared mapper
|
||||
mapper.onEvent("tool.start", null)
|
||||
mapper.onEvent("tool.complete", null)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
Before Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 204 KiB |
@@ -4,93 +4,77 @@
|
||||
|
||||
## Short Description (≤80 chars)
|
||||
|
||||
Your self-hosted Hermes AI agent, in your pocket — chat, voice, and control.
|
||||
Your Hermes AI agent, in your pocket — chat, voice, and control.
|
||||
|
||||
## Full Description
|
||||
## Full Description (plain text, no markdown — paste as-is; ≤4000 chars)
|
||||
|
||||
**Hermes-Relay** is the native Android client for the Hermes agent platform. Point it at your own self-hosted Hermes server and chat with your agent, talk to it hands-free, and manage it — models, keys, skills, profiles — from anywhere.
|
||||
Hermes-Relay is the native Android client for the Hermes agent platform. Point it at your own Hermes server and chat with your agent, talk to it hands-free, and manage it — models, keys, skills, profiles — from anywhere.
|
||||
|
||||
Built for developers and AI enthusiasts who run their own Hermes agent instance. This is not a hosted AI service — it is a companion app for your own infrastructure, and it talks only to servers you configure.
|
||||
It's not a hosted AI service. It's a companion app for the Hermes agent you run, and it talks only to the servers you configure.
|
||||
|
||||
━━━ QUICK START ━━━
|
||||
QUICK START
|
||||
|
||||
1. Run hermes-agent with its API server enabled on your computer or home server.
|
||||
2. Install Hermes-Relay and enter your server's address (for example http://192.168.1.100:8642).
|
||||
3. The setup wizard probes what your server supports and shows you a readiness card — then you're talking.
|
||||
3. The setup wizard checks what your server supports and shows a readiness card — then you're talking.
|
||||
|
||||
A plain Hermes install is enough: chat, management, and voice all work without any plugin or extra services.
|
||||
A plain Hermes install is enough. Chat, management, and voice all work with no plugin or extra services.
|
||||
|
||||
━━━ HOW IT WORKS ━━━
|
||||
HOW IT WORKS
|
||||
|
||||
Hermes-Relay connects directly to your Hermes API Server for real-time streaming chat. The Manage tab and voice use your Hermes dashboard with one sign-in. If you also run the optional Hermes relay service, the app can pair by QR code and add power tools: remote terminal, notification companion, media handoff, relay-session management, and additional voice engines.
|
||||
Chat streams directly from your Hermes API Server in real time. Manage and voice use your Hermes dashboard with one sign-in. Run the optional relay service and the app can pair by QR code to add power tools: remote terminal, notification companion, media handoff, relay-session management, and more voice engines.
|
||||
|
||||
━━━ GOOGLE PLAY BUILD ━━━
|
||||
GOOGLE PLAY BUILD
|
||||
|
||||
The Google Play build ships **Hermes Bridge Core** only. It does not include AccessibilityService-based Device Control and cannot read your screen, tap, type, swipe, capture screenshots, send SMS, place calls, access contacts or location, or perform unattended phone control.
|
||||
The Google Play build ships Hermes Bridge Core only. It has no AccessibilityService Device Control: it cannot read your screen, tap, type, swipe, screenshot, send SMS, place calls, or access contacts or location. Device Control is reserved for sideload builds distributed outside Google Play.
|
||||
|
||||
Device Control is reserved for sideload builds distributed outside Google Play.
|
||||
FEATURES
|
||||
|
||||
━━━ FEATURES ━━━
|
||||
◆ Streaming Chat — real-time and token-by-token, with live reasoning, markdown, tool-call visibility, image/PDF/file attachments, mid-turn steering, edit-and-resend, and a searchable command palette.
|
||||
◆ Manage Your Agent — the Hermes dashboard on your phone: switch models from your provider catalog, manage provider keys (masked), edit profiles, and browse, install, and update skills.
|
||||
◆ Voice Mode — talk hands-free using your server's speech providers, no plugin needed. Relay-paired setups add per-profile voices and an experimental realtime engine.
|
||||
◆ Works Away From Home — add a Tailscale or public URL and the app switches routes automatically; when a server is unreachable it tells you what to fix instead of just going red.
|
||||
◆ Sessions — create, switch, rename, and delete chats; message history loads on demand.
|
||||
◆ Multiple Servers & Profiles — connect to more than one server (home and work) and switch in a tap; overlay an agent profile or personality per conversation.
|
||||
◆ Relay Power Tools (optional) — pair by QR code for a remote terminal, relay-session management, and per-feature grants.
|
||||
◆ Notification Companion (optional) — forward notification metadata to your paired relay so your assistant can summarize it. Toggle it anytime in system settings.
|
||||
◆ Stats for Nerds — local-only counters for response timing, token usage, cost, and stream health.
|
||||
◆ Material You — Material 3 dynamic color, light/dark/system themes, and haptics.
|
||||
|
||||
◆ Streaming Chat
|
||||
Chat with your Hermes agent in real time. Responses stream token-by-token with markdown rendering, tool-call visibility, file attachments, and a searchable command palette.
|
||||
|
||||
◆ Manage Your Agent
|
||||
The full Hermes dashboard, native on your phone: switch models from your provider catalog, manage provider keys (write-only and masked), create and edit agent profiles, and browse, install, and update skills.
|
||||
|
||||
◆ Voice Mode
|
||||
Talk to your agent hands-free. Voice works on a plain Hermes install using the speech providers your server is configured with — no plugin needed. Relay-paired setups add per-profile voice providers and an experimental realtime conversation engine.
|
||||
|
||||
◆ Works Away From Home
|
||||
Add your server's Tailscale or public URL and the app switches routes automatically — local network at home, your secure fallback everywhere else. Routes are editable any time, and when a server is unreachable the status tells you what to fix instead of just going red.
|
||||
|
||||
◆ Sessions
|
||||
Create, switch, rename, and delete chat sessions. Message history loads from your Hermes server on demand.
|
||||
|
||||
◆ Multiple Servers, Profiles, and Personalities
|
||||
Connect to more than one Hermes server (home and work, dev and prod) and switch in one tap. Overlay an agent profile or personality per conversation.
|
||||
|
||||
◆ Relay Power Tools (optional)
|
||||
Scan a relay QR code to pair, then open a remote terminal to your Hermes host, manage relay sessions, and control per-feature grants from the app.
|
||||
|
||||
◆ Notification Companion
|
||||
Optional Android notification access lets Hermes-Relay forward posted-notification metadata to your paired relay so your assistant can summarize recent notifications. You enable or revoke this in Android system settings at any time.
|
||||
|
||||
◆ Media Handoff
|
||||
Fetch relay-registered media into chat and share supported media through Android-native flows.
|
||||
|
||||
◆ Stats for Nerds
|
||||
Built-in local analytics show response timing, token usage, cost estimates, and stream health. These counters stay on your phone.
|
||||
|
||||
◆ Material You Design
|
||||
Full Material 3 with dynamic color theming, light/dark/system modes, animated splash screen, and haptic feedback.
|
||||
|
||||
━━━ SECURITY & PRIVACY ━━━
|
||||
SECURITY & PRIVACY
|
||||
|
||||
• API keys and relay tokens are stored in encrypted Android storage
|
||||
• HTTPS is enforced for remote connections
|
||||
• Cleartext is permitted only for localhost/LAN development servers
|
||||
• No telemetry, no ads, no tracking, no third-party analytics SDKs
|
||||
• Notification companion is optional and user-controlled
|
||||
• Microphone is optional and requested only when you use Voice mode
|
||||
• HTTPS is enforced for remote connections; cleartext only for localhost/LAN
|
||||
• No telemetry, ads, tracking, or third-party analytics SDKs
|
||||
• Notification access and the microphone are optional and user-controlled
|
||||
• All app traffic goes only to servers you configure
|
||||
|
||||
━━━ REQUIREMENTS ━━━
|
||||
REQUIREMENTS
|
||||
|
||||
• Android 8.0 or later (API 26+)
|
||||
• A running Hermes agent instance (chat, management, and voice need nothing else)
|
||||
• A running Hermes agent (chat, management, and voice need nothing else)
|
||||
• Optional Hermes relay service for power tools (terminal, notifications, media)
|
||||
• Network access to your server (local network, VPN, or internet)
|
||||
|
||||
━━━ OPEN SOURCE ━━━
|
||||
OPEN SOURCE
|
||||
|
||||
Hermes-Relay is MIT licensed. Source code, documentation, and issue tracking are available on GitHub.
|
||||
Hermes-Relay is MIT licensed. Source, docs, and issue tracking are on GitHub.
|
||||
|
||||
This app is a community project and is not affiliated with or endorsed by NousResearch.
|
||||
|
||||
## Release Notes
|
||||
|
||||
v0.8.1 makes the standard no-plugin setup first-class. Voice now works on a plain Hermes install — one dashboard sign-in unlocks it. The Manage tab reaches parity with the desktop dashboard: models, provider keys, profiles, and a skills hub with install and update. Connection routes are editable on the phone, and remote access is built into the whole journey — a setup field, a readiness card, a smarter "unreachable" diagnosis, and a one-tap Tailscale shortcut.
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
Hermes-Relay 1.0 — our biggest release.
|
||||
|
||||
Standard path: chat, Manage, and voice now run on a plain Hermes agent, no plugin. The relay plugin is optional now — Advanced power tools only.
|
||||
|
||||
Chat streams reasoning live, attaches images/PDFs/files, steers a running turn, and edits & resends. Switch agent profiles per conversation. Manage hits desktop-dashboard parity.
|
||||
|
||||
Plus a redesigned input bar, seamless LAN/Tailscale handoffs, and a broad polish + QoL pass.
|
||||
```
|
||||
|
||||
## Category
|
||||
|
||||
@@ -104,3 +88,29 @@ Not designed for children.
|
||||
## Tags
|
||||
|
||||
ai, agent, hermes, developer tools, chat, voice, self-hosted, remote, open source
|
||||
|
||||
## Play Console Declarations
|
||||
|
||||
Submission-time declarations the Play Console requires — keep in sync with the merged `googlePlay` manifest.
|
||||
|
||||
### Foreground service permissions
|
||||
|
||||
The Play build declares **`FOREGROUND_SERVICE_SPECIAL_USE`** for `GatewayKeepAliveService`, backing the opt-in **Keep connected in background** feature (off by default). At submission, complete **App content → Foreground service permissions** for `specialUse`:
|
||||
|
||||
- **Use case:** maintains a persistent connection to the user's own Hermes agent server so the assistant stays responsive and delivers replies while the app is backgrounded.
|
||||
- **Why a foreground service:** it's a real-time, user-initiated streaming connection that must survive Doze / background execution limits; `dataSync` is force-stopped after a 6-hour/day cap on Android 15, so `specialUse` is the only fit for "stay connected."
|
||||
- **User control:** off by default; enabled only via *Settings → Chat → Keep connected in background*; shows an ongoing notification with a **Disconnect** action; ends when the app is swiped from recents.
|
||||
- Google usually asks for a short screen recording of the toggle + notification.
|
||||
|
||||
The Play build does **not** declare `FOREGROUND_SERVICE_MEDIA_PROJECTION` or the Device Control accessibility/bridge services — those are sideload-only.
|
||||
|
||||
### Data safety
|
||||
|
||||
No data collection or sharing to declare: no telemetry, ads, or third-party analytics SDKs; all traffic goes only to user-configured servers; credentials are stored in encrypted on-device storage. Mirror the **Security & Privacy** section above when filling the Data safety form.
|
||||
|
||||
### Sensitive / runtime permissions in the Play build
|
||||
|
||||
- `RECORD_AUDIO` — Voice mode, requested at use.
|
||||
- `POST_NOTIFICATIONS` — turn-complete + keep-alive notifications, requested on API 33+.
|
||||
- `CAMERA` — QR pairing / attachments, requested at use.
|
||||
- Notification listener (companion) — user-enabled in system settings.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Privacy & Data Handling
|
||||
|
||||
Hermes-Relay is a self-hosted AI agent companion app. It connects only to servers you configure — there are no cloud accounts, hosted backends, ads, or third-party analytics.
|
||||
Hermes-Relay is a companion app for the Hermes agent. It connects only to servers you configure — there are no cloud accounts, hosted backends, ads, or third-party analytics.
|
||||
|
||||
## No External Data Transmission
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ Multi-endpoint pairing (ADR 24) makes "same phone, different networks" a first-c
|
||||
|
||||
See [docs/privacy.md](privacy.md) for the full privacy and data handling policy. Key points:
|
||||
|
||||
- **No external data transmission** — the app connects only to your self-hosted Hermes servers
|
||||
- **No external data transmission** — the app connects only to the Hermes servers you configure
|
||||
- **Local-only analytics** — Stats for Nerds counters are stored in DataStore on-device, never sent externally
|
||||
- **Encrypted credential storage** — API keys and session tokens use EncryptedSharedPreferences (AES-256-GCM, hardware-backed Android Keystore)
|
||||
- **No tracking, ads, or third-party SDKs**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "0.8.1"
|
||||
appVersionCode = "11"
|
||||
appVersionName = "1.0.0"
|
||||
appVersionCode = "12"
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.3.20"
|
||||
compose-bom = "2026.03.01"
|
||||
@@ -17,6 +17,7 @@ core-ktx = "1.18.0"
|
||||
datastore = "1.1.1"
|
||||
splashscreen = "1.2.0"
|
||||
markdown-renderer = "0.30.0"
|
||||
coil = "3.4.0"
|
||||
haze = "1.7.2"
|
||||
mlkit-barcode = "17.3.0"
|
||||
camera = "1.6.0"
|
||||
@@ -62,6 +63,10 @@ okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver",
|
||||
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
|
||||
markdown-renderer-code = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-code", version.ref = "markdown-renderer" }
|
||||
|
||||
# Coil 3 — async image loading (generated-image rendering in chat)
|
||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
||||
|
||||
# Haze (glassmorphism blur)
|
||||
haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" }
|
||||
haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "haze" }
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -0,0 +1,97 @@
|
||||
// scripts/gen-feature-graphic.mjs
|
||||
//
|
||||
// Regenerates the Google Play Store feature graphic
|
||||
// (assets/play-store-feature-1024x500.png) from an inline SVG, so the asset is
|
||||
// reproducible instead of a mystery binary. Edit the SVG below (tagline, trio,
|
||||
// palette) and re-run to update the banner.
|
||||
//
|
||||
// Palette: RelayRefresh — base #08090D, electric-indigo accent
|
||||
// (#6E7CFF / #4F5BD5 / #3A44B8), warm-white ink #F4F1E9. Mark = the "Chevron
|
||||
// Compass" from assets/logo.svg, recolored from the legacy purple to indigo.
|
||||
//
|
||||
// Rasterizer: @resvg/resvg-js (Rust resvg — exact pixel dims, no headless
|
||||
// browser). It is not a declared dependency; it is present transitively via
|
||||
// vitepress (the docs toolchain). Run from the repo root so Node resolves it
|
||||
// from the root node_modules:
|
||||
//
|
||||
// node scripts/gen-feature-graphic.mjs
|
||||
//
|
||||
// Play feature-graphic spec: exactly 1024x500 PNG/JPEG, < 15 MB.
|
||||
|
||||
import { Resvg } from '@resvg/resvg-js'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const OUT = fileURLToPath(new URL('../assets/play-store-feature-1024x500.png', import.meta.url))
|
||||
|
||||
const svg = `<svg width="1024" height="500" viewBox="0 0 1024 500" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0B0C16"/>
|
||||
<stop offset="1" stop-color="#08090D"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="50%" cy="22%" r="42%">
|
||||
<stop offset="0" stop-color="#6E7CFF" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="#6E7CFF" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="mark" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#8E9BFF"/>
|
||||
<stop offset="1" stop-color="#4F5BD5"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ghost" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#6E7CFF" stop-opacity="0.30"/>
|
||||
<stop offset="1" stop-color="#6E7CFF" stop-opacity="0.04"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="1024" height="500" fill="url(#bg)"/>
|
||||
<rect width="1024" height="500" fill="url(#glow)"/>
|
||||
|
||||
<g stroke="#6E7CFF" stroke-opacity="0.05" stroke-width="22" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M -40 150 L 110 0 L 260 150"/>
|
||||
<path d="M 764 500 L 914 350 L 1064 500"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(412,40) scale(0.39)" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(256,256) scale(1.30) translate(-256,-256)" stroke="url(#ghost)" stroke-width="22">
|
||||
<path d="M 112 188 L 192 268 L 320 268 L 400 188"/>
|
||||
<path d="M 112 116 L 192 196"/>
|
||||
<path d="M 400 116 L 320 196"/>
|
||||
</g>
|
||||
<g stroke="url(#mark)" stroke-width="36">
|
||||
<path d="M 256 192 L 320 256 L 256 320 L 192 256 Z"/>
|
||||
<path d="M 128 192 L 256 64 L 384 192"/>
|
||||
<path d="M 128 320 L 256 448 L 384 320"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<text x="512" y="288" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
|
||||
font-size="68" font-weight="700" letter-spacing="-1" fill="#F4F1E9">Hermes-Relay</text>
|
||||
|
||||
<text x="512" y="330" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif"
|
||||
font-size="20" font-weight="600" letter-spacing="5" fill="#6E7CFF">YOUR HERMES AGENT, IN YOUR POCKET</text>
|
||||
|
||||
<line x1="462" y1="356" x2="562" y2="356" stroke="#3A44B8" stroke-width="2" stroke-linecap="round"/>
|
||||
|
||||
<g font-family="Segoe UI, Arial, sans-serif" text-anchor="middle">
|
||||
<text x="256" y="415" font-size="27" font-weight="700" letter-spacing="1" fill="#F4F1E9">Chat</text>
|
||||
<text x="512" y="415" font-size="27" font-weight="700" letter-spacing="1" fill="#F4F1E9">Voice</text>
|
||||
<text x="768" y="415" font-size="27" font-weight="700" letter-spacing="1" fill="#F4F1E9">Manage</text>
|
||||
|
||||
<text x="256" y="443" font-size="14" font-weight="600" letter-spacing="2" fill="#68647D">LIVE STREAMING</text>
|
||||
<text x="512" y="443" font-size="14" font-weight="600" letter-spacing="2" fill="#68647D">HANDS-FREE</text>
|
||||
<text x="768" y="443" font-size="14" font-weight="600" letter-spacing="2" fill="#68647D">FULL DASHBOARD</text>
|
||||
</g>
|
||||
|
||||
<circle cx="384" cy="406" r="3" fill="#4F5BD5"/>
|
||||
<circle cx="640" cy="406" r="3" fill="#4F5BD5"/>
|
||||
</svg>`
|
||||
|
||||
const resvg = new Resvg(svg, {
|
||||
fitTo: { mode: 'width', value: 1024 },
|
||||
background: '#08090D',
|
||||
font: { loadSystemFonts: true, defaultFontFamily: 'Segoe UI' },
|
||||
})
|
||||
const png = resvg.render().asPng()
|
||||
writeFileSync(OUT, png)
|
||||
console.log('wrote', OUT, '—', png.length, 'bytes')
|
||||
@@ -1,5 +1,23 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vitepress'
|
||||
|
||||
// Single source of truth for the displayed version: read appVersionName from
|
||||
// the Android version catalog at docs-build time so the nav badge can never
|
||||
// drift from the shipped app. Returns '' (badge hidden) on any failure.
|
||||
function resolveAppVersion(): string {
|
||||
try {
|
||||
const tomlPath = fileURLToPath(new URL('../../gradle/libs.versions.toml', import.meta.url))
|
||||
const match = readFileSync(tomlPath, 'utf-8').match(/^appVersionName\s*=\s*"([^"]+)"/m)
|
||||
return match ? match[1] : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const appVersion = resolveAppVersion()
|
||||
|
||||
export default defineConfig({
|
||||
base: '/hermes-relay/',
|
||||
title: 'Hermes-Relay',
|
||||
@@ -59,6 +77,11 @@ export default defineConfig({
|
||||
{ text: 'Reference', link: '/reference/api' },
|
||||
{ text: 'GitHub', link: 'https://github.com/Codename-11/hermes-relay' },
|
||||
{ text: 'Privacy', link: '/privacy' },
|
||||
// Release badge — derived from gradle/libs.versions.toml, links to the
|
||||
// GitHub Releases list. Hidden if the version couldn't be resolved.
|
||||
...(appVersion
|
||||
? [{ text: `v${appVersion}`, link: 'https://github.com/Codename-11/hermes-relay/releases' }]
|
||||
: []),
|
||||
],
|
||||
|
||||
sidebar: {
|
||||
|
||||
@@ -1,53 +1,444 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { withBase } from 'vitepress'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
// Canonical sphere algorithm — same import path as SphereMark.vue. The hero
|
||||
// demo is a code recreation of the app (not a video): DOM chat chrome over the
|
||||
// real MorphingSphereCore algorithm, driven through the actual product state
|
||||
// machine (Idle → Listening → Thinking + toolCallBurst → Speaking → Idle).
|
||||
import {
|
||||
SphereState,
|
||||
paramsFor,
|
||||
colorsFor,
|
||||
forEachSphereCell,
|
||||
} from '../../../../preview/web/sphere.js'
|
||||
|
||||
const TRANSITION_AT_SECONDS = 12
|
||||
// ── Scene script ────────────────────────────────────────────────────────────
|
||||
// One master clock, looped with modulo — every visual is a pure function of
|
||||
// t, so the loop restart is just t wrapping to 0. Timings in seconds.
|
||||
const PROMPT = 'Quick check — server uptime and memory?'
|
||||
const ANSWER = 'Uptime: 46d 0h 42m\nMemory: 12.6 GiB / 32.4 GiB (39%)'
|
||||
const TYPE_CPS = 17
|
||||
const STREAM_CPS = 30
|
||||
|
||||
const showLogo = ref(false)
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const CHECKS = ['state restored', 'route · LAN', 'hermes online']
|
||||
const CHECK_AT = [0.7, 1.5, 2.3]
|
||||
const BOOT_END = 3.3 // boot text fades, chat chrome fades in
|
||||
const TYPE_START = 4.3
|
||||
const TYPE_END = TYPE_START + PROMPT.length / TYPE_CPS
|
||||
const SEND_AT = TYPE_END + 0.45
|
||||
const INDICATOR_AT = SEND_AT + 0.5
|
||||
const TOOL_AT = SEND_AT + 1.5
|
||||
const TOOL_DONE = TOOL_AT + 3.0
|
||||
const ANSWER_START = TOOL_DONE + 0.5
|
||||
const ANSWER_END = ANSWER_START + ANSWER.length / STREAM_CPS
|
||||
const IDLE_AT = ANSWER_END + 0.8
|
||||
const FADE_AT = ANSWER_END + 3.6
|
||||
const LOOP_LEN = FADE_AT + 0.8
|
||||
|
||||
function handleTimeUpdate() {
|
||||
if (showLogo.value) return
|
||||
const v = videoEl.value
|
||||
if (v && v.currentTime >= TRANSITION_AT_SECONDS) {
|
||||
showLogo.value = true
|
||||
// ── Reactive scene state (SSR renders the boot frame) ──────────────────────
|
||||
const bootGone = ref(false)
|
||||
const checksDone = ref(0)
|
||||
const typed = ref('')
|
||||
const sent = ref(false)
|
||||
const indicatorOn = ref(false)
|
||||
const toolState = ref<'none' | 'running' | 'done'>('none')
|
||||
const answerText = ref('')
|
||||
const answerDone = ref(false)
|
||||
const fadingOut = ref(false)
|
||||
|
||||
// Which glyph the single trailing slot shows — mirrors ChatInputBar's
|
||||
// AnimatedContent morph (SEND / VOICE / STOP). Typing → send (indigo arrow,
|
||||
// glow); mid-stream with an empty field → stop (danger circle); at rest →
|
||||
// voice (GraphicEq waveform). The dedicated slash button is gone.
|
||||
const inputTrailing = computed<'send' | 'voice' | 'stop'>(() => {
|
||||
if (typed.value) return 'send'
|
||||
if (sent.value && !answerDone.value) return 'stop'
|
||||
return 'voice'
|
||||
})
|
||||
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const screenEl = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// ── Sphere plumbing (lean cut of SphereMark's tween rig — no gaze) ──────────
|
||||
const COLS = 58
|
||||
const ROWS = 34
|
||||
|
||||
class Tween {
|
||||
current: number; target: number; start: number; startTime: number; duration: number
|
||||
constructor(value: number) {
|
||||
this.current = value; this.target = value; this.start = value
|
||||
this.startTime = 0; this.duration = 0.8
|
||||
}
|
||||
setTarget(v: number, nowSec: number, duration = 0.8) {
|
||||
if (v === this.target) return
|
||||
this.start = this.current; this.target = v
|
||||
this.startTime = nowSec; this.duration = duration
|
||||
}
|
||||
update(nowSec: number) {
|
||||
if (this.duration <= 0) { this.current = this.target; return }
|
||||
// Clamp at BOTH ends — smoothstep fed a negative time extrapolates
|
||||
// cubically and explodes the params (the periods-in-the-eye bug).
|
||||
const t = Math.min(1, Math.max(0, (nowSec - this.startTime) / this.duration))
|
||||
const e = t * t * (3 - 2 * t)
|
||||
this.current = this.start + (this.target - this.start) * e
|
||||
}
|
||||
}
|
||||
|
||||
// Failsafe in case timeupdate never fires (slow connection, paused autoplay)
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const initialP = paramsFor(SphereState.Thinking)
|
||||
const initialC = colorsFor(SphereState.Thinking)
|
||||
const tw = {
|
||||
breatheSpeed: new Tween(initialP.breatheSpeed),
|
||||
breatheAmp: new Tween(initialP.breatheAmp),
|
||||
lightSpeedX: new Tween(initialP.lightSpeedX),
|
||||
lightSpeedY: new Tween(initialP.lightSpeedY),
|
||||
lightInfluence: new Tween(initialP.lightInfluence),
|
||||
coreTightness: new Tween(initialP.coreTightness),
|
||||
turbulenceAmp: new Tween(initialP.turbulenceAmp),
|
||||
rippleScale: new Tween(initialP.rippleScale),
|
||||
heartbeatSpeed: new Tween(initialP.heartbeatSpeed),
|
||||
radialFlowSpeed: new Tween(initialP.radialFlowSpeed),
|
||||
cr1: new Tween(initialC.r1), cg1: new Tween(initialC.g1), cb1: new Tween(initialC.b1),
|
||||
cr2: new Tween(initialC.r2), cg2: new Tween(initialC.g2), cb2: new Tween(initialC.b2),
|
||||
intensity: new Tween(0),
|
||||
}
|
||||
|
||||
let sphereState: string = SphereState.Thinking
|
||||
function retargetTo(state: string, nowSec: number) {
|
||||
if (state === sphereState) return
|
||||
sphereState = state
|
||||
const p = paramsFor(state)
|
||||
const c = colorsFor(state)
|
||||
tw.breatheSpeed.setTarget(p.breatheSpeed, nowSec)
|
||||
tw.breatheAmp.setTarget(p.breatheAmp, nowSec)
|
||||
tw.lightSpeedX.setTarget(p.lightSpeedX, nowSec)
|
||||
tw.lightSpeedY.setTarget(p.lightSpeedY, nowSec)
|
||||
tw.lightInfluence.setTarget(p.lightInfluence, nowSec)
|
||||
tw.coreTightness.setTarget(p.coreTightness, nowSec)
|
||||
tw.turbulenceAmp.setTarget(p.turbulenceAmp, nowSec)
|
||||
tw.rippleScale.setTarget(p.rippleScale, nowSec)
|
||||
tw.heartbeatSpeed.setTarget(p.heartbeatSpeed, nowSec)
|
||||
tw.radialFlowSpeed.setTarget(p.radialFlowSpeed, nowSec)
|
||||
tw.cr1.setTarget(c.r1, nowSec); tw.cg1.setTarget(c.g1, nowSec); tw.cb1.setTarget(c.b1, nowSec)
|
||||
tw.cr2.setTarget(c.r2, nowSec); tw.cg2.setTarget(c.g2, nowSec); tw.cb2.setTarget(c.b2, nowSec)
|
||||
}
|
||||
|
||||
// ── Clock — accumulates only while on screen, so the loop resumes where it
|
||||
// paused instead of jump-cutting when the reader scrolls back up. ───────────
|
||||
let rafId = 0
|
||||
let elapsed = 0
|
||||
let lastFrameMs = 0
|
||||
let isVisible = true
|
||||
let reduceMotion = false
|
||||
|
||||
function sceneStateFor(t: number) {
|
||||
if (t < BOOT_END) return SphereState.Thinking
|
||||
if (t >= TYPE_START && t < SEND_AT) return SphereState.Listening
|
||||
if (t >= SEND_AT && t < ANSWER_START) return SphereState.Thinking
|
||||
if (t >= ANSWER_START && t < IDLE_AT) return SphereState.Speaking
|
||||
return SphereState.Idle
|
||||
}
|
||||
|
||||
function applyScene(t: number) {
|
||||
bootGone.value = t >= BOOT_END
|
||||
let n = 0
|
||||
for (let i = 0; i < CHECK_AT.length; i++) if (t >= CHECK_AT[i]) n++
|
||||
checksDone.value = n
|
||||
const typedCount = Math.max(0, Math.min(PROMPT.length, Math.floor((t - TYPE_START) * TYPE_CPS)))
|
||||
typed.value = t >= SEND_AT ? '' : PROMPT.slice(0, typedCount)
|
||||
sent.value = t >= SEND_AT
|
||||
indicatorOn.value = t >= INDICATOR_AT && t < TOOL_AT
|
||||
toolState.value = t < TOOL_AT ? 'none' : t < TOOL_DONE ? 'running' : 'done'
|
||||
const streamed = Math.max(0, Math.min(ANSWER.length, Math.floor((t - ANSWER_START) * STREAM_CPS)))
|
||||
answerText.value = t < ANSWER_START ? '' : ANSWER.slice(0, streamed)
|
||||
answerDone.value = t >= ANSWER_END
|
||||
fadingOut.value = t >= FADE_AT
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const canvas = canvasEl.value
|
||||
if (!canvas) return
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const cw = canvas.clientWidth
|
||||
const ch = canvas.clientHeight
|
||||
if (cw <= 0 || ch <= 0) return
|
||||
canvas.width = Math.floor(cw * dpr)
|
||||
canvas.height = Math.floor(ch * dpr)
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
|
||||
// sceneT loops (drives WHAT the sphere is doing); clockT is monotonic and
|
||||
// drives the tween rig + noise fields. The app's sphere clock never rewinds
|
||||
// (MorphingSphere.kt animatedTime), so neither can ours — a looped clock sent
|
||||
// the tweens a negative elapsed at every wrap and the extrapolation slammed
|
||||
// char indices to the ramp floor: rings of '·'/'.' through the sphere's eye.
|
||||
function drawSphere(sceneT: number, clockT: number) {
|
||||
const canvas = canvasEl.value
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
if (!canvas.width || !canvas.height) {
|
||||
resize()
|
||||
if (!canvas.width || !canvas.height) return
|
||||
}
|
||||
|
||||
retargetTo(sceneStateFor(sceneT), clockT)
|
||||
for (const k in tw) (tw as Record<string, Tween>)[k].update(clockT)
|
||||
// Streaming shimmer while Speaking; toolCallBurst pulse decays from the
|
||||
// moment the tool card lands — same inputs the app feeds the renderer.
|
||||
tw.intensity.setTarget(sphereState === SphereState.Speaking ? 0.4 : 0, clockT, 0.4)
|
||||
const burst = sceneT >= TOOL_AT && sceneT < TOOL_AT + 1.2 ? Math.exp(-(sceneT - TOOL_AT) / 0.35) : 0
|
||||
|
||||
const canvasW = canvas.clientWidth
|
||||
const canvasH = canvas.clientHeight
|
||||
const cellW = canvasW / COLS
|
||||
const cellH = canvasH / ROWS
|
||||
const charSize = Math.min(cellW * 1.3, cellH * 1.1)
|
||||
|
||||
ctx.clearRect(0, 0, canvasW, canvasH)
|
||||
ctx.font = `${charSize}px "Space Mono", ui-monospace, Menlo, Consolas, monospace`
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
|
||||
const time = reduceMotion ? 0 : clockT
|
||||
const frame = {
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
charAspect: cellW / cellH,
|
||||
state: sphereState,
|
||||
time,
|
||||
colorPhase: (((time * 1000) % 8000) / 8000) * 6.2832,
|
||||
breatheSpeed: tw.breatheSpeed.current,
|
||||
breatheAmp: tw.breatheAmp.current,
|
||||
lightSpeedX: tw.lightSpeedX.current,
|
||||
lightSpeedY: tw.lightSpeedY.current,
|
||||
lightInfluence: tw.lightInfluence.current,
|
||||
coreTightness: tw.coreTightness.current,
|
||||
turbulenceAmp: tw.turbulenceAmp.current,
|
||||
rippleScale: tw.rippleScale.current,
|
||||
heartbeatSpeed: tw.heartbeatSpeed.current,
|
||||
radialFlowSpeed: tw.radialFlowSpeed.current,
|
||||
cr1: tw.cr1.current, cg1: tw.cg1.current, cb1: tw.cb1.current,
|
||||
cr2: tw.cr2.current, cg2: tw.cg2.current, cb2: tw.cb2.current,
|
||||
intensity: tw.intensity.current,
|
||||
toolCallBurst: burst,
|
||||
voiceAmplitude: 0,
|
||||
voiceMode: false,
|
||||
voiceRadiusScale: 1,
|
||||
lightAngleBiasX: 0,
|
||||
lightAngleBiasY: 0,
|
||||
// Natural light orbit only — gaze tracking is SphereMark's job further
|
||||
// down the page; two competing eyes on one page would fight for attention.
|
||||
lightAngleBlend: 0,
|
||||
// The app passes no shadowStrength (MorphingSphere.kt → core default 0):
|
||||
// legacy pearl shading. SphereMark's 0.6 is its own eye look, not the app's.
|
||||
shadowStrength: 0,
|
||||
}
|
||||
|
||||
forEachSphereCell(frame, (col: number, row: number, ch: string, r: number, g: number, b: number, a: number) => {
|
||||
const px = col * cellW
|
||||
const py = row * cellH + cellH * 0.8
|
||||
ctx.fillStyle = `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a.toFixed(3)})`
|
||||
ctx.fillText(ch, px, py)
|
||||
})
|
||||
}
|
||||
|
||||
function render() {
|
||||
const nowMs = performance.now()
|
||||
const dt = Math.min(0.1, (nowMs - lastFrameMs) / 1000)
|
||||
lastFrameMs = nowMs
|
||||
if (isVisible) {
|
||||
elapsed += dt
|
||||
applyScene(elapsed % LOOP_LEN)
|
||||
drawSphere(elapsed % LOOP_LEN, elapsed)
|
||||
}
|
||||
rafId = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
// Static frames (scrubber / reduced motion): retarget once, then snap every
|
||||
// tween to its target so the frozen frame shows settled params, not the
|
||||
// mid-transition values a single update() at t=startTime would give.
|
||||
function drawSphereSettled(sceneT: number, clockT: number) {
|
||||
drawSphere(sceneT, clockT)
|
||||
for (const k in tw) {
|
||||
const w = (tw as Record<string, Tween>)[k]
|
||||
w.current = w.target
|
||||
w.start = w.target
|
||||
}
|
||||
drawSphere(sceneT, clockT)
|
||||
}
|
||||
|
||||
let intersectionObserver: IntersectionObserver | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
fallbackTimer = setTimeout(() => {
|
||||
showLogo.value = true
|
||||
}, (TRANSITION_AT_SECONDS + 3) * 1000)
|
||||
reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
resize()
|
||||
|
||||
// Design-review scrubber: ?demoT=<seconds> freezes the scene at that point
|
||||
// of the timeline (also what headless screenshot tooling uses — rAF-driven
|
||||
// clocks don't advance reliably under virtual-time fast-forward).
|
||||
const forcedT = new URLSearchParams(window.location.search).get('demoT')
|
||||
if (forcedT !== null && !Number.isNaN(parseFloat(forcedT))) {
|
||||
const t = parseFloat(forcedT) % LOOP_LEN
|
||||
screenEl.value?.classList.add('had-static')
|
||||
applyScene(t)
|
||||
drawSphereSettled(t, t)
|
||||
return
|
||||
}
|
||||
|
||||
if (reduceMotion) {
|
||||
// Static completed scene: full conversation visible, sphere drawn once.
|
||||
applyScene(IDLE_AT + 0.1)
|
||||
fadingOut.value = false
|
||||
drawSphereSettled(IDLE_AT + 0.1, 0)
|
||||
return
|
||||
}
|
||||
|
||||
lastFrameMs = performance.now()
|
||||
if (screenEl.value) {
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
(entries) => { for (const entry of entries) isVisible = entry.isIntersecting },
|
||||
{ threshold: 0 }
|
||||
)
|
||||
intersectionObserver.observe(screenEl.value)
|
||||
resizeObserver = new ResizeObserver(() => resize())
|
||||
resizeObserver.observe(screenEl.value)
|
||||
}
|
||||
rafId = requestAnimationFrame(render)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (fallbackTimer) clearTimeout(fallbackTimer)
|
||||
cancelAnimationFrame(rafId)
|
||||
intersectionObserver?.disconnect()
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hero-demo">
|
||||
<div class="hero-demo-frame">
|
||||
<Transition name="hero-fade" mode="out-in">
|
||||
<video
|
||||
v-if="!showLogo"
|
||||
ref="videoEl"
|
||||
key="video"
|
||||
class="hero-demo-media hero-demo-video"
|
||||
:src="withBase('/chat_demo.mp4')"
|
||||
:poster="withBase('/chat_demo_poster.jpg')"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
/>
|
||||
<div v-else key="logo" class="hero-demo-media hero-demo-logo">
|
||||
<img :src="withBase('/logo.svg')" alt="Hermes-Relay" />
|
||||
<div class="hero-demo" role="img"
|
||||
aria-label="Animated demo of the Hermes-Relay Android app: it connects to your Hermes agent, runs a server health check through a tool call, and streams the answer back.">
|
||||
<div class="hero-demo-frame" aria-hidden="true">
|
||||
<div ref="screenEl" class="had-screen" :class="{ 'had-fading': fadingOut }">
|
||||
<!-- Sphere — one canvas shared by boot and chat so the gate sphere
|
||||
visibly settles into being the conversation's backdrop. -->
|
||||
<canvas ref="canvasEl" class="had-sphere" :class="{ 'had-sphere-chat': bootGone }"></canvas>
|
||||
|
||||
<!-- Boot gate -->
|
||||
<div class="had-boot" :class="{ 'had-hidden': bootGone }">
|
||||
<div class="had-boot-title">Hermes-Relay</div>
|
||||
<div class="had-boot-sub">agent interface</div>
|
||||
<div class="had-boot-checks">
|
||||
<div v-for="(label, i) in CHECKS" :key="label" class="had-check"
|
||||
:class="{ 'had-check-on': checksDone > i }">
|
||||
<span class="had-check-mark">{{ checksDone > i ? '✓' : '·' }}</span> {{ label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Chat -->
|
||||
<div class="had-chat" :class="{ 'had-hidden': !bootGone }">
|
||||
<!-- Header mirrors the live app 1:1 (assets/screenshots/02_chat.png):
|
||||
drawer hamburger · avatar+presence · name/model · LAN chip ·
|
||||
share / inspector / tune action cluster. -->
|
||||
<div class="had-header">
|
||||
<svg class="had-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round">
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
<div class="had-avatar">H<span class="had-avatar-dot"></span></div>
|
||||
<div class="had-id">
|
||||
<div class="had-name">Hermes</div>
|
||||
<div class="had-model">gpt-5.5 · default</div>
|
||||
</div>
|
||||
<div class="had-chip">LAN</div>
|
||||
<!-- Three SEPARATE bordered buttons in the app — not one cluster. -->
|
||||
<span class="had-btn">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="18" cy="5" r="2.7" /><circle cx="6" cy="12" r="2.7" /><circle cx="18" cy="19" r="2.7" />
|
||||
<path d="M8.3 10.9 15.7 6.1M8.3 13.1l7.4 4.8" fill="none" stroke="currentColor"
|
||||
stroke-width="2" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="had-btn had-btn-code"></></span>
|
||||
<span class="had-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round">
|
||||
<path d="M4 7h9M19.5 7h.5M4 12h.5M11 12h9M4 17h9M19.5 17h.5" />
|
||||
<circle cx="16" cy="7" r="2.3" /><circle cx="7.5" cy="12" r="2.3" /><circle cx="16" cy="17" r="2.3" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="had-tabs">
|
||||
<span class="had-tab had-tab-active">Chat</span>
|
||||
<span class="had-tab">Manage</span>
|
||||
<span class="had-tab">Bridge</span>
|
||||
</div>
|
||||
|
||||
<div class="had-messages">
|
||||
<div class="had-day">Today</div>
|
||||
|
||||
<div v-if="sent" class="had-bubble had-user">
|
||||
{{ PROMPT }}
|
||||
<div class="had-time">9:41 AM</div>
|
||||
</div>
|
||||
|
||||
<div v-if="indicatorOn" class="had-agent-label">Hermes</div>
|
||||
<div v-if="indicatorOn" class="had-bubble had-agent had-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<div v-if="toolState !== 'none'" class="had-tool">
|
||||
<span class="had-tool-icon"><></span>
|
||||
<span class="had-tool-name">execute_code</span>
|
||||
<template v-if="toolState === 'done'">
|
||||
<span class="had-tool-done">✓</span>
|
||||
<span class="had-tool-chev">⌄</span>
|
||||
</template>
|
||||
<span v-else class="had-tool-bar"><span></span></span>
|
||||
</div>
|
||||
|
||||
<div v-if="answerText" class="had-agent-label">Hermes</div>
|
||||
<div v-if="answerText" class="had-bubble had-agent had-answer">{{ answerText }}<span
|
||||
v-if="!answerDone" class="had-caret"></span>
|
||||
<div v-if="answerDone" class="had-time">9:41 AM</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input bar mirrors the app's ChatInputBar: a "+" (tap attaches,
|
||||
long-press opens the command palette — no dedicated slash
|
||||
button), a rounded pill field, then ONE trailing slot that
|
||||
morphs send ⇄ voice ⇄ stop without ever widening the bar. -->
|
||||
<div class="had-input">
|
||||
<span class="had-input-plus">+</span>
|
||||
<div class="had-field" :class="{ 'had-field-busy': sent && !answerDone }">
|
||||
<template v-if="typed">{{ typed }}<span class="had-caret"></span></template>
|
||||
<span v-else class="had-placeholder">Message…</span>
|
||||
</div>
|
||||
<!-- Trailing slot — one button, three faces. -->
|
||||
<span v-if="inputTrailing === 'send'" class="had-trailing had-trailing-send">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else-if="inputTrailing === 'stop'" class="had-trailing had-trailing-stop">
|
||||
<span class="had-stop-square"></span>
|
||||
</span>
|
||||
<!-- GraphicEq waveform = "voice conversation", not "record". -->
|
||||
<span v-else class="had-trailing had-trailing-voice">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="3.5" y="9" width="2.6" height="6" rx="1.3" />
|
||||
<rect x="8.0" y="5" width="2.6" height="14" rx="1.3" />
|
||||
<rect x="12.5" y="8" width="2.6" height="8" rx="1.3" />
|
||||
<rect x="17.0" y="3.5" width="2.6" height="17" rx="1.3" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="had-status">
|
||||
<span class="had-status-ok">api online / LAN</span>
|
||||
<span>gpt-5.5 / profile: default</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -60,6 +451,7 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* Phone bezel — carried over from the video hero so the silhouette is stable. */
|
||||
.hero-demo-frame {
|
||||
position: relative;
|
||||
display: block;
|
||||
@@ -70,8 +462,7 @@ onBeforeUnmount(() => {
|
||||
0 0 0 1px var(--vp-c-divider),
|
||||
0 30px 60px -20px rgba(0, 0, 0, 0.5),
|
||||
0 0 80px -10px var(--vp-c-brand-soft);
|
||||
width: clamp(180px, 62vw, 280px);
|
||||
max-height: 70vh;
|
||||
width: clamp(200px, 62vw, 290px);
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
@@ -86,52 +477,390 @@ onBeforeUnmount(() => {
|
||||
height: 6px;
|
||||
background: #000;
|
||||
border-radius: 3px;
|
||||
z-index: 2;
|
||||
z-index: 3;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.hero-demo-media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
/* The screen is always the app's dark cockpit — that's what the app looks
|
||||
like. Container-query units keep every element proportional to frame width. */
|
||||
.had-screen {
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
aspect-ratio: 1080 / 2244;
|
||||
border-radius: 28px;
|
||||
background: #08090D;
|
||||
overflow: hidden;
|
||||
transition: opacity 600ms ease;
|
||||
}
|
||||
.hero-demo-video {
|
||||
height: auto;
|
||||
.had-fading { opacity: 0; }
|
||||
|
||||
.had-hidden { opacity: 0; pointer-events: none; }
|
||||
|
||||
/* ── Sphere canvas — settles from gate centerpiece into chat backdrop ── */
|
||||
.had-sphere {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 26%;
|
||||
width: 88cqw;
|
||||
aspect-ratio: 1 / 1;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0.95;
|
||||
transition: top 900ms ease, opacity 900ms ease, width 900ms ease;
|
||||
z-index: 0;
|
||||
}
|
||||
.hero-demo-logo {
|
||||
aspect-ratio: 1080 / 2340;
|
||||
.had-sphere-chat {
|
||||
top: 34%;
|
||||
width: 80cqw;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Scrubber mode (?demoT=) — freeze every transition so review frames are
|
||||
exact, not caught mid-fade. */
|
||||
.had-static, .had-static * { transition: none !important; animation: none !important; }
|
||||
|
||||
/* ── Boot gate ── */
|
||||
.had-boot {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 12cqw;
|
||||
transition: opacity 500ms ease;
|
||||
}
|
||||
.had-boot-title {
|
||||
color: #F7F6F0;
|
||||
font-size: 7.4cqw;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.had-boot-sub {
|
||||
color: rgba(247, 246, 240, 0.5);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 3.4cqw;
|
||||
letter-spacing: 0.3em;
|
||||
margin: 1.5cqw 0 6cqw;
|
||||
}
|
||||
.had-boot-checks {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 3.2cqw;
|
||||
line-height: 1.9;
|
||||
color: rgba(247, 246, 240, 0.35);
|
||||
}
|
||||
.had-check { transition: color 300ms ease; }
|
||||
.had-check-on { color: rgba(247, 246, 240, 0.85); }
|
||||
.had-check-mark { color: var(--hr-green); display: inline-block; width: 4cqw; }
|
||||
|
||||
/* ── Chat chrome ── */
|
||||
.had-chat {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: opacity 600ms ease;
|
||||
}
|
||||
.had-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.6cqw;
|
||||
padding: 6.5cqw 3.5cqw 2cqw;
|
||||
}
|
||||
.had-menu {
|
||||
width: 4.8cqw;
|
||||
height: 4.8cqw;
|
||||
flex: none;
|
||||
color: #F7F6F0;
|
||||
margin-right: 1cqw;
|
||||
}
|
||||
.had-btn {
|
||||
width: 6.6cqw;
|
||||
height: 6.6cqw;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(110, 124, 255, 0.16) 0%,
|
||||
rgba(8, 9, 13, 1) 70%
|
||||
);
|
||||
background: #0B0C14;
|
||||
border: 1px solid rgba(247, 246, 240, 0.45);
|
||||
border-radius: 2.2cqw;
|
||||
color: #F7F6F0;
|
||||
}
|
||||
.hero-demo-logo img {
|
||||
width: 55%;
|
||||
height: auto;
|
||||
filter: drop-shadow(0 0 24px rgba(110, 124, 255, 0.5));
|
||||
.had-btn svg { width: 3.7cqw; height: 3.7cqw; }
|
||||
.had-btn-code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 2.5cqw;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
/* Light avatar with dark initial — matches the app, not inverted. */
|
||||
.had-avatar {
|
||||
position: relative;
|
||||
width: 9.6cqw;
|
||||
height: 9.6cqw;
|
||||
border-radius: 50%;
|
||||
background: #E9E7F2;
|
||||
color: #16172B;
|
||||
font-size: 4.2cqw;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
}
|
||||
.had-avatar-dot {
|
||||
position: absolute;
|
||||
right: -0.4cqw;
|
||||
bottom: -0.4cqw;
|
||||
width: 2.8cqw;
|
||||
height: 2.8cqw;
|
||||
border-radius: 50%;
|
||||
background: var(--hr-green);
|
||||
border: 0.6cqw solid #08090D;
|
||||
}
|
||||
.had-id { flex: 1; min-width: 0; margin-left: 0.8cqw; }
|
||||
.had-name {
|
||||
color: #FFFFFF;
|
||||
font-size: 3.7cqw;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.had-model {
|
||||
color: rgba(247, 246, 240, 0.55);
|
||||
font-size: 2.7cqw;
|
||||
line-height: 1.3;
|
||||
}
|
||||
/* Solid filled pill in the app — no outline. */
|
||||
.had-chip {
|
||||
flex: none;
|
||||
font-size: 2.2cqw;
|
||||
font-weight: 600;
|
||||
color: #F7F6F0;
|
||||
background: #34343E;
|
||||
border-radius: 2.4cqw;
|
||||
padding: 0.9cqw 2cqw;
|
||||
margin-right: 0.6cqw;
|
||||
}
|
||||
.had-tabs {
|
||||
display: flex;
|
||||
gap: 2.4cqw;
|
||||
padding: 2cqw 3.5cqw 2.5cqw;
|
||||
}
|
||||
.had-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 3cqw;
|
||||
font-weight: 600;
|
||||
color: rgba(247, 246, 240, 0.85);
|
||||
background: #0A0A10;
|
||||
border: 1px solid rgba(247, 246, 240, 0.25);
|
||||
border-radius: 3.6cqw;
|
||||
padding: 2.1cqw 0;
|
||||
}
|
||||
.had-tab-active {
|
||||
background: #20225A;
|
||||
border-color: rgba(99, 110, 230, 0.65);
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Crossfade between video and logo */
|
||||
.hero-fade-enter-active,
|
||||
.hero-fade-leave-active {
|
||||
transition: opacity 900ms ease;
|
||||
.had-messages {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
padding: 1cqw 4.5cqw 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-fade-enter-from,
|
||||
.hero-fade-leave-to {
|
||||
opacity: 0;
|
||||
.had-day {
|
||||
width: fit-content;
|
||||
margin: 0 auto 3cqw;
|
||||
font-size: 2.8cqw;
|
||||
color: rgba(247, 246, 240, 0.55);
|
||||
background: #191B31;
|
||||
border-radius: 3cqw;
|
||||
padding: 0.9cqw 3cqw;
|
||||
}
|
||||
.had-bubble {
|
||||
max-width: 78%;
|
||||
border-radius: 3.6cqw;
|
||||
padding: 2.6cqw 3.4cqw;
|
||||
font-size: 3.4cqw;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 2.6cqw;
|
||||
white-space: pre-line;
|
||||
animation: had-pop 350ms ease;
|
||||
}
|
||||
@keyframes had-pop {
|
||||
from { opacity: 0; transform: translateY(2cqw); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.had-user {
|
||||
margin-left: auto;
|
||||
background: #AEBFFF;
|
||||
color: #14152A;
|
||||
border-bottom-right-radius: 1.2cqw;
|
||||
}
|
||||
.had-agent {
|
||||
margin-right: auto;
|
||||
background: rgba(25, 27, 49, 0.92);
|
||||
color: #F7F6F0;
|
||||
border-bottom-left-radius: 1.2cqw;
|
||||
}
|
||||
/* The app renders the agent name as a small dark chip above the turn. */
|
||||
.had-agent-label {
|
||||
width: fit-content;
|
||||
font-size: 2.7cqw;
|
||||
color: rgba(247, 246, 240, 0.7);
|
||||
background: #191B31;
|
||||
border-radius: 2.2cqw;
|
||||
padding: 0.8cqw 2.4cqw;
|
||||
margin: 0 0 1.4cqw 0;
|
||||
}
|
||||
.had-time {
|
||||
font-size: 2.5cqw;
|
||||
opacity: 0.55;
|
||||
margin-top: 1.2cqw;
|
||||
}
|
||||
|
||||
.had-indicator { display: inline-flex; gap: 1.4cqw; padding: 3cqw 3.6cqw; }
|
||||
.had-indicator span {
|
||||
width: 1.8cqw;
|
||||
height: 1.8cqw;
|
||||
border-radius: 50%;
|
||||
background: rgba(247, 246, 240, 0.7);
|
||||
animation: had-bounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
.had-indicator span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.had-indicator span:nth-child(3) { animation-delay: 0.3s; }
|
||||
@keyframes had-bounce {
|
||||
0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
|
||||
30% { transform: translateY(-1.2cqw); opacity: 1; }
|
||||
}
|
||||
|
||||
.had-tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2.2cqw;
|
||||
background: rgba(18, 20, 38, 0.95);
|
||||
border: 1px solid rgba(247, 246, 240, 0.1);
|
||||
border-radius: 2.6cqw;
|
||||
padding: 2.4cqw 3.2cqw;
|
||||
margin-bottom: 2.6cqw;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 3cqw;
|
||||
color: rgba(247, 246, 240, 0.85);
|
||||
animation: had-pop 350ms ease;
|
||||
}
|
||||
.had-tool-icon { color: var(--vp-c-brand-1); font-size: 2.8cqw; }
|
||||
.had-tool-name { flex: 1; }
|
||||
.had-tool-done { color: rgba(247, 246, 240, 0.75); font-size: 3cqw; }
|
||||
.had-tool-chev { color: rgba(247, 246, 240, 0.45); font-size: 3cqw; line-height: 0.6; }
|
||||
.had-tool-bar {
|
||||
flex: none;
|
||||
width: 16cqw;
|
||||
height: 1.1cqw;
|
||||
border-radius: 1cqw;
|
||||
background: rgba(247, 246, 240, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
.had-tool-bar span {
|
||||
display: block;
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
border-radius: 1cqw;
|
||||
background: #6E7CFF;
|
||||
animation: had-scan 1.1s infinite ease-in-out;
|
||||
}
|
||||
@keyframes had-scan {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(250%); }
|
||||
}
|
||||
|
||||
.had-caret {
|
||||
display: inline-block;
|
||||
width: 0.5cqw;
|
||||
height: 3.4cqw;
|
||||
background: #AEBFFF;
|
||||
margin-left: 0.6cqw;
|
||||
vertical-align: -0.5cqw;
|
||||
animation: had-blink 0.9s steps(1) infinite;
|
||||
}
|
||||
@keyframes had-blink { 50% { opacity: 0; } }
|
||||
|
||||
.had-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2.4cqw;
|
||||
padding: 2cqw 4cqw 1.6cqw;
|
||||
}
|
||||
.had-input-plus {
|
||||
flex: none;
|
||||
color: rgba(247, 246, 240, 0.6);
|
||||
font-size: 5cqw;
|
||||
line-height: 1;
|
||||
width: 7cqw;
|
||||
text-align: center;
|
||||
}
|
||||
/* Pill field — surfaceContainerHigh + hairline outline, like the app's
|
||||
BasicTextField (no heavy outlined-field chrome). */
|
||||
.had-field {
|
||||
flex: 1;
|
||||
min-height: 9cqw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(247, 246, 240, 0.06);
|
||||
border: 1px solid rgba(247, 246, 240, 0.16);
|
||||
border-radius: 5cqw;
|
||||
padding: 1.4cqw 3.4cqw;
|
||||
font-size: 3.3cqw;
|
||||
color: #F7F6F0;
|
||||
transition: border-color 300ms ease;
|
||||
}
|
||||
.had-field-busy { border-color: rgba(110, 124, 255, 0.4); }
|
||||
.had-placeholder { color: rgba(247, 246, 240, 0.35); }
|
||||
|
||||
/* One trailing slot, three faces — circular tap target, never widens. */
|
||||
.had-trailing {
|
||||
flex: none;
|
||||
width: 8.8cqw;
|
||||
height: 8.8cqw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.had-trailing svg { width: 5cqw; height: 5cqw; }
|
||||
.had-trailing-send {
|
||||
color: #6E7CFF;
|
||||
/* purpleGlow — the bar's one flourish, dark-only like the app */
|
||||
box-shadow: 0 0 7cqw -1cqw rgba(110, 124, 255, 0.65);
|
||||
}
|
||||
.had-trailing-voice { color: #6E7CFF; }
|
||||
.had-trailing-stop {
|
||||
border: 1px solid var(--hr-danger);
|
||||
}
|
||||
.had-stop-square {
|
||||
width: 3cqw;
|
||||
height: 3cqw;
|
||||
border-radius: 0.8cqw;
|
||||
background: var(--hr-danger);
|
||||
}
|
||||
|
||||
.had-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 2.5cqw;
|
||||
color: rgba(247, 246, 240, 0.45);
|
||||
background: #0B0C14;
|
||||
padding: 1.6cqw 4.5cqw 2.2cqw;
|
||||
}
|
||||
.had-status-ok { color: var(--hr-green); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-demo-frame {
|
||||
padding: 8px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
.hero-demo-media {
|
||||
border-radius: 22px;
|
||||
}
|
||||
.had-screen { border-radius: 22px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
// Official "Get it on Google Play" badge → the Play listing. Used both in the
|
||||
// home hero (slotted via theme/index.ts) and inline in markdown (registered
|
||||
// globally in enhanceApp, so `<StoreBadge />` works in any .md page).
|
||||
import { withBase } from 'vitepress'
|
||||
|
||||
const PLAY_URL =
|
||||
'https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
class="store-badge"
|
||||
:href="PLAY_URL"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Get Hermes-Relay on Google Play"
|
||||
>
|
||||
<img
|
||||
:src="withBase('/badges/google-play.svg')"
|
||||
alt="Get it on Google Play"
|
||||
width="200"
|
||||
height="60"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.store-badge {
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
border-radius: 9px;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
.store-badge:hover {
|
||||
opacity: 0.88;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.store-badge img {
|
||||
height: 56px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -160,9 +160,18 @@ html:not(.dark) .VPNavBar {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Store badge + platform note under the hero action buttons */
|
||||
.hero-store-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* Platform-availability note under the hero action buttons */
|
||||
.hero-platform-note {
|
||||
margin: 14px 0 0;
|
||||
margin: 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -170,6 +179,9 @@ html:not(.dark) .VPNavBar {
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
@media (max-width: 959px) {
|
||||
.hero-store-row {
|
||||
align-items: center;
|
||||
}
|
||||
.hero-platform-note {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||