Compare commits
@@ -111,7 +111,12 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -r relay_server/requirements.txt
|
||||
# Editable install pulls the full runtime dependency set from
|
||||
# pyproject.toml (requests, aiohttp, segno, httpx, websocket-client,
|
||||
# pyyaml). test_native_layout_imports imports the whole relay module
|
||||
# chain in a clean subprocess, so the minimal relay_server/requirements
|
||||
# set is not enough on its own.
|
||||
pip install -e .
|
||||
pip install pytest responses
|
||||
|
||||
- name: Run focused Plugin tests
|
||||
@@ -119,4 +124,5 @@ jobs:
|
||||
python -m pytest \
|
||||
plugin/tests/test_relay_security.py \
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.py
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_native_layout_imports.py
|
||||
|
||||
@@ -8,19 +8,50 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings: edit your server's voice engine.** Voice settings now has a **Server voice config** section that reads and writes the host's text-to-speech and speech-to-text settings — provider, voice, model, language, and per-provider options — over the dashboard, the same config the official desktop app edits. It includes an **ElevenLabs voice picker** that lists the voices available on your server's ElevenLabs key (and tells you when no key is set). Works on the no-plugin (Standard) path; sign in to Manage to use it.
|
||||
- **Desktop CLI: `hermes-relay audit`.** Shows what the remote agent has actually run on this machine through the desktop tools — tool, status, and a short detail per call — read from a local log, no network or auth. Answers "what did the agent just do?" at a glance.
|
||||
- **Desktop CLI: `hermes-relay relay`.** Inspect the relay server itself: `relay info` (version, uptime, sessions — on the relay host), `relay security` (runtime auth toggles), and `relay context` (audit the system-prompt context the relay injects into the agent, which works from a remote machine with your session).
|
||||
- **Desktop CLI: `hermes-relay relay`.** Inspect the relay server itself: `relay info` (version, uptime, sessions — on the relay host), `relay security` (runtime auth toggles), `relay context` (audit the system-prompt context the relay injects into the agent, which works from a remote machine with your session), and `relay queue` (list — or `--clear` / `--cancel <id>` — the messages your agent queued for an offline phone; on the relay host).
|
||||
- **Desktop CLI: background daemon.** `hermes-relay daemon start` runs the headless tool router in the background (no console window, survives closing the terminal), with `daemon stop` and `daemon status` to manage it. `daemon status` reports state, uptime, relay, and advertised-tool count; bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
|
||||
- **Desktop CLI: per-command help.** Every subcommand now answers `--help`, and `devices`/`sessions`/`plugins`/`voice`/`relay` print their own usage (sub-commands, flags, examples) instead of a terse "unknown sub-verb".
|
||||
- **Desktop CLI: startup banner.** A slim "Hermes Relay" wordmark shows atop `--help`, the first-run welcome, and the chat REPL — and `hermes-relay logo` prints it on demand. Suppressed for piped/`--json`/`--no-color` output.
|
||||
- **Animated "thinking" indicator.** While a reply streams, the in-bubble working indicator can now be a small dot-matrix animation instead of the three dots. Pick a motion (Wave, Pulse, Bounce, Sparkle) and a color (match-text or a brand accent) in Chat settings, with a live preview. It follows light/dark and your app theme, and goes static when animations are turned off.
|
||||
- **Proactive messages from the agent to your phone.** Your Hermes agent can reach out to the paired phone on its own — via `send_message target=phone` or a cron `deliver=phone`. Messages surface as a system notification, collect in a dedicated Hermes inbox, and can be injected into the active chat to continue the conversation (selected per message). Off by default and gated on pairing: nothing is pushed unless you enable it on the server (`PHONE_ENABLED`) and opt in on the phone ("Let Hermes message me"). Delivered over the existing relay connection through the upstream platform-plugin API (no fork).
|
||||
- **Reply to your agent's messages (two-way).** A proactive message is now a conversation, not a one-way ping: reply straight from the notification (inline Reply) or from the Hermes inbox, and your answer goes back to the agent and continues the same thread. The phone behaves like any other Hermes messaging platform — the reply arrives as an inbound message the agent processes and answers. Rides the same paired relay connection; no extra setup beyond the proactive opt-in above. If your phone is offline when the agent answers, the message is queued and delivered when you reconnect — not lost.
|
||||
- **Pick your font.** A Font picker in Appearance sets the app-wide typeface — **Inter** (the new default), **Nunito**, or your **system** font — each previewed in its own face and applied instantly across the app, no restart. Code and timestamps stay monospaced. (Bundled faces are SIL OFL.)
|
||||
- **Quick Controls in Settings.** A Quick Controls card at the top of Settings groups the switches you flip most often — **Persistent connection** and **Turn-complete alerts** — so they're one tap from the Settings root instead of buried in a sub-screen.
|
||||
- **Connections: a cleaner list and a tabbed detail.** Settings → Connections is now a scannable list — each server shows an **Active** badge and an at-a-glance capability summary (API · Dashboard · Voice · Relay) — and tapping a server opens a focused detail screen with **Overview**, **Routes**, **Advanced**, and **Security** tabs. Rename / re-pair / revoke / remove moved into the detail's **⋮** menu, and **relay sessions** (review and revoke the phones paired with that server) get a clear home under Security.
|
||||
- **Keep connected through deep sleep (sideload).** When **Persistent connection** is on, Settings offers a one-tap "Allow unrestricted battery" prompt so the connection survives Android's deep-sleep (Doze) — without it, the OS pauses background networking after the screen's been off a while even with a foreground service. (Sideload only; Google Play restricts this permission.)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Clearer, snappier voice capture and playback.** Voice now engages the device's echo-cancellation and noise-suppression while recording (matching the desktop's microphone setup), and requests audio focus before the first reply so the opening words aren't clipped on a cold start. Listening timing also matches the official desktop: auto-stop ~1.25s after you stop speaking (was 3s), give up after 12s with no speech, and cap a turn at 60s.
|
||||
- **Refreshed chat look.** Message bubbles are wider and denser, each assistant turn shows a small Hermes avatar to its left (once per group), and code blocks are richer — a language label, a copy button, and a clearer inset so fenced code and inline `code` no longer blend into the bubble.
|
||||
- **Desktop CLI: visual + ergonomics refresh.** A single color theme across the CLI, aligned tables for `devices`/`sessions`, status dots for on/off states, and progress spinners for slow operations (the multi-endpoint pairing probe and the gateway connect) so nothing looks hung. Errors now suggest the fix (e.g. re-pair on auth failure).
|
||||
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
|
||||
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
|
||||
- **Persistent connection (was "keep chat connected").** The background keep-alive and its notification are reframed from a "chat connection" to your overall connection to Hermes — it holds the app's connection open in the background so messages and live features stay responsive, and for relay-paired setups also keeps device control and notification mirroring reachable. The toggle moved out of Chat settings into the new top-level Quick Controls card.
|
||||
- **Chat is the home; simpler top-level navigation.** The Chat / Manage / Bridge mode strip is gone — Chat is now full-height, and Manage and Bridge are reached from Settings (Settings → Hermes management / Bridge), each with a back arrow to Chat. Terminal and Settings remain quick icons in the chat top bar.
|
||||
- **Gentler reconnects when your server is unreachable.** After the server has been unreachable for a while, the app stops retrying every ~15 seconds and drops to a slower poll — easier on the battery — and still reconnects immediately the moment the network changes or the server comes back.
|
||||
- **Connection status stays out of your way.** Connection feedback now sits exactly where it matters and never covers the nav or shifts the screen. Your **agent's** connection shows in the header subtitle under the agent name — it reads *Reconnecting…* / *Connecting…* / *Disconnected* and crossfades back to the model when it recovers, the same place messaging apps put it. The **relay** link (bridge / terminal / voice) shows only as a small amber *Reconnecting…* cue in the bottom status strip, since it doesn't block chat. Returning to the app from the background is now fully silent instead of flashing a misleading "connection changed" for the same connection re-handshaking.
|
||||
- **Realtime voice: quieter progress.** The periodic spoken status updates during a long task ("Using cronjob…") are now off by default — the agent speaks at the milestones that matter (task started in background, finished, or failed) and the visual progress chip covers the in-between. A server setting brings the timed narration back if you prefer it.
|
||||
- **Realtime voice: a live background-task chip.** The "working on it" chip in voice mode now actually shows what's happening: the current step ("Running command"), how many steps have finished, and a running timer — with a pulse so you can tell it's alive. It also reads the connection honestly ("Reconnecting — your task is still running" during a blip, "Done — delivering the answer…" while the reply queues up), and a ✕ on the chip cancels the task outright.
|
||||
- **Realtime voice: snappier long-task handoffs and first turns.** When a clearly long-running tool starts (cron, desktop, browser work), the agent hands the task to the background right away instead of waiting out the full grace period — and the voice session now warms up when you open voice mode, so the first turn skips the connection setup it used to pay.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Two voice controls that did nothing.** The disabled "Auto-TTS" toggle and the "STT language" picker under "Coming soon" in Voice settings are gone: the official desktop doesn't read every typed message aloud, and speech-to-text language is a server-side setting now editable in the new Server voice config section.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Back button on Manage and Bridge now works.** The back arrow on the Manage ("Hermes management") and Bridge screens did nothing — it tried to jump to Chat in a way that silently no-op'd. Back now reliably returns to the screen you opened it from.
|
||||
- **Dropped relay connections from a status-report race.** The phone's periodic device-status report could occasionally be sent to the relay *before* the connection had finished authenticating, which made the relay reject the whole connection and forced a reconnect. The app now holds every message until the connection is authenticated, so the handshake always completes first.
|
||||
- **Fewer needless connection re-checks when switching apps.** Returning to the app after a quick glance at another app no longer triggers a full connection re-probe (and the brief "checking…" flash) when the connection was already healthy — it only re-checks after a longer absence or if something actually looks off.
|
||||
- **No more scary "server isn't accepting connections" pop-up on first load.** A bare bottom message could flash on cold start while the app was still establishing its first connection (the background session-list load failing before the server was reachable). That state is now shown only by the themed connection banner at the top — the redundant pop-up is suppressed for cold-start/reconnect bootstrapping, while real failures while you're using the app still surface normally.
|
||||
- **Reconnect loop on remote (Tailscale) connections.** Connecting from off your home network could make chat loop — repeatedly reconnecting before it finally settled — because a brief route-probe miss flipped the active route back to the (unreachable) home address and rebuilt the chat connection against it. The app now keeps the last working route through a transient miss, tolerates a slow first handshake on remote links, and absorbs VPN-interface churn, so a remote connection settles quickly instead of thrashing.
|
||||
- **Realtime voice: background tasks survive a brief disconnect.** Asking the voice agent to run a longer task in the background no longer loses the result to a momentary network drop — the server keeps the run alive across the reconnect and delivers the answer once you're back, and a task that runs too long is now stopped cleanly instead of hanging silently.
|
||||
- **Realtime voice: the spoken answer is no longer dropped when a background task finishes.** When the agent completed a longer background task, a harmless internal provider notice was being treated as a fatal error and closed the voice session right as the reply was about to be spoken (surfacing an "xAI realtime error" toast with Retry). Those transient notices no longer end the turn, so the answer is actually spoken.
|
||||
- **Realtime voice: the answer waits for you instead of playing to a dead connection.** If a background task finishes while your phone is disconnected, the spoken summary is now held and delivered when the voice session reconnects — and the phone keeps retrying that reconnect for several minutes instead of giving up after one attempt. If the voice session is gone for good, the result arrives as a notification instead (the full answer is always in the chat).
|
||||
- **Realtime voice: asking for a second task while one is running no longer breaks the first.** The agent now tells you the earlier task is still in progress (wait, check status, or cancel) instead of silently losing its result.
|
||||
|
||||
## [1.2.6] - 2026-06-27
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
## What This Is
|
||||
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with an optional Python relay plugin/server (aiohttp) for the Hermes agent platform. Vanilla Hermes chat, Manage, and dashboard voice work against unmodified upstream Hermes. Relay adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management.
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with an optional Python relay plugin/server (aiohttp) for the Hermes agent platform. Vanilla Hermes chat, Manage, and dashboard voice work against unmodified upstream Hermes. The Relay plugin adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management via the official Hermes web dashboard.
|
||||
|
||||
**Current state:** v1.0.0 stable. The default no-plugin path supports chat, Manage, and voice on vanilla upstream Hermes. Chat auto-prefers the dashboard `/api/ws` gateway transport when Manage auth is ready, then falls back to API-server SSE routes. Vanilla Hermes voice uses dashboard `/api/audio/*` with the Manage session. Relay remains an additive power path for terminal, bridge/device control, notification companion, extra/provider-native voice, remote access, and desktop tooling. Two Android product flavors ship: `googlePlay` (conservative, no unattended Device Control surface) and `sideload` (full-capability).
|
||||
**Current state:** Reference latest released version for stable state and current dev branch for working state. The default no-plugin path supports chat, Manage, and voice on vanilla upstream Hermes. Chat auto-prefers the dashboard `/api/ws` gateway transport when Manage auth is ready, then falls back to API-server SSE routes. Vanilla Hermes voice uses dashboard `/api/audio/*` with the Manage session. Relay remains an additive power path for terminal, bridge/device control, notification companion, extra/provider-native voice, remote access, and desktop tooling. Two Android product flavors ship: `googlePlay` (conservative, no unattended Device Control surface) and `sideload` (full-capability).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -25,19 +25,21 @@ The Vanilla Hermes path must stay upstream-only. API-server bearer auth and dash
|
||||
|
||||
**Vanilla Hermes endpoints (confirmed in hermes-agent source):**
|
||||
|
||||
| Endpoint | Purpose | Tool Call Format |
|
||||
|----------|---------|-----------------|
|
||||
| `POST /v1/chat/completions` | OpenAI-compatible chat (stream=true for SSE) | Inline markdown text (`` `💻 terminal` ``) — no separate tool events |
|
||||
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
|
||||
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/capabilities` | Machine-readable feature + endpoint discovery | Use before assuming optional surfaces exist |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /v1/skills` | Read-only skill list for the API-server agent | `{"object":"list","data":[...]}` |
|
||||
| `GET /v1/toolsets` | Read-only API-server toolset inventory | `{"object":"list","platform":"api_server","data":[...]}` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions/*` | Native session CRUD, messages, fork, sync chat, SSE chat | Upstream merged via NousResearch/hermes-agent PR #33134 |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
|
||||
|
||||
| Endpoint | Purpose | Tool Call Format |
|
||||
| --------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `POST /v1/chat/completions` | OpenAI-compatible chat (stream=true for SSE) | Inline markdown text (``💻 terminal``) — no separate tool events |
|
||||
| `POST /v1/runs` | Start an agent run | Returns `run_id` |
|
||||
| `GET /v1/runs/{run_id}/events` | SSE stream of run lifecycle events | **Structured events**: `tool.started`, `tool.completed`, `message.delta`, `reasoning.available`, `run.completed`, `run.failed` |
|
||||
| `POST /v1/responses` | OpenAI Responses API format | Structured `function_call` objects (non-streaming only) |
|
||||
| `GET /v1/capabilities` | Machine-readable feature + endpoint discovery | Use before assuming optional surfaces exist |
|
||||
| `GET /v1/models` | List available models | — |
|
||||
| `GET /v1/skills` | Read-only skill list for the API-server agent | `{"object":"list","data":[...]}` |
|
||||
| `GET /v1/toolsets` | Read-only API-server toolset inventory | `{"object":"list","platform":"api_server","data":[...]}` |
|
||||
| `GET/POST/PATCH/DELETE /api/sessions/*` | Native session CRUD, messages, fork, sync chat, SSE chat | Upstream merged via NousResearch/hermes-agent PR #33134 |
|
||||
| `GET /health` | Health check | — |
|
||||
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management (api_server surface) | — |
|
||||
|
||||
|
||||
**Compatibility endpoints (not all native upstream API-server routes):**
|
||||
|
||||
@@ -47,34 +49,38 @@ Upstream main now contains the focused session-control API (`#33134`) and read-o
|
||||
2. **Bootstrap compatibility** (`plugin/hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
|
||||
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
|----------|---------|-------------|
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
|
||||
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
|
||||
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
|
||||
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail | Bootstrap/fork legacy; prefer native `/v1/skills` for lists |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
|
||||
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
| -------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
|
||||
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
|
||||
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
|
||||
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail | Bootstrap/fork legacy; prefer native `/v1/skills` for lists |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
|
||||
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
|
||||
|
||||
|
||||
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"`, `ConnectionViewModel.resolveStreamingEndpoint()` picks `sessions`, `completions`, or `runs` based on the capability snapshot.
|
||||
|
||||
**Dashboard web server (separate surface — standard Manage / Desktop remote gateway):**
|
||||
|
||||
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 **Vanilla Hermes (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.
|
||||
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 **Vanilla Hermes (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 uses it for Manage, Vanilla Hermes voice, and the gateway chat transport. `/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 remain the SSE fallback. 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.
|
||||
2. **Sessions API** — Native upstream emits structured SSE (`run.started`, `message.started`, `assistant.delta`, `tool.progress`, `tool.started/completed/failed`, `assistant.completed`, `run.completed`, `done`). `run.completed.messages` can reconcile authoritative per-turn transcript.
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (``💻 terminal``).
|
||||
|
||||
## Key Instructions
|
||||
|
||||
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection path — gateway/API chat, Manage, and Vanilla Hermes 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.
|
||||
@@ -131,6 +137,7 @@ hermes-android/
|
||||
## Project Conventions
|
||||
|
||||
### File Structure
|
||||
|
||||
- **Root-level:** README.md, CLAUDE.md, AGENTS.md, DEVLOG.md, TODO.md, .gitignore
|
||||
- **docs/** — spec, decisions, security, and any other long-form documentation
|
||||
- **DEVLOG.md** — update at end of each work session with what was done + verification (the factual record of *what happened*). It churns; do NOT park forward work here.
|
||||
@@ -149,6 +156,7 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
- **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.
|
||||
- **OkHttp** for WebSocket + SSE — `okhttp` for WSS relay, `okhttp-sse` for API streaming
|
||||
@@ -158,6 +166,7 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
- **Min SDK 26, Target SDK 35, Compile SDK 37** / **Kotlin 2.0+**, JVM toolchain 17
|
||||
|
||||
### Code Style — Desktop CLI (Node/TypeScript)
|
||||
|
||||
- **Node ≥21** — uses built-in global `WebSocket` (no `ws`/`undici` dep). Strict TS, ES modules, `NodeNext` resolution.
|
||||
- **Zero runtime deps** — `@types/node` + `tsx`/`rimraf`/`typescript` are devDeps only. Ship compiled `dist/`, not tsx.
|
||||
- **One binary, subcommands** — idiomatic for Node CLIs (codex, continue, vite pattern). Bare invocation is `chat`.
|
||||
@@ -165,11 +174,13 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
- **Dev loop:** `npx tsx src/cli.ts <args>` (no rebuild). `npm run build` + `npm link` before pushing to verify the bin shim. Never ship tsx in the published tarball — pre-build with `tsc` so Windows `npm install -g` can cmd-shim the JS directly.
|
||||
|
||||
### Code Style — Server (Python)
|
||||
|
||||
- **aiohttp** — async, matches existing Hermes relay patterns
|
||||
- **Type hints everywhere** — Python 3.11+ syntax
|
||||
- **asyncio** — no threading; **structured logging** — use `logging`, not print()
|
||||
|
||||
### Git
|
||||
|
||||
- **Conventional Commits:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
|
||||
- **Branching model (as of 2026-04-19):** `main` + `dev`. Feature branches target `dev`, not `main`. `main` receives only release merges (and tags). No straight-to-main exemption — even single-file typos go through `dev`.
|
||||
- **Merge style:** `git merge --no-ff` — no squash. Preserves per-commit trail for agent-team branches on every merge in the chain (feature → dev → main).
|
||||
@@ -179,166 +190,169 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
- **Branch protection** on `main` — direct push blocked; only release-merge PRs from `dev` land here. `dev` also requires CI to pass on PRs but accepts feature-branch merges freely.
|
||||
|
||||
### Testing
|
||||
|
||||
- **Android:** JUnit + Compose testing for UI, MockK for mocks
|
||||
- **Python:** `python -m unittest plugin.tests.test_<name>` — avoid bare `pytest` (conftest imports `responses` which may not be installed in the venv)
|
||||
- **CI is split by path:** `.github/workflows/ci-android.yml` runs on app/Gradle changes; `.github/workflows/ci-plugin.yml` runs on plugin/Python changes. Both trigger on pushes to `main` and `dev` and on PRs targeting either. Build + tests must pass before merge to `dev`; release-merge to `main` requires the same.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Why |
|
||||
|------|-----|
|
||||
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
|
||||
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
|
||||
| `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 |
|
||||
| `network/models/SessionModels.kt` | Session, message, SSE event data models |
|
||||
| `data/FeatureFlags.kt` | Feature gating — DEV_MODE + DataStore overrides; `BuildFlavor` (googlePlay/sideload Tier flags) |
|
||||
| **App — Auth** | |
|
||||
| `auth/AuthManager.kt` | Wires SessionTokenStore + CertPinStore; parses auth.ok; `applyServerIssuedCodeAndReset()` |
|
||||
| `auth/SessionTokenStore.kt` | Keystore (StrongBox) + EncryptedSharedPrefs fallback; lossless migration on upgrade |
|
||||
| `auth/CertPinStore.kt` | TOFU cert pinning — SHA-256 SPKI per host:port in DataStore |
|
||||
| `auth/PairedSession.kt` | PairedSession state + PairedDeviceInfo wire model |
|
||||
| `data/Endpoint.kt` | `EndpointCandidate` / `ApiEndpoint` / `RelayEndpoint` — multi-endpoint pairing (ADR 24); `displayLabel()` for LAN/Tailscale/Public/Custom chips |
|
||||
| `network/RelayHttpClient.kt` | OkHttp for /media, /sessions (list/revoke/extend), /health |
|
||||
| **App — Bridge** | |
|
||||
| `network/handlers/BridgeCommandHandler.kt` | Routes `bridge.command` → ActionExecutor; full path inventory + safety-rail integration |
|
||||
| `viewmodel/BridgeViewModel.kt` | BridgeScreen VM — masterToggle, bridgeStatus, permissionStatus, activityLog |
|
||||
| `bridge/BridgeSafetyManager.kt` | Blocklist + destructive-verb confirmation + auto-disable timer; fails-closed on /call and /send_sms |
|
||||
| `data/BridgeSafetyPreferences.kt` | DataStore for blocklist, destructive verbs, auto-disable minutes, confirmation timeout |
|
||||
| `ui/screens/BridgeScreen.kt` | Bridge UI — master → permission checklist → [Advanced] → unattended → safety → activity log (v0.4.1 reorder) |
|
||||
| `ui/components/UnattendedAccessRow.kt` | Unattended toggle card (sideload); `enabled=masterEnabled`; inline `KeyguardDetectedAlert` |
|
||||
| `ui/components/UnattendedGlobalBanner.kt` | 28dp amber strip at scaffold top when master+unattended on (sideload); tap → Bridge tab |
|
||||
| `bridge/BridgeStatusOverlay.kt` | WindowManager overlay; `ConfirmationOverlayHost`; requires `SavedStateRegistryOwner` init order (CREATED→restore→RESUMED) |
|
||||
| `accessibility/HermesAccessibilityService.kt` | AccessibilityService subclass; `@Volatile instance` singleton for BridgeCommandHandler |
|
||||
| `accessibility/ScreenReader.kt` | UI tree → ScreenContent; `findNodeBoundsByText()`, `findFocusedInput()` |
|
||||
| `accessibility/ActionExecutor.kt` | Gesture/text dispatch via GestureDescription + ACTION_SET_TEXT; pressKey maps vocab only |
|
||||
| **App — Voice** | |
|
||||
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
|
||||
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
|
||||
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
|
||||
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
|
||||
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
|
||||
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
|
||||
| `ui/components/VoiceModeOverlay.kt` | Full-screen voice UI — MorphingSphere + VoiceWaveform + mic button |
|
||||
| `ui/components/MorphingSphere.kt` | Compose renderer for the agent sphere — delegates math to `MorphingSphereCore` |
|
||||
| `ui/components/MorphingSphereCore.kt` | Platform-agnostic sphere algorithm (`kotlin.math` only) — single source of truth; mirrored byte-for-byte in `preview/web/sphere.js` |
|
||||
| `preview/web/` | Zero-dep browser harness — live `index.html` preview + `parity-check.mjs`; paired with `MorphingSphereCoreParityTest` (JVM) for struct/full checksum diffing |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `plugin/relay/channels/bridge.py` | Bridge handler — `handle_command()` mints request_id, awaits response, 30s timeout |
|
||||
| `plugin/relay/channels/notifications.py` | Bounded deque (100) of notification metadata; in-memory only |
|
||||
| `plugin/relay/media.py` | MediaRegistry — LRU token store; `strict_sandbox` off by default for `/media/by-path` |
|
||||
| `plugin/relay/voice.py` | Voice endpoints — transcribe, synthesize, voice_config; lazy tool imports |
|
||||
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret`; canonical form preserves `endpoints` array order + role strings verbatim (ADR 24) |
|
||||
| `plugin/relay/tailscale.py` | First-class Tailscale helper (ADR 25) — `status()` / `enable(port)` / `disable(port)` / `canonical_upstream_present()`; safe-absent via shell-out to `tailscale` CLI |
|
||||
| `plugin/relay/_env_bootstrap.py` | Loads `~/.hermes/.env` before relay imports; called from both entry points |
|
||||
| **Plugin — Tools + Installer** | |
|
||||
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
|
||||
| `plugin/tools/android_navigate.py` | Vision-driven navigation loop; up to 20 iterations; `llm_gap` error until vision client wired |
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `plugin/doctor.py` | `hermes relay doctor`; checks standard upstream API/dashboard reachability, Relay loopback state, plugin layout, and compat hook state |
|
||||
| `plugin/compat.py` | `hermes relay compat status/install/remove`; owns the optional `hermes_relay_bootstrap.pth` lifecycle |
|
||||
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Legacy import shim for old `.pth` files and editable installs |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
| `plugin/dashboard/src/index.jsx` | React root registering `hermes-relay` plugin with 4-tab shell |
|
||||
| `plugin/dashboard/dist/index.js` | Committed IIFE bundle loaded verbatim by dashboard |
|
||||
| **Desktop CLI** | |
|
||||
| `desktop/package.json` | `@hermes-relay/cli` package manifest — Node ≥21, one `hermes-relay` bin, pre-built dist |
|
||||
| `desktop/bin/hermes-relay.js` | Tiny shim: `import('../dist/cli.js').then(m => m.main())` + error surfacing |
|
||||
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
|
||||
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat`; command-scoped `--help` falls through to each command |
|
||||
| `desktop/src/lib/theme.ts` | Shared ANSI palette + `colorEnabled()` + `Theme` (semantic helpers, `statusDot`) — single visual language; `--no-color`/`NO_COLOR`/TTY aware |
|
||||
| `desktop/src/lib/table.ts` | Zero-dep column-aligned table renderer (ANSI-width aware, last column flexes to terminal width) — used by devices/sessions/audit |
|
||||
| `desktop/src/lib/spinner.ts` | Stderr braille spinner for slow ops (pair probe, gateway connect); no-op when piped/quiet/json |
|
||||
| `desktop/src/lib/usage.ts` | `UsageSpec` + `renderUsage`/`printUsage`/`unknownSubcommand` — per-subcommand `--help` + self-documenting sub-verb fallback |
|
||||
| `desktop/src/lib/hints.ts` | `suggestedFix(err, ctx)` → next-step command (re-pair on auth fail, etc.); `formatError` renders error + hint |
|
||||
| `desktop/src/lib/logo.ts` | Slim box-drawing "Hermes Relay" wordmark; shown atop `--help`, first-run welcome, REPL header, and `hermes-relay logo`; theme/no-color aware |
|
||||
| `desktop/src/lib/auditLog.ts` | Local desktop-tool audit JSONL (`~/.hermes/desktop-audit.jsonl`); router appends per dispatch; backs `audit` command (relay's ring is loopback-only) |
|
||||
| `desktop/src/lib/daemonStatus.ts` | Daemon heartbeat file (`~/.hermes/daemon-status.json`) + `isPidAlive` liveness; backs `daemon --status` |
|
||||
| `desktop/src/commands/audit.ts` | `hermes-relay audit` — tails the local audit log into a table (WHEN/TOOL/STATUS/DETAIL); `--limit`, `--json` |
|
||||
| `desktop/src/commands/relay.ts` | `hermes-relay relay info/security/context` — relay-server management surface; info/security loopback-only, context works remote with bearer |
|
||||
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
|
||||
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
|
||||
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
|
||||
| `desktop/src/commands/tools.ts` | `tools.list` RPC → enabled/available toolsets; `--verbose` lists individual tools |
|
||||
| `desktop/src/commands/status.ts` | Local read of `~/.hermes/remote-sessions.json`; renders `grants:` + `expires:` + `route:`; `--json` redacts tokens, `--reveal-tokens` opts in |
|
||||
| `desktop/src/commands/devices.ts` | Server-side session management — `GET/DELETE/PATCH /sessions` via `fetch` over http(s)://host:port; `list` / `revoke <prefix>` / `extend <prefix> --ttl <s>` |
|
||||
| `desktop/src/banner.ts` | `buildConnectBanner({url, meta, endpointRole})` → "Connected via LAN (plain) — server 0.6.0"; `humanExpiry()` for TTL formatting |
|
||||
| `desktop/src/endpoint.ts` | `EndpointCandidate` / `EndpointRole` types + `displayLabel()` — mirrors Android `data/Endpoint.kt` |
|
||||
| `desktop/src/pairingQr.ts` | `decodePairingPayload` (JSON or base64), `payloadToCandidates` (v3 verbatim / v1–v2 synthesized), `probeCandidatesByPriority` (`Promise.any` within tier, `AbortSignal.any`, 4s timeout, 60s cache) |
|
||||
| `desktop/src/certPin.ts` | `extractSpkiSha256(der)` via `crypto.X509Certificate` + `publicKey.export({type:'spki'})`; `pinKey(url)`, `comparePins()`, `isSecureUrl()` |
|
||||
| `desktop/src/tools/router.ts` | `DesktopToolRouter.attach(relay)` — `onChannel('desktop')` dispatch under 30s `AbortController`; heartbeat enriched with host/platform/version/uptime_ms + sticky `last_error` for `desktop_health` |
|
||||
| `desktop/src/tools/handlerSet.ts` | Single source of truth for the desktop tool map — `DESKTOP_HANDLERS` + `DESKTOP_ADVERTISED_TOOLS`; consumed by `chat.ts` / `shell.ts` / `daemon.ts` so adding a tool is a one-file change |
|
||||
| `desktop/src/tools/consent.ts` | `ensureToolsConsent(url)` — stored per-URL in `toolsConsented`; TTY prompt; non-TTY fails closed |
|
||||
| `desktop/src/tools/handlers/fs.ts` | `readFileHandler` / `writeFileHandler` / `patchHandler` — strict unified-diff applier, no fuzz |
|
||||
| `desktop/src/tools/handlers/terminal.ts` | `bash -lc` / `cmd /c`, SIGKILL on timeout or abort, returns `{stdout, stderr, exit_code, duration_ms}` |
|
||||
| `desktop/src/tools/handlers/powershell.ts` | Spawns `pwsh`/`powershell` directly with `-Command -`, script piped via stdin — no cmd.exe quote-mangling; auto-picks pwsh > powershell |
|
||||
| `desktop/src/tools/handlers/process.ts` | `spawn_detached` (unref'd, returns pid+log_path), `list_processes` (tasklist /FO CSV — no /V to dodge window-title latency), `kill_process`, `find_pid_by_port` (netstat/lsof/ss) |
|
||||
| `desktop/src/tools/handlers/jobs.ts` | Job API — `~/.hermes/desktop-jobs/<id>/{stdout.log, stderr.log, meta.json}` is source of truth across daemon restarts; `taskkill /T` on Windows so build trees die fully |
|
||||
| `desktop/src/tools/handlers/transfer.ts` | `copy_directory` via `fs.cp`, `zip`/`unzip` via tar > zip > PowerShell probe, `checksum` streamed (sha256/sha1/md5) |
|
||||
| `desktop/src/tools/handlers/search.ts` | ripgrep with pure-Node fallback, skips `.git`/`node_modules`/`dist`/`.next`/`.cache` |
|
||||
| `desktop/src/renderer.ts` | Streams `message.delta` → stdout, tool events → decorated lines; NO_COLOR / --json / --quiet aware |
|
||||
| `desktop/src/pairing.ts` | readline-based 6-char prompt (`A-Z0-9`); headless mirror of TUI's Ink prompt; `validatePairingPayloadString` discriminated-union wrapper |
|
||||
| `desktop/src/credentials.ts` | Precedence: `--token` → `--pair-qr` (probe+pair) → `--code` → stored → prompt; returns `Credentials{sessionToken?, pairingCode?, resolvedEndpoint?}` |
|
||||
| `desktop/src/transport/RelayTransport.ts` | Fork of ui-tui's transport + reconnect state machine (`idle/connecting/connected/reconnecting`, exp backoff 1→30s, 5min on 429, gate re-check post-sleep) + pre-WS TLS probe for TOFU |
|
||||
| `desktop/src/remoteSessions.ts` | Same file path as TUI (`~/.hermes/remote-sessions.json`, 0600); schema widened with `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented`; `saveSession` back-compat overload |
|
||||
| `desktop/src/commands/daemon.ts` | Headless WSS + tool router for always-on access; JSON-line logs; fails closed on missing consent unless `--allow-tools` with explicit `--token` |
|
||||
| `desktop/src/commands/doctor.ts` | Local-only diagnostic report — version / binary path / PATH / sessions / daemon detection; `--json` for support-paste; omits tokens entirely |
|
||||
| `desktop/src/relayUrlPrompt.ts` | First-run URL fallback — `resolveFirstRunUrl()` auto-picks single stored session, numbered picker for multiple, welcome banner for zero; throws on non-interactive + ambiguous |
|
||||
| `desktop/src/version.ts` | Build-time-generated constant (`npm run gen:version` before every build) — Bun compiled binaries can't read package.json via `__dirname` so version is embedded at build |
|
||||
| `desktop/scripts/install.sh` / `install.ps1` | curl/iwr one-liner installers — download prebuilt Bun binary (no Node required), SHA256-verified, API-resolver for `latest` that includes prereleases, version-aware pre/post-install readback |
|
||||
| `desktop/scripts/uninstall.sh` / `uninstall.ps1` | 3-tier removal — default (binary + PATH), `--purge` (also wipes `~/.hermes/remote-sessions.json`), `--service` (stub for future service installers); Windows iex-safe env-var fallback |
|
||||
| `desktop/README.md` | User-facing install + usage reference |
|
||||
| **Desktop CLI — dev iteration** | |
|
||||
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
|
||||
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
|
||||
| `release-cli.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
|
||||
| **Server — Desktop tool routing (Phase B)** | |
|
||||
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
|
||||
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
|
||||
| **Gradle modules — experimental Quest/XR (in development)** | |
|
||||
| `relay-core/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.core`) — shared pairing/transport/terminal/voice/wire for the Quest port; not yet wired into the shipped `:app` |
|
||||
| `relay-ui/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.ui`) — shared Compose UI (sphere, terminal WebView, QR scanner) for the Quest port; carries its own sphere copy |
|
||||
| `quest/` | [EXPERIMENTAL] Meta Spatial SDK Quest/XR app — gradle `includeBuild("quest")`; needs further development, not shipped |
|
||||
| **Tooling — dev iteration (not shipped)** | |
|
||||
| `ui-preview/` | Desktop Compose Hot Reload harness — JVM Compose for Desktop; source-shares `MorphingSphereCore` from `:relay-ui`; `Main.kt` gallery; see `ui-preview/README.md` |
|
||||
| `app/src/test/.../screenshots/StoreScreenshotTest.kt` | Roborazzi host-side store/docs screenshot renderer — deterministic, no device, exact 1080×2160; reuses real components+chrome with mock data; `capture(name, themeId){…}` renders any view; see `docs/screenshot-automation.md` §Deterministic rendering (JDK-21 + no-plugin gotchas) |
|
||||
|
||||
| File | Why |
|
||||
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `docs/spec.md` | Full specification — protocol, UI layouts, phases, dependencies |
|
||||
| `docs/decisions.md` | Architecture decisions — framework choice, channel design, auth model |
|
||||
| `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 (Scaffold + Compose nav); Chat is home — no mode strip, Manage/Bridge reached via Settings; `bottomBar` is a status pill, not a NavigationBar |
|
||||
| `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 |
|
||||
| `network/models/SessionModels.kt` | Session, message, SSE event data models |
|
||||
| `data/FeatureFlags.kt` | Feature gating — DEV_MODE + DataStore overrides; `BuildFlavor` (googlePlay/sideload Tier flags) |
|
||||
| **App — Auth** | |
|
||||
| `auth/AuthManager.kt` | Wires SessionTokenStore + CertPinStore; parses auth.ok; `applyServerIssuedCodeAndReset()` |
|
||||
| `auth/SessionTokenStore.kt` | Keystore (StrongBox) + EncryptedSharedPrefs fallback; lossless migration on upgrade |
|
||||
| `auth/CertPinStore.kt` | TOFU cert pinning — SHA-256 SPKI per host:port in DataStore |
|
||||
| `auth/PairedSession.kt` | PairedSession state + PairedDeviceInfo wire model |
|
||||
| `data/Endpoint.kt` | `EndpointCandidate` / `ApiEndpoint` / `RelayEndpoint` — multi-endpoint pairing (ADR 24); `displayLabel()` for LAN/Tailscale/Public/Custom chips |
|
||||
| `network/RelayHttpClient.kt` | OkHttp for /media, /sessions (list/revoke/extend), /health |
|
||||
| **App — Bridge** | |
|
||||
| `network/handlers/BridgeCommandHandler.kt` | Routes `bridge.command` → ActionExecutor; full path inventory + safety-rail integration |
|
||||
| `viewmodel/BridgeViewModel.kt` | BridgeScreen VM — masterToggle, bridgeStatus, permissionStatus, activityLog |
|
||||
| `bridge/BridgeSafetyManager.kt` | Blocklist + destructive-verb confirmation + auto-disable timer; fails-closed on /call and /send_sms |
|
||||
| `data/BridgeSafetyPreferences.kt` | DataStore for blocklist, destructive verbs, auto-disable minutes, confirmation timeout |
|
||||
| `ui/screens/BridgeScreen.kt` | Bridge UI — master → permission checklist → [Advanced] → unattended → safety → activity log (v0.4.1 reorder) |
|
||||
| `ui/components/UnattendedAccessRow.kt` | Unattended toggle card (sideload); `enabled=masterEnabled`; inline `KeyguardDetectedAlert` |
|
||||
| `ui/components/UnattendedGlobalBanner.kt` | 28dp amber strip at scaffold top when master+unattended on (sideload); tap → Bridge tab |
|
||||
| `bridge/BridgeStatusOverlay.kt` | WindowManager overlay; `ConfirmationOverlayHost`; requires `SavedStateRegistryOwner` init order (CREATED→restore→RESUMED) |
|
||||
| `accessibility/HermesAccessibilityService.kt` | AccessibilityService subclass; `@Volatile instance` singleton for BridgeCommandHandler |
|
||||
| `accessibility/ScreenReader.kt` | UI tree → ScreenContent; `findNodeBoundsByText()`, `findFocusedInput()` |
|
||||
| `accessibility/ActionExecutor.kt` | Gesture/text dispatch via GestureDescription + ACTION_SET_TEXT; pressKey maps vocab only |
|
||||
| **App — Voice** | |
|
||||
| `voice/VoiceViewModel.kt` | Voice turn state machine; TTS queue; `ignoreAssistantId`; `errorEvents: SharedFlow` |
|
||||
| `audio/VoiceRecorder.kt` | MediaRecorder wrapper; perceptual amplitude curve; `.m4a` at 16kHz/64kbps |
|
||||
| `audio/VoicePlayer.kt` | Media3 ExoPlayer (gapless TTS queue) + Visualizer; amplitude StateFlow; `awaitCompletion()` via coroutine; `audioSessionId` is a thread-safe `@Volatile` cache |
|
||||
| `network/RelayVoiceClient.kt` | OkHttp for `/voice/transcribe`, `/synthesize`, `/config` |
|
||||
| `voice/VoiceBridgeIntentHandler.kt` | Interface routing voice utterances to bridge; impls per flavor via factory |
|
||||
| `voice/VoiceIntentClassifier.kt` | Regex phone-control classifier (sideload only); false-negatives preferred over false-positives |
|
||||
| `ui/components/VoiceModeOverlay.kt` | Full-screen voice UI — MorphingSphere + VoiceWaveform + mic button |
|
||||
| `ui/components/MorphingSphere.kt` | Compose renderer for the agent sphere — delegates math to `MorphingSphereCore` |
|
||||
| `ui/components/MorphingSphereCore.kt` | Platform-agnostic sphere algorithm (`kotlin.math` only) — single source of truth; mirrored byte-for-byte in `preview/web/sphere.js` |
|
||||
| `preview/web/` | Zero-dep browser harness — live `index.html` preview + `parity-check.mjs`; paired with `MorphingSphereCoreParityTest` (JVM) for struct/full checksum diffing |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `plugin/relay/channels/bridge.py` | Bridge handler — `handle_command()` mints request_id, awaits response, 30s timeout |
|
||||
| `plugin/relay/channels/notifications.py` | Bounded deque (100) of notification metadata; in-memory only |
|
||||
| `plugin/relay/media.py` | MediaRegistry — LRU token store; `strict_sandbox` off by default for `/media/by-path` |
|
||||
| `plugin/relay/voice.py` | Voice endpoints — transcribe, synthesize, voice_config; lazy tool imports |
|
||||
| `plugin/relay/qr_sign.py` | HMAC-SHA256 QR signing; secret at `~/.hermes/hermes-relay-qr-secret`; canonical form preserves `endpoints` array order + role strings verbatim (ADR 24) |
|
||||
| `plugin/relay/tailscale.py` | First-class Tailscale helper (ADR 25) — `status()` / `enable(port)` / `disable(port)` / `canonical_upstream_present()`; safe-absent via shell-out to `tailscale` CLI |
|
||||
| `plugin/relay/_env_bootstrap.py` | Loads `~/.hermes/.env` before relay imports; called from both entry points |
|
||||
| **Plugin — Tools + Installer** | |
|
||||
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
|
||||
| `plugin/tools/android_navigate.py` | Vision-driven navigation loop; up to 20 iterations; `llm_gap` error until vision client wired |
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `plugin/doctor.py` | `hermes relay doctor`; checks standard upstream API/dashboard reachability, Relay loopback state, plugin layout, and compat hook state |
|
||||
| `plugin/compat.py` | `hermes relay compat status/install/remove`; owns the optional `hermes_relay_bootstrap.pth` lifecycle |
|
||||
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Legacy import shim for old `.pth` files and editable installs |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
| `plugin/dashboard/src/index.jsx` | React root registering `hermes-relay` plugin with 4-tab shell |
|
||||
| `plugin/dashboard/dist/index.js` | Committed IIFE bundle loaded verbatim by dashboard |
|
||||
| **Desktop CLI** | |
|
||||
| `desktop/package.json` | `@hermes-relay/cli` package manifest — Node ≥21, one `hermes-relay` bin, pre-built dist |
|
||||
| `desktop/bin/hermes-relay.js` | Tiny shim: `import('../dist/cli.js').then(m => m.main())` + error surfacing |
|
||||
| `desktop/src/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
|
||||
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat`; command-scoped `--help` falls through to each command |
|
||||
| `desktop/src/lib/theme.ts` | Shared ANSI palette + `colorEnabled()` + `Theme` (semantic helpers, `statusDot`) — single visual language; `--no-color`/`NO_COLOR`/TTY aware |
|
||||
| `desktop/src/lib/table.ts` | Zero-dep column-aligned table renderer (ANSI-width aware, last column flexes to terminal width) — used by devices/sessions/audit |
|
||||
| `desktop/src/lib/spinner.ts` | Stderr braille spinner for slow ops (pair probe, gateway connect); no-op when piped/quiet/json |
|
||||
| `desktop/src/lib/usage.ts` | `UsageSpec` + `renderUsage`/`printUsage`/`unknownSubcommand` — per-subcommand `--help` + self-documenting sub-verb fallback |
|
||||
| `desktop/src/lib/hints.ts` | `suggestedFix(err, ctx)` → next-step command (re-pair on auth fail, etc.); `formatError` renders error + hint |
|
||||
| `desktop/src/lib/logo.ts` | Slim box-drawing "Hermes Relay" wordmark; shown atop `--help`, first-run welcome, REPL header, and `hermes-relay logo`; theme/no-color aware |
|
||||
| `desktop/src/lib/auditLog.ts` | Local desktop-tool audit JSONL (`~/.hermes/desktop-audit.jsonl`); router appends per dispatch; backs `audit` command (relay's ring is loopback-only) |
|
||||
| `desktop/src/lib/daemonStatus.ts` | Daemon heartbeat file (`~/.hermes/daemon-status.json`) + `isPidAlive` liveness; backs `daemon --status` |
|
||||
| `desktop/src/commands/audit.ts` | `hermes-relay audit` — tails the local audit log into a table (WHEN/TOOL/STATUS/DETAIL); `--limit`, `--json` |
|
||||
| `desktop/src/commands/relay.ts` | `hermes-relay relay info/security/context/queue` — relay-server management surface; info/security/queue loopback-only, context works remote with bearer; `queue` lists/cancels the agent→phone outbound buffer (`--clear` / `--cancel <id>`) |
|
||||
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
|
||||
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
|
||||
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
|
||||
| `desktop/src/commands/tools.ts` | `tools.list` RPC → enabled/available toolsets; `--verbose` lists individual tools |
|
||||
| `desktop/src/commands/status.ts` | Local read of `~/.hermes/remote-sessions.json`; renders `grants:` + `expires:` + `route:`; `--json` redacts tokens, `--reveal-tokens` opts in |
|
||||
| `desktop/src/commands/devices.ts` | Server-side session management — `GET/DELETE/PATCH /sessions` via `fetch` over http(s)://host:port; `list` / `revoke <prefix>` / `extend <prefix> --ttl <s>` |
|
||||
| `desktop/src/banner.ts` | `buildConnectBanner({url, meta, endpointRole})` → "Connected via LAN (plain) — server 0.6.0"; `humanExpiry()` for TTL formatting |
|
||||
| `desktop/src/endpoint.ts` | `EndpointCandidate` / `EndpointRole` types + `displayLabel()` — mirrors Android `data/Endpoint.kt` |
|
||||
| `desktop/src/pairingQr.ts` | `decodePairingPayload` (JSON or base64), `payloadToCandidates` (v3 verbatim / v1–v2 synthesized), `probeCandidatesByPriority` (`Promise.any` within tier, `AbortSignal.any`, 4s timeout, 60s cache) |
|
||||
| `desktop/src/certPin.ts` | `extractSpkiSha256(der)` via `crypto.X509Certificate` + `publicKey.export({type:'spki'})`; `pinKey(url)`, `comparePins()`, `isSecureUrl()` |
|
||||
| `desktop/src/tools/router.ts` | `DesktopToolRouter.attach(relay)` — `onChannel('desktop')` dispatch under 30s `AbortController`; heartbeat enriched with host/platform/version/uptime_ms + sticky `last_error` for `desktop_health` |
|
||||
| `desktop/src/tools/handlerSet.ts` | Single source of truth for the desktop tool map — `DESKTOP_HANDLERS` + `DESKTOP_ADVERTISED_TOOLS`; consumed by `chat.ts` / `shell.ts` / `daemon.ts` so adding a tool is a one-file change |
|
||||
| `desktop/src/tools/consent.ts` | `ensureToolsConsent(url)` — stored per-URL in `toolsConsented`; TTY prompt; non-TTY fails closed |
|
||||
| `desktop/src/tools/handlers/fs.ts` | `readFileHandler` / `writeFileHandler` / `patchHandler` — strict unified-diff applier, no fuzz |
|
||||
| `desktop/src/tools/handlers/terminal.ts` | `bash -lc` / `cmd /c`, SIGKILL on timeout or abort, returns `{stdout, stderr, exit_code, duration_ms}` |
|
||||
| `desktop/src/tools/handlers/powershell.ts` | Spawns `pwsh`/`powershell` directly with `-Command -`, script piped via stdin — no cmd.exe quote-mangling; auto-picks pwsh > powershell |
|
||||
| `desktop/src/tools/handlers/process.ts` | `spawn_detached` (unref'd, returns pid+log_path), `list_processes` (tasklist /FO CSV — no /V to dodge window-title latency), `kill_process`, `find_pid_by_port` (netstat/lsof/ss) |
|
||||
| `desktop/src/tools/handlers/jobs.ts` | Job API — `~/.hermes/desktop-jobs/<id>/{stdout.log, stderr.log, meta.json}` is source of truth across daemon restarts; `taskkill /T` on Windows so build trees die fully |
|
||||
| `desktop/src/tools/handlers/transfer.ts` | `copy_directory` via `fs.cp`, `zip`/`unzip` via tar > zip > PowerShell probe, `checksum` streamed (sha256/sha1/md5) |
|
||||
| `desktop/src/tools/handlers/search.ts` | ripgrep with pure-Node fallback, skips `.git`/`node_modules`/`dist`/`.next`/`.cache` |
|
||||
| `desktop/src/renderer.ts` | Streams `message.delta` → stdout, tool events → decorated lines; NO_COLOR / --json / --quiet aware |
|
||||
| `desktop/src/pairing.ts` | readline-based 6-char prompt (`A-Z0-9`); headless mirror of TUI's Ink prompt; `validatePairingPayloadString` discriminated-union wrapper |
|
||||
| `desktop/src/credentials.ts` | Precedence: `--token` → `--pair-qr` (probe+pair) → `--code` → stored → prompt; returns `Credentials{sessionToken?, pairingCode?, resolvedEndpoint?}` |
|
||||
| `desktop/src/transport/RelayTransport.ts` | Fork of ui-tui's transport + reconnect state machine (`idle/connecting/connected/reconnecting`, exp backoff 1→30s, 5min on 429, gate re-check post-sleep) + pre-WS TLS probe for TOFU |
|
||||
| `desktop/src/remoteSessions.ts` | Same file path as TUI (`~/.hermes/remote-sessions.json`, 0600); schema widened with `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented`; `saveSession` back-compat overload |
|
||||
| `desktop/src/commands/daemon.ts` | Headless WSS + tool router for always-on access; JSON-line logs; fails closed on missing consent unless `--allow-tools` with explicit `--token` |
|
||||
| `desktop/src/commands/doctor.ts` | Local-only diagnostic report — version / binary path / PATH / sessions / daemon detection; `--json` for support-paste; omits tokens entirely |
|
||||
| `desktop/src/relayUrlPrompt.ts` | First-run URL fallback — `resolveFirstRunUrl()` auto-picks single stored session, numbered picker for multiple, welcome banner for zero; throws on non-interactive + ambiguous |
|
||||
| `desktop/src/version.ts` | Build-time-generated constant (`npm run gen:version` before every build) — Bun compiled binaries can't read package.json via `__dirname` so version is embedded at build |
|
||||
| `desktop/scripts/install.sh` / `install.ps1` | curl/iwr one-liner installers — download prebuilt Bun binary (no Node required), SHA256-verified, API-resolver for `latest` that includes prereleases, version-aware pre/post-install readback |
|
||||
| `desktop/scripts/uninstall.sh` / `uninstall.ps1` | 3-tier removal — default (binary + PATH), `--purge` (also wipes `~/.hermes/remote-sessions.json`), `--service` (stub for future service installers); Windows iex-safe env-var fallback |
|
||||
| `desktop/README.md` | User-facing install + usage reference |
|
||||
| **Desktop CLI — dev iteration** | |
|
||||
| `npm run smoke` (in `desktop/`) | Builds Windows binary + runs `--version` / `--help` / `doctor`, fails loud on zero-output. Local pre-flight before cutting any tag. |
|
||||
| `npm run gen:version` | Regenerates `src/version.ts` from `package.json`. Runs automatically before every `build` / `build:bin:*`. |
|
||||
| `release-cli.yml → Smoke-test Linux binary` step | CI-side equivalent: runs compiled Linux binary through the same 3-command check before uploading assets. Catches silent-exit-0 + segfault classes. |
|
||||
| **Server — Desktop tool routing (Phase B)** | |
|
||||
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
|
||||
| `plugin/tools/desktop_tool.py` | 24 `desktop_*` tools (fs/shell/powershell/process/jobs/transfer/health) — registers with `tools.registry` under `desktop` toolset; per-tool `check_fn` pings `/desktop/_ping?tool=<name>`; `desktop_health` is `_RELAY_ONLY` and pings `/desktop/health` so it works even when the client is wedged |
|
||||
| **Gradle modules — experimental Quest/XR (in development)** | |
|
||||
| `relay-core/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.core`) — shared pairing/transport/terminal/voice/wire for the Quest port; not yet wired into the shipped `:app` |
|
||||
| `relay-ui/` | [EXPERIMENTAL] Android library (`com.axiomlabs.hermesrelay.ui`) — shared Compose UI (sphere, terminal WebView, QR scanner) for the Quest port; carries its own sphere copy |
|
||||
| `quest/` | [EXPERIMENTAL] Meta Spatial SDK Quest/XR app — gradle `includeBuild("quest")`; needs further development, not shipped |
|
||||
| **Tooling — dev iteration (not shipped)** | |
|
||||
| `ui-preview/` | Desktop Compose Hot Reload harness — JVM Compose for Desktop; source-shares `MorphingSphereCore` from `:relay-ui`; `Main.kt` gallery; see `ui-preview/README.md` |
|
||||
| `app/src/test/.../screenshots/StoreScreenshotTest.kt` | Roborazzi host-side store/docs screenshot renderer — deterministic, no device, exact 1080×2160; reuses real components+chrome with mock data; `capture(name, themeId){…}` renders any view; see `docs/screenshot-automation.md` §Deterministic rendering (JDK-21 + no-plugin gotchas) |
|
||||
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
@@ -349,15 +363,18 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
- **Don't put documentation in root** — long-form docs go in `docs/`
|
||||
- **Don't forget DEVLOG.md** — update it (record *what happened*)
|
||||
- **Don't bury follow-ups** — deferred work / known gaps go in `TODO.md`, never in DEVLOG or one-off code/doc comments
|
||||
- **Don't touch production / remote hosts** — automation and orchestrated agents must NEVER SSH into, deploy to, pull/restart/reconfigure, or push code to a live/remote Hermes host. Building, on-device testing, and server deployment are owner-driven (see Server Deployment). Stop at committing on your branch; surface "this needs a deploy/on-device check" rather than doing it.
|
||||
|
||||
## MCP Tooling
|
||||
|
||||
Two MCP servers are configured for AI-assisted development. See `docs/mcp-tooling.md` for full reference.
|
||||
|
||||
| Server | Layer | Requires |
|
||||
|--------|-------|----------|
|
||||
|
||||
| Server | Layer | Requires |
|
||||
| ------------------- | --------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `android-tools-mcp` | IDE/Build — Compose previews, Gradle, code search, Android docs | Android Studio running with project open |
|
||||
| `mobile-mcp` | Device/Runtime — tap, swipe, screenshot, app management | ADB + connected device/emulator |
|
||||
| `mobile-mcp` | Device/Runtime — tap, swipe, screenshot, app management | ADB + connected device/emulator |
|
||||
|
||||
|
||||
## Dev Workflow
|
||||
|
||||
@@ -396,35 +413,44 @@ Curls every bridge HTTP route via `localhost:8767`. Catches the silent-drop regr
|
||||
|
||||
Server is a Linux box running hermes-agent with hermes-relay editable-installed (`pip install -e`). Sensitive details (IP, user, secrets) in `~/SYSTEM.md` on the server — not in this repo.
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| hermes-agent repo | `~/.hermes/hermes-agent/` |
|
||||
| hermes-relay clone | `~/.hermes/hermes-relay/` |
|
||||
| Plugin symlink | `~/.hermes/plugins/hermes-relay` → `~/.hermes/hermes-relay/plugin` |
|
||||
| Config | `~/.hermes/config.yaml` + `~/.hermes/.env` |
|
||||
| Relay log | `journalctl --user -u hermes-relay -f` |
|
||||
|
||||
| What | Where |
|
||||
| ------------------ | ------------------------------------------------------------------ |
|
||||
| hermes-agent repo | `~/.hermes/hermes-agent/` |
|
||||
| hermes-relay clone | `~/.hermes/hermes-relay/` |
|
||||
| Plugin symlink | `~/.hermes/plugins/hermes-relay` → `~/.hermes/hermes-relay/plugin` |
|
||||
| Config | `~/.hermes/config.yaml` + `~/.hermes/.env` |
|
||||
| Relay log | `journalctl --user -u hermes-relay -f` |
|
||||
|
||||
|
||||
**Update:** `hermes-relay-update` (idempotent, re-fetches install.sh). Or manually: `git pull --ff-only && systemctl --user restart hermes-relay`.
|
||||
|
||||
**Compat hook:** `hermes relay compat status/install/remove` manages only the
|
||||
|
||||
optional `hermes_relay_bootstrap.pth` startup hook. New installs load the
|
||||
|
||||
plugin-owned bootstrap from `plugin/hermes_relay_bootstrap/`; the repo-root
|
||||
|
||||
package is only a legacy import shim. Vanilla Hermes chat, Manage, and dashboard voice
|
||||
|
||||
must not depend on this hook.
|
||||
|
||||
**Key conventions:**
|
||||
- Phone re-pairs after each relay restart (SessionManager is in-memory; wiped on restart)
|
||||
|
||||
- Phone pairing **survives** relay restart — `SessionManager` persists sessions to `~/.hermes/hermes-relay-sessions.json` (`server.py:88-90`, `persistence_path` from `RelayConfig.from_env`); a trusted-device refresh token recovers a lost/revoked/reset session without a new QR scan. (Only the in-memory *live-connection presence* clears on restart; the phone reconnects automatically.)
|
||||
- Use `python -m unittest` not `pytest` — conftest imports `responses` which may not be installed
|
||||
- `_env_bootstrap.py` loads `~/.hermes/.env` on every relay start — no stale API keys
|
||||
|
||||
### Where Python vs. Kotlin changes land
|
||||
|
||||
| Change type | Who restarts? | Command |
|
||||
|---|---|---|
|
||||
| Plugin tool (`android_tool.py` etc.) | `hermes-gateway.service` | `systemctl --user restart hermes-gateway` |
|
||||
| Relay code (`plugin/relay/*.py`) | `hermes-relay.service` | `systemctl --user restart hermes-relay` |
|
||||
| Pair CLI / skill files | — | No restart — fresh process / scanned on invocation |
|
||||
| Android app | Bailey (Studio) | Studio run button |
|
||||
|
||||
| Change type | Who restarts? | Command |
|
||||
| ------------------------------------ | ------------------------ | -------------------------------------------------- |
|
||||
| Plugin tool (`android_tool.py` etc.) | `hermes-gateway.service` | `systemctl --user restart hermes-gateway` |
|
||||
| Relay code (`plugin/relay/*.py`) | `hermes-relay.service` | `systemctl --user restart hermes-relay` |
|
||||
| Pair CLI / skill files | — | No restart — fresh process / scanned on invocation |
|
||||
| Android app | Bailey (Studio) | Studio run button |
|
||||
|
||||
|
||||
### Release Process
|
||||
|
||||
@@ -434,58 +460,63 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
- **Relay plugin version source:** `pyproject.toml`; keep plugin/dashboard metadata synced with `scripts/check-plugin-version-sync.py`; bump with `scripts/bump-plugin-version.sh`
|
||||
- **Desktop CLI version source:** `desktop/package.json`; regenerate `desktop/src/version.ts` with `npm run gen:version`
|
||||
- **Track audit:** `python scripts/check-version-tracks.py` reports Android, plugin, and CLI versions without forcing them to match
|
||||
- **`appVersionCode` is monotonic** — always increment across Android prereleases
|
||||
- `**appVersionCode` is monotonic** — always increment across Android prereleases
|
||||
- **Cut a release:** bump the target surface → commit → merge `dev` to `main` → tag with `android-v*`, `plugin-v*`, or `cli-v*` → push tag → CI builds + GitHub Release
|
||||
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
|
||||
|
||||
## Integration Points
|
||||
|
||||
| Surface | Endpoint | Notes |
|
||||
|---------|----------|-------|
|
||||
| Chat (gateway) | Dashboard `POST /api/auth/ws-ticket` -> WS `/api/ws` | Vanilla Hermes dashboard/tui_gateway path; live thinking/reasoning; requires dashboard auth |
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
|
||||
| Manage | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Vanilla Hermes dashboard surface; do not proxy through Relay |
|
||||
| Vanilla Hermes voice | Dashboard `POST /api/audio/transcribe`, `POST /api/audio/speak` | Vanilla Hermes no-plugin voice; uses dashboard session from Manage |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
| Tailscale Serve (ADR 25) | `hermes-relay-tailscale enable\|disable\|status` CLI | Fronts loopback `:8767` with `tailscale serve --bg --https=<port>`; auto-retires on upstream PR #9295 |
|
||||
| Inbound media (token) | `GET /media/{token}` | Bearer auth; 24h TTL |
|
||||
| Inbound media (path) | `GET /media/by-path?path=<abs>` | Permissive by default; `RELAY_MEDIA_STRICT_SANDBOX=1` to restrict |
|
||||
| Session management | `GET /sessions`, `DELETE /sessions/{prefix}`, `PATCH /sessions/{prefix}` | List/revoke/extend; RelayHttpClient |
|
||||
| Voice transcribe | `POST /voice/transcribe` | multipart/form-data; bearer auth |
|
||||
| Voice synthesize | `POST /voice/synthesize` | JSON → audio/mpeg; max 5000 chars |
|
||||
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
|
||||
| Plugin diagnostics | `hermes relay doctor --json` | Reports upstream route reachability, Relay loopback state, plugin layout, and legacy bootstrap state |
|
||||
| Compat hook lifecycle | `hermes relay compat status/install/remove` | Optional legacy API compatibility hook; not required for the standard path |
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list | revoke <prefix> | extend <prefix> --ttl <s>`; bearer token from stored session; token prefix only (never full token) |
|
||||
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
|
||||
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
|
||||
| Desktop health | HTTP `GET /desktop/health` | Returns full status snapshot — connected/host/platform/version/pid/uptime/advertised_tools/last_error/recent_commands. Loopback-only. Backs the `desktop_health` agent tool, which intentionally does NOT round-trip through the client so it remains callable when other tools are wedged. |
|
||||
|
||||
| Surface | Endpoint | Notes |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Chat (gateway) | Dashboard `POST /api/auth/ws-ticket` -> WS `/api/ws` | Vanilla Hermes dashboard/tui_gateway path; live thinking/reasoning; requires dashboard auth |
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
|
||||
| Manage | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Vanilla Hermes dashboard surface; do not proxy through Relay |
|
||||
| Vanilla Hermes voice | Dashboard `POST /api/audio/transcribe`, `POST /api/audio/speak` | Vanilla Hermes no-plugin voice; uses dashboard session from Manage |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
| Tailscale Serve (ADR 25) | `hermes-relay-tailscale enable|disable|status` CLI | Fronts loopback `:8767` with `tailscale serve --bg --https=<port>`; auto-retires on upstream PR #9295 |
|
||||
| Inbound media (token) | `GET /media/{token}` | Bearer auth; 24h TTL |
|
||||
| Inbound media (path) | `GET /media/by-path?path=<abs>` | Permissive by default; `RELAY_MEDIA_STRICT_SANDBOX=1` to restrict |
|
||||
| Session management | `GET /sessions`, `DELETE /sessions/{prefix}`, `PATCH /sessions/{prefix}` | List/revoke/extend; RelayHttpClient |
|
||||
| Voice transcribe | `POST /voice/transcribe` | multipart/form-data; bearer auth |
|
||||
| Voice synthesize | `POST /voice/synthesize` | JSON → audio/mpeg; max 5000 chars |
|
||||
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
|
||||
| Plugin diagnostics | `hermes relay doctor --json` | Reports upstream route reachability, Relay loopback state, plugin layout, and legacy bootstrap state |
|
||||
| Compat hook lifecycle | `hermes relay compat status/install/remove` | Optional legacy API compatibility hook; not required for the standard path |
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list |
|
||||
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
|
||||
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
|
||||
| Desktop health | HTTP `GET /desktop/health` | Returns full status snapshot — connected/host/platform/version/pid/uptime/advertised_tools/last_error/recent_commands. Loopback-only. Backs the `desktop_health` agent tool, which intentionally does NOT round-trip through the client so it remains callable when other tools are wedged. |
|
||||
|
||||
|
||||
## Upstream References
|
||||
|
||||
| Topic | Upstream File |
|
||||
|-------|--------------|
|
||||
| API endpoints | `gateway/platforms/api_server.py` — all registered HTTP routes |
|
||||
| Platform adapter interface | `gateway/platforms/base.py` — `BasePlatformAdapter` abstract class |
|
||||
| Adding a platform | `gateway/platforms/ADDING_A_PLATFORM.md` — 16-step checklist |
|
||||
| Platform registration | `gateway/run.py` → `_create_adapter()`, `gateway/config.py` → `Platform` enum |
|
||||
| Channel directory | `gateway/channel_directory.py` — how platforms/channels are enumerated |
|
||||
| Send message routing | `tools/send_message_tool.py` → `platform_map` dict |
|
||||
| SSE streaming (runs) | `gateway/platforms/api_server.py` → runs endpoint, `_on_tool_progress` |
|
||||
|
||||
| Topic | Upstream File |
|
||||
| -------------------------- | ----------------------------------------------------------------------------- |
|
||||
| API endpoints | `gateway/platforms/api_server.py` — all registered HTTP routes |
|
||||
| Platform adapter interface | `gateway/platforms/base.py` — `BasePlatformAdapter` abstract class |
|
||||
| Adding a platform | `gateway/platforms/ADDING_A_PLATFORM.md` — 16-step checklist |
|
||||
| Platform registration | `gateway/run.py` → `_create_adapter()`, `gateway/config.py` → `Platform` enum |
|
||||
| Channel directory | `gateway/channel_directory.py` — how platforms/channels are enumerated |
|
||||
| Send message routing | `tools/send_message_tool.py` → `platform_map` dict |
|
||||
| SSE streaming (runs) | `gateway/platforms/api_server.py` → runs endpoint, `_on_tool_progress` |
|
||||
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **[hermes-agent](https://github.com/NousResearch/hermes-agent)** — the agent platform (gateway, WebAPI, plugin system)
|
||||
- **[android-tools-mcp](https://github.com/Codename-11/android-tools-mcp)** — our fork of Android Studio MCP bridge (Compose previews, Gradle, docs)
|
||||
- **[mobile-mcp](https://github.com/mobile-next/mobile-mcp)** — device control MCP server (ADB, tap/swipe, screenshots)
|
||||
- [**hermes-agent**](https://github.com/NousResearch/hermes-agent) — the agent platform (gateway, WebAPI, plugin system)
|
||||
- [**android-tools-mcp**](https://github.com/Codename-11/android-tools-mcp) — our fork of Android Studio MCP bridge (Compose previews, Gradle, docs)
|
||||
- [**mobile-mcp**](https://github.com/mobile-next/mobile-mcp) — device control MCP server (ADB, tap/swipe, screenshots)
|
||||
|
||||
|
||||
@@ -1,5 +1,412 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-01 — Realtime voice: live background-run chip (progress, phases, cancel)
|
||||
|
||||
**Why.** With timer-driven spoken progress off by default (see the robustness batch
|
||||
below), the voice overlay's background-run chip became the primary in-between signal —
|
||||
but it was a static string set at promotion and cleared at completion. The relay's
|
||||
`hermes.run.progress` events already carry `active_tool_name` / `completed_tool_count`
|
||||
/ `elapsed_ms` (added by the ADR 33 plan precisely for a live chip) and the client
|
||||
ignored them; none of the new connection states (resume retrying, result deferred) had
|
||||
any visual; and there was no cancel affordance despite the path existing.
|
||||
|
||||
**What.**
|
||||
- `RelayVoiceClient` parses the progress extras (`active_tool_name`,
|
||||
`completed_tool_count`, `elapsed_ms`) into `RealtimeVoiceEvent`.
|
||||
- `VoiceViewModel`: `BackgroundRunState` gains `statusLine` / `completedToolCount` /
|
||||
`startedAtMs` / `phase` (`RUNNING` / `RECONNECTING` / `DELIVERING`). Tool-start and
|
||||
progress events drive the live line (reusing `realtimeToolStatusLine`); handoff
|
||||
labels flip the chip to RECONNECTING during a mid-run socket drop and back on
|
||||
"Voice reconnected"; `background_completed` shows a DELIVERING chip until the first
|
||||
summary audio (20s watchdog for visual-only delivery); a new turn while a run is
|
||||
active sets a "Still working on the earlier task…" line; `cancelBackgroundRun()`
|
||||
sends the existing `response.cancel` and flips the chip to "Cancelling…" (the
|
||||
relay's `hermes.run.cancelled` clears it).
|
||||
- `VoiceModeOverlay`: the static chip is now `BackgroundRunChip` — pulsing dot
|
||||
(tertiary while reconnecting), phase-aware title, live detail line
|
||||
(step · N steps · m:ss ticker), and a ✕ that cancels; remembers the last non-null
|
||||
state so the exit fade doesn't snap empty (PermissionDeniedChip pattern).
|
||||
`ChatScreen` wires `onBackgroundRunCancel`.
|
||||
|
||||
**Verification.** `assembleSideloadDebug` + `lintSideloadDebug` green. Client-only —
|
||||
no relay changes. On-device check rides the pending realtime e2e list in TODO;
|
||||
ambient visibility outside voice mode deferred to coordinate with the
|
||||
connection-management status-strip work (TODO).
|
||||
|
||||
## 2026-07-01 — Realtime voice: ADR 33 robustness batch (deliver-on-reattach, adaptive promotion, milestone speech, resume retry, prewarm)
|
||||
|
||||
**Why.** An architecture review of the ADR 33 background-run path against current
|
||||
realtime-API practice (the interim-tool-result + later-injection shape now ships
|
||||
natively in gpt-realtime; the design itself is sound) surfaced a set of lifecycle and
|
||||
UX gaps: a result completing while the phone was detached was spoken into the bounded
|
||||
replay ring (a long summary can evict its own head), a second `hermes_run_task`
|
||||
overwrote `session.hermes_task` and cancelled the first run's delivery (silent orphan),
|
||||
timer-driven spoken progress narrated over the floor every N seconds, promotion always
|
||||
waited the full 6s grace even when the first tool was obviously long, a failed client
|
||||
resume parked forever on "waiting for route change", and the first voice turn paid the
|
||||
session POST + websocket + provider connect.
|
||||
|
||||
**What (relay — `broker.py`, `providers/*.py`, `config.py`, `profile_voice.py`, `server.py`).**
|
||||
- **Deliver-on-reattach:** `_deliver_background_result` holds the result as
|
||||
`session.pending_background_result` when the phone is detached; resume injects it
|
||||
after replay (`_deliver_pending_background_result`). `_close_native_session` pushes an
|
||||
undelivered result via a new `proactive_push` hook (wired to `ProactiveChannel.push`,
|
||||
which buffers for an offline phone) — including the close-races-completion case where
|
||||
the run finished but delivery was cancelled.
|
||||
- **Busy answer:** a second `hermes_run_task` while one is in flight returns a speakable
|
||||
`already_running` result instead of orphaning run #1 (`max_background_runs=1`).
|
||||
- **Adaptive promotion:** the Hermes event stream flags known-long tools on start
|
||||
(`RELAY_VOICE_LONG_TOOL_HINTS`, default cron/desktop_/browser/execute_code/terminal/
|
||||
spawn); `_run_brokered_tool` races that signal against the grace window and promotes
|
||||
after a short quick-finish window (1.5s) so fast long-class calls stay Tier A.
|
||||
- **Milestone speech:** timer-driven spoken progress is now config-wired per session and
|
||||
**off by default** (`realtime_voice_progress_spoken_after_ms: 0`); promotion handoff,
|
||||
completion, and failure speech unchanged; `hermes.run.progress` events keep flowing
|
||||
for the visual chip.
|
||||
- **Robustness plumbing:** done-callbacks log unretrieved failures on `hermes_task` /
|
||||
`background_delivery_task`; provider websockets use an explicit `heartbeat=20.0` +
|
||||
connect-bounded `ClientTimeout(total=None)` instead of an ambient total timeout.
|
||||
|
||||
**What (Android).** `RelayVoiceClient`: a failed realtime resume now retries every 10s
|
||||
for up to 5 min (matching the relay's extended detached window) alongside the existing
|
||||
route-change trigger; new `prewarm` mode opens the persistent session with no first turn
|
||||
and the guards disarmed. `VoiceViewModel`: `enterVoiceMode()` prewarms the persistent
|
||||
Realtime Agent session (engine + toggle gated, silent on failure); turn-scoped side
|
||||
effects are skipped for the warm-up and the first utterance rides `submitRealtimeTurn`.
|
||||
|
||||
**Verification.** `python -m unittest` — 65 realtime tests green, including 4 new
|
||||
promotion tests (deferred-injection-on-resume, busy second task, long-tool promotes
|
||||
before an 8s grace, config default 0); the spoken-status routes test now opts in via
|
||||
the per-session knob. One pre-existing failure noted in `test_realtime_voice_routes`
|
||||
(xai oauth pool fixture; fails at HEAD too — recorded in TODO). Android
|
||||
`assembleSideloadDebug` green. Injection framing (function-call output instead of a
|
||||
synthetic user message) deliberately deferred pending an xAI parity check — in TODO.
|
||||
|
||||
## 2026-07-01 — Realtime voice: a benign provider cancel-notice no longer kills the turn
|
||||
|
||||
**Why.** On-device + correlated relay/event-log tracing of a realtime voice run showed
|
||||
the spoken answer was never heard after a promoted/background Hermes run: the summary
|
||||
re-injection called `cancel_response()` when no provider response was active, xAI replied
|
||||
`Cancellation failed: no active response found`, the relay forwarded it as a fatal
|
||||
`voice.error`, and the client closed the whole realtime session (surfacing an error toast
|
||||
+ Retry) right as the reply was about to be spoken. The relay's own background-run
|
||||
keep-alive was working; the session was being deliberately torn down by the client on a
|
||||
non-fatal notice.
|
||||
|
||||
**What.**
|
||||
- `broker.py`: `_deliver_background_result` now passes `cancel_current=not floor_idle` to
|
||||
`_inject_background_summary`, which only calls `cancel_response()` when a response is
|
||||
likely still speaking — avoiding the needless cancel (and its benign error) at source.
|
||||
`_pump_provider_events` classifies benign provider notices (no-active-response /
|
||||
cancellation-failed) via `_is_benign_provider_error()` and logs them as a non-fatal
|
||||
`voice.realtime_agent.provider_notice` instead of a fatal `voice.error`.
|
||||
- `RelayVoiceClient.kt`: a `voice.error` matching a transient provider notice is logged
|
||||
and ignored (session stays alive) instead of `completeFailure` + close — defense in
|
||||
depth so no benign error can nuke a live turn.
|
||||
|
||||
**Verification.** `python -m unittest` — 61 realtime-broker tests green, including a new
|
||||
test asserting a benign provider error is not forwarded as `voice.error` while a genuinely
|
||||
fatal one still is. Client change is scoped to the realtime `voice.error` branch. On-box +
|
||||
on-device e2e verification is owner-driven.
|
||||
|
||||
## 2026-07-01 — Realtime voice: background runs survive a transient drop
|
||||
|
||||
**Why.** In realtime voice mode, asking the agent to run a long/background Hermes task
|
||||
(ADR 33 promotion) could lose the result on a brief network blip. Correlated client +
|
||||
relay logs traced the trigger to a transient Wi-Fi outage (all sockets dropped together,
|
||||
recovered ~20s later) — but the relay tore the realtime session down 30s after any
|
||||
disconnect (`_RESUME_TTL_SECONDS`), well before a minutes-long durable run finishes,
|
||||
cancelling result delivery and orphaning the run. A separate factor: a tool that hung
|
||||
server-side left the run waiting indefinitely.
|
||||
|
||||
**What (`plugin/relay/realtime_agent/broker.py`).**
|
||||
- **Keep a detached session alive while a background run is in flight** — the resume
|
||||
window stretches from 30s to a background cap (default 6 min,
|
||||
`RELAY_VOICE_BACKGROUND_DETACHED_MAX_MS`); a poll loop closes only after the run
|
||||
finishes plus a grace. The existing event/audio replay ring then re-delivers the
|
||||
result when the client resumes, so a transient drop mid-run no longer loses it.
|
||||
- **Bound a run** — a hung run is cancelled at a hard cap (default 5 min,
|
||||
`RELAY_VOICE_BACKGROUND_RUN_MAX_MS`) and surfaced as a `background_completed` error
|
||||
instead of pinning the delivery task forever.
|
||||
- **Cancel the orphaned run on close** so a hung tool can't keep executing against the
|
||||
gateway after the session is gone; **guard the summary send** so a dead provider
|
||||
socket can't turn result delivery into an unhandled background-task crash.
|
||||
|
||||
**Verification.** `python -m unittest` — 60 realtime-broker tests green, including two
|
||||
new promotion tests: a detached session with a live run survives past a shrunken base
|
||||
TTL and records the result for replay; a hung run times out and is cancelled.
|
||||
`py_compile` clean. The complementary app-side realtime-resume retry (the client gave
|
||||
up reconnecting after one attempt while the gateway/relay sockets recovered) is tracked
|
||||
for the connection-management work. On-box verification is owner-driven.
|
||||
|
||||
## 2026-07-01 — Chat markdown typography + bubble grouping polish
|
||||
|
||||
**Why.** Markdown headings in chat rendered at display scale: `MarkdownContent` set
|
||||
only `paragraph`/`code` in `markdownTypography()`, so `h1..h6` fell through to the
|
||||
mikepenz M3 defaults (h1=`displayLarge` — 57sp in this app's scale) and a single `#`
|
||||
became a billboard inside a ~272dp bubble. List items also rendered 2sp larger than
|
||||
paragraphs (the library `text`/list role defaults to bodyLarge 16sp), and every bubble
|
||||
stamped its own timestamp — noisier than any mainstream chat client.
|
||||
|
||||
**What.**
|
||||
- **`MarkdownContent`** — explicit chat-tuned type ramp: h1 20sp → h6 13sp, all
|
||||
derived from bodyLarge/bodyMedium so the live font-picker still applies, largest
|
||||
heading ~1.4× the 14sp body; `paragraph`/`text`/`bullet`/`ordered`/`list` unified to
|
||||
14sp; inline + fenced code 13sp (tracking reset to 0); italic muted blockquote;
|
||||
`textLink` given a primary accent + underline. Side effect: settled body/list sizes
|
||||
now match the streaming renderer, so the stream-end reflow shrinks to headings only.
|
||||
- **`MessageBubble`** — timestamp gated to `isLastInGroup` (was on every bubble),
|
||||
alpha 0.5 → 0.6; long-press action menu fires a haptic on open; streaming dots show
|
||||
only before the first token (previously throbbed under the text for the whole turn).
|
||||
- **`ChatScreen`** — same-author grouping now breaks on a >5min gap (`GROUP_GAP_MS`),
|
||||
so a conversation resumed after a pause gets a fresh name label + its own timestamp.
|
||||
|
||||
**Verification.** CLI `assembleSideloadDebug` compiled clean; `markdownTypography` /
|
||||
`markdownColor` parameter names verified against the installed mikepenz 0.42.0 source
|
||||
(the naive `markdownColor(linkText=…)` would not have compiled — 0.42.0 has no such
|
||||
param; link color rides `textLink`). Deferred items (streaming/final render parity,
|
||||
15sp body, wide-table scroll, tail-on-last-only, etc.) recorded in `TODO.md`. On-device
|
||||
review pending.
|
||||
|
||||
## 2026-07-01 — Connection status: two-connection model (subtitle + bottom strip, no top surface)
|
||||
|
||||
**Why.** The persistence-tiered top strip (previous entry, shipped in the prior
|
||||
`refactor(connections)` commit) still surfaced connection status *above the nav*, and
|
||||
on-device it read as obtrusive: a reconnect flashed a status that covered the profile,
|
||||
and it fired "at random". Iterating with the owner surfaced the real problem — and it
|
||||
wasn't a UI-placement problem.
|
||||
|
||||
**Trace (both sides).** Added permanent INFO logging to the client
|
||||
(`ConnectionViewModel`: every relay `state→state role→role` transition + every
|
||||
`handoff:` record) and read the relay's log server-side (read-only journalctl). They
|
||||
correlate to the millisecond:
|
||||
|
||||
- Client `onFailure SocketException "Software caused connection abort"` ⇄ relay
|
||||
`Client disconnected 172.16.24.13` at the **same instant** → a real network teardown,
|
||||
not a spurious client event. Reconnect then **timed out after 20s**; a later attempt
|
||||
reconnected then dropped again. The relay itself was healthy throughout (loopback
|
||||
`/phone/replies` polling never missed).
|
||||
- Root cause of the flapping: the **Wi-Fi path** between the phone and the LAN relay —
|
||||
raw logcat showed Samsung's `SemWifiIntelligentConnectionManager` cycling the radio.
|
||||
|
||||
**The realization.** There are **two independent connections**, and conflating them was
|
||||
the design error: **chat/agent** (gateway/API, `apiReachable`) — if this is down you
|
||||
genuinely can't talk to the agent; and the **relay socket** (`:8767`, bridge / terminal
|
||||
/ relay-voice) — when only this reconnects, chat still works over the gateway. The
|
||||
flapping was mostly the *relay* socket, so it never deserved a prominent interruption.
|
||||
|
||||
**What.** Landed on a two-connection model with **no top-of-screen surface**:
|
||||
- **Chat/agent → the chat header subtitle** (WhatsApp-style; `ChatScreen`). The model
|
||||
line swaps to `Reconnecting…` (was connected) / `Connecting…` (cold) / `Disconnected`,
|
||||
amber/red, and crossfades back to the model on recovery. This slot was already there;
|
||||
added the reconnecting-vs-connecting wording (`everConnected`).
|
||||
- **Relay socket → the bottom `RelayStatusStrip`** amber `Reconnecting…` cue only
|
||||
(`ReconnectingCue`), gated by a new **`postResumeQuiet`** window so a benign
|
||||
background→foreground re-handshake is fully silent (the health "Connecting" path used
|
||||
to leak the cue there and clear with no resolution).
|
||||
- **Handoff de-dup:** merged the three positive branches ("restored" + "connected" +
|
||||
"route changed") into one, deciding "Connected to Hermes" vs "Connection changed ·
|
||||
LAN → Tailscale" at the actual connect via `lastConnectedRole` — so a flap or a swap
|
||||
can't emit a pair. Route change is ambient-only (the bottom strip's route label).
|
||||
- **Removed the top strip entirely** — the `ConnectionStatusSurface`/`presentationSurface`
|
||||
tiering + `ConnectionStatusBanner` top render fell out of use and were **removed**
|
||||
in a follow-up commit (`ConnectionHandoffBanner`/`ConnectionStatusBanner`/
|
||||
`PulsingSyncIcon` + `ConnectionStatusSurface`/`presentationSurface`/its test).
|
||||
`ConnectionStatusToast` is deliberately parked as a general toast primitive.
|
||||
- Permanent client-side logging (tag `ConnectionVM`, pairs with `ConnectionManager`) so
|
||||
"why did that status appear?" is a one-line `logcat -s ConnectionVM ConnectionManager`.
|
||||
|
||||
**Verification.** `:app:assembleSideloadDebug` + `testSideloadDebugUnitTest` green;
|
||||
iterated on-device across the build cycle. Server access was read-only (journalctl) —
|
||||
no restart/deploy. Flap-specific cases hard to re-verify once the network stabilized
|
||||
(tracked in `TODO.md`).
|
||||
|
||||
## 2026-06-30 — Connection status: tier the surface by persistence, not severity
|
||||
|
||||
**Why.** After the connections restructure, every reconnect/handoff/health blip
|
||||
rendered as the take-space `ConnectionStatusBanner` — it shoved the whole app down
|
||||
~50px, then snapped it back. Reconnects are the *most frequent* connection event, so
|
||||
the most frequent event was also the most disruptive. Worse, the intended "error →
|
||||
floating overlay" branch was **dead**: `buildGlobalConnectionStatus` only ever emits
|
||||
`Warning`/`Info` and handoffs only emit `Success`/`Info`, so `ConnectionStatusTone.Error`
|
||||
is never produced and *everything* routed through the take-space banner. The bottom
|
||||
`RelayStatusStrip` already carries steady-state (transport tier + route), so the top
|
||||
surface was partly redundant too.
|
||||
|
||||
**What.** Re-tiered the connection-status surface by **persistence, not severity**
|
||||
(user decision: "lean on the bottom strip, float on failure"):
|
||||
- **`viewmodel/RelayUiState.kt`: `ConnectionStatusSurface { None, Float, Banner }` +
|
||||
`ConnectionStatusSnapshot.presentationSurface()`.** `active` (in-flight
|
||||
reconnect/checking) → `None`; `success` (reconnected / route switched) → `Float`;
|
||||
sustained `Warning`/`Error` (no connection / no internet / API/relay unreachable) →
|
||||
`Banner`. This maps 1:1 onto the existing handoff producers: "Reconnecting" /
|
||||
"Connection interrupted" are `active` → strip; "Connection restored" / "Connected" /
|
||||
"route changed" are `success` → float; the health-derived Warnings are sustained →
|
||||
banner.
|
||||
- **`ui/components/RelayStatusStrip.kt`: `reconnecting` param + `ReconnectingCue`.** A
|
||||
routine in-progress reconnect now shows *only* an amber, softly-pulsing
|
||||
"Reconnecting…" cue in the always-visible bottom strip (replacing the route label,
|
||||
which is in flux mid-reconnect) — **zero layout shift** for the common case. Pulse
|
||||
is frame-throttled via `rememberAmbientPhase`.
|
||||
- **`ui/RelayApp.kt`:** the two existing render sites are re-routed off
|
||||
`presentationSurface()` — take-space `ConnectionStatusBanner` fires only for
|
||||
`Banner`, floating `ConnectionStatusToast` only for `Float`, and `None` lights the
|
||||
strip via `connectionReconnecting` (computed off the raw status, not the
|
||||
dismiss-gated toast, since the cue mirrors live state and isn't dismissible). No
|
||||
component rewrites — both surfaces already existed; only the routing changed.
|
||||
|
||||
Net: routine reconnect = a quiet strip cue, no shift; recovery = a brief self-dismissing
|
||||
float; a stuck/actionable problem = the honest take-space banner. The post-resume
|
||||
`suppressedTransientReconnect` silencing still applies (no handoff recorded → nothing
|
||||
anywhere for a benign resume).
|
||||
|
||||
**Verification.** `:app:lintSideloadDebug` — _pending_ (running). On-device pass owner-driven.
|
||||
|
||||
## 2026-06-30 — Update discovery: app-facing relay route + About version readout
|
||||
|
||||
**Why.** The update-discovery work (CLI + dashboard) shipped the same day, but the phone was the missing surface — the app could read the relay's version from `/health` yet had no signal that a newer relay *release* existed. The dashboard's check is dashboard-auth-gated, so the app needs its own route on the relay port.
|
||||
|
||||
**What.**
|
||||
- **`plugin/relay/server.py`: `GET /relay/update-check`.** App-facing twin of the dashboard route (bearer for the paired phone, loopback for diagnostics — same gate as `/phone/threads`). Reuses `plugin/update_check.check()` in an executor so the blocking GitHub fetch never stalls the event loop; result cached an hour; degrades to `update_available=false` + `error` offline (never a 5xx).
|
||||
- **App (`RelayHttpClient.fetchUpdateCheck()` → `ConnectionViewModel.relayUpdateInfo` → `AboutScreen`).** A "Relay" row beside the existing app-Version row shows the connected relay's version and, when it trails the latest release, a soft nudge + a Copy-the-command action (`hermes plugins update hermes-relay` vs `hermes-relay-update`). Refreshed on each `auth.ok`; fail-soft (older relay 404 → no row). The app never talks to GitHub — the relay is the single source of truth.
|
||||
|
||||
**Verification.** `:app:assembleSideloadDebug` — **BUILD SUCCESSFUL**; installed + launched on the test device. Relay route verified live on the host (loopback `GET /relay/update-check` → `{"current":"1.2.1","latest":"1.2.1","update_available":false}`). The About "Relay" row reads "On v1.2.1 — up to date"; the nudge stays hidden until a newer `plugin-v*` release exists.
|
||||
|
||||
## 2026-06-30 — Per-profile enablement helper + update discovery
|
||||
|
||||
**Why.** Two gaps surfaced while productizing the phone Threads work: (1) Hermes installs a plugin's *code* once but enables it *per profile*, so a multi-agent host hand-edits N `config.yaml` files to expose the relay's tools everywhere — and docs didn't explain the install-once / enable-per-profile / **pair-once** split. (2) Updating works (`hermes plugins update` / `hermes-relay-update`) but nothing *tells* an operator a newer plugin release exists, and the app/plugin/CLI version tracks aren't surfaced together.
|
||||
|
||||
**What.**
|
||||
- **`plugin/profiles.py` + `hermes relay profiles list|enable [--all|NAME]`.** Enumerates the default config + every `profiles/<name>/config.yaml`, reports `hermes-relay` enablement, and bulk-adds it to `plugins.enabled` (removing it from `disabled`), backing up each rewritten file to `.bak` and skipping already-enabled configs (comments/order preserved in the common case). Pairing is untouched — one relay, pair once.
|
||||
- **`plugin/update_check.py` + `hermes relay update-check` + dashboard "Plugin version" card.** Compares the installed `plugin.relay.__version__` against the latest `plugin-v*` GitHub release, detects the right update command (full-relay shim present → `hermes-relay-update`, else `hermes plugins update hermes-relay`), surfaced in the Management tab via loopback `GET /api/plugins/hermes-relay/update-check` (GitHub fetch cached 1h; degrades to "couldn't check" offline — never a 5xx). Reuses the existing update mechanisms; no new updater.
|
||||
- **Docs.** New `configuration.md` subsections: "Profiles & the relay" (the two axes + the `profiles` commands) and "Keeping the relay plugin updated" (update-check + the two update commands).
|
||||
|
||||
**Verification.** `python -m unittest plugin.tests.test_profiles plugin.tests.test_update_check plugin.dashboard.test_plugin_api` — 39 pass. Dashboard bundle rebuilt (esbuild). App-side version display + soft "relay outdated" banner deferred (needs an app build + a relay-side update-check route).
|
||||
|
||||
## 2026-06-30 — Connections restructure + animated, dismissible status banner
|
||||
|
||||
**Why.** Two pain points in the connection manager: (1) the reconnect/handoff status
|
||||
read as static — the non-error path used `ConnectionStatusBanner`, which capped at
|
||||
two flat text lines, while the richer animated per-step stepper (spinner → green ✓ →
|
||||
red ✕) existed only in `ConnectionStatusToast`, shown for Error tone only; and (2)
|
||||
the Connections settings screen was overloaded — the active connection's entire deep
|
||||
body (Features / Routes / Advanced / Security + a 5-button action row + stacked route
|
||||
nudges) was crammed into one card inside the list, so the list wasn't scannable.
|
||||
|
||||
**Animated, take-space, dismissible banner (`ui/components/ConnectionHandoffBanner.kt`,
|
||||
`ui/RelayApp.kt`).** `ConnectionStatusBanner` now renders the same `ConnectionStepRow`/
|
||||
`StepGlyph` animated stepper the toast uses (capped at the last 3 entries, up from 2
|
||||
flat lines); per-step state already comes stamped from `buildGlobalConnectionProbeEntries`,
|
||||
so "checking → green dot → red ✕" tracks real health. The non-error banner moved out of
|
||||
the floating overlay into the persistent take-space stack above the Scaffold (expand/
|
||||
shrinkVertically + the surface's `animateContentSize` keep the push-down smooth, not a
|
||||
hard snap; the error toast still floats). The banner gained an explicit close (×) control
|
||||
and a swipe-up gesture, both wired to the existing `dismissedStatusKey` so a dismissed
|
||||
status stays hidden until its content identity changes.
|
||||
|
||||
**No misleading "Connection changed" on resume (`viewmodel/ConnectionViewModel.kt`).**
|
||||
The `Reconnecting` handoff was renamed from "Connection changed" (which implied a
|
||||
different connection) to a neutral "Reconnecting"; change-implying copy is reserved for
|
||||
the genuine route-switch branch. A foreground-resume timestamp (from `AppForegroundTracker`)
|
||||
now suppresses the transient reconnect banner within `RELAY_RECONNECT_GRACE_MS` — a quick
|
||||
same-connection re-handshake after returning to the app shows nothing (and its
|
||||
"Connection restored" pair stays silent too); only a reconnect still down past the grace
|
||||
window surfaces "Reconnecting".
|
||||
|
||||
**Connections list + tabbed detail (`ui/screens/ConnectionsSettingsScreen.kt`,
|
||||
`ui/screens/ConnectionDetailScreen.kt`, `ui/components/ActiveConnectionSections.kt`,
|
||||
`ui/RelayApp.kt`).** `ConnectionsSettingsScreen` is now a scannable list — each card is
|
||||
label + an `Active` badge (active connection) + a one-line status + the capability
|
||||
timeline summary (the dot/label/value rows users liked), and tapping drills into a new
|
||||
`ConnectionDetailScreen`. The detail is a 4-tab screen (Overview / Routes / Advanced /
|
||||
Security) with a `⋮` overflow menu for rename / re-pair / revoke / remove. Overview leads
|
||||
with the steps/timeline (`ActiveCardFeaturesSection`); Routes hosts the relocated ADR-24
|
||||
route block (extracted verbatim into `ActiveCardRoutesSection`, reusing `EndpointsCard` +
|
||||
`RouteEditorDialog`); Advanced/Security reuse the existing section composables. A new
|
||||
`Screen.ConnectionDetail` route (`settings/connections/{connectionId}`) is wired in the
|
||||
NavHost. Non-active connections show an Overview-only "Switch to this connection" preview.
|
||||
Relay sessions are surfaced in the Security tab (`ActiveCardSecurityPosture` already shows
|
||||
the active-session count). The `Active` badge is preserved on both the list card and the
|
||||
detail's top bar.
|
||||
|
||||
**Verification.** `:app:compileSideloadDebugKotlin` BUILD SUCCESSFUL; `:app:lintSideloadDebug`
|
||||
run locally. Store screenshot mock (`StoreScreenshotTest.ConnectionsScene`) updated to the
|
||||
new list design (Active badge kept). On-device verification (banner animation/dismissal,
|
||||
resume copy, the tabbed flow) is owner-driven from Android Studio.
|
||||
|
||||
## 2026-06-30 — Phone home channel: auto-config + dashboard name (silence upstream /sethome nudge)
|
||||
|
||||
**Why.** Sending into a phone Thread surfaced an upstream onboarding notice on every new Thread's first message — "📬 No home channel is set for Phone … /sethome". Upstream's nudge (`gateway/run.py`) checks the `PHONE_HOME_CHANNEL` *env var* directly, not the adapter's seeded config, and a single paired phone has exactly one logical home — so the prompt is friction with no decision behind it (unlike Telegram/Discord, where `/sethome` picks among many chats).
|
||||
|
||||
**What.**
|
||||
- **Auto-default (`plugin/phone_platform.py`).** `register_phone_platform` now pre-fills `PHONE_HOME_CHANNEL=phone` (the only sensible value — what `/sethome` would persist) when the platform is enabled and the env var is unset/blank, respecting an explicit operator override. Presence of that env var is exactly what the upstream notice checks, so it no longer fires.
|
||||
- **Dashboard name field (`plugin/dashboard/*`).** A "Home channel" card on the Relay → Management tab shows the effective home-channel name + (read-only) channel id and saves a display name via the host `PUT /api/env` (`PHONE_HOME_CHANNEL_NAME`), mirroring the existing Agent-context toggle pattern. Backed by a new loopback `GET /api/plugins/hermes-relay/phone/config` that reads the adapter's env resolution. Rebuilt `dist/index.js`.
|
||||
- **Docs.** A brief "Phone Threads (proactive messaging) — Beta" subsection in `user-docs/reference/configuration.md`: the opt-in (`PHONE_ENABLED`), the auto-home-channel (no `/sethome`), and how to rename it.
|
||||
|
||||
**Verification.** `python -m unittest plugin.tests.test_phone_platform plugin.dashboard.test_plugin_api` — 54 pass (new: 4 home-channel-default cases + 2 `/phone/config` cases). Dashboard bundle rebuilt via `npm run build` (esbuild OK; "Home channel" + "phone/config" present in `dist/index.js`). The auto-default applies on the next gateway restart; the name field also applies on gateway restart (env read at adapter construction). Host confirmation that the notice is gone — pending a gateway restart.
|
||||
|
||||
## 2026-06-30 — Settings/nav refresh + remote reconnect fix
|
||||
|
||||
**Why.** A UX/robustness pass across five threads: (1) the background keep-alive read as "chat"-only in its notification + settings copy, underselling what it actually holds open; (2) it lived buried in Chat settings though it's a connection-level control flipped often; (3) the Chat/Manage/Bridge mode strip spent a chrome band on every screen, including the chat home; (4) the non-error connection-status banner popped in/out and hard-reflowed the UI; (5) a remote (Tailscale) connection looped — repeatedly reconnecting before it settled.
|
||||
|
||||
**Reconnect loop — root cause + fix (`network/relay/ConnectionManager.kt`, `network/upstream/GatewayChatClient.kt`, `ui/RelayApp.kt`).** One shared `EndpointResolver` publishes `_activeEndpoint` for the relay socket AND every HTTP/chat surface; `effectiveApiServerUrl`/`effectiveDashboardUrl` fall back to the saved (priority-0, home-LAN) host when it is null. On a transient cold-route probe miss the AUTOMATIC paths nulled `_activeEndpoint`, flipping chat/dashboard onto the dead home address and rebuilding the gateway chat client — a self-sustaining flap until Tailscale warmed. The pre-existing hysteresis guard keyed on the relay socket being `Connected`, which the default no-relay chat path never reaches, so it protected nobody there. Fix: extend the `sustainedLossDeclared` hysteresis to the two AUTOMATIC null-sinks (`scheduleNetworkReResolve`, `scheduleReconnect`) only — the MANUAL paths (`refreshActiveEndpoint`, `probeAndReconnectNow`) still publish null on a genuinely dead route, which `ConnectionManagerRouteTest` asserts. Plus: an explicit 20s `connectTimeout` on the relay + gateway OkHttp clients (DERP cold starts exceed the 10s default); a 2-consecutive-failure threshold before `markActiveEndpointUnreachable` poisons the shared cache; `clearCache` moved into the debounced re-resolve so VPN-tun-interface churn coalesces into one probe; and the `sustainedLossDeclared` latch reset on every resolve-success edge. A 750ms debounce on the gateway-client re-acquisition (`RelayApp.kt`) is belt-and-suspenders on top, leaving first-connect latency unaffected (only a genuine route change waits).
|
||||
|
||||
**Keep-alive reframe + Quick Controls (`network/upstream/GatewayKeepAliveService.kt`, `data/GatewayKeepAlivePrefs.kt`, `AndroidManifest.xml`, `ui/screens/SettingsScreen.kt`, `ui/screens/ChatSettingsScreen.kt`).** Notification, channel, settings, and manifest `specialUse` subtype copy reframed off "chat" to an honest "Persistent connection": it holds the app's connection to Hermes open in the background (for relay-paired setups this also keeps device control + notification mirroring reachable) — it does NOT warm Manage (stateless HTTP) or voice (per-turn sockets), and nothing survives swipe-away. Stop action "Disconnect" → "Turn off". The toggle moved out of Chat settings into a new **Quick Controls** card at the top of the top-level Settings landing (beside Active Agent / Profile lock), since it is connection-level and frequently toggled; the card also hosts a **Turn-complete alerts** toggle. Both wire to existing `ConnectionViewModel` flows (`gatewayKeepAlive` / `notifyTurnComplete`) — no new pref.
|
||||
|
||||
**Mode-strip removal (`ui/screens/ChatScreen.kt`, `DashboardManagementScreen.kt`, `BridgeScreen.kt`, `BridgeCoreScreen.kt`).** The triplicated `RelayModeStrip` is removed from all four screens. Chat is the full-height home; Manage and Bridge are reached from Settings (the "Hermes management" / "Bridge" entries already existed), each now with a TopAppBar Up→Chat back arrow (Material Up semantics; system Back still pops to Settings). `RelayModeStrip`/`RelayPrimaryMode` stay defined in `ui/components/RelayCockpitChrome.kt` because the store-screenshot harness renders them. This also corrects a stale CLAUDE.md note ("Main scaffold — bottom nav"): there was never a bottom `NavigationBar`; the `bottomBar` slot is a status pill, and primary nav had been this per-screen top strip.
|
||||
|
||||
**Toast overlay (`ui/RelayApp.kt`).** The non-error connection-status banner moved from the take-space `Column` (fade-only `AnimatedVisibility` → instant height pop + Scaffold reflow) into the existing floating-overlay `Column` alongside the error toast, reusing the house `slideInVertically+fadeIn` / `slideOutVertically+fadeOut` spec. An overlay occupies zero layout space, so content no longer reflows on appear/disappear, and it drops out of the Scaffold status-bar inset accounting (matching a comment that already described it as a floating overlay). Refines the take-space banner introduced in 1.2.6.
|
||||
|
||||
**Verification.** `:app:compileSideloadDebugKotlin` / `:app:assembleSideloadDebug` — BUILD SUCCESSFUL at each step (no new warnings in changed files); installed sideload debug to the test device. The 6 existing `ConnectionManagerRouteTest` cases hold by inspection (the manual-path null-publish contracts are untouched). An independent adversarial review of the reconnect diff confirmed the hysteresis is correct, dead-route recovery is preserved (onLost still poisons after the grace window), and no existing test breaks; it surfaced one latch-reset gap, which was fixed. On-device proof of the loop fix is a logcat signature ("network onAvailable" + "re-resolve miss … keeping route" with no gateway client rebuild during a tun blip) — pending. Commits landed scoped (pathspec) on `dev` alongside a concurrent session's Threads/phone work.
|
||||
|
||||
## 2026-06-29 — Phone platform: unified-session "Threads" surface (slices 1–5 + 7)
|
||||
|
||||
**Why.** The proactive agent→phone conversation surfaced only as a flat notification inbox. The decision (ADR 12, refined) is to make it a **Thread**: a `source=phone` gateway session rendered as a first-class conversation *inside the one Chat surface* (sessions tagged by source), not a separate tab — the agent lane is just a chat the agent can start. Distinguishers are session properties (agent-can-initiate, relay `proactive` transport/gating, standing DM), not a separate UI.
|
||||
|
||||
**What.**
|
||||
- **Drawer source tags (slice 1).** `ChatSession` gained `source` (carried from upstream `sessions.source` at the one wire→UI mapping in `ChatHandler`); the drawer renders a "Thread" chip + a custom `ThreadSpoolGlyph` (a clean Canvas thread-spool, not a phone icon) on `source=phone` rows, plus a Threads filter + a header affordance gated on `threadsCapabilityActive` (relay-paired + "Let Hermes message me") or the presence of a Thread.
|
||||
- **Open + reply (slices 2, 4).** Selecting a Thread loads its server history via the existing `loadSessionHistory` path (free). A composer send while a `source=phone` session is active branches in `sendMessageInternal` to route over `proactive.reply` (continues the gateway phone session) instead of the normal chat transport; the user bubble carries a `MessageDeliveryStatus` (SENDING → DELIVERED on the relay ack / FAILED), rendered as a quiet caption.
|
||||
- **Relay ack + cancel (slice 7).** `ProactiveChannel` emits a new server→app `proactive.reply.ack {client_msg_id, status, ts}` when it buffers a reply, and accepts an app→server `proactive.cancel {message_id}` (WS twin of `DELETE /phone/outbound`, reusing `cancel_outbound`). The app stamps its bubble id as the reply's `message_id` so the ack settles the exact bubble. `proactive.reply.ack` is also handled client-side (`ProactiveMessageHandler.onReplyAck` → `ChatViewModel.onProactiveReplyAck`).
|
||||
- **Capability surfacing (slice 5).** A "Threads" capability row joins Live thinking / Media / Terminal / Voice in `SessionPathCard` (`ConnectionInfoSheet`).
|
||||
- **Create a Thread (slice 8, user-initiated — Discord-style).** A "+ New Thread" affordance in the drawer's Threads view names a thread; `ChatViewModel.startNewThread` mints a fresh `chat_id`, and the first composer message opens it over `proactive.reply` (the gateway creates the `source=phone` session keyed by that id). `switchToCreatedThread` polls the session list, switches to the real session, and applies the name. Existing-thread replies now route by the `chat_id` parsed from the session id (`…:dm:<chat_id>`), so multiple threads each reach their own conversation (home thread → `phone`; opaque id → home fallback).
|
||||
|
||||
**Verification.** `python -m unittest plugin.tests.test_proactive_channel plugin.tests.test_phone_platform` — 55 pass (new: reply-ack on `handle`, no-ack on empty, `proactive.cancel` drops queued one/all). `:app:assembleSideloadDebug` — **BUILD SUCCESSFUL** (clean compile, no new warnings in changed files); installed sideload debug to the test device. An independent adversarial review of the diff found no compile/logic defects (imports resolve, defaulted params break no call sites, both `when`s exhaustive, ack id passes through unchanged). On-device behavior pending. **"Delivered" requires the relay running the updated `proactive.py`** (the reply itself works on the old relay; only the ack is new).
|
||||
|
||||
**On-device verifies for the Thread create-flow.** Three things the build can't confirm: (1) a fresh-`chat_id` inbound with no `reply_to` creates a new `source=phone` gateway session (architecturally yes — `handle_message` → `get_or_create`); (2) the phone session's id carries the `…:dm:<chat_id>` form the client parses for reply routing + the post-create switch (defensive: an opaque id falls back to the home channel, so the single home thread stays safe); (3) `renameSession` titles a phone-platform session. If (2) differs on-device, the fix is a one-line parse change once a real phone session id is observed.
|
||||
|
||||
**Follow-on — replies render in-thread + inbox retired (2026-06-29).** On-device, an agent reply to a Thread only surfaced as a notification + the legacy inbox, never in the open conversation. `ProactiveMessageHandler.dispatch` now routes an inbound `phone.message` whose `chat_id` matches the open Thread (or a pending "+ New Thread" draft) inline as an ASSISTANT bubble (`ChatHandler.addAgentThreadMessage` — `clientOnly`, idempotent on `message_id`) and **suppresses the notification + inbox entry** when shown there; non-matching / no-thread-open messages still notify + inbox. With the conversation now living in the Thread, the redundant `HermesInboxScreen` is **retired**: deleted, its route + nav entry removed, the notification tap + Settings "View messages" re-pointed to Chat, and the surface renamed **"Hermes messages" → "Threads"** (`ProactiveSettingsScreen` / `SettingsScreen` / notification channel). `ProactiveInboxStore` is now a viewer-less write-only log (fully retireable — TODO).
|
||||
|
||||
**Deferred (see TODO).** Per-session unread badge; outbound reply outbox/retry (needs multiplexer connection-state); **exact-Thread deep-link** from the notification (opens Chat today, not the specific Thread — needs a select-session-on-entry signal); remove the now-orphaned `ProactiveInboxStore`; **agent-initiated** named Threads (the upstream `send_message` thread/chat_id param — user-initiated create now ships).
|
||||
|
||||
## 2026-06-29 — Phone platform (Phase 2c: two-way reply — device round-trip + fixes)
|
||||
|
||||
**Why.** The Phase 2c inbound reply leg shipped off-device (unit tests + lint), pending a live round-trip. The first on-device test surfaced that a reply never produced an agent answer: the agent→phone push worked and the reply reached the relay (buffered), but nothing drained it — the gateway's `PhoneAdapter` never connected, so its inbound `/phone/replies` poll loop never ran. Two distinct faults, one masking the other.
|
||||
|
||||
**Fix 1 — `PhoneAdapter.connect()` signature (`plugin/phone_platform.py`).** The gateway's platform supervisor calls `adapter.connect(is_reconnect=…)` (the `BasePlatformAdapter.connect` contract). `PhoneAdapter.connect(self)` didn't accept the keyword, so every connect raised `TypeError` and the adapter — and its reply loop — never came up. Added `*, is_reconnect: bool = False` to match the base + the ntfy template it was modeled on (behavior otherwise unchanged). Added a `ConnectContractTests` regression guard asserting the signature via `inspect`, since the live adapter binds to the gateway base class that's absent in CI — the exact blind spot that let this ship.
|
||||
|
||||
**Fix 2 — operational: a stale duplicate plugin masked every deploy.** Even after Fix 1 the adapter still didn't connect. Root cause: a prior plugin-clone rebuild had left a full backup copy of the old plugin *inside the user-plugins directory* (`hermes-relay.copy-backup-…`). The plugin loader dedups discovered plugins by manifest name; both the live symlink and the backup carry `name: hermes-relay`, and the backup sorted last, so it *won* the dedup — the gateway loaded old code and silently ignored the deployed clone. Removing the backup from the plugins directory let the real plugin load, register the `phone` platform, connect the adapter, and drain the buffered reply. Follow-up filed (TODO): the installer should purge old backup copies out of the plugins directory so this can't recur.
|
||||
|
||||
**Also.** The previously-silent `except Exception` around phone-platform registration now logs at `warning` (was `debug`) — a real registration failure (e.g. an upstream `PlatformEntry` signature drift) should be visible, not lost.
|
||||
|
||||
**Verification.** `python -m unittest plugin.tests.test_phone_platform plugin.tests.test_proactive_channel` — 49 pass (incl. the new connect-contract guard). Two-way reply round-trip confirmed end-to-end on-device: agent → phone notification → inline reply → drained through `/phone/replies` → `handle_message` → agent answer back in the *same* thread. One observed gap: when the phone has dropped its (connection-scoped) proactive subscription, the agent's answer `503`s and is lost — tracked as **outbound buffering** in TODO.
|
||||
|
||||
**Follow-on — outbound buffering (`plugin/relay/channels/proactive.py`, `server.py`).** Closes that gap. When no phone is subscribed, `ProactiveChannel.push()` now *queues* the message in a bounded deque (drop-oldest, 24 h staleness TTL) and returns `{delivered: false, queued: true, …}` instead of raising 503; `_flush_outbound` delivers the backlog FIFO on the next `proactive.subscribe` (stale entries pruned, socket-died-mid-flush re-buffers the remainder). Queued messages are inspectable + cancelable before they flush via `peek_outbound`/`cancel_outbound` and new loopback routes `GET /phone/outbound` (count + summaries) and `DELETE /phone/outbound[?message_id=…]` (cancel all / one). The adapter's `send()` is unchanged — a 200 queued reads as success. 54 proactive + phone tests pass (new: flush-on-subscribe FIFO, bounded drop-oldest, stale-drop, cancel one/all, close clears). UI surfacing of the queued state (host-side `relay` view + per-message status in the threaded surface) is specced in TODO.
|
||||
|
||||
## 2026-06-28 — Phone platform (Phase 2c: two-way reply — the inbound leg)
|
||||
|
||||
**Why.** Proactive messaging was push-only: the agent could message the phone, but the user couldn't answer. The phone was registered as a Hermes *platform* but only the outbound half (`send()`) was wired; its inbound path was a no-op, so a reply never reached the agent. Phase 2c wires the inbound leg so a reply becomes an inbound platform message the agent processes on the `phone` channel and answers over the existing `send()` — closing the loop into a conversation.
|
||||
|
||||
**What (three legs across relay, plugin, app).**
|
||||
- **Relay — receive + buffer (`plugin/relay/channels/proactive.py`, `server.py`).** `ProactiveChannel.handle` learns a new inbound `proactive.reply` envelope (`{text, chat_id, reply_to, message_id, ts}`), added to the frozen wire-envelope docstring. Replies are buffered in a bounded `deque` (drop-oldest) + `asyncio.Event`; `take_replies(timeout)` is the long-poll drain. New loopback-only `GET /phone/replies` route mirrors the outbound `POST /phone/message` hop — the relay and the gateway adapter are different processes, so a reply is parked and polled rather than handed over in-process.
|
||||
- **Plugin — inbound loop (`plugin/phone_platform.py`).** `PhoneAdapter.connect()` now also spawns a `self._running`-guarded long-poll loop (mirror of the bundled adapters' receive loops, e.g. ntfy `_run_stream`) against `/phone/replies`, with backoff. Each reply → `build_source(chat_id=…, role_authorized=True)` + `MessageEvent(reply_to_message_id=…)` → `await self.handle_message()`. `disconnect()` cancels the loop. The reply continues the originating conversation: `chat_id` keys the session, `reply_to` anchors it.
|
||||
- **App — capture the reply (`notifications/`, `ui/screens/HermesInboxScreen.kt`, `viewmodel/ConnectionViewModel.kt`).** `ProactiveMessageNotifier` gains an inline Reply action via `RemoteInput` + a **mutable** broadcast `PendingIntent` (FLAG_MUTABLE guarded for API < 31) carrying `chat_id`/`message_id`. New `ProactiveReplyReceiver` reads the typed text and sends a `proactive.reply` over the relay WS via a static `ChannelMultiplexer` slot (mirror of `HermesNotificationCompanion`), then re-posts a confirmation. The Hermes inbox gets a per-card reply box. `ProactiveInboxEntry` gains `chatId` (back-compat default) so an inbox reply threads correctly; `ConnectionViewModel.sendProactiveReply()` is the in-app send path.
|
||||
|
||||
**Key design decision — authorization.** The gateway's `_is_user_authorized` (`gateway/authz_mixin.py`) is **default-deny** for plugin platforms, so a reply would silently vanish unless authorized. The adapter builds the inbound source `role_authorized=True`: the relay's pairing/session-token layer already authenticated the device before the reply reached `/phone/replies`, so the adapter legitimately vouches for it. This makes replies work with zero extra config — no need to weaken the allowlist with `PHONE_ALLOW_ALL_USERS`.
|
||||
|
||||
**Scope guardrails.** No chat *visuals* touched (inbox renders its own cards; `MessageBubble`/theme untouched). Upstream-or-plugin only — no fork: the reply rides our plugin's relay routes + the upstream `handle_message`/`build_source` platform API. Offline replies drop best-effort (same semantics as the notification companion); a persistent send-when-reconnected queue is left to Phase 3.
|
||||
|
||||
**Verification.** `python -m unittest plugin.tests.test_proactive_channel plugin.tests.test_phone_platform` — 48 tests pass (new: reply buffer/drain, empty-text drop, late-reply wake, timeout→[], bounded drop-oldest, close clears; reply-URL + `_normalize_reply` helpers). `./gradlew :app:lintSideloadDebug` — BUILD SUCCESSFUL (no new findings in the changed files; the mutable-PendingIntent is guarded so no `UnspecifiedImmutableFlag`). **Maintainer (off-device):** on-device pairing + reply round-trip from both the notification and the inbox; a live gateway discovering the plugin and the adapter's poll loop reaching `/phone/replies`; confirming a reply continues the correct session. See TODO.md.
|
||||
|
||||
## 2026-06-28 — Phone platform: advertise the capability to the agent
|
||||
|
||||
**Why.** The `phone` platform worked and was discoverable via `send_message action=list` (the channel directory includes plugin-registered platforms), but it was not *proactively* advertised: `platform_hint` only injects for the **inbound** platform of a turn (`system_prompt.py`), which never fires for a push-only platform, and the `send_message` schema's `target` examples (upstream core, no-fork) don't list `phone`. So the agent wouldn't reach for it on its own.
|
||||
@@ -74,6 +481,28 @@
|
||||
- **Off by default.** Nothing is advertised or pushed unless `PHONE_ENABLED` is truthy.
|
||||
|
||||
**Verification.** `python -m py_compile` clean on the new + edited files. `python -m unittest plugin.tests.test_phone_platform` — 22 tests pass (env gating, relay-URL precedence, payload construction/truncation/surfacing-lift, `_env_enablement`, and the standalone sender over a fake httpx client: success / 503-no-phone / disabled / unreachable). Relay route, end-to-end routing, and the live-gateway plugin-discovery check are Phase 1b+ and a maintainer on-box step.
|
||||
## 2026-06-28 — Standard (no-relay) voice parity with hermes-desktop
|
||||
|
||||
**Why.** The Standard (vanilla-Hermes, no-plugin) voice path lagged the official hermes-desktop voice experience on three fronts. (1) Server-side voice config (tts/stt provider, voice, model) was unreachable from the app: the Manage "Config" tab fetches `/api/config/schema` and renders it read-only via `summarizeKeyValueOrList`, which only ever surfaced the schema's two top-level keys (`fields`, `category_order`) — never the `tts.*`/`stt.*` values — and `DashboardApiClient` had no `PUT /api/config` path. (2) Audio capture/playback had no echo-cancellation / noise-suppression and a cold-start silent-first-turn window. (3) Listen timing and two dead controls drifted from desktop. (Endpoint + streaming parity were already met — `StandardHermesVoiceClient` matches the dashboard `/api/audio/*` base64 contract, and `VoiceViewModel` already sentence-chunks SSE the way desktop's `use-voice-conversation.ts` does.)
|
||||
|
||||
**What.**
|
||||
- **Server voice config editor (Standard path).** New `DashboardApiClient` methods — `getConfig()`, `getConfigSchema()`, `updateConfig(config, profile)` (`PUT /api/config`), and `getElevenLabsVoices()` (`GET /api/audio/elevenlabs/voices`) — plus pure, unit-tested helpers in `DashboardConfigEditing.kt` (schema parse, `tts.*`/`stt.*` dot-path filter, immutable dot-path read/merge). A new **Server voice config** card in Voice settings loads the schema + values, renders provider-scoped `tts.*`/`stt.*` controls, and saves via GET → merge → PUT-whole (upstream `save_config` overwrites the document, so a partial PUT would drop keys). Includes the **ElevenLabs voice picker** (`tts.elevenlabs.voice_id` → dropdown sourced from the server's key; graceful when `available:false`). Standard voice is host-global, so writes target the launch profile config (`profile = null`).
|
||||
- **Audio quality.** `VoicePlayer.defaultExoPlayer()` now sets `USAGE_MEDIA` / `CONTENT_TYPE_SPEECH` audio attributes with `handleAudioFocus=true`, warming the output path before the first clip (the standard-path twin of the relay PCM deep-buffer cold-start fix). `VoiceRecorder` attaches `AcousticEchoCanceler` + `NoiseSuppressor` on the capture session when the device exposes them (desktop's `getUserMedia({echoCancellation, noiseSuppression})`); both best-effort, released with the recorder.
|
||||
- **VAD parity.** `VoiceViewModel`'s silence watchdog now matches desktop `voice_mode`: end-of-speech default 1250 ms (was 3000; slider re-ranged to 0.75–5 s in 250 ms steps), idle/no-speech auto-close at 12 s (cancels without transcribing), and a 60 s hard turn cap. The existing 0.08 amplitude floor already aligns with desktop's 0.075 `silenceLevel`.
|
||||
- **Cleanup.** Removed the disabled "Auto-TTS" toggle and dead "STT language" picker (the "Coming soon" card) plus their prefs / keys / setters — desktop has no read-every-typed-message feature, and STT language is a server-side `stt.*.language` key now editable in the new card.
|
||||
- **Boundary.** The Relay voice path (`RelayVoiceClient`, streaming PCM, realtime-agent) was not touched.
|
||||
|
||||
**Verification.** `:app:lint` green (BUILD SUCCESSFUL). `:app:testGooglePlayDebugUnitTest` compiles and the new suites pass — `DashboardApiClientTest` (31/31, incl. 6 new) and `DashboardConfigEditingTest` (8/8); the only failures are three pre-existing DataStore "multiple instances" Windows flakes (`BargeInPreferencesTest`, `ProfileSelectionStoreTest`, `ProfileSessionStoreTest`), all in untouched files. Static read confirmed the Config-tab finding (no editable `tts.*`/`stt.*`). On-device render of the new card and a live save/round-trip against a dashboard are flagged for the maintainer (no dashboard available in this environment). Branch `Codename-11/voice-standard-parity` off `dev`; not pushed.
|
||||
## 2026-06-28 — Chat UI refresh: "Blend" bubbles + assistant avatar + selectable font system
|
||||
|
||||
**Why.** Move the chat surface toward a polished blend of Telegram bubbles and Discord density, and replace the single hardcoded sans with a proper user-selectable font system (Inter default). Chat-UI + theme lane only — audio/voice/network untouched (a separate worktree owns voice).
|
||||
|
||||
**What.**
|
||||
- **Font system.** New `AppFont` registry (Inter / Nunito / System) backing a DataStore pref (`ConnectionViewModel.appFont` / `setAppFont`, key `app_font`). `Type.kt` typography is now built per body family via `appTypography(body)`; `HermesRelayTheme` gains `appFontId` and rebuilds the Material `Typography` from the selected face so the whole app re-themes live (no restart), keeping `Monospace` for code/metadata. Inter + Nunito ship as SIL OFL variable TTFs in `app/src/main/res/font` (weight-instanced 400/500/600/700 via `FontVariation`, `@OptIn(ExperimentalTextApi)`); license texts in `licenses/`. A Font picker in `AppearanceSettingsScreen` previews each option in its own face and persists on tap.
|
||||
- **Bubbles + avatar.** Assistant turns get a Hermes brand-mark avatar (reusing `splash_icon`) in a reserved left gutter, drawn once per group like the name label; `MessageBubble`'s content column is wrapped in that gutter Row. Compact-phone bubble cap 300→340dp, chat-list inset 16→12dp, and denser bubble padding (h14/v9) for Discord-like rhythm. The grouped flat-edge shape system is preserved.
|
||||
- **Code blocks.** Streaming fence rebuilt Discord-style: a contrasting inset surface with a thin header (language label + copy-to-clipboard affordance that flips to a check) over a horizontally-scrollable monospace body. Markdown code/inline-code backgrounds switched to contrasting container steps (`surfaceContainerLowest` / `surfaceContainerHighest`) so code no longer blends into the surfaceVariant assistant bubble.
|
||||
|
||||
**Verification.** Host-side Roborazzi (`StoreScreenshotTest`): added `s09_blend_chat` (Hermes dark + Nous-blue light) and `s10_font_picker`. Renders confirm the avatar/grouping/width/density, the code-block + inline-code contrast in both dark and light, and that Inter and Nunito load as visibly distinct faces in the picker. `./gradlew :app:lint` run clean. On-device typeface crispness and feel (avatar size, width, density on a real device) remain a maintainer gate.
|
||||
|
||||
## 2026-06-28 — Dev-loop polish after the live smoke test
|
||||
|
||||
|
||||
@@ -6,18 +6,280 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Open-issue resolution batch (2026-07-06) — owner GitHub actions + deferrals
|
||||
|
||||
Plan: `docs/plans/2026-07-06-open-issue-resolution.md` (13 open issues triaged;
|
||||
fix-state claims verified against tags with `git merge-base --is-ancestor`).
|
||||
**Automation never posts to GitHub** — every comment/close/label below is an
|
||||
owner action, deliberately queued here:
|
||||
|
||||
- [ ] **#131** — close: fixed by `3573ba8` (PR #136), shipped android-v1.2.5
|
||||
(reporter was on 1.2.3). Optionally re-check Play vitals for the
|
||||
"Invalid URL host" signature on ≥1.2.5 first.
|
||||
- [ ] **#129** — close: fixed by `99b9cf1` (PR #128), shipped android-v1.2.4
|
||||
(owner already promised v1.2.4 in-thread).
|
||||
- [ ] **#124** — post the promised follow-up + close: fixed by `802385c`
|
||||
(PR #125), first shipped android-v1.2.3.
|
||||
- [ ] **#70** — close both prongs: original keyset force-close fixed `48ddba5`
|
||||
(android-v1.1.0); the in-thread TLS/Tailscale crash is #124's bug, fixed
|
||||
android-v1.2.3. Invite reopening if it recurs on ≥1.2.3.
|
||||
- [ ] **#94** — pull Play Console vitals for the versionCode-13 / Z Fold7
|
||||
cluster; hardening shipped `a455e46` (android-v1.2.0). Confirm no
|
||||
recurrence on v1.2.x, then close.
|
||||
- [ ] **#155 / #154** — support comments + close as user-config: `localhost`
|
||||
on the phone points at the phone itself (#154 is the downstream probe
|
||||
failure of the same misconfig). Link the new troubleshooting entry once
|
||||
it deploys. Relabel away from `bug`/`area:plugin`.
|
||||
- [ ] **#146** — needs-info comment (Tailscale up on the phone? follow-up
|
||||
Error entry? agent bound on the tailnet address?); close as support if
|
||||
no response.
|
||||
- [ ] **#166** — relabel `area:plugin` → `area:android`; reply with the root
|
||||
cause (phone drops the SSE socket on long local-model turns; upstream
|
||||
finishes + persists the answer; app now recovers it) and credit the
|
||||
reporter's `supports_async_delivery` instinct. Ask: screen off during
|
||||
the hang? does reopening the session later show the answer?
|
||||
- [ ] **#165** — reply: both failure modes confirmed (absolute `plugin.`
|
||||
imports under the native loader; install.sh layout assumptions); fix
|
||||
ships as plugin-v1.3.1. The uv-pip gap they mention was already fixed in
|
||||
plugin-v1.1.0+. Owner must e2e the fix on the official Docker image.
|
||||
- [ ] **#145** — confirm-triage reply; on-device check after fix (max font +
|
||||
display size, all 5 slides); close after the next android-v* release.
|
||||
- [ ] **#144** — close after the next android-v* release demonstrates the
|
||||
2-asset layout + new Download block; optionally edit the published
|
||||
android-v1.2.6 release body to drop the "Parity/testing artifact" wording.
|
||||
- [ ] **#121** — label (`enhancement` + area) and milestone onto the next
|
||||
`cli-v*` release; it's scheduled feature work, not part of this batch.
|
||||
|
||||
Deferred from the batch (coordination / decisions):
|
||||
|
||||
- **Localhost-advisory UI wiring** (`ConnectionWizard` / `ConnectionDetailScreen`
|
||||
`supportingText`) — the util (`ServerAddress.loopbackHostWarning`) + tests land
|
||||
in WS-C, but the wizard wiring waits on the parallel connections-UI workstream
|
||||
to avoid colliding in those files.
|
||||
- **#166 optional hardening** — extend the opt-in keep-alive foreground service
|
||||
to cover an in-flight SSE turn (reduces disconnect incidence; googlePlay-flavor
|
||||
FGS declaration implications). Recovery poller ships without it.
|
||||
- **Upstream PR candidates from #166** — intentional detached-run semantics on
|
||||
client disconnect in `_handle_session_chat_stream`; pollable/resumable
|
||||
session-turn status. Decide whether to file against hermes-agent.
|
||||
- **"Vanilla Hermes" docs naming** — app dropped the label in v1.2.2; docs still
|
||||
use it as a concept term. Owner decision whether to retire it docs-wide
|
||||
(WS-F only fixes verbatim UI-label quotes).
|
||||
- **Docker venv pivot for install.sh** — beyond steer-to-native: optionally
|
||||
create a dedicated relay venv under a writable path so the full installer
|
||||
works in-container.
|
||||
|
||||
## Connections UI / status banner (2026-06-30 restructure follow-ups)
|
||||
|
||||
The Connections screen was split into a scannable list + a tabbed detail screen
|
||||
(Overview / Routes / Advanced / Security). Connection-status presentation went
|
||||
through a few iterations (persistence-tiered top strip → no-float → …) and **landed
|
||||
on a two-connection model (2026-07-01):**
|
||||
- **Chat/agent** (gateway/API) → the chat header **subtitle** swaps model ⇄
|
||||
"Reconnecting…"/"Connecting…"/"Disconnected" (WhatsApp-style; `ChatScreen`).
|
||||
- **Relay socket** (bridge/terminal/relay-voice) → the **bottom `RelayStatusStrip`**
|
||||
amber "Reconnecting…" cue only.
|
||||
- **No top-of-screen surface** for connection status at all (no strip, banner, or
|
||||
float). Route changes are **ambient only** (the bottom strip's route label updates;
|
||||
no explicit "switched to Tailscale" notification — decided 2026-07-01).
|
||||
|
||||
Deferred:
|
||||
|
||||
- ~~**Dead connection-status-surface code.**~~ *(Cleaned up 2026-07-01.)* Removed the
|
||||
now-unused top-strip machinery: `ConnectionHandoffBanner` + `ConnectionStatusBanner`
|
||||
(+ `PulsingSyncIcon`), `ConnectionStatusSurface` + `presentationSurface()` +
|
||||
`ConnectionStatusSurfaceTest`. **`ConnectionStatusToast` retained** as a parked
|
||||
general-purpose toast primitive (the only surface with a live multi-step stepper;
|
||||
decouple from `ConnectionStatusSnapshot` + rename to `StatusToast` on first reuse).
|
||||
- **Bottom-strip route-change flash (optional).** Route change is ambient-only for
|
||||
now. If a "switched to Tailscale" confirmation is wanted, surface it briefly in the
|
||||
**bottom strip** (where the route label already lives), not the top — keeps the
|
||||
no-top-chrome principle. The VM still detects + logs the change (`lastConnectedRole`).
|
||||
- ~~**Resume suppression covers only the handoff path.**~~ *(Fixed 2026-07-01.)* Added
|
||||
`postResumeQuiet`: a benign background→foreground re-handshake no longer flashes the
|
||||
bottom-strip "Reconnecting…" cue (the health "Connecting" path used to leak it).
|
||||
- **Stuck "Reconnecting" cue during a sustained/flapping outage (backoff gaps).**
|
||||
Confirmed in a both-sides trace (DEVLOG 2026-07-01): when a reconnect attempt fails
|
||||
(`Reconnecting→Disconnected`) **no handoff branch matches**, so the active
|
||||
"Reconnecting" handoff persists on its 30s backstop — including during the backoff
|
||||
*gap* where the socket is idle (`Disconnected`, not actually trying) and during the
|
||||
20s connect timeout. The cue then clears on the timer with no resolution ("no toast
|
||||
after"). The post-resume case is fixed (`postResumeQuiet`); this sustained/non-resume
|
||||
case is not. Fix idea: drive the bottom-strip cue off the LIVE
|
||||
`relayConnectionState`/`relayUiState` (show only while actually Connecting/
|
||||
Reconnecting), and clear the active handoff when `relayUiState` goes `Stale`/`Expired`
|
||||
so the live "Relay unreachable" state surfaces instead of a stuck cue. Deferred:
|
||||
hard to repro on a stable network; also risks surfacing the take-space "unreachable"
|
||||
banner more often on a chronically-flappy link (decide the escalation threshold).
|
||||
- **Rapid real flaps still churn the cue/subtitle.** On a genuinely flapping network
|
||||
(DEVLOG 2026-07-01 — Samsung adaptive Wi-Fi cycling the radio), each real drop→recover
|
||||
toggles the bottom-strip cue (relay) and, if chat drops too, the header subtitle. Now
|
||||
unobtrusive (no top surface), but a short coalescing/debounce would quiet a
|
||||
chronically-flappy link further. Deferred — the flap is environmental, not an app bug.
|
||||
- **Non-active connection detail is Overview-only.** Routes/Advanced/Security tabs
|
||||
appear only for the active connection (they read the single active-connection VM
|
||||
state); a non-active connection shows a "Switch to this connection" CTA. A future
|
||||
read-only preview of a non-active connection's saved routes could be nice.
|
||||
- **Store screenshot regeneration.** The `07_connections` scene mock was updated to
|
||||
the new list design; confirm the regenerated PNG + Play-graphics export at
|
||||
release-prep (only auto-publishes on a `main` release merge).
|
||||
|
||||
## Chat UI/UX polish (2026-07-01 readability pass)
|
||||
|
||||
A 5-agent audit compared the chat surface to Discord/Telegram/Messenger/iMessage/
|
||||
GitHub-mobile. **Shipped this pass (pending on-device verification):** a chat-tuned
|
||||
`markdownTypography()` ramp (headings were falling through to M3 display roles —
|
||||
h1=`displayLarge` 57sp in this app's scale — so a `#` was a billboard; now h1≈20sp
|
||||
scaling down, list/paragraph unified to 14sp, inline+fenced code 13sp, `textLink`
|
||||
accent+underline) in `MarkdownContent.kt`; timestamp gated to `isLastInGroup` (was on
|
||||
every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
|
||||
conversation gets its own beat; long-press haptic on the action menu; streaming dots
|
||||
gated to pre-first-token. Deferred:
|
||||
|
||||
- **Streaming↔final render parity (kill the reflow).** `StreamingMarkdownContent`
|
||||
renders raw markdown source (`## `, `**bold**`, `- item`) as plain 14sp text for the
|
||||
whole turn, then swaps to the full renderer at completion — headings still pop
|
||||
14sp→20sp on finalize (much reduced now that settled headings are small and lists no
|
||||
longer resize, but not zero). Run the real renderer on the settled prefix and keep
|
||||
only the trailing unterminated block raw. Riskier (partial-fence flicker) — needs
|
||||
on-device testing. Highest-effort audit item.
|
||||
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
|
||||
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
|
||||
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
|
||||
stays ~36–38 chars/line. Debatable/broad — left out of the certain heading win.
|
||||
- **Tail-corner on last-in-group only (design decision).** The audit flagged the
|
||||
per-bubble bottom tail as "half-implemented," but it's a deliberate aesthetic
|
||||
(every bubble tails). Switching to iMessage-style "tail on the last bubble only"
|
||||
changes the look — get design intent before flipping. `isLastInGroup` is now
|
||||
meaningful (grouping breaks on gaps) so it's ready if wanted.
|
||||
- **Wide tables.** GFM tables use the default renderer on ~272dp (columns crush);
|
||||
code fences already horizontal-scroll. Add a custom `table` component in
|
||||
`markdownComponents` with `horizontalScroll` + ~110dp min column + right-edge fade.
|
||||
- **Assistant bubble width decoupled from user.** Both cap at 300dp though only the
|
||||
assistant carries markdown/code; let the assistant run wider (~92% of available /
|
||||
340–360dp cap) so fences wrap/scroll later. Keep user ~300dp.
|
||||
- **Token counts out of the bubble; delivery → glyph.** Move `TokenDisplay` to a
|
||||
long-press "message info" sheet; collapse `Sending…/Delivered/Not sent` to a single
|
||||
trailing check/clock/! glyph on the last bubble (declutters every message).
|
||||
- **SelectionContainer vs long-press conflict.** Long-pressing the words can start
|
||||
text selection instead of opening Copy/Quote. Pick one owner (drop
|
||||
`SelectionContainer`, expose Copy via the menu — chat-app norm — or move actions to a
|
||||
kebab). Needs on-device confirmation of the current conflict first.
|
||||
- **Jump-to-bottom FAB unread badge** + drop the no-op tap ripple on bubbles
|
||||
(`combinedClickable onClick={}` still ripples). Telegram pattern.
|
||||
- **Sessions-transport `animateItem` flash.** Stream-complete rebuilds the list with
|
||||
new ids → every visible bubble replays its enter animation (gateway transport,
|
||||
stable id, is unaffected). Reuse the streaming bubble's id for the final message.
|
||||
- **Viewport re-pin on the `isStreaming` true→false height growth** (gateway
|
||||
transport): `ChatScreen` early-returns on `onlyStreamingFlagChanged`; issue one
|
||||
`withFrameNanos{}` + instant `scrollToItem(last)` when the flag flips and the user
|
||||
isn't scrolled away. Largely neutralized once render parity removes the height delta.
|
||||
- **Full 15-role `Typography` + metadata contrast.** Type.kt declares only 7 roles at
|
||||
0 tracking; the rest inherit M3 defaults with 0.1–0.5sp tracking (ChatScreen uses
|
||||
several) — declare all 15 for one coherent scale. Separately, floor muted-metadata
|
||||
alpha at ≥0.6 and verify ≥4.5:1 per theme (11sp timestamps were alpha 0.5 over
|
||||
`onSurfaceVariant` ≈ 2–2.5:1; the surviving timestamp is now 0.6).
|
||||
|
||||
## Realtime voice (ADR 33) follow-ups — 2026-07-01 robustness batch
|
||||
|
||||
The deliver-on-reattach / adaptive-promotion / milestone-speech / resume-retry /
|
||||
prewarm batch shipped (see DEVLOG 2026-07-01). Deferred:
|
||||
|
||||
- **Result injection framing (needs xAI parity check).** The completed background
|
||||
summary is injected as a synthetic *user* message (`send_text` →
|
||||
`conversation.item.create` role=user). Cleaner per current realtime-API practice:
|
||||
inject as a function-call output / out-of-band response so the model can't mistake
|
||||
it for the human speaking. OpenAI realtime supports this; xAI support unverified —
|
||||
requires a live parity test before switching. Keep the user-message path as the
|
||||
fallback.
|
||||
- **Pre-existing test failure:** `test_realtime_voice_routes.py::
|
||||
test_reads_hermes_xai_oauth_credential_pool` fails at HEAD too (`token is None`) —
|
||||
looks like an environment/fixture dependency on a local xai oauth pool, not a code
|
||||
regression. Diagnose or gate on the fixture.
|
||||
- **Prewarm cost watch.** Voice-mode entry now opens the provider session before the
|
||||
first utterance. If users habitually open+close voice mode without speaking, idle
|
||||
provider sessions cost connect/teardown churn — consider a short "no utterance in
|
||||
N min → close" reaper if it shows up in practice.
|
||||
- **E2E verification pending** for the new paths on-device: deferred result spoken on
|
||||
resume after a mid-run drop; proactive notification when the session dies for good;
|
||||
busy answer on a second task; adaptive promotion timing; first-turn latency with
|
||||
prewarm; the live background-run chip (progress line/steps/timer, RECONNECTING and
|
||||
DELIVERING phases, ✕-to-cancel).
|
||||
- **Ambient background-run visibility OUTSIDE voice mode.** Exiting the voice overlay
|
||||
mid-run leaves no on-screen indication a task is still going (the run survives and
|
||||
the result arrives as a notification via the proactive fallback). Surface a small
|
||||
indicator on the chat screen — natural home is the bottom `RelayStatusStrip`, which
|
||||
the connection-management work owns → **coordinate before implementing**.
|
||||
- **Dev-env note:** the local hermes-agent app venv (`AppData/Local/hermes/...`) can
|
||||
prune `aiohttp`/`segno` (uv sync), breaking `python -m unittest plugin.tests.*` with
|
||||
ModuleNotFoundError — restore with
|
||||
`uv pip install --python <venv>/Scripts/python.exe aiohttp segno`.
|
||||
|
||||
## Phone as a Hermes platform (proactive agent → phone)
|
||||
|
||||
Phase 1 (end-to-end spine) shipped on `Codename-11/phone-platform` — `send_message target=phone` → loopback `/phone/message` → relay `ProactiveChannel` → phone WSS → system notification, gated off by default (`PHONE_ENABLED` server-side + "Let Hermes message me" app-side + pairing). Remaining:
|
||||
|
||||
- **Phase 2a — dedicated "Hermes" inbox surface.** An always-present inbound conversation/section for agent-initiated messages (reuse chat *rendering* components, do NOT restyle — chat-ux worktree owns visuals). Land proactive messages there in addition to the notification. `ProactiveMessageHandler.onReceived` + `dispatch()` are the seams already in place; key the surfacing on `ProactiveMessage.surfacing` (notification / inbox / session / default = notification + inbox). Needs a small persistence store + a nav entry.
|
||||
- **Phase 2b — session injection.** Deliver a proactive message into the relevant/active chat session (continue that conversation) when `surfacing == "session"`. Keep the `ChatViewModel` change SMALL/localized (one injection entry point) to avoid conflicting with the chat-ux branch.
|
||||
- **Phase 3 — full controls.** DataStore-backed `ProactivePreferences` expanding `data/ProactivePrefs.kt`: quiet hours / DND (suppress or defer), per-profile push scoping, rate limiting (debounce/cap), and TTS-on-voice (route to the existing voice player API when a voice turn is active — call, don't modify, the voice path). Surface on the existing `ProactiveSettingsScreen`.
|
||||
- **Phase 2c — two-way reply. SHIPPED + DEVICE-VERIFIED (2026-06-29).** All three legs landed: (1) inline-reply notification (`RemoteInput` + `ProactiveReplyReceiver`) + a reply box in the Hermes inbox; (2) `proactive.reply` envelope (app→relay) buffered by `ProactiveChannel` + a loopback `GET /phone/replies` long-poll; (3) `PhoneAdapter.connect()` inbound loop drains `/phone/replies` → `handle_message()` so the reply continues the originating conversation (keyed by `chat_id`/`reply_to`), and the agent's answer rides the existing `send()` back. Inbound source is `role_authorized=True` (the relay pairing layer is the auth boundary), so replies work without `PHONE_ALLOW_ALL_USERS`. Verified via `python -m unittest` (proactive + phone tests) and `./gradlew :app:lint`, then **end-to-end on-device (2026-06-29)** after two faults the device test surfaced (see DEVLOG): `PhoneAdapter.connect()` was missing the `is_reconnect` kwarg the gateway passes (→ `TypeError`, adapter never connected, reply loop never polled); and a stale duplicate plugin copy in the user-plugins dir was winning the loader's name-dedup, so the gateway loaded old code and ignored every deploy. Residual deferred (below): outbound buffering / a persistent send-when-reconnected queue (currently the agent's answer `503`s and is **lost** if the phone's subscription dropped); inbound media in replies (text-first in v1).
|
||||
- **Phase 3 — full controls.** DataStore-backed `ProactivePreferences` expanding `data/ProactivePrefs.kt`: quiet hours / DND (suppress or defer), per-profile push scoping, rate limiting (debounce/cap), and TTS-on-voice (route to the existing voice player API when a voice turn is active — call, don't modify, the voice path). Surface on the existing `ProactiveSettingsScreen`. Also: a persistent outbound-reply queue so a reply typed while the relay is disconnected (notification inline-reply in a killed process, or a dropped WS) is sent on the next connect instead of dropped.
|
||||
|
||||
**Maintainer verification (live box + device — can't be done off-device):**
|
||||
- Live gateway must discover the plugin (`~/.hermes/plugins/hermes-relay` → `plugin/`) and `plugins.enabled` must include `hermes-relay` for the `phone` platform to register. Confirm `phone` appears in `hermes gateway status` with `PHONE_ENABLED=1`.
|
||||
- End-to-end: with the app paired + "Let Hermes message me" on, run `send_message target=phone text=...` (and a cron `deliver=phone`) and confirm a notification on the device. Verify 503 (no phone) and the off-by-default gates.
|
||||
- Confirm `standalone_sender_fn` path (out-of-process cron) reaches the relay.
|
||||
- **Phase 2c reply round-trip — ✅ DONE (verified on-device 2026-06-29).** Confirmed: agent → phone notification → inline reply → drained through the relay's loopback `GET /phone/replies` (different process) → `handle_message` (`role_authorized=True`, no `PHONE_ALLOW_ALL_USERS`) → agent answer back in the *same* thread. Both fixes required (see DEVLOG / the Phase 2c bullet above).
|
||||
- **FIX: cron `deliver=phone` / standalone send is broken.** Live testing: `hermes send --to phone` returns `{"error": "Unknown platform: phone"}`. The standalone (non-gateway) send path doesn't run a `kind=standalone` plugin's programmatic `ctx.register_platform`, so it never learns `phone` — only the running gateway (which loads `register()` at startup) does. The agent path (`send_message target=phone` in the gateway) works and was verified end-to-end on-device; the standalone/cron path needs the platform discoverable there too (declare it so the standalone loader picks it up, or route cron through the gateway). Until then `cron deliver=phone` won't work.
|
||||
- **FIX: installer leaves stale plugin backup copies in the plugins dir (root cause of the 2026-06-29 round-trip failure).** `install.sh`'s plugin-clone rebuild backs the old copy up *inside* `~/.hermes/plugins/` (e.g. `hermes-relay.copy-backup-…`). Because the loader dedups discovered plugins by manifest `name` and both copies declare `name: hermes-relay`, the backup can win the dedup and the gateway loads stale code — so every later deploy is silently ignored. Fix: back up *outside* the plugins dir (or delete the old copy), and have `hermes relay doctor` warn when more than one directory under `~/.hermes/plugins/` resolves to the same plugin `name`.
|
||||
|
||||
## Phone platform — usability roadmap (post device-verification, 2026-06-29)
|
||||
|
||||
**North-star (2026-06-29): the phone should replace Discord-on-the-phone for agent contact.** The agent lane is meant to be a place you live in — proactive messages land, you reply inline or open a real thread, you multitask in and out of it like a chat app. That framing (not "an inbox of notifications") drives every item below: it must feel like a first-class messaging surface, attributed as its own gateway lane, with the conversation persisted and continuable.
|
||||
|
||||
**Decision (2026-06-29): "separate lanes, unified surface."** The phone/agent conversation stays its own **gateway-platform lane** — distinct from the Standard Chat tab, which must keep working on vanilla upstream Hermes with no plugin — but is surfaced as a **first-class chat-style thread** that reuses the chat UI and sits alongside Chat. NOT a Chat "transport": a transport is an interchangeable pipe for the *same* user-chat conversation; the phone platform is a *different* conversation (agent-initiated, own session store/attribution, relay auth), so treating it as a transport miscategorizes it and couples a standard surface to a relay-only capability.
|
||||
|
||||
**Refinement (2026-06-29) — unified-session model: "Threads."** Going further on "unified surface": the agent conversation is **not a separate tab/segment** at all — it is a **source-tagged session inside the one Chat surface**, a **Thread** (`source=phone`). What makes a Thread special vs. a normal gateway chat are *session properties*, not a separate UI: (a) the agent can initiate, (b) relay `proactive` transport + relay-gated, (c) standing/named DM. **Scrollback = the gateway session store** (same read path Chat uses); **live receive = relay `proactive` push** (→ notification); **send = `proactive.reply`**. `ProactiveInboxStore` is demoted to a live-push cache + outbox (no parallel history). The Thread capability shows in the **best-path/capability UI** (relay tier, like terminal/bridge/voice) and as a clean **Threads** entry — thread-spool icon, NOT a phone glyph — pinned atop the session drawer when active; never a connection-wizard step. Degrades cleanly (no plugin → no `source=phone` sessions → Chat unchanged). **Supersedes the "separate Agent lane / 4th nav segment" sketch** and merges with the "source attribution in Chat" goal below. Keep the two "gateway" senses straight: *platform layer* (the Thread's `source`) ≠ *dashboard `/api/ws` transport* (how live bytes flow). Full re-cut: docs/decisions.md ADR 12.
|
||||
|
||||
- **Outbound buffering — ✅ relay-side DONE (2026-06-29).** `ProactiveChannel.push()` now queues agent→phone messages in a bounded deque (drop-oldest, 24 h TTL) when no phone is subscribed and returns `{queued: true}` (not 503); `_flush_outbound` delivers FIFO on the next subscribe (stale pruned). Inspect/cancel via `peek_outbound`/`cancel_outbound` + loopback `GET`/`DELETE /phone/outbound`. **UI surfacing of the queued state** (host-side, since the queue exists while the phone is OFFLINE): (a) ✅ **desktop CLI `relay queue` / `relay queue --clear` / `--cancel <id>` DONE (2026-06-29)** over the new endpoints (loopback-only — run on the relay host); a dashboard Relay-tab view is the optional GUI equivalent; (b) **remaining** — in the threaded agent surface, mark messages that arrived-while-away, and show the user's OWN pending replies (the Phase 3 reply queue) with a sending/Cancel affordance — that's where phone-side "queued + cancel" belongs.
|
||||
- **Threads surface (unified-session model — see ADR 12 + the Refinement above).** Build order, each shippable: **(1)** source tags in the session drawer (`source=phone` → clean **Threads** chip + thread-spool icon, NOT a phone glyph) — also delivers the "source attribution in Chat" goal; **(2)** open a Thread in Chat from its session-store history (reuse the existing message-history path); **(3)** route the live `proactive` push into the session view + notification + unread, demoting `ProactiveInboxStore` to cache/outbox; **(4)** reply from the Chat composer via `proactive.reply` + persist the user turn + local `Sending/Queued/Failed` status — **MVP**; **(5)** a **Threads capability row** in the best-path UI + a pinned **Threads** entry atop the drawer (thread-spool icon, shown only when relay-paired + opted-in) + retire `HermesInboxScreen`, re-point the notification deep-link + Settings "View messages"; **(6)** outbox/retry on reconnect; **(7)** relay `proactive.reply.ack` (honest Delivered) + `proactive.cancel`; **(8)** multi-thread `chat_id` (named/project Threads). **Verify gate before (1):** confirm the app's session-list/history path surfaces a `source=phone` session cleanly (upstream `session.list` returns all sources flat, so it should — but check whether the drawer currently filters it out). Honesty call: do NOT show "Delivered" until (7) lands (can't confirm it client-side before the ack).
|
||||
- **Status (2026-06-29, implemented UNBUILT — verify in Studio):** **CODE-COMPLETE on `dev`:** slice **1** (drawer source tags + `ThreadSpoolGlyph` + Threads filter), **2** (open a Thread from history — free via the existing `loadSessionHistory` path), **3-parse** (carry `reply_to` on `ProactiveMessage`), **4** (composer reply in a `source=phone` session routes over `proactive.reply`; `MessageDeliveryStatus` SENDING→DELIVERED/FAILED on the bubble), **5** (Threads capability row in `SessionPathCard` + `threadsCapabilityActive` drawer wiring), **7** (relay `proactive.reply.ack` + `proactive.cancel` — 25/25 `unittest` green — and client ack handling). **DONE since (2026-06-29, built + on phone):** live **in-thread reply rendering** (an agent reply lands in the open Thread as an ASSISTANT bubble, suppressing the notification/inbox — `injectIntoThread`); **user-created named Threads** ("+ New Thread"); **retire `HermesInboxScreen`** (deleted; route + nav removed; notification tap + Settings "View messages" re-pointed to Chat; surface renamed "Hermes messages" → **"Threads"**); relay slice-7 ack/cancel **DEPLOYED** to the host so **"Delivered" is live**. **DEFERRED (reasons):** per-session **unread badge**; **outbox/retry** (needs multiplexer connection-state); **exact-Thread deep-link** from the notification (opens Chat today, not the specific thread — needs select-session-on-entry); **remove the now-orphaned `ProactiveInboxStore`** (viewer-less write-only log); **agent-initiated** named Threads (upstream `send_message` thread param). On-device verifies for the create-flow: fresh-`chat_id` auto-create, the `…:dm:<chat_id>` id form, `renameSession` on a phone session.
|
||||
- **User-created Threads (slice 8, Discord-style) — CODE-COMPLETE on `dev` (built + installed 2026-06-29; on-device behavior pending).** "+ New Thread" in the drawer's Threads view → name dialog → `ChatViewModel.startNewThread` mints a fresh `chat_id`; the first composer message opens it over `proactive.reply` (gateway auto-creates the `source=phone` session) → `switchToCreatedThread` polls + switches to the real session + applies the name. Existing-thread replies route by the `chat_id` parsed from the session id (`…:dm:<chat_id>`; opaque id → home fallback). **On-device verifies:** (1) a fresh-`chat_id` no-`reply_to` inbound creates a new `source=phone` session; (2) the phone session id carries the `…:dm:<chat_id>` form the client parses; (3) `renameSession` titles a phone session. **Remaining slice-8:** AGENT-initiated named Threads (the upstream `send_message` thread/chat_id param so the agent can open its own named Threads).
|
||||
- **`chat_id` not exposed by `/api/sessions` (root cause of the 2026-06-29 on-device create-flow bugs — fixed client-side).** Confirmed on the host: a phone session's `id` is a timestamp (e.g. `20260629_204755_94f391d6`); the real `chat_id` lives in the `session_key` (`agent:main:phone:dm:<chat_id>`) and a `chat_id` column — but `/api/sessions` returns **neither `chat_id` nor `session_key`**, only `source` + the timestamp `id`. So the client could not map a session ↔ its `chat_id`, which broke create-thread switch/rename + reply routing + in-thread injection. **Client workaround shipped:** find a created thread by session-list **diff** (the new `source=phone` session), keep an in-memory `sessionId → chat_id` map (learned at creation + from incoming `phone.message`s) for reply routing, and inject by source (+ learned chat_id) rather than a parsed id. **Limitation:** for a thread the app didn't create *this* session (agent-created, another device, or after an app restart) `chat_id` is unknown until a message arrives while viewing it → its replies fall back to the home channel until then. **RESOLVED via the plugin (2026-06-29, per upstream-or-plugin policy):** the relay now exposes `GET /phone/threads` (`plugin/relay/session_store.py` reads the gateway store read-only → `[{session_id, chat_id, title}]`; `server.py` `handle_phone_threads`, bearer for the app / loopback for diag; 5 unit tests). The app (`RelayHttpClient.fetchPhoneThreads` → `ConnectionViewModel.phoneThreadChatIds` on every `auth.ok` → `ChatViewModel.seedThreadChatIds`, authoritative over the learned map) now routes replies correctly for **any** Thread — incl. ones it didn't create + after restart. Deployed + verified live. **Still-nice-to-have (lower priority): the upstream PR** to add `chat_id`/`session_key` to `/api/sessions` (the standard-path proper fix; the relay route then becomes redundant + the client prefers upstream when present).
|
||||
- **Threads as named/project conversations (Discord-parity — folds into multi-thread #8).** A stable *named* `chat_id` per project = a persistent, agent-reachable project Thread (Discord named-thread parity for "persist a session for a project"). Enables: the agent **opening** a new named Thread for a background job/topic (a relay/gateway "open thread" affordance + a `send_message`-adjacent tool); cron/job updates landing in their own Thread; and replying to a Thread from any surface (desktop CLI / dashboard) since it is just a gateway session. Also evaluate per-Thread profile binding (a project Thread uses the "work" profile — ties to profile=contact).
|
||||
- **Thread vs. normal gateway chat — keep complementary, don't force one.** A Thread is a gateway chat + proactive delivery + `source` attribution. Use a *normal* gateway chat for live foreground interactive work (live `reasoning.delta` over `/api/ws`); use a *Thread* for persistent/named/agent-reachable/background-delivered conversations. Possible future enhancement (verify first): when a Thread is open in the foreground, allow a live `/api/ws prompt.submit` turn into that `source=phone` session for live reasoning — but confirm it does NOT break platform attribution or the proactive reply loop before relying on it; the proven send path stays `proactive.reply`.
|
||||
- **LOOK INTO (own item, owner-requested 2026-06-29): live `/api/ws` transport for a foregrounded Thread.** Goal: when a Thread is open in the app foreground, give it the *same* live experience as Chat (live `reasoning.delta` + tool-progress) by running the turn over the `/api/ws` dashboard-gateway transport into that `source=phone` session, instead of the notification-grade `proactive.reply` path. Spec the experiment: (1) does `session.resume` + `prompt.submit` on a `source=phone` session over `/api/ws` keep `source=phone` (not silently re-tag `tui`)? (2) does it bypass `PhoneAdapter` / the role_authorized reply loop, and does that matter when the user is the one typing? (3) reconcile the two send paths (foreground→`/api/ws`, background/notification→`proactive.reply`) without double-sends. If it holds, a Thread becomes "background-delivered like a DM, but live like Chat when you open it" — the best of both. Until verified, `proactive.reply` stays the only send path.
|
||||
- **Docs/user-docs for Threads (lockstep — author with the user-facing slices 4–5).** Dev refs are done (ADR 12 carries the unified-session decision + the two-"gateway" split). Still to write when the surface ships: a plain-language `user-docs/features/threads.md` — what a Thread *is*, **Chat vs Threads** (live foreground work vs. persistent, agent-reachable conversations), the two opt-in gates, that it's relay-only — plus a **brief in-app explainer** (e.g. a one-line hint on the Threads filter empty state or a small info affordance, not a wall of text), and `docs/relay-protocol.md` + relay-server route docs for the wire. Replace the stale user-docs "Coming Soon → Push Notifications" row; keep it distinct from the clipboard inbox and the inbound Notification Companion.
|
||||
- **More Threads fold-ins (capture now, build with the relevant slice).** (a) **Read-state back to the agent** — tell the gateway you saw a proactive message (Discord-style read receipt) so the agent knows; fold into the `proactive.reply.ack` design (#7). (b) **Cross-surface reply** — because a Thread is just a gateway session, a reply could come from the desktop CLI / dashboard too, not only the phone; near-free once unified, verify the reply routing. (c) **Priority/importance on a proactive message** — let the agent mark urgent vs FYI → notification importance / quiet-hours bypass; small payload field + maps to the notifier channel.
|
||||
- **Per-thread `chat_id`.** Everything is hardcoded `chat_id="phone"` (one thread) today; the adapter already plumbs `chat_id`, so varying it yields multiple threads (per topic, or the agent opening distinct conversations). Ties into the threaded surface.
|
||||
- **Message status + delivery state.** Surface sent / delivered / queued / failed per message in the thread (depends on outbound buffering's queued state) so the user knows whether the agent actually reached them.
|
||||
- **Auto-title the phone thread** like other sessions (first confirm whether the gateway already auto-titles platform sessions; wire it through if so).
|
||||
### Discord/Telegram replacement — capability gaps (to fully retire reaching for them)
|
||||
|
||||
The gateway-platform model is the *correct + sufficient architecture* (the phone is a registered platform peer, so anything that routes to a platform — `send_message`, cron `deliver=`, channel directory, background jobs — can reach the phone). These are the concrete gaps between "architecturally a peer" and "I never open Discord":
|
||||
|
||||
- **Guaranteed background delivery (the biggest gap; no push today).** Delivery is **live-WSS-only** + a 24 h relay buffer; there is **no FCM/UnifiedPush** wake-up. If the app process is dead AND not holding a socket, a message waits for the next reconnect, and the relay buffer is ephemeral (lost on relay restart). Discord/Telegram feel instant because they wake the device via push even when the app is dead. Decide a **push transport**: **UnifiedPush/ntfy** (recommended — self-hostable, no Google dependency, upstream *already* ships an `ntfy` platform, on-brand for self-hosted) vs **FCM** (simplest UX but adds Play Services + a push relay; clashes with self-hosted ethos — at most the `googlePlay` flavor) vs **persistent foreground keep-alive service** holding the relay WSS (zero new infra, like `GatewayKeepAliveService`, but battery cost + Doze-fragile). Likely: UnifiedPush primary + foreground-keepalive fallback.
|
||||
- **Cron / background-job delivery is BROKEN** (already tracked above): `deliver=phone` standalone path → `Unknown platform: phone`. This is load-bearing for "receiver of crons/background jobs" — fix is required, not optional, for the replacement goal.
|
||||
- **Multi-thread is wired-for but never varied** (already tracked: per-thread `chat_id`). For real DM/channel parity the agent must *open distinct threads* (vary `chat_id` per topic/job), the app must render a **thread list** (N conversations, not one), and replies route back by `chat_id`+`reply_to` (already plumbed).
|
||||
- **Durable history / scrollback.** The relay buffer is ephemeral; a real messaging surface needs persisted scrollback. Read the gateway **session store** for the `phone` platform's history (relay-exposed read path) so reopening a thread shows the full conversation, not just buffered-while-away.
|
||||
- **Profile = contact mapping (new idea, fold in).** Multiple Hermes **profiles** (distinct agent personas/configs) could each be a distinct thread *source*/"contact" — DMing different agents. Maps cleanly onto the per-thread `chat_id` + source-attribution work; lets the app feel like a contact list of agents.
|
||||
- **Per-thread notification controls + deep-link (Discord-parity affordances).** Per-thread notification channels, mute/DND/quiet-hours (Phase 3 partially), and a notification that **deep-links into the exact thread** (tap → land in that conversation) so dipping in/out while multitasking is frictionless.
|
||||
- **Agent-initiated rich content.** Agent → phone thread with **images/cards** (relay media infra + `InboundAttachmentCard`/`HermesCardBubble` already exist on the chat side — reuse). Inbound (phone → agent) reply media stays deferred (text-first), but outbound rich content is low-cost parity.
|
||||
- **In-thread "agent is working" indicator.** A typing/working state in the thread while the agent thinks/runs tools (Discord typing-dots parity) — the chat surface already has thinking indicators to reuse.
|
||||
|
||||
- **Source/platform attribution + filtering in the drawer (NOW READY — owner-requested 2026-06-29; the gateway/Threads surface has shipped).** `/api/sessions` DOES expose `source` (confirmed live: `tui`, `cli`, `api_server`, `web`, `discord`, `telegram`, `cron`, `webhook`, `phone`). Build: **(a)** a clean **source badge** per session in the drawer — phone → the thread-spool (done); discord / telegram / cron / webhook / web → a small per-platform chip/icon (match hermes-desktop's convention); the app's own `tui`/`api_server` chats get no badge (or a subtle one). **(b)** a **filter** (drawer dropdown) to show/hide sources. **(c)** a **setting** (Chat settings) for the default — **hide the agent's other-gateway/automation sessions (cron / webhook / discord / telegram) by default** so the drawer shows just your chats + Threads, with a toggle to reveal them (the live default `state.db` is full of cron/discord/webhook noise). Persist the visibility prefs. Can't see the official desktop (no clone) — infer its chip styling; match exactly if specifics surface. Standard-path: read-only display of the upstream `source` field. Fold cross-restart **Thread-name persistence** (currently in-memory) into this drawer pass.
|
||||
- **Beta-gate the Threads featureset (owner direction 2026-06-29).** Mark Threads **Beta** with a clean badge in the UI (the Threads filter chip + the best-path "Threads" capability row) until the enhancements land. Full (non-beta) release is gated on: **live `/api/ws` transport for a foregrounded Thread** (an open Thread streams like Chat — the headline), per-session **unread**, the **`chat_id`-on-`/api/sessions` upstream fix** (so threads route after restart / cross-device), and **outbox/retry**.
|
||||
|
||||
## Voice — Standard-path parity follow-ups
|
||||
|
||||
- **On-device verification of the Server voice config card.** Static read + unit tests cover the client/merge logic, but the card's render, provider-scoped field switching, the ElevenLabs picker (key-present and `available:false`), and a live save round-trip against a real dashboard still need an on-device pass. Confirm a save reaches `config.yaml` and the next voice turn reflects it.
|
||||
- **Generic Manage "Config" tab is still read-only.** This work added a *voice-scoped* editor; the Manage Config tab still renders `/api/config/schema` as two non-editable rows (`fields`, `category_order`). A full schema-driven editor for all categories (general/agent/terminal/…) grouped by `category_order`, GET-merge-PUT-whole, is a separate, larger task if we want full desktop Config parity.
|
||||
- **`silenceThresholdMs` default change (3000 → 1250 ms) is user-facing.** Confirm on-device that 1.25 s end-of-speech doesn't clip slow speakers in real use, and that the new 12 s idle/no-speech auto-close (which now also applies to Tap-to-talk — previously "wait forever") feels right. Easy to revert the default if too aggressive.
|
||||
- **Standard voice config targets the launch-profile config (`profile = null`).** Standard voice is host-global, so the editor writes the base `config.yaml`. If a user runs a non-default *launch* profile, revisit whether to scope the config write to the active profile (the dashboard `/api/config?profile=` supports it).
|
||||
- **CLI `voice.*` config not surfaced.** The editor covers `tts.*`/`stt.*`; the separate `voice.*` block (record_key, beep_enabled, the CLI's own silence_threshold/duration) is intentionally out of scope — surface it only if a phone use-case appears.
|
||||
## Chat UI refresh — follow-ups
|
||||
|
||||
- **`/font <name>` slash command (optional, deferred).** The chat-UX brief floated a chat slash command mirroring the Appearance Font picker. Deferred to keep the work inside the chat-UI/theme lane: it needs the command intercepted in the chat send path (`ChatViewModel`) before it forwards to the server, plus a `SlashCommand` palette entry. The settings picker is the primary, shipped surface. Add `/font` later as a thin wrapper over `ConnectionViewModel.setAppFont`, discoverable via the slash palette.
|
||||
- **On-device verification of the chat refresh.** The Roborazzi harness proves layout + that Inter/Nunito load as distinct faces host-side, but the final typeface crispness and feel (avatar size, bubble width, density on a real Samsung) are a maintainer on-device gate. Confirm the variable-font weights (400/500/600/700) resolve on-device and Inter reads clean at body sizes.
|
||||
- **Growing the font set.** The `AppFont` registry is open — add more OFL/Apache faces (e.g. a serif or a display mono) by dropping a TTF into `app/src/main/res/font` and adding one enum entry; keep the license text in `licenses/`.
|
||||
|
||||
## Crash-class follow-ups
|
||||
|
||||
@@ -220,6 +482,8 @@ Things to look into:
|
||||
- **Skill distribution as separate from plugin distribution** — right now skills ride along with the plugin install via `external_dirs`. Should skills be installable independently (e.g. `hermes skill install <git-url>`)? Would that fragment maintenance or improve reuse?
|
||||
- **Tool registration discoverability** — `android_*` tools register at gateway import time. There's no canonical "list installed plugin tools" API. Would adding one to upstream make sense, or is `gateway tool list` already enough?
|
||||
- **Versioning + compatibility ranges** — `pip install -e` doesn't enforce version pins between hermes-agent and our plugin. A breaking change in upstream's plugin loader could silently break us. Do we need a `hermes_compat: ">=0.8.0,<1.0.0"` field somewhere?
|
||||
- **Update discovery (shipped 2026-06-30 — CLI + dashboard + app).** `hermes relay update-check`, a dashboard "Plugin version" card, and an app **About → "Relay"** row all compare the installed plugin against the latest `plugin-v*` release and surface the right update command (`hermes plugins update hermes-relay` vs `hermes-relay-update`). The app polls the relay's `GET /relay/update-check` (`:8767`, bearer) on each `auth.ok`; the relay is the single source of truth (the app never hits GitHub). Possible polish (deferred): a more prominent dismissible "relay is behind" banner outside About (today it's capability-first + the About row), and showing the app's own version alongside the relay's in the same readout (the app-Version row already exists separately just above it).
|
||||
- **Per-profile enablement (shipped 2026-06-30).** `hermes relay profiles list|enable [--all|NAME]` + `plugin/profiles.py` resolve the install-once/enable-per-profile papercut; docs now cover the pair-once/one-relay model. Possible follow-up: an `install.sh` / `hermes plugins install` prompt offering "enable for all existing profiles" so new installs don't need the manual `profiles enable --all`.
|
||||
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla upstream. This is intentional but feels like a hack. Upstream PR #8556 (`feat/session-api`) will eventually let us delete it — verified 2026-04-15 that its scope covers the full bootstrap surface (sessions, memory, skills, config, available-models). Track that PR's status periodically.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to #8556. Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
|
||||
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 166 KiB |
@@ -70,8 +70,18 @@
|
||||
</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
|
||||
<!-- Inline-reply receiver for proactive-message notifications
|
||||
(Phase 2c — two-way phone messaging). Not exported: it is only
|
||||
ever triggered by the app's own mutable RemoteInput PendingIntent
|
||||
delivered by the system, never by a third party. -->
|
||||
<receiver
|
||||
android:name=".notifications.ProactiveReplyReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Opt-in "Persistent connection" — holds the user's connection to
|
||||
Hermes open while backgrounded so messages and live features stay
|
||||
responsive (relay-paired setups also keep device control +
|
||||
notification mirroring reachable). 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. -->
|
||||
@@ -81,7 +91,7 @@
|
||||
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'." />
|
||||
android:value="Keeps the user's connection to their Hermes agent open in the background so messages and live features stay responsive, only when the user has explicitly enabled 'Persistent connection'." />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.media.audiofx.Visualizer
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -425,9 +427,25 @@ class VoicePlayer(
|
||||
* Production ExoPlayer factory — used as the default for [VoicePlayer].
|
||||
* Split out as a top-level function so unit tests can swap it for a
|
||||
* MockK mock without touching Media3's `Builder` class loader.
|
||||
*
|
||||
* Audio attributes (USAGE_MEDIA + CONTENT_TYPE_SPEECH) with
|
||||
* `handleAudioFocus = true` are set so ExoPlayer requests audio focus when
|
||||
* the first TTS clip starts, which warms the audio HAL output path before
|
||||
* playback begins. Without them the very first turn of a cold voice session
|
||||
* could lose its opening syllables to the AudioTrack/HAL allocation window —
|
||||
* the standard-path twin of the deep-buffer cold-start the relay PCM player
|
||||
* already mitigates. SPEECH also lets the system duck other audio
|
||||
* appropriately for a spoken assistant reply.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun defaultExoPlayer(context: Context): ExoPlayer =
|
||||
ExoPlayer.Builder(context)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ true,
|
||||
)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
|
||||
@@ -5,6 +5,8 @@ import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -55,6 +57,8 @@ class VoiceRecorder(
|
||||
private val bufferLock = Any()
|
||||
private val stopRequested = AtomicBoolean(false)
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var echoCanceler: AcousticEchoCanceler? = null
|
||||
private var noiseSuppressor: NoiseSuppressor? = null
|
||||
private var currentOutputFile: File? = null
|
||||
private var readThread: Thread? = null
|
||||
private var readDone: CountDownLatch? = null
|
||||
@@ -117,6 +121,7 @@ class VoiceRecorder(
|
||||
throw e
|
||||
}
|
||||
|
||||
attachVoiceEffects(recorder.audioSessionId)
|
||||
audioRecord = recorder
|
||||
val done = CountDownLatch(1)
|
||||
readDone = done
|
||||
@@ -224,7 +229,44 @@ class VoiceRecorder(
|
||||
_amplitude.value = sqrt(floored)
|
||||
}
|
||||
|
||||
/**
|
||||
* Engage the platform's hardware echo-cancellation and noise-suppression
|
||||
* on the [AudioRecord] capture session when the device exposes them —
|
||||
* parity with hermes-desktop's `getUserMedia({echoCancellation,
|
||||
* noiseSuppression})`. Both are best-effort: many mid-range and older
|
||||
* devices report [AcousticEchoCanceler.isAvailable] / [NoiseSuppressor.isAvailable]
|
||||
* false, in which case capture proceeds raw (the same behaviour as before
|
||||
* this change). AEC in particular keeps the device's own TTS playback from
|
||||
* bleeding into the next captured utterance during back-to-back voice turns.
|
||||
*/
|
||||
private fun attachVoiceEffects(sessionId: Int) {
|
||||
if (AcousticEchoCanceler.isAvailable()) {
|
||||
echoCanceler = try {
|
||||
AcousticEchoCanceler.create(sessionId)?.apply { enabled = true }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "AcousticEchoCanceler unavailable: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
if (NoiseSuppressor.isAvailable()) {
|
||||
noiseSuppressor = try {
|
||||
NoiseSuppressor.create(sessionId)?.apply { enabled = true }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "NoiseSuppressor unavailable: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseRecorder() {
|
||||
echoCanceler?.let { fx ->
|
||||
try { fx.release() } catch (_: Exception) { }
|
||||
}
|
||||
echoCanceler = null
|
||||
noiseSuppressor?.let { fx ->
|
||||
try { fx.release() } catch (_: Exception) { }
|
||||
}
|
||||
noiseSuppressor = null
|
||||
audioRecord?.let { record ->
|
||||
try { record.release() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
@@ -111,6 +111,15 @@ data class ChatMessage(
|
||||
* reconcile normally. Only [clientOnly] gates orphan preservation.
|
||||
*/
|
||||
val clientOnly: Boolean = false,
|
||||
/**
|
||||
* Delivery state for a message the user sends into an agent **Thread** over
|
||||
* the relay proactive channel ([com.hermesandroid.relay.viewmodel.ChatViewModel]
|
||||
* routes `source=phone` sessions here instead of the normal chat send).
|
||||
* `SENDING` until the relay acks (`proactive.reply.ack`) → `DELIVERED`;
|
||||
* `FAILED` on a send error. Null for ordinary chat messages — those render
|
||||
* no status affix.
|
||||
*/
|
||||
val deliveryStatus: MessageDeliveryStatus? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -304,6 +313,17 @@ enum class MessageRole {
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery state of a user reply sent into an agent Thread over the relay
|
||||
* proactive channel. Only set on Thread replies; ordinary chat messages leave
|
||||
* it null and show no status affix.
|
||||
*
|
||||
* - [SENDING] handed to the relay; awaiting the per-reply ack.
|
||||
* - [DELIVERED] the relay acked (`proactive.reply.ack`) — buffered for the agent.
|
||||
* - [FAILED] the send errored (e.g. relay disconnected).
|
||||
*/
|
||||
enum class MessageDeliveryStatus { SENDING, DELIVERED, FAILED }
|
||||
|
||||
data class ChatSession(
|
||||
val sessionId: String,
|
||||
val title: String?,
|
||||
@@ -311,7 +331,14 @@ data class ChatSession(
|
||||
val messageCount: Int = 0,
|
||||
val updatedAt: Long = 0L,
|
||||
val startedAt: Long = 0L,
|
||||
val lastActivityAt: Long = 0L
|
||||
val lastActivityAt: Long = 0L,
|
||||
/**
|
||||
* Originating gateway platform/source for this session (upstream `sessions.source`):
|
||||
* `tui`/`api_server` for ordinary app chats, `phone` for an agent **Thread**, and
|
||||
* `discord`/`slack`/… for other platforms. Null when the server didn't supply it or
|
||||
* for locally-created optimistic rows. Drives the drawer's Thread tag (see ADR 12).
|
||||
*/
|
||||
val source: String? = null,
|
||||
) {
|
||||
val activityTimestamp: Long
|
||||
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
|
||||
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
* Single source of truth for the opt-in "keep the app's connection to Hermes
|
||||
* alive in the background" preference. Off by default.
|
||||
*
|
||||
* Shared by [com.hermesandroid.relay.viewmodel.ConnectionViewModel] (the
|
||||
|
||||
@@ -27,6 +27,13 @@ data class ProactiveInboxEntry(
|
||||
val text: String,
|
||||
/** Epoch millis the message was received (server `sent_at` when present). */
|
||||
val receivedAt: Long,
|
||||
/**
|
||||
* Conversation the message belongs to (server `chat_id`). Carried so an
|
||||
* inbox reply (Phase 2c) continues the same thread. Nullable + defaulted
|
||||
* so blobs persisted before 2c still decode (kotlinx tolerates the absent
|
||||
* field).
|
||||
*/
|
||||
val chatId: String? = null,
|
||||
)
|
||||
|
||||
private val Context.proactiveInboxStore: DataStore<Preferences> by
|
||||
@@ -38,12 +45,14 @@ private val INBOX_JSON = stringPreferencesKey("entries_json")
|
||||
private const val MAX_ENTRIES = 100
|
||||
|
||||
/**
|
||||
* DataStore-backed store for the "Hermes" inbox of agent-initiated messages.
|
||||
* Entries are kept newest-first, deduped by id (so a re-delivered message
|
||||
* doesn't double up), and capped at [MAX_ENTRIES]. Survives app restart.
|
||||
* DataStore-backed durable log of agent-initiated messages. Entries are kept
|
||||
* newest-first, deduped by id (so a re-delivered message doesn't double up), and
|
||||
* capped at [MAX_ENTRIES]. Survives app restart.
|
||||
*
|
||||
* Phase 3 may grow this (read/unread, per-profile filtering); for Phase 2a it
|
||||
* is a flat capped log feeding [com.hermesandroid.relay.ui.screens.HermesInboxScreen].
|
||||
* Demoted (2026-06-29): the agent conversation now lives as a Thread in Chat (the
|
||||
* gateway session is the durable history), so the in-app inbox view is retired.
|
||||
* This store is only fed for messages NOT shown in an open Thread; it currently
|
||||
* has no viewer and is fully retireable — see TODO.
|
||||
*/
|
||||
class ProactiveInboxRepository(private val context: Context) {
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.sessionSourceDataStore by preferencesDataStore(name = "session_sources")
|
||||
private val KEY_HIDDEN = stringSetPreferencesKey("hidden_sources")
|
||||
|
||||
/**
|
||||
* Session `source`s hidden from the drawer by default — the agent's noisiest
|
||||
* automation lanes. Everything else (your chats, Threads, discord, telegram, …)
|
||||
* shows. The user can hide/reveal more from the drawer source filter or Chat
|
||||
* settings; both edit the same persisted set.
|
||||
*/
|
||||
val DEFAULT_HIDDEN_SOURCES = setOf("cron", "webhook")
|
||||
|
||||
/** DataStore for which gateway sources the drawer hides. */
|
||||
class SessionSourcePrefs(private val context: Context) {
|
||||
|
||||
val hiddenSources: Flow<Set<String>> = context.sessionSourceDataStore.data.map { prefs ->
|
||||
prefs[KEY_HIDDEN] ?: DEFAULT_HIDDEN_SOURCES
|
||||
}
|
||||
|
||||
suspend fun setHidden(source: String, hidden: Boolean) {
|
||||
val key = source.trim().lowercase()
|
||||
if (key.isBlank()) return
|
||||
context.sessionSourceDataStore.edit { prefs ->
|
||||
val cur = prefs[KEY_HIDDEN] ?: DEFAULT_HIDDEN_SOURCES
|
||||
prefs[KEY_HIDDEN] = if (hidden) cur + key else cur - key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private val Context.threadNameDataStore by preferencesDataStore(name = "thread_names")
|
||||
private val KEY_NAMES = stringPreferencesKey("names_json")
|
||||
|
||||
/**
|
||||
* Persists user-chosen agent **Thread** names (`sessionId` → name) so a named
|
||||
* Thread keeps its name across app restarts — the user's name is authoritative
|
||||
* (Discord-style), overriding the gateway's async auto-title which would
|
||||
* otherwise clobber it. Applied to the drawer via
|
||||
* [com.hermesandroid.relay.network.upstream.ChatHandler.setUserThreadNames].
|
||||
*/
|
||||
class ThreadNameStore(private val context: Context) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val ser = MapSerializer(String.serializer(), String.serializer())
|
||||
|
||||
private fun decode(raw: String?): Map<String, String> =
|
||||
raw?.let { runCatching { json.decodeFromString(ser, it) }.getOrNull() } ?: emptyMap()
|
||||
|
||||
val names: Flow<Map<String, String>> = context.threadNameDataStore.data.map { prefs ->
|
||||
decode(prefs[KEY_NAMES])
|
||||
}
|
||||
|
||||
suspend fun setName(sessionId: String, name: String) {
|
||||
val id = sessionId.trim()
|
||||
val value = name.trim()
|
||||
if (id.isBlank() || value.isBlank()) return
|
||||
context.threadNameDataStore.edit { prefs ->
|
||||
prefs[KEY_NAMES] = json.encodeToString(ser, decode(prefs[KEY_NAMES]) + (id to value))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,19 +19,23 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
*
|
||||
* - [interactionMode] how the mic button behaves: "tap" | "hold" | "continuous".
|
||||
* Drives the VoiceViewModel's InteractionMode enum at startup.
|
||||
* - [silenceThresholdMs] auto-stop threshold for listening: after this many
|
||||
* ms of amplitude below the silence floor, stopListening() is called.
|
||||
* - [autoTts] future — read TTS on every non-voice assistant message.
|
||||
* - [language] STT language hint. Stored; not yet wired to /voice/transcribe
|
||||
* (V1 doesn't accept a language param).
|
||||
* - [silenceThresholdMs] end-of-speech threshold for listening: after this many
|
||||
* ms of amplitude below the silence floor (once speech has been heard),
|
||||
* stopListening() is called. Default 1250 ms matches hermes-desktop
|
||||
* voice_mode `silenceMs`. (Idle/no-speech 12 s and a 60 s hard turn cap are
|
||||
* fixed in VoiceViewModel, not user-tunable — see startSilenceWatchdog.)
|
||||
*
|
||||
* Note: the standard path has no client-side auto-TTS or STT-language pref.
|
||||
* hermes-desktop only speaks responses during an active voice conversation
|
||||
* (no "read every typed message"), and STT language is a server-side
|
||||
* `stt.*.language` config edited via the Server voice config card, not a
|
||||
* client param — so neither is faked here.
|
||||
*/
|
||||
data class VoiceSettings(
|
||||
val engineMode: String = VoiceEngineMode.HermesVoiceOutput.storageValue,
|
||||
val audioRoute: String = VoiceAudioRoute.Auto.storageValue,
|
||||
val interactionMode: String = "tap",
|
||||
val silenceThresholdMs: Long = 3000L,
|
||||
val autoTts: Boolean = false,
|
||||
val language: String = "",
|
||||
val silenceThresholdMs: Long = 1250L,
|
||||
val realtimeTraceDetails: Boolean = false,
|
||||
/**
|
||||
* When true (default), Realtime Agent keeps one provider session/socket open
|
||||
@@ -167,15 +171,12 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
// Why these stay global: interaction-mode and silence-threshold are
|
||||
// ergonomic input preferences about *how the user drives the mic*, not
|
||||
// about the agent's voice — a user wants the same tap/hold/continuous
|
||||
// habit regardless of which profile is active. auto-tts and the STT
|
||||
// language hint are dead/experimental controls today, and the two
|
||||
// realtime diagnostic toggles (trace details, persistent session) are
|
||||
// habit regardless of which profile is active. The two realtime
|
||||
// diagnostic toggles (trace details, persistent session) are
|
||||
// engine-behaviour switches that aren't profile-specific. Keeping them
|
||||
// un-namespaced means switching profiles never churns these.
|
||||
private val KEY_INTERACTION_MODE = stringPreferencesKey("voice_interaction_mode")
|
||||
private val KEY_SILENCE_THRESHOLD_MS = longPreferencesKey("voice_silence_threshold_ms")
|
||||
private val KEY_AUTO_TTS = booleanPreferencesKey("voice_auto_tts")
|
||||
private val KEY_LANGUAGE = stringPreferencesKey("voice_language")
|
||||
private val KEY_REALTIME_TRACE_DETAILS = booleanPreferencesKey("voice_realtime_trace_details")
|
||||
private val KEY_REALTIME_PERSISTENT_SESSION =
|
||||
booleanPreferencesKey("voice_realtime_persistent_session")
|
||||
@@ -183,9 +184,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
const val DEFAULT_ENGINE_MODE = "hermes_voice_output"
|
||||
const val DEFAULT_AUDIO_ROUTE = "auto"
|
||||
const val DEFAULT_INTERACTION_MODE = "tap"
|
||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 3000L
|
||||
const val DEFAULT_AUTO_TTS = false
|
||||
const val DEFAULT_LANGUAGE = ""
|
||||
// 1250 ms matches hermes-desktop voice_mode `silenceMs` end-of-speech.
|
||||
const val DEFAULT_SILENCE_THRESHOLD_MS = 1250L
|
||||
const val DEFAULT_REALTIME_TRACE_DETAILS = false
|
||||
const val DEFAULT_REALTIME_PERSISTENT_SESSION = true
|
||||
|
||||
@@ -251,8 +251,6 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
// --- global (shared across profiles) ---
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
autoTts = prefs[KEY_AUTO_TTS] ?: DEFAULT_AUTO_TTS,
|
||||
language = prefs[KEY_LANGUAGE] ?: DEFAULT_LANGUAGE,
|
||||
realtimeTraceDetails = prefs[KEY_REALTIME_TRACE_DETAILS]
|
||||
?: DEFAULT_REALTIME_TRACE_DETAILS,
|
||||
realtimePersistentSession = prefs[KEY_REALTIME_PERSISTENT_SESSION]
|
||||
@@ -339,14 +337,6 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
dataStore.edit { it[KEY_SILENCE_THRESHOLD_MS] = ms.coerceAtLeast(500L) }
|
||||
}
|
||||
|
||||
suspend fun setAutoTts(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_AUTO_TTS] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setLanguage(language: String) {
|
||||
dataStore.edit { it[KEY_LANGUAGE] = language }
|
||||
}
|
||||
|
||||
suspend fun setRealtimeTraceDetails(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_TRACE_DETAILS] = enabled }
|
||||
}
|
||||
|
||||
@@ -116,6 +116,11 @@ class ConnectionManager(
|
||||
|
||||
private fun buildClient(): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
// OkHttp's 10s default connectTimeout is LAN-tuned; a Tailscale
|
||||
// DERP-relayed cold-start handshake can exceed it, and a failed
|
||||
// connect feeds the onFailure → markUnreachable → route-flap loop.
|
||||
// Give the remote first-handshake room to complete.
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.pingInterval(30, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
// Swap in the current pin snapshot on every connect. We DON'T hold a
|
||||
@@ -149,6 +154,23 @@ class ConnectionManager(
|
||||
@Volatile
|
||||
private var lastUpgradeResponseCode: Int? = null
|
||||
|
||||
// Consecutive relay socket failures (response == null) since the last
|
||||
// successful onOpen. One slow Tailscale/DERP cold-start handshake must not
|
||||
// immediately evict the active route from the SHARED resolver cache (chat +
|
||||
// dashboard ride the same resolver), so we only poison the route after a
|
||||
// couple of consecutive transport-level failures.
|
||||
@Volatile
|
||||
private var consecutiveSocketFailures = 0
|
||||
|
||||
// The relay requires the FIRST frame on a socket to be `system/auth` and
|
||||
// rejects the whole connection otherwise ("expected system/auth, got
|
||||
// <channel>/<type>"). `authenticated` gates [send] so nothing (notably the
|
||||
// periodic bridge.status reporter) can race the auth handshake on a fresh
|
||||
// or reconnecting socket. False from the start of every connect until the
|
||||
// server confirms `auth.ok`; reset on close/failure/disconnect.
|
||||
@Volatile
|
||||
private var authenticated = false
|
||||
|
||||
private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
@@ -224,6 +246,10 @@ class ConnectionManager(
|
||||
private const val TAG = "ConnectionManager"
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BASE_BACKOFF_MS = 1_000L
|
||||
// How many consecutive relay socket failures before we mark the active
|
||||
// endpoint unreachable in the shared resolver cache. Tolerates a single
|
||||
// cold-start blip on a slow remote (Tailscale DERP) link.
|
||||
private const val MARK_UNREACHABLE_AFTER_FAILURES = 2
|
||||
// Settle window before re-resolving after a network event. Long
|
||||
// enough to coalesce the onAvailable burst of a handoff, short
|
||||
// enough that a route swap still feels immediate.
|
||||
@@ -243,6 +269,17 @@ class ConnectionManager(
|
||||
// banned forever. Waiting at least as long as the server's block
|
||||
// duration lets the ban expire naturally.
|
||||
private const val RATE_LIMIT_BACKOFF_MS = 300_000L
|
||||
|
||||
// Slow-poll tier. Against a paired-but-genuinely-dead server the
|
||||
// exponential backoff otherwise caps at ~16s and retries forever, which
|
||||
// is steady battery + log noise for no benefit. After this many
|
||||
// consecutive failed attempts (~5 min of continuous failure at the cap)
|
||||
// we drop to a 5-min poll until the server recovers. A network change
|
||||
// re-resolves + reconnects immediately regardless of this delay (see the
|
||||
// onAvailable callback), and reconnectAttempt resets to 0 on a
|
||||
// successful onOpen, so recovery is never gated on the slow interval.
|
||||
private const val SLOW_POLL_AFTER_ATTEMPTS = 20
|
||||
private const val SLOW_POLL_BACKOFF_MS = 300_000L
|
||||
}
|
||||
|
||||
fun setInsecureMode(enabled: Boolean) {
|
||||
@@ -547,19 +584,34 @@ class ConnectionManager(
|
||||
* reconnects a disconnected socket on the same winner — preserving the
|
||||
* pre-refactor relay-path behavior.
|
||||
*/
|
||||
private fun scheduleNetworkReResolve(closeReason: String) {
|
||||
private fun scheduleNetworkReResolve(closeReason: String, wipeCache: Boolean) {
|
||||
if (endpointResolver == null) return
|
||||
networkResolveJob?.cancel()
|
||||
networkResolveJob = scope.launch {
|
||||
delay(NETWORK_RESOLVE_DEBOUNCE_MS)
|
||||
// Wipe the probe cache INSIDE the debounced job (not synchronously in
|
||||
// onAvailable) so a burst of network/VPN-interface callbacks —
|
||||
// Tailscale's tun churns onAvailable repeatedly — coalesces into a
|
||||
// single cache wipe + re-probe instead of one per event. onLost
|
||||
// manages its own cache (clear + markUnreachable) and passes false.
|
||||
if (wipeCache) endpointResolver?.clearCache()
|
||||
val current = serverUrl
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
if (resolved == null) {
|
||||
// Don't clear a live socket's endpoint on a transient probe
|
||||
// miss — only drop the published route when nothing is
|
||||
// actually connected.
|
||||
if (_connectionState.value != ConnectionState.Connected) {
|
||||
// Hysteresis for the AUTOMATIC (network-callback) path. A
|
||||
// transient cold-route probe miss must NOT null the published
|
||||
// endpoint: effectiveApiServerUrl/effectiveDashboardUrl then fall
|
||||
// back to the saved (home-LAN) host — dead for a remote device —
|
||||
// and rebuild the chat client against it. That is the Tailscale
|
||||
// reconnect loop. The old guard keyed on the relay socket being
|
||||
// Connected, which the standard (no-relay) chat path never
|
||||
// reaches, so it protected nobody there. Keep the last-known
|
||||
// route unless a sustained loss was actually declared (onLost
|
||||
// grace elapsed) or there was never a route to keep.
|
||||
if (sustainedLossDeclared || _activeEndpoint.value == null) {
|
||||
_activeEndpoint.value = null
|
||||
} else {
|
||||
Log.i(TAG, "re-resolve miss but ${_activeEndpoint.value?.role} was live and loss not sustained — keeping route")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
@@ -615,8 +667,9 @@ class ConnectionManager(
|
||||
// route (usually the same one); the rebuild only fires if the
|
||||
// URL actually moved.
|
||||
networkLossJob?.cancel()
|
||||
endpointResolver?.clearCache()
|
||||
scheduleNetworkReResolve("Network change — switching endpoint")
|
||||
// Cache wipe happens inside the debounced re-resolve so a burst
|
||||
// of onAvailable (VPN tun churn) coalesces into one wipe+probe.
|
||||
scheduleNetworkReResolve("Network change — switching endpoint", wipeCache = true)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
@@ -634,7 +687,10 @@ class ConnectionManager(
|
||||
sustainedLossDeclared = true
|
||||
endpointResolver?.clearCache()
|
||||
markActiveEndpointUnreachable("network lost (sustained)")
|
||||
scheduleNetworkReResolve("Network lost — switching endpoint")
|
||||
// wipeCache=false: we just cleared + poisoned the dead route
|
||||
// above; re-wiping inside the job would drop that negative
|
||||
// entry and let the dead route win the resolve again.
|
||||
scheduleNetworkReResolve("Network lost — switching endpoint", wipeCache = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -692,6 +748,7 @@ class ConnectionManager(
|
||||
)
|
||||
webSocket?.close(1000, "Client disconnect")
|
||||
webSocket = null
|
||||
authenticated = false
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_isInsecureConnection.value = false
|
||||
// ADR 24: clear manual override on explicit disconnect — a "Use
|
||||
@@ -715,6 +772,17 @@ class ConnectionManager(
|
||||
}
|
||||
|
||||
fun send(envelope: Envelope) {
|
||||
// Hold every non-auth frame until the server has accepted our
|
||||
// `system/auth` envelope. Otherwise a sender that fires on its own
|
||||
// cadence — e.g. BridgeStatusReporter's 30s/immediate tick — can beat
|
||||
// the auth handshake on a fresh socket, and the relay rejects the
|
||||
// whole connection (forcing a reconnect). Dropping a periodic frame is
|
||||
// harmless: the next tick re-sends once authenticated.
|
||||
val isAuthFrame = envelope.channel == "system" && envelope.type == "auth"
|
||||
if (!authenticated && !isAuthFrame) {
|
||||
Log.d(TAG, "send: holding ${envelope.channel}/${envelope.type} until auth.ok")
|
||||
return
|
||||
}
|
||||
val text = json.encodeToString(envelope)
|
||||
webSocket?.send(text)
|
||||
}
|
||||
@@ -755,6 +823,9 @@ class ConnectionManager(
|
||||
// pin store snapshot — crucial right after applyServerIssuedCodeAndReset
|
||||
// wipes a pin for re-pair. buildClient() does a tiny DataStore read
|
||||
// via runBlocking, so it runs on the IO dispatcher inside [scope].
|
||||
// Every new socket starts unauthenticated — the send-gate stays closed
|
||||
// (auth frame excepted) until this socket's own auth.ok arrives.
|
||||
authenticated = false
|
||||
client = buildClient()
|
||||
|
||||
val request = Request.Builder()
|
||||
@@ -772,6 +843,7 @@ class ConnectionManager(
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
lastUpgradeResponseCode = null
|
||||
consecutiveSocketFailures = 0
|
||||
_connectionState.value = ConnectionState.Connected
|
||||
Log.i(TAG, "onOpen: WSS handshake complete ($url)")
|
||||
DiagnosticsLog.record(
|
||||
@@ -808,6 +880,15 @@ class ConnectionManager(
|
||||
}
|
||||
try {
|
||||
val envelope = json.decodeFromString<Envelope>(text)
|
||||
// Open the send-gate the instant the server confirms auth,
|
||||
// BEFORE routing — so anything handleAuthOk triggers
|
||||
// (e.g. proactive.subscribe) is allowed through.
|
||||
if (envelope.channel == "system") {
|
||||
when (envelope.type) {
|
||||
"auth.ok" -> authenticated = true
|
||||
"auth.fail" -> authenticated = false
|
||||
}
|
||||
}
|
||||
multiplexer.route(envelope)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Malformed relay envelope: ${e.message}")
|
||||
@@ -832,6 +913,7 @@ class ConnectionManager(
|
||||
detail = "code=$code reason=$reason",
|
||||
url = url,
|
||||
)
|
||||
authenticated = false
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
scheduleReconnect()
|
||||
}
|
||||
@@ -856,8 +938,19 @@ class ConnectionManager(
|
||||
)
|
||||
lastUpgradeResponseCode = code
|
||||
if (response == null) {
|
||||
markActiveEndpointUnreachable("socket failure")
|
||||
// Transport-level failure (no HTTP upgrade response): on a
|
||||
// remote (Tailscale) link the first handshake can fail cold.
|
||||
// Don't evict the only working route from the shared resolver
|
||||
// on a single blip — wait for it to repeat. A genuinely
|
||||
// sustained network loss is handled separately by onLost.
|
||||
consecutiveSocketFailures++
|
||||
if (consecutiveSocketFailures >= MARK_UNREACHABLE_AFTER_FAILURES) {
|
||||
markActiveEndpointUnreachable("socket failure x$consecutiveSocketFailures")
|
||||
} else {
|
||||
Log.i(TAG, "relay socket failure $consecutiveSocketFailures/$MARK_UNREACHABLE_AFTER_FAILURES — not yet poisoning route")
|
||||
}
|
||||
}
|
||||
authenticated = false
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
scheduleReconnect()
|
||||
}
|
||||
@@ -899,28 +992,46 @@ class ConnectionManager(
|
||||
// normal exponential cadence and we'll re-fill the ban bucket on
|
||||
// every attempt, extending the ban indefinitely. Wait out the
|
||||
// server's full block window instead.
|
||||
val backoffMs = if (lastUpgradeResponseCode == 429) {
|
||||
Log.i(TAG, "scheduleReconnect: rate-limited (429) — backing off ${RATE_LIMIT_BACKOFF_MS}ms")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect delayed",
|
||||
detail = "Rate limited; retrying in ${RATE_LIMIT_BACKOFF_MS / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
RATE_LIMIT_BACKOFF_MS
|
||||
} else {
|
||||
(BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
}
|
||||
if (lastUpgradeResponseCode != 429) {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay reconnect scheduled",
|
||||
detail = "Retrying in ${backoffMs / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
val backoffMs = when {
|
||||
// Server-issued 429 means we're IP-banned — wait out the full
|
||||
// block window instead of re-filling the ban bucket at our normal
|
||||
// cadence.
|
||||
lastUpgradeResponseCode == 429 -> {
|
||||
Log.i(TAG, "scheduleReconnect: rate-limited (429) — backing off ${RATE_LIMIT_BACKOFF_MS}ms")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect delayed",
|
||||
detail = "Rate limited; retrying in ${RATE_LIMIT_BACKOFF_MS / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
RATE_LIMIT_BACKOFF_MS
|
||||
}
|
||||
// Sustained failure against a paired-but-dead server: stop hammering
|
||||
// every ~16s forever; drop to a slow poll until it recovers.
|
||||
reconnectAttempt >= SLOW_POLL_AFTER_ATTEMPTS -> {
|
||||
Log.i(TAG, "scheduleReconnect: sustained failure (attempt $reconnectAttempt) — slow-polling every ${SLOW_POLL_BACKOFF_MS / 1000}s")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Relay reconnect slow-polling",
|
||||
detail = "Server unreachable for a while; retrying every ${SLOW_POLL_BACKOFF_MS / 1000}s until it recovers (a network change reconnects immediately)",
|
||||
url = url,
|
||||
)
|
||||
SLOW_POLL_BACKOFF_MS
|
||||
}
|
||||
else -> {
|
||||
val ms = (BASE_BACKOFF_MS * (1L shl minOf(reconnectAttempt - 1, 4)))
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = "Relay reconnect scheduled",
|
||||
detail = "Retrying in ${ms / 1000}s",
|
||||
url = url,
|
||||
)
|
||||
ms
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
@@ -932,8 +1043,18 @@ class ConnectionManager(
|
||||
val resolved = resolveBestEndpointSafe()
|
||||
val targetUrl = resolved?.relay?.url
|
||||
if (resolved != null) {
|
||||
// Mirror scheduleNetworkReResolve: clear the sustained-loss
|
||||
// latch on a successful resolve so a later transient miss
|
||||
// doesn't null a route we just reconnected. (The latch is set
|
||||
// in onLost's grace job but can be cleared on EITHER success
|
||||
// edge — network-callback or relay-timer.)
|
||||
sustainedLossDeclared = false
|
||||
_activeEndpoint.value = resolved
|
||||
} else {
|
||||
} else if (sustainedLossDeclared || _activeEndpoint.value == null) {
|
||||
// Same hysteresis as scheduleNetworkReResolve: a transient
|
||||
// miss during a relay reconnect must not flip every effective
|
||||
// URL back to the dead saved host. Keep the last-known route;
|
||||
// we fall through to doConnect(url) and retry it with backoff.
|
||||
_activeEndpoint.value = null
|
||||
}
|
||||
if (targetUrl != null && normalizeRelayUrl(targetUrl) != url) {
|
||||
|
||||
@@ -51,6 +51,21 @@ class ProactiveMessageHandler(
|
||||
private val toInbox: ((ProactiveMessage) -> Unit)? = null,
|
||||
/** Sink for injecting into the active chat session (Phase 2b). */
|
||||
var toSession: ((ProactiveMessage) -> Unit)? = null,
|
||||
/**
|
||||
* Sink for the relay's per-reply ack (`proactive.reply.ack`) — lets the
|
||||
* chat layer settle a Thread reply bubble from SENDING → DELIVERED. Wired
|
||||
* after construction (the ChatViewModel isn't available at build time).
|
||||
* `(clientMsgId, status)`.
|
||||
*/
|
||||
var onReplyAck: ((String, String) -> Unit)? = null,
|
||||
/**
|
||||
* Show an inbound message inline in the Chat **Thread** it belongs to, when
|
||||
* that Thread is currently open. Returns true if it was shown there — in
|
||||
* which case the message is NOT also notified or added to the inbox (you're
|
||||
* already looking at the conversation). The unified-Threads counterpart of
|
||||
* [toSession]; wired after construction.
|
||||
*/
|
||||
var injectIntoThread: ((ProactiveMessage) -> Boolean)? = null,
|
||||
) {
|
||||
|
||||
fun onMessage(envelope: Envelope) {
|
||||
@@ -65,21 +80,33 @@ class ProactiveMessageHandler(
|
||||
}
|
||||
// Subscribe ack — informational; nothing to do client-side.
|
||||
"proactive.subscribed" -> Log.d(TAG, "proactive subscribe acked")
|
||||
// Per-reply ack — settle the matching Thread reply bubble (the
|
||||
// `client_msg_id` is the id the app stamped on its own reply).
|
||||
"proactive.reply.ack" -> {
|
||||
val clientMsgId = envelope.payload["client_msg_id"]?.jsonPrimitive?.contentOrNull
|
||||
val status = envelope.payload["status"]?.jsonPrimitive?.contentOrNull ?: "received"
|
||||
if (!clientMsgId.isNullOrBlank()) onReplyAck?.invoke(clientMsgId, status)
|
||||
}
|
||||
else -> Log.d(TAG, "ignoring proactive type ${envelope.type}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Route a parsed message: inbox always, plus the surface its hint selects. */
|
||||
/** Route a parsed message: into the open Thread if it belongs there, else
|
||||
* the durable inbox log + the surface its hint selects. */
|
||||
private fun dispatch(msg: ProactiveMessage) {
|
||||
// The inbox is the always-present durable log of agent-initiated
|
||||
// messages — record every one regardless of surfacing.
|
||||
// Unified Threads: if this message belongs to the Thread currently open
|
||||
// in Chat, render it inline there and STOP — no notification, no inbox
|
||||
// entry (you're already looking at the conversation).
|
||||
if (injectIntoThread?.invoke(msg) == true) return
|
||||
// Otherwise the inbox is the durable log of agent-initiated messages and
|
||||
// the surfacing hint selects the additional surface.
|
||||
toInbox?.invoke(msg)
|
||||
when (msg.surfacing?.lowercase()) {
|
||||
"inbox" -> { /* inbox only — already recorded above */ }
|
||||
"session" -> {
|
||||
val sink = toSession
|
||||
// Inject into the active session; if no session sink is wired
|
||||
// (or no active chat), fall back to a notification so it isn't
|
||||
// Legacy explicit "inject into active session" path; if no sink
|
||||
// (or no active chat) fall back to a notification so it isn't
|
||||
// silently missed (the inbox copy already exists either way).
|
||||
if (sink != null) sink.invoke(msg) else notify(msg)
|
||||
}
|
||||
@@ -94,6 +121,7 @@ class ProactiveMessageHandler(
|
||||
title = msg.title,
|
||||
text = msg.text,
|
||||
messageId = msg.messageId,
|
||||
chatId = msg.chatId,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,6 +135,7 @@ class ProactiveMessageHandler(
|
||||
title = payload["title"]?.jsonPrimitive?.contentOrNull,
|
||||
surfacing = payload["surfacing"]?.jsonPrimitive?.contentOrNull,
|
||||
sentAt = payload["sent_at"]?.jsonPrimitive?.contentOrNull?.toLongOrNull(),
|
||||
replyTo = payload["reply_to"]?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -126,4 +155,6 @@ data class ProactiveMessage(
|
||||
val title: String?,
|
||||
val surfacing: String?,
|
||||
val sentAt: Long?,
|
||||
/** Id of the message this one answers, if any (server threading hint). */
|
||||
val replyTo: String? = null,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -61,12 +62,12 @@ class RelayHttpClient(
|
||||
/**
|
||||
* True when relay media is actually FETCHABLE right now: a non-blank relay
|
||||
* URL AND a current paired session token. Synchronous. The token check
|
||||
* matters because the relay's SessionManager is in-memory and wiped on
|
||||
* restart, so a configured relay URL can outlive the pairing — gating on URL
|
||||
* alone made the media-capability badge read "available" while every
|
||||
* matters because a configured relay URL can outlive a usable pairing — the
|
||||
* session can expire, be revoked, or never have been established — so gating
|
||||
* on URL alone made the media-capability badge read "available" while every
|
||||
* `/media/by-path` fetch failed for a missing token. Now the badge (and the
|
||||
* SSE media hint) agree with what the fetch can do, and self-correct on
|
||||
* re-pair.
|
||||
* SSE media hint) agree with what the fetch can do, and self-correct once a
|
||||
* valid paired token is present.
|
||||
*/
|
||||
fun mediaUrlConfigured(): Boolean =
|
||||
!relayUrlProvider().isNullOrBlank() && !pairedTokenSnapshot().isNullOrBlank()
|
||||
@@ -394,6 +395,167 @@ class RelayHttpClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** One phone Thread's identity from the relay's `/phone/threads`. */
|
||||
@Serializable
|
||||
data class PhoneThreadInfo(
|
||||
@SerialName("session_id") val sessionId: String = "",
|
||||
@SerialName("chat_id") val chatId: String = "",
|
||||
val title: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PhoneThreadsResponse(
|
||||
val threads: List<PhoneThreadInfo> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetch the phone-Thread `session_id → chat_id` map the upstream
|
||||
* `/api/sessions` omits (the relay reads it from the gateway store). The app
|
||||
* seeds its reply-routing map from this so a Thread it didn't create — or any
|
||||
* Thread after a restart — routes replies to the right conversation.
|
||||
*
|
||||
* Optional + fail-soft: an older relay without the route returns 404 → an
|
||||
* empty list, and the client falls back to its learned map.
|
||||
*/
|
||||
suspend fun fetchPhoneThreads(): Result<List<PhoneThreadInfo>> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
|
||||
}
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
val url = try {
|
||||
"$httpBase/phone/threads".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}"))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
val client = okHttpClient.newBuilder()
|
||||
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) {
|
||||
return@withContext Result.success(emptyList())
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
val reason = when (response.code) {
|
||||
401, 403 -> "Unauthorized — re-pair with the relay"
|
||||
in 500..599 -> "Relay error (HTTP ${response.code})"
|
||||
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
|
||||
}
|
||||
return@withContext Result.failure(IOException(reason))
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.success(emptyList())
|
||||
}
|
||||
Result.success(
|
||||
sessionsJson.decodeFromString(PhoneThreadsResponse.serializer(), body).threads
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "fetchPhoneThreads failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchPhoneThreads parse error: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** The relay's update-check result from `/relay/update-check`. */
|
||||
@Serializable
|
||||
data class RelayUpdateInfo(
|
||||
val current: String = "",
|
||||
val latest: String? = null,
|
||||
@SerialName("update_available") val updateAvailable: Boolean = false,
|
||||
@SerialName("update_command") val updateCommand: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Ask the relay whether a newer plugin release is available — it compares its
|
||||
* installed version against the latest `plugin-v*` GitHub release (cached an
|
||||
* hour server-side, so the app polling this is cheap). Surfaced as a soft,
|
||||
* dismissible "your relay is behind" nudge plus a version readout.
|
||||
*
|
||||
* Optional + fail-soft: an older relay without the route returns 404 → null,
|
||||
* and the app simply shows no update hint.
|
||||
*/
|
||||
suspend fun fetchUpdateCheck(): Result<RelayUpdateInfo?> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
if (relayUrl.isEmpty()) {
|
||||
return@withContext Result.failure(IllegalStateException("Relay URL not configured"))
|
||||
}
|
||||
val sessionToken = sessionTokenProvider()
|
||||
if (sessionToken.isNullOrBlank()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Relay not paired — session token missing")
|
||||
)
|
||||
}
|
||||
val httpBase = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
val url = try {
|
||||
"$httpBase/relay/update-check".toHttpUrl()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}"))
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Authorization", "Bearer $sessionToken")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
// Slightly longer than the other reads — a cache-miss on the relay does a
|
||||
// GitHub round-trip in an executor before responding.
|
||||
val client = okHttpClient.newBuilder()
|
||||
.callTimeout(8, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
try {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) {
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
val reason = when (response.code) {
|
||||
401, 403 -> "Unauthorized — re-pair with the relay"
|
||||
in 500..599 -> "Relay error (HTTP ${response.code})"
|
||||
else -> "HTTP ${response.code}: ${response.message.ifBlank { "request failed" }}"
|
||||
}
|
||||
return@withContext Result.failure(IOException(reason))
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isBlank()) {
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
Result.success(
|
||||
sessionsJson.decodeFromString(RelayUpdateInfo.serializer(), body)
|
||||
)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "fetchUpdateCheck failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchUpdateCheck parse error: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Paired-device management (2026-04-11 security overhaul)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -107,6 +108,27 @@ class RelayVoiceClient(
|
||||
private const val REALTIME_AGENT_WAIT_SLICE_MS = 1_000L
|
||||
private const val REALTIME_INPUT_CHUNK_BYTES = 6_400
|
||||
private const val SESSION_CALL_TIMEOUT_SECONDS = 15L
|
||||
|
||||
/**
|
||||
* Periodic resume retry for the realtime-agent socket. A failed resume
|
||||
* used to park forever on "waiting for route change" — but the relay
|
||||
* holds a detached session (and any background run's result) open for
|
||||
* minutes, so the client should keep knocking until that window closes.
|
||||
*/
|
||||
private const val REALTIME_RESUME_RETRY_INTERVAL_MS = 10_000L
|
||||
private const val REALTIME_RESUME_RETRY_WINDOW_MS = 5 * 60_000L
|
||||
|
||||
/**
|
||||
* Provider "errors" that must NOT end a live realtime turn. Cancelling a
|
||||
* response when none is active (the background-summary re-injection path)
|
||||
* makes xAI emit "Cancellation failed: no active response found" — a benign
|
||||
* notice that should never close the session or dead-end the user with a
|
||||
* Retry. The relay now filters these too; this is defense in depth.
|
||||
*/
|
||||
private fun isTransientRealtimeProviderError(message: String): Boolean {
|
||||
val m = message.lowercase()
|
||||
return m.contains("no active response") || m.contains("cancellation failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionClient(): OkHttpClient =
|
||||
@@ -1232,6 +1254,13 @@ class RelayVoiceClient(
|
||||
onHandoff: (VoiceHandoffEvent) -> Unit = {},
|
||||
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
|
||||
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
|
||||
/**
|
||||
* Open the session + socket without a first turn (voice-mode entry
|
||||
* warm-up). No input is sent and no response is requested; the turn
|
||||
* guards stay disarmed until the first real utterance arrives on
|
||||
* [turnInputs]. Persistent mode only.
|
||||
*/
|
||||
prewarm: Boolean = false,
|
||||
onEvent: (RealtimeVoiceEvent, RealtimeAgentSessionControl) -> Unit,
|
||||
): Result<RealtimeVoiceSummary> = withContext(Dispatchers.IO) {
|
||||
val persistent = turnInputs != null
|
||||
@@ -1269,7 +1298,9 @@ class RelayVoiceClient(
|
||||
val lastEventAtMs = AtomicLong(turnStartedAtMs.get())
|
||||
// True while a turn is awaiting its response. In persistent mode the idle
|
||||
// guard only applies while a turn is active; between-turn idle is normal.
|
||||
val activeTurn = AtomicBoolean(true)
|
||||
// A prewarm open has no turn in flight, so its guards stay disarmed
|
||||
// until the first utterance arrives on the turn channel.
|
||||
val activeTurn = AtomicBoolean(!prewarm)
|
||||
// W3: set true once a turn is known to be a long/background Hermes run
|
||||
// (e.g. `hermes.run.promoted`). The relay can legitimately go quiet for
|
||||
// minutes while such a run executes, so the 90s idle guard would kill an
|
||||
@@ -1277,6 +1308,10 @@ class RelayVoiceClient(
|
||||
// persistent between-turn idle is — REALTIME_AGENT_MAX_TURN_MS remains
|
||||
// the absolute backstop. Reset at every turn boundary.
|
||||
val longRunningTurn = AtomicBoolean(false)
|
||||
// True while a resume attempt has failed and we're between retries —
|
||||
// the periodic retry loop only knocks while this is set; a successful
|
||||
// socket open clears it.
|
||||
val resumeWaiting = AtomicBoolean(false)
|
||||
val inputChunks = buildList {
|
||||
var offset = 0
|
||||
var chunkId = 1L
|
||||
@@ -1390,6 +1425,7 @@ class RelayVoiceClient(
|
||||
webSocket.close(1000, "stale route")
|
||||
return
|
||||
}
|
||||
resumeWaiting.set(false)
|
||||
if (resume) {
|
||||
val resumeToken = session.resumeToken.orEmpty()
|
||||
Log.i(
|
||||
@@ -1491,9 +1527,21 @@ class RelayVoiceClient(
|
||||
}
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
} else if (event.type == "voice.error" || event.type == "voice.session.resume_failed") {
|
||||
} else if (event.type == "voice.session.resume_failed") {
|
||||
completeFailure(event.message ?: "Realtime agent error")
|
||||
webSocket.close(1011, "provider error")
|
||||
} else if (event.type == "voice.error") {
|
||||
val msg = event.message ?: "Realtime agent error"
|
||||
if (isTransientRealtimeProviderError(msg)) {
|
||||
// A benign provider notice (e.g. a cancel with no
|
||||
// active response) must not tear down a live turn —
|
||||
// the reply is often still on its way. The relay now
|
||||
// filters these too; this is defense in depth.
|
||||
Log.i(TAG, "Realtime agent transient provider notice ignored: $msg")
|
||||
} else {
|
||||
completeFailure(msg)
|
||||
webSocket.close(1011, "provider error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1505,7 +1553,8 @@ class RelayVoiceClient(
|
||||
return
|
||||
}
|
||||
if (session.resumeSupported && !session.resumeToken.isNullOrBlank() && resumeAttempted.get()) {
|
||||
Log.i(TAG, "Realtime agent resume websocket failed; waiting for route change: ${t.message}")
|
||||
Log.i(TAG, "Realtime agent resume websocket failed; will retry: ${t.message}")
|
||||
resumeWaiting.set(true)
|
||||
requestRouteProbeOnce("Realtime agent", t.message, routeProbeRequested)
|
||||
onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
@@ -1547,7 +1596,8 @@ class RelayVoiceClient(
|
||||
return
|
||||
}
|
||||
if (code != 1000 && session.resumeSupported && !session.resumeToken.isNullOrBlank() && resumeAttempted.get()) {
|
||||
Log.i(TAG, "Realtime agent resume websocket closed; waiting for route change: $code $reason")
|
||||
Log.i(TAG, "Realtime agent resume websocket closed; will retry: $code $reason")
|
||||
resumeWaiting.set(true)
|
||||
requestRouteProbeOnce("Realtime agent", "Closed $code $reason", routeProbeRequested)
|
||||
onHandoff(
|
||||
VoiceHandoffEvent(
|
||||
@@ -1697,6 +1747,28 @@ class RelayVoiceClient(
|
||||
onHandoff = onHandoff,
|
||||
completeFailure = ::completeFailure,
|
||||
)
|
||||
// Periodic resume retry: a failed resume used to park forever waiting
|
||||
// for a route-change signal, while the relay held the detached session
|
||||
// (and any background run's result) open for minutes. Keep knocking on
|
||||
// an interval until the retry window closes; the route watcher stays as
|
||||
// the fast path when the network actually switches.
|
||||
val resumeRetry: Job? = if (session.resumeSupported && !session.resumeToken.isNullOrBlank()) {
|
||||
launch {
|
||||
val deadline = System.currentTimeMillis() + REALTIME_RESUME_RETRY_WINDOW_MS
|
||||
while (!completed.get() && System.currentTimeMillis() < deadline) {
|
||||
delay(REALTIME_RESUME_RETRY_INTERVAL_MS)
|
||||
if (completed.get() || !resumeWaiting.get()) continue
|
||||
try {
|
||||
Log.i(TAG, "Realtime agent periodic resume retry")
|
||||
openSocket(resume = true)
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "Realtime agent periodic resume retry failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
try {
|
||||
awaitRealtimeAgentCompletion()
|
||||
} catch (e: Exception) {
|
||||
@@ -1705,6 +1777,7 @@ class RelayVoiceClient(
|
||||
Result.failure(IOException(e.message ?: "Realtime agent timed out", e))
|
||||
} finally {
|
||||
routeWatcher?.cancel()
|
||||
resumeRetry?.cancel()
|
||||
turnReader?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -2297,6 +2370,9 @@ class RelayVoiceClient(
|
||||
responseDoneMs = (metrics?.get("response_done_ms") as? JsonPrimitive)?.doubleOrNull,
|
||||
tier = (obj["tier"] as? JsonPrimitive)?.contentOrNull,
|
||||
floor = (obj["floor"] as? JsonPrimitive)?.contentOrNull,
|
||||
activeToolName = (obj["active_tool_name"] as? JsonPrimitive)?.contentOrNull,
|
||||
completedToolCount = (obj["completed_tool_count"] as? JsonPrimitive)?.intOrNull,
|
||||
elapsedMs = (obj["elapsed_ms"] as? JsonPrimitive)?.longOrNull,
|
||||
raw = raw,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
@@ -2669,6 +2745,10 @@ data class RealtimeVoiceEvent(
|
||||
// ADR 33: background-run promotion fields.
|
||||
val tier: String? = null,
|
||||
val floor: String? = null,
|
||||
// hermes.run.progress extras — drive the live background-run chip.
|
||||
val activeToolName: String? = null,
|
||||
val completedToolCount: Int? = null,
|
||||
val elapsedMs: Long? = null,
|
||||
val raw: String,
|
||||
) {
|
||||
val isAudioDelta: Boolean
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
@@ -222,6 +223,34 @@ class ChatHandler {
|
||||
private val _sessions = MutableStateFlow<List<ChatSession>>(emptyList())
|
||||
val sessions: StateFlow<List<ChatSession>> = _sessions.asStateFlow()
|
||||
|
||||
// User-chosen Thread names (sessionId → name), authoritative over the
|
||||
// server's auto-title — applied in [updateSessions] so the gateway's async
|
||||
// auto-titler can't clobber the name. Fed by ChatViewModel. In-memory for
|
||||
// now (survives list refreshes within a session); cross-restart persistence
|
||||
// is a follow-up (see TODO).
|
||||
private val userThreadNames = mutableMapOf<String, String>()
|
||||
|
||||
/** Record a user-chosen name for one Thread session + re-apply it now. */
|
||||
fun setUserThreadName(sessionId: String, name: String) {
|
||||
userThreadNames[sessionId] = name
|
||||
reapplyThreadNames()
|
||||
}
|
||||
|
||||
/** Merge persisted user-thread-names in (e.g. the initial DataStore load) —
|
||||
* merge, not replace, so a just-created name set this session isn't clobbered
|
||||
* by a slightly-stale persisted emission. */
|
||||
fun setUserThreadNames(names: Map<String, String>) {
|
||||
userThreadNames.putAll(names)
|
||||
reapplyThreadNames()
|
||||
}
|
||||
|
||||
private fun reapplyThreadNames() {
|
||||
if (userThreadNames.isEmpty()) return
|
||||
_sessions.update { list ->
|
||||
list.map { s -> userThreadNames[s.sessionId]?.let { s.copy(title = it) } ?: s }
|
||||
}
|
||||
}
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
@@ -308,6 +337,18 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the [ChatMessage.deliveryStatus] of a sent message by id — used by
|
||||
* the agent-Thread reply path (`source=phone`) to move a bubble through
|
||||
* SENDING → DELIVERED (on the relay's `proactive.reply.ack`) / FAILED.
|
||||
* No-op when the id isn't present (it may have aged out of the window).
|
||||
*/
|
||||
fun updateDeliveryStatus(messageId: String, status: MessageDeliveryStatus) {
|
||||
_messages.update { list ->
|
||||
list.map { if (it.id == messageId) it.copy(deliveryStatus = status) else it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -347,6 +388,29 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an agent-initiated message inline in the open Thread as an
|
||||
* ASSISTANT bubble (the unified-Threads live path — the agent's reply shows
|
||||
* in the conversation, not just as a notification). [clientOnly] preserves it
|
||||
* across the history reconcile; idempotent on the proactive [messageId] so a
|
||||
* re-delivered push (e.g. an outbound-buffer flush) never double-posts.
|
||||
*/
|
||||
fun addAgentThreadMessage(text: String, messageId: String?, agentName: String?) {
|
||||
val id = messageId?.let { "proactive-$it" } ?: "proactive-${java.util.UUID.randomUUID()}"
|
||||
_messages.update { list ->
|
||||
if (messageId != null && list.any { it.id == id }) return@update list
|
||||
val msg = ChatMessage(
|
||||
id = id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = text,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
agentName = agentName,
|
||||
clientOnly = true,
|
||||
)
|
||||
(list + msg).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
|
||||
@@ -1390,7 +1454,11 @@ class ChatHandler {
|
||||
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
|
||||
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
|
||||
val serverTitle = item.title?.takeIf { it.isNotBlank() }
|
||||
val resolvedTitle = serverTitle
|
||||
// A user-chosen Thread name is authoritative (Discord-style): it
|
||||
// overrides the server's auto-title so the gateway's async auto-titler
|
||||
// can't clobber the name the user set.
|
||||
val resolvedTitle = userThreadNames[item.id]
|
||||
?: serverTitle
|
||||
?: existingById[item.id]?.title?.takeIf { it.isNotBlank() }
|
||||
ChatSession(
|
||||
sessionId = item.id,
|
||||
@@ -1400,6 +1468,11 @@ class ChatHandler {
|
||||
updatedAt = activityAtMs,
|
||||
startedAt = startedAtMs,
|
||||
lastActivityAt = lastActivityAtMs,
|
||||
// Carry the upstream platform/source so the drawer can tag agent
|
||||
// Threads (source=phone) — this is the one list site fed by the wire
|
||||
// SessionItem; the other ChatSession() call sites are local optimistic
|
||||
// rows (default source). (ADR 12 — Threads surface, slice 1.)
|
||||
source = item.source,
|
||||
)
|
||||
}.sortedByDescending { it.activityTimestamp }
|
||||
// Preserve the active session's optimistic row when the server list
|
||||
|
||||
@@ -82,6 +82,23 @@ data class DashboardChatDisplaySettings(
|
||||
val toolDisplay: String? = null,
|
||||
)
|
||||
|
||||
/** One entry from `GET /api/audio/elevenlabs/voices` — non-secret voice metadata. */
|
||||
data class ElevenLabsVoice(
|
||||
val voiceId: String,
|
||||
val name: String,
|
||||
val label: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Result of `GET /api/audio/elevenlabs/voices`. [available] is false when the
|
||||
* server has no `ELEVENLABS_API_KEY` configured (the picker degrades to a free
|
||||
* text field in that case); true with a populated [voices] list otherwise.
|
||||
*/
|
||||
data class ElevenLabsVoices(
|
||||
val available: Boolean,
|
||||
val voices: List<ElevenLabsVoice>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Native client for the Hermes dashboard/admin server (:9119).
|
||||
*
|
||||
@@ -211,6 +228,48 @@ class DashboardApiClient(
|
||||
suspend fun getChatDisplaySettings(): Result<DashboardChatDisplaySettings> =
|
||||
getJsonObject("/api/config").mapCatching { root -> parseChatDisplaySettings(root) }
|
||||
|
||||
// --- Config tree (dashboard parity with hermes-desktop Settings → config.yaml) ---
|
||||
|
||||
/**
|
||||
* The full runtime config VALUES as a nested tree (model/tts/stt/...).
|
||||
* Upstream strips internal `_`-prefixed keys server-side, so the object is
|
||||
* safe to mutate and round-trip back through [updateConfig].
|
||||
*/
|
||||
suspend fun getConfig(): Result<JsonObject> = getJsonObject("/api/config")
|
||||
|
||||
/**
|
||||
* The config SCHEMA: `{fields: {<dot.path>: {type, description, category,
|
||||
* options?}}, category_order: [...]}`. Describes how to render each field;
|
||||
* pair it with [getConfig] for current values. Note this is distinct from
|
||||
* the values tree — `fields` keys are flat dot-paths, the values are nested.
|
||||
*/
|
||||
suspend fun getConfigSchema(): Result<JsonObject> = getJsonObject("/api/config/schema")
|
||||
|
||||
/**
|
||||
* Replace the runtime config (`PUT /api/config`). Upstream `save_config`
|
||||
* writes the WHOLE document, so [config] MUST be the full values tree
|
||||
* (read [getConfig], mutate, write back) — a partial object would drop
|
||||
* every key it omits. [profile] null/blank targets the launch profile.
|
||||
*/
|
||||
suspend fun updateConfig(config: JsonObject, profile: String? = null): Result<JsonObject> =
|
||||
putJsonObject(
|
||||
path = "/api/config",
|
||||
payload = buildJsonObject {
|
||||
put("config", config)
|
||||
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", it) }
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* ElevenLabs voice catalog for the `tts.elevenlabs.voice_id` picker
|
||||
* (`GET /api/audio/elevenlabs/voices`, dashboard cookie auth). Returns
|
||||
* `available=false` with an empty list when the server has no API key
|
||||
* configured; the API key itself never leaves the server.
|
||||
*/
|
||||
suspend fun getElevenLabsVoices(): Result<ElevenLabsVoices> = withContext(Dispatchers.IO) {
|
||||
getJson("/api/audio/elevenlabs/voices").mapCatching { parseElevenLabsVoices(it) }
|
||||
}
|
||||
|
||||
/** Full provider/model universe — REST twin of the TUI's `model.options` RPC. */
|
||||
suspend fun getModelOptions(): Result<JsonObject> = getJsonObject("/api/model/options")
|
||||
|
||||
@@ -844,6 +903,20 @@ class DashboardApiClient(
|
||||
name.equals("basic", ignoreCase = true) ||
|
||||
name.equals("password", ignoreCase = true)
|
||||
|
||||
fun parseElevenLabsVoices(root: JsonObject): ElevenLabsVoices {
|
||||
val available = root.booleanField("available") ?: false
|
||||
val voices = (root["voices"] as? JsonArray).orEmpty().mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val voiceId = obj.stringField("voice_id") ?: return@mapNotNull null
|
||||
ElevenLabsVoice(
|
||||
voiceId = voiceId,
|
||||
name = obj.stringField("name") ?: voiceId,
|
||||
label = obj.stringField("label") ?: obj.stringField("name") ?: voiceId,
|
||||
)
|
||||
}
|
||||
return ElevenLabsVoices(available = available, voices = voices)
|
||||
}
|
||||
|
||||
fun parseChatDisplaySettings(root: JsonObject): DashboardChatDisplaySettings {
|
||||
val config = root["config"] as? JsonObject
|
||||
val display = (config?.get("display") as? JsonObject)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
/**
|
||||
* Pure helpers for the dashboard config-editing surface (`GET /api/config`,
|
||||
* `GET /api/config/schema`, `PUT /api/config`).
|
||||
*
|
||||
* These are deliberately free of Android / OkHttp dependencies so the
|
||||
* GET → mutate → PUT-whole flow can be unit-tested without a server. The
|
||||
* critical invariant they protect: upstream `save_config` writes the WHOLE
|
||||
* config document, so a write must round-trip the entire values tree with the
|
||||
* one changed leaf replaced — never a partial object. [withConfigValue] /
|
||||
* [applyConfigEdits] build that full tree immutably.
|
||||
*
|
||||
* The schema (`fields`) keys are flat dot-paths (`tts.elevenlabs.voice_id`);
|
||||
* the values tree (`GET /api/config`) is nested. [configValueAt] bridges the
|
||||
* two by walking the dot-path into the nested tree.
|
||||
*/
|
||||
|
||||
/** UI field kinds emitted by upstream `_infer_type` + `_SCHEMA_OVERRIDES`. */
|
||||
enum class ConfigFieldType {
|
||||
String,
|
||||
Number,
|
||||
Boolean,
|
||||
/** A `select` override — render as a dropdown over [ConfigSchemaField.options]. */
|
||||
Select,
|
||||
List,
|
||||
Object,
|
||||
Unknown;
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: kotlin.String?): ConfigFieldType = when (value?.trim()?.lowercase()) {
|
||||
"string" -> String
|
||||
"number", "integer", "float" -> Number
|
||||
"boolean", "bool" -> Boolean
|
||||
"select" -> Select
|
||||
"list", "array" -> List
|
||||
"object", "dict" -> Object
|
||||
else -> Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One editable field from `GET /api/config/schema` `fields`. */
|
||||
data class ConfigSchemaField(
|
||||
/** Flat dot-path, e.g. `tts.elevenlabs.voice_id`. */
|
||||
val key: String,
|
||||
val type: ConfigFieldType,
|
||||
val description: String?,
|
||||
val category: String?,
|
||||
/** Allowed values when [type] is [ConfigFieldType.Select]; empty otherwise. */
|
||||
val options: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse the `fields` map from `GET /api/config/schema` into ordered
|
||||
* [ConfigSchemaField]s. Insertion order is preserved (the server orders
|
||||
* fields meaningfully — e.g. `model` then `model_context_length`).
|
||||
*/
|
||||
fun parseConfigSchema(schemaRoot: JsonObject): List<ConfigSchemaField> {
|
||||
val fields = schemaRoot["fields"] as? JsonObject ?: return emptyList()
|
||||
return fields.mapNotNull { (key, value) ->
|
||||
val obj = value as? JsonObject ?: return@mapNotNull null
|
||||
ConfigSchemaField(
|
||||
key = key,
|
||||
type = ConfigFieldType.fromWire(obj.configString("type")),
|
||||
description = obj.configString("description"),
|
||||
category = obj.configString("category"),
|
||||
options = (obj["options"] as? JsonArray)
|
||||
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
?: emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of schema fields that configure standard-path voice — the
|
||||
* `tts.*` and `stt.*` keys. Filtered by dot-path prefix rather than the
|
||||
* `category` field so it is robust to upstream's category-merging.
|
||||
*/
|
||||
fun voiceConfigFields(fields: List<ConfigSchemaField>): List<ConfigSchemaField> =
|
||||
fields.filter { it.key.startsWith("tts.") || it.key.startsWith("stt.") }
|
||||
|
||||
/** Read the value at a dot-path from the nested config values tree, or null. */
|
||||
fun configValueAt(tree: JsonObject, dotPath: String): JsonElement? {
|
||||
var current: JsonElement = tree
|
||||
for (part in dotPath.split('.')) {
|
||||
val obj = current as? JsonObject ?: return null
|
||||
current = obj[part] ?: return null
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of [tree] with [value] set at [dotPath], creating intermediate
|
||||
* objects as needed. Immutable: the input tree is never mutated, and object
|
||||
* key order is preserved so a round-trip leaves untouched sections byte-stable.
|
||||
*/
|
||||
fun withConfigValue(tree: JsonObject, dotPath: String, value: JsonElement): JsonObject =
|
||||
setIn(tree, dotPath.split('.'), 0, value)
|
||||
|
||||
/** Apply many dot-path edits onto [tree], returning the fully-merged tree. */
|
||||
fun applyConfigEdits(tree: JsonObject, edits: Map<String, JsonElement>): JsonObject {
|
||||
var result = tree
|
||||
for ((path, value) in edits) {
|
||||
result = withConfigValue(result, path, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun setIn(
|
||||
obj: JsonObject,
|
||||
parts: List<String>,
|
||||
index: Int,
|
||||
value: JsonElement,
|
||||
): JsonObject {
|
||||
val key = parts[index]
|
||||
// LinkedHashMap copy preserves existing key order; a new key appends.
|
||||
val next = LinkedHashMap<String, JsonElement>(obj)
|
||||
next[key] = if (index == parts.lastIndex) {
|
||||
value
|
||||
} else {
|
||||
val child = obj[key] as? JsonObject ?: JsonObject(emptyMap())
|
||||
setIn(child, parts, index + 1, value)
|
||||
}
|
||||
return JsonObject(next)
|
||||
}
|
||||
|
||||
private fun JsonObject.configString(name: String): String? =
|
||||
(this[name] as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
|
||||
@@ -172,6 +172,11 @@ class GatewayChatClient(
|
||||
|
||||
private val client: OkHttpClient = (okHttpClient ?: OkHttpClient())
|
||||
.newBuilder()
|
||||
// The 10s default connectTimeout is LAN-tuned; a remote dashboard
|
||||
// reached over Tailscale (DERP cold start) can take longer to complete
|
||||
// the WS upgrade. A failed connect drops chat to the SSE fallback and a
|
||||
// 5s cooldown, so give the first remote handshake room.
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.pingInterval(30, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
@@ -22,10 +22,14 @@ 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".
|
||||
* Opt-in foreground service that holds the app process up so the app's
|
||||
* connection to Hermes survives Android's background-freeze / Doze — i.e.
|
||||
* "persistent connection". Concretely it keeps the gateway chat WebSocket
|
||||
* (held by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]'s
|
||||
* [GatewayChatClient]) open; for relay-paired setups, holding the whole
|
||||
* process up incidentally also keeps the relay WSS — device control and
|
||||
* notification mirroring — reachable. It does NOT warm Manage (stateless
|
||||
* HTTP) or voice (per-turn sockets).
|
||||
*
|
||||
* # Both flavors (Play declaration required)
|
||||
*
|
||||
@@ -56,7 +60,7 @@ class GatewayKeepAliveService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "GatewayKeepAliveSvc"
|
||||
const val CHANNEL_ID = "gateway_keepalive"
|
||||
private const val CHANNEL_NAME = "Background connection"
|
||||
private const val CHANNEL_NAME = "Persistent connection"
|
||||
const val NOTIFICATION_ID = 4713
|
||||
const val ACTION_STOP = "com.hermesandroid.relay.gateway.KEEPALIVE_STOP"
|
||||
|
||||
@@ -146,14 +150,14 @@ class GatewayKeepAliveService : Service() {
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes stays connected")
|
||||
.setContentText("Keeping your chat connection warm in the background.")
|
||||
.setContentTitle("Hermes connection active")
|
||||
.setContentText("Keeping your connection to Hermes open in the background.")
|
||||
.setContentIntent(tapPending)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.addAction(0, "Disconnect", stopPending)
|
||||
.addAction(0, "Turn off", stopPending)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -164,7 +168,7 @@ class GatewayKeepAliveService : Service() {
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).apply {
|
||||
description =
|
||||
"Persistent indicator while Hermes keeps your chat connection open in the background."
|
||||
"Shows while Hermes keeps its connection open in the background so messages and live features stay responsive."
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
@@ -31,23 +32,25 @@ import com.hermesandroid.relay.R
|
||||
* into and should see promptly, so the channel is `IMPORTANCE_HIGH`.
|
||||
*
|
||||
* Tap routes through the existing deep-link path (MainActivity
|
||||
* [MainActivity.EXTRA_NAV_ROUTE] → NavRouteRequest) to the Hermes inbox.
|
||||
* [MainActivity.EXTRA_NAV_ROUTE] → NavRouteRequest) to Chat, where the message
|
||||
* lives as a Thread.
|
||||
*/
|
||||
object ProactiveMessageNotifier {
|
||||
|
||||
private const val TAG = "ProactiveNotifier"
|
||||
private const val CHANNEL_ID = "hermes_proactive"
|
||||
private const val CHANNEL_NAME = "Hermes messages"
|
||||
private const val CHANNEL_NAME = "Threads"
|
||||
|
||||
/** Base for derived notification ids — keeps us clear of other slots. */
|
||||
private const val ID_BASE = 0x48524D00 // "HRM" + 00
|
||||
|
||||
/**
|
||||
* Tap route — the dedicated Hermes inbox (Phase 2a). Must match
|
||||
* `Screen.HermesInbox.route` in RelayApp. Routed via the EXTRA_NAV_ROUTE
|
||||
* deep-link path (MainActivity → NavRouteRequest → RelayApp collector).
|
||||
* Tap route — opens Chat, where the message lives as a Thread. Must match
|
||||
* `Screen.Chat.route()` in RelayApp. Routed via the EXTRA_NAV_ROUTE deep-link
|
||||
* path (MainActivity → NavRouteRequest → RelayApp collector). Opening the
|
||||
* exact Thread by chat_id is a follow-up (see TODO).
|
||||
*/
|
||||
private const val INBOX_ROUTE = "hermes_inbox"
|
||||
private const val TAP_ROUTE = "chat"
|
||||
|
||||
/**
|
||||
* Post (or replace) a proactive-message notification.
|
||||
@@ -55,7 +58,10 @@ object ProactiveMessageNotifier {
|
||||
* @param title Display title; blank falls back to "Hermes".
|
||||
* @param text The agent's message body.
|
||||
* @param messageId Server-assigned id; used to derive a stable slot so a
|
||||
* re-delivery replaces rather than stacks. Blank → a fresh slot.
|
||||
* re-delivery replaces rather than stacks. Blank → a fresh slot. Also
|
||||
* carried to [ProactiveReplyReceiver] as the reply's `reply_to` anchor.
|
||||
* @param chatId Conversation the message belongs to; carried to the reply
|
||||
* receiver so the user's answer continues the same thread.
|
||||
*/
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
fun notify(
|
||||
@@ -63,6 +69,7 @@ object ProactiveMessageNotifier {
|
||||
title: String?,
|
||||
text: String,
|
||||
messageId: String?,
|
||||
chatId: String?,
|
||||
) {
|
||||
ensureChannel(context)
|
||||
if (!hasPostNotificationsPermission(context)) {
|
||||
@@ -73,7 +80,7 @@ object ProactiveMessageNotifier {
|
||||
|
||||
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, INBOX_ROUTE)
|
||||
putExtra(MainActivity.EXTRA_NAV_ROUTE, TAP_ROUTE)
|
||||
}
|
||||
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
// Distinct requestCode per slot so each notification gets its own
|
||||
@@ -92,6 +99,7 @@ object ProactiveMessageNotifier {
|
||||
.setContentText(collapsed)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(expanded))
|
||||
.setContentIntent(tapPending)
|
||||
.addAction(buildReplyAction(context, notificationId, resolvedTitle, messageId, chatId))
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
@@ -101,6 +109,98 @@ object ProactiveMessageNotifier {
|
||||
}.onFailure { Log.w(TAG, "notify failed", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the inline Reply action (Phase 2c). The [RemoteInput] lets the user
|
||||
* type a reply straight from the shade; the broadcast PendingIntent must be
|
||||
* **mutable** so the system can fill the typed text into it before delivery
|
||||
* to [ProactiveReplyReceiver].
|
||||
*/
|
||||
private fun buildReplyAction(
|
||||
context: Context,
|
||||
notificationId: Int,
|
||||
title: String,
|
||||
messageId: String?,
|
||||
chatId: String?,
|
||||
): NotificationCompat.Action {
|
||||
val remoteInput = RemoteInput.Builder(ProactiveReplyReceiver.KEY_REPLY_TEXT)
|
||||
.setLabel("Reply to Hermes")
|
||||
.build()
|
||||
|
||||
val replyIntent = Intent(context, ProactiveReplyReceiver::class.java).apply {
|
||||
action = ProactiveReplyReceiver.ACTION_REPLY
|
||||
putExtra(ProactiveReplyReceiver.EXTRA_MESSAGE_ID, messageId)
|
||||
putExtra(ProactiveReplyReceiver.EXTRA_CHAT_ID, chatId)
|
||||
putExtra(ProactiveReplyReceiver.EXTRA_TITLE, title)
|
||||
putExtra(ProactiveReplyReceiver.EXTRA_NOTIFICATION_ID, notificationId)
|
||||
}
|
||||
// FLAG_MUTABLE is required for RemoteInput on API 31+; the constant is
|
||||
// API 31, so guard the reference (pre-31 PendingIntents are mutable by
|
||||
// default, which is what RemoteInput needs there too).
|
||||
val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val replyPending = PendingIntent.getBroadcast(
|
||||
context,
|
||||
notificationId,
|
||||
replyIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,
|
||||
)
|
||||
|
||||
return NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_menu_send,
|
||||
"Reply",
|
||||
replyPending,
|
||||
)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-post a notification in the same [notificationId] slot to confirm a
|
||||
* sent reply (and clear the system's lingering RemoteInput progress
|
||||
* spinner). Called by [ProactiveReplyReceiver] after a reply is handed off.
|
||||
*
|
||||
* @param delivered false only when the relay wasn't reachable (no live
|
||||
* multiplexer) — the user is told to open the app and retry.
|
||||
*/
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
fun confirmReply(
|
||||
context: Context,
|
||||
notificationId: Int,
|
||||
title: String?,
|
||||
replyText: String,
|
||||
delivered: Boolean,
|
||||
) {
|
||||
ensureChannel(context)
|
||||
if (!hasPostNotificationsPermission(context)) return
|
||||
|
||||
val resolvedTitle = title?.takeIf { it.isNotBlank() } ?: "Hermes"
|
||||
val line = if (delivered) {
|
||||
"You: ${replyText.take(1000)}"
|
||||
} else {
|
||||
"Reply not sent — open the app and try again."
|
||||
}
|
||||
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(resolvedTitle)
|
||||
.setContentText(line.take(120))
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(line))
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
// A confirmation, not a fresh ping — don't re-alert the user.
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
|
||||
runCatching {
|
||||
NotificationManagerCompat.from(context).notify(notificationId, builder.build())
|
||||
}.onFailure { Log.w(TAG, "confirmReply failed", it) }
|
||||
}
|
||||
|
||||
/** Derive a stable notification slot from the message id. */
|
||||
private fun slotFor(messageId: String?): Int {
|
||||
val key = messageId?.takeIf { it.isNotBlank() } ?: return ID_BASE
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.hermesandroid.relay.notifications
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.RemoteInput
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
/**
|
||||
* Captures an inline reply typed into a proactive-message notification and
|
||||
* sends it back to the agent as a `proactive.reply` envelope (Phase 2c — the
|
||||
* inbound half of two-way phone messaging).
|
||||
*
|
||||
* [ProactiveMessageNotifier] attaches a [RemoteInput] Reply action whose
|
||||
* (mutable) PendingIntent targets this receiver, carrying the originating
|
||||
* message's `chat_id` / `message_id` as extras. On reply the system fills the
|
||||
* RemoteInput results in and delivers the broadcast here; we read the text,
|
||||
* push a `proactive.reply` over the live relay WS via [multiplexer], and
|
||||
* re-post the notification as a confirmation (clearing the system's reply
|
||||
* spinner).
|
||||
*
|
||||
* **Reach to the relay mirrors [HermesNotificationCompanion].** A receiver
|
||||
* lives outside the ViewModel scope (and may run in a freshly-spawned process
|
||||
* if the app was killed), so it can't hold a ViewModel reference. It reads the
|
||||
* live [ChannelMultiplexer] from a static slot that [ConnectionViewModel]
|
||||
* injects. When the slot is null (app process gone / never connected) the
|
||||
* reply is dropped best-effort — the same "don't replay while out of range"
|
||||
* semantics as the notification companion — and the confirmation tells the
|
||||
* user to open the app. The in-app inbox reply box is the reliable path when
|
||||
* disconnected.
|
||||
*/
|
||||
class ProactiveReplyReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_REPLY) return
|
||||
|
||||
val text = RemoteInput.getResultsFromIntent(intent)
|
||||
?.getCharSequence(KEY_REPLY_TEXT)
|
||||
?.toString()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
|
||||
val chatId = intent.getStringExtra(EXTRA_CHAT_ID)
|
||||
val messageId = intent.getStringExtra(EXTRA_MESSAGE_ID)
|
||||
val title = intent.getStringExtra(EXTRA_TITLE)
|
||||
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)
|
||||
|
||||
if (text.isEmpty()) {
|
||||
Log.d(TAG, "empty reply text — ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
val delivered = sendReply(text = text, chatId = chatId, replyTo = messageId)
|
||||
|
||||
// Replace the heads-up (and its lingering reply spinner) with a
|
||||
// confirmation. `notificationId` matches the slot the original used.
|
||||
ProactiveMessageNotifier.confirmReply(
|
||||
context = context,
|
||||
notificationId = notificationId,
|
||||
title = title,
|
||||
replyText = text,
|
||||
delivered = delivered,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a `proactive.reply` to the relay multiplexer. Returns true when the
|
||||
* envelope was handed off (the WS layer drops it silently if the relay is
|
||||
* momentarily disconnected); false only when no multiplexer is wired
|
||||
* (process not connected) — that's the case worth telling the user about.
|
||||
*/
|
||||
private fun sendReply(text: String, chatId: String?, replyTo: String?): Boolean {
|
||||
val mux = multiplexer
|
||||
if (mux == null) {
|
||||
Log.i(TAG, "no multiplexer — relay not connected; dropping reply")
|
||||
return false
|
||||
}
|
||||
return runCatching {
|
||||
mux.send(
|
||||
Envelope(
|
||||
channel = "proactive",
|
||||
type = "proactive.reply",
|
||||
payload = buildJsonObject {
|
||||
put("text", text)
|
||||
if (!chatId.isNullOrBlank()) put("chat_id", chatId)
|
||||
if (!replyTo.isNullOrBlank()) put("reply_to", replyTo)
|
||||
put("ts", System.currentTimeMillis())
|
||||
},
|
||||
),
|
||||
)
|
||||
true
|
||||
}.onFailure { Log.w(TAG, "failed to send proactive.reply", it) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ProactiveReplyRcvr"
|
||||
|
||||
/** Explicit action so a stray broadcast can't trigger a send. */
|
||||
const val ACTION_REPLY = "com.hermesandroid.relay.action.PROACTIVE_REPLY"
|
||||
|
||||
/** RemoteInput result key carrying the typed reply text. */
|
||||
const val KEY_REPLY_TEXT = "key_proactive_reply_text"
|
||||
|
||||
const val EXTRA_MESSAGE_ID = "extra_proactive_message_id"
|
||||
const val EXTRA_CHAT_ID = "extra_proactive_chat_id"
|
||||
const val EXTRA_TITLE = "extra_proactive_title"
|
||||
const val EXTRA_NOTIFICATION_ID = "extra_proactive_notification_id"
|
||||
|
||||
/**
|
||||
* Live relay multiplexer, injected by [com.hermesandroid.relay.viewmodel.ConnectionViewModel]
|
||||
* (mirror of [HermesNotificationCompanion.multiplexer]). Null when the
|
||||
* app isn't connected.
|
||||
*/
|
||||
@Volatile
|
||||
var multiplexer: ChannelMultiplexer? = null
|
||||
}
|
||||
}
|
||||
@@ -85,8 +85,6 @@ import com.hermesandroid.relay.ui.components.avatar.LocalPetPlaybackSpeed
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalPetStabilize
|
||||
import com.hermesandroid.relay.ui.components.avatar.PetLoader
|
||||
import com.hermesandroid.relay.ui.components.avatar.SphereAvatar
|
||||
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.ChatTransportStatusBadge
|
||||
import com.hermesandroid.relay.ui.components.ChatTransportTier
|
||||
@@ -139,7 +137,6 @@ import com.hermesandroid.relay.ui.screens.SettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.TerminalScreen
|
||||
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProactiveSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.HermesInboxScreen
|
||||
import com.hermesandroid.relay.ui.screens.VoiceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.prewarmDashboardManage
|
||||
import com.hermesandroid.relay.ui.theme.AppThemes
|
||||
@@ -154,7 +151,6 @@ import com.hermesandroid.relay.network.shared.AutoVoiceAudioClient
|
||||
import com.hermesandroid.relay.network.upstream.DynamicDashboardCookieJar
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceAudioClientAdapter
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStatusTone
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
@@ -259,15 +255,30 @@ sealed class Screen(
|
||||
}
|
||||
}
|
||||
data object ConnectionsSettings : Screen("settings/connections", "Connections", Icons.Filled.Settings)
|
||||
// Level-2 detail for a single connection (tabbed: Overview / Routes /
|
||||
// Advanced / Security). Drilled into from the Connections list. The
|
||||
// `connectionId` path segment survives process death via SavedStateHandle;
|
||||
// the route template registers a typed StringType arg and `route(id)`
|
||||
// builds the concrete URI (mirrors Screen.ProfileInspector).
|
||||
data object ConnectionDetail : Screen(
|
||||
"settings/connections/{connectionId}",
|
||||
"Connection",
|
||||
Icons.Filled.Settings,
|
||||
) {
|
||||
const val ARG_CONNECTION_ID: String = "connectionId"
|
||||
fun route(connectionId: String): String {
|
||||
val encoded = java.net.URLEncoder.encode(connectionId, "UTF-8")
|
||||
.replace("+", "%20")
|
||||
return "settings/connections/$encoded"
|
||||
}
|
||||
}
|
||||
data object VoiceSettings : Screen("voice_settings", "Voice", Icons.Filled.Settings)
|
||||
// === PHASE3-notif-listener-followup ===
|
||||
data object NotificationCompanionSettings :
|
||||
Screen("settings/notifications", "Notification companion", Icons.Filled.Settings)
|
||||
// === END PHASE3-notif-listener-followup ===
|
||||
data object ProactiveSettings :
|
||||
Screen("settings/proactive", "Hermes messages", Icons.Filled.Settings)
|
||||
data object HermesInbox :
|
||||
Screen("hermes_inbox", "Hermes inbox", Icons.Filled.Settings)
|
||||
Screen("settings/proactive", "Threads", Icons.Filled.Settings)
|
||||
data object PermissionsSettings : Screen("settings/permissions", "Permissions", Icons.Filled.Settings)
|
||||
// === PHASE3-safety-rails: bridge safety route ===
|
||||
data object BridgeSafetySettings :
|
||||
@@ -393,10 +404,25 @@ fun RelayApp() {
|
||||
// the entire StateFlow snapshot was preserved across backgrounding
|
||||
// even when the underlying server had died or the network had flipped.
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// Timestamp of the last ON_PAUSE, so ON_RESUME can debounce the re-probe by
|
||||
// how long we were actually away (a quick app-switch skips it).
|
||||
val lastPausedAtMs = remember { mutableStateOf(0L) }
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
connectionViewModel.revalidate()
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> lastPausedAtMs.value = System.currentTimeMillis()
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
// First resume (cold start) forces a probe; otherwise pass
|
||||
// the away-duration so a brief, healthy switch-away skips
|
||||
// the cache-clearing re-probe + Probing badge flash.
|
||||
val awayMs = if (lastPausedAtMs.value == 0L) {
|
||||
Long.MAX_VALUE
|
||||
} else {
|
||||
System.currentTimeMillis() - lastPausedAtMs.value
|
||||
}
|
||||
connectionViewModel.revalidateOnResume(awayMs)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
@@ -791,13 +817,29 @@ fun RelayApp() {
|
||||
// client to follow the new dashboard route instead of stranding the turn on
|
||||
// the dead one.
|
||||
val effectiveApiUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
|
||||
// Debounce a route FLIP before re-acquiring the gateway chat client. The
|
||||
// network-layer hysteresis (ConnectionManager) already keeps _activeEndpoint
|
||||
// stable on a transient endpoint-resolution miss, so effectiveApiUrl should
|
||||
// not flap — this is belt-and-suspenders against any residual sub-second
|
||||
// LAN⇄Tailscale flip, which would otherwise shutdown the warm gateway socket
|
||||
// (when idle) or retarget mid-turn (burning MAX_TURN_REJOINS). The FIRST
|
||||
// acquisition (lastAcquiredApiUrl == null) and non-url key changes
|
||||
// (url unchanged) are NOT delayed, so cold-start connect latency is
|
||||
// unaffected; only a genuine url change waits for a settle window, and if
|
||||
// the url flips back within it the LaunchedEffect cancels + restarts so no
|
||||
// rebuild happens.
|
||||
var lastAcquiredApiUrl by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(streamingEndpoint, serverCapabilities, gatewayAvailability, effectiveApiUrl) {
|
||||
if (lastAcquiredApiUrl != null && lastAcquiredApiUrl != effectiveApiUrl) {
|
||||
delay(750L)
|
||||
}
|
||||
val resolved = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
|
||||
chatViewModel.streamingEndpoint = resolved
|
||||
chatViewModel.sseFallbackEndpoint = connectionViewModel.resolveSseStreamingEndpoint()
|
||||
chatViewModel.updateGatewayClient(
|
||||
if (resolved == "gateway") connectionViewModel.activeGatewayChatClient() else null,
|
||||
)
|
||||
lastAcquiredApiUrl = effectiveApiUrl
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
@@ -819,6 +861,7 @@ fun RelayApp() {
|
||||
val themePreference by connectionViewModel.theme.collectAsState()
|
||||
val appThemeId by connectionViewModel.appTheme.collectAsState()
|
||||
val fontScale by connectionViewModel.fontScale.collectAsState()
|
||||
val appFontId by connectionViewModel.appFont.collectAsState()
|
||||
|
||||
// Resolve the active sphere skin (built-in / adaptive / user-loaded) and
|
||||
// publish it + the full available set so every MorphingSphere picks it up
|
||||
@@ -879,6 +922,7 @@ fun RelayApp() {
|
||||
appThemeId = appThemeId,
|
||||
themePreference = themePreference,
|
||||
fontScale = fontScale,
|
||||
appFontId = appFontId,
|
||||
) {
|
||||
// Surface a crash report from a previous session, if any. Renders a
|
||||
// platform Dialog (own window) so tree position is z-order-agnostic;
|
||||
@@ -917,6 +961,38 @@ fun RelayApp() {
|
||||
}
|
||||
chatViewModel.injectProactiveMessage(text)
|
||||
}
|
||||
// Agent Thread reply path: a send from the chat composer while a
|
||||
// source=phone Thread is open routes over the relay proactive
|
||||
// channel (continues the gateway phone session) instead of a normal
|
||||
// chat send; the relay's per-reply ack settles the bubble's status.
|
||||
chatViewModel.onProactiveReply = { text, chatId, replyTo, messageId ->
|
||||
connectionViewModel.sendProactiveReply(text, chatId, replyTo, messageId)
|
||||
}
|
||||
connectionViewModel.proactiveMessageHandler.onReplyAck = { clientMsgId, status ->
|
||||
chatViewModel.onProactiveReplyAck(clientMsgId, status)
|
||||
}
|
||||
// Unified Threads: render an inbound agent message inline in the open
|
||||
// Thread (suppressing the notification/inbox) when it belongs there.
|
||||
connectionViewModel.proactiveMessageHandler.injectIntoThread = { msg ->
|
||||
chatViewModel.injectThreadMessage(msg)
|
||||
}
|
||||
// Persist + re-apply user-chosen Thread names so a named Thread keeps
|
||||
// its name across restart/reconnect (overrides the gateway auto-title).
|
||||
chatViewModel.onSaveThreadName = { sessionId, name ->
|
||||
connectionViewModel.saveThreadName(sessionId, name)
|
||||
}
|
||||
launch {
|
||||
connectionViewModel.threadNames.collect { names ->
|
||||
chatViewModel.applyPersistedThreadNames(names)
|
||||
}
|
||||
}
|
||||
// Seed reply routing from the relay's /phone/threads (the session→
|
||||
// chat_id map the API omits), so any Thread routes replies correctly.
|
||||
launch {
|
||||
connectionViewModel.phoneThreadChatIds.collect { map ->
|
||||
chatViewModel.seedThreadChatIds(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(onboardingCompleted, postOnboardingRoute) {
|
||||
@@ -981,12 +1057,14 @@ fun RelayApp() {
|
||||
val bridgeReturnAction: (() -> Unit)? = bridgePrimaryReturnRoute?.let { route ->
|
||||
{
|
||||
clearBridgeReturn()
|
||||
// Reliable navigate to the remembered route. saveState +
|
||||
// restoreState no-op'd when the route was Chat (the start
|
||||
// destination), leaving the user stuck on Bridge.
|
||||
navController.navigate(route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
inclusive = false
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1011,6 +1089,7 @@ fun RelayApp() {
|
||||
// without the Chat/Terminal/Bridge/Settings tabs peeking through below.
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val globalConnectionStatus by connectionViewModel.globalConnectionStatus.collectAsState()
|
||||
val postResumeQuiet by connectionViewModel.postResumeQuiet.collectAsState()
|
||||
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
|
||||
val apiHealth by connectionViewModel.apiServerHealth.collectAsState()
|
||||
val relayReady by connectionViewModel.relayReady.collectAsState()
|
||||
@@ -1321,39 +1400,24 @@ fun RelayApp() {
|
||||
val updateHandle = rememberUpdateAvailability()
|
||||
val availableUpdateStatus by updateHandle.visibleStatus
|
||||
|
||||
// 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 &&
|
||||
!suppressGlobalChrome &&
|
||||
!showStartupSphere &&
|
||||
!voiceUiState.voiceMode
|
||||
// Split the connection-status surface by severity (user request): the
|
||||
// frequent transient/active/warning states render as a take-space top
|
||||
// BANNER (content slides down, no overlay), while a persistent ERROR
|
||||
// keeps the floating overlay so it still demands attention. Steady
|
||||
// state is null (buildGlobalConnectionStatus → else null), so the
|
||||
// banner only occupies space during a transition/problem.
|
||||
val connectionStatusIsError =
|
||||
globalConnectionStatus?.tone == ConnectionStatusTone.Error
|
||||
val showConnectionStatusBanner = showConnectionStatusToast && !connectionStatusIsError
|
||||
val showConnectionStatusOverlay = showConnectionStatusToast && connectionStatusIsError
|
||||
val onConnectionStatusBannerClick: () -> Unit = {
|
||||
val title = globalConnectionStatus?.title.orEmpty()
|
||||
val destination = when {
|
||||
title.contains("No Hermes connection", ignoreCase = true) -> Screen.Pair.route()
|
||||
title.contains("dashboard", ignoreCase = true) -> Screen.Manage.route
|
||||
else -> Screen.ConnectionsSettings.route
|
||||
}
|
||||
navController.navigate(destination) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
// Connection status has no top-of-screen surface. The two connections are
|
||||
// surfaced where they matter, never covering or shifting the top:
|
||||
// • Chat/agent (gateway/API) → the chat header SUBTITLE swaps the model
|
||||
// line for "Connecting…"/"Disconnected" when the chat path is down
|
||||
// (WhatsApp-style; see ChatScreen). That's the "can I talk to the
|
||||
// agent?" signal.
|
||||
// • Relay socket (bridge/terminal/relay-voice) → the bottom
|
||||
// RelayStatusStrip's "Reconnecting…" cue only. It never blocks chat,
|
||||
// so it stays ambient. (`connectionReconnecting` below.)
|
||||
// A routine in-progress reconnect surfaces only in the bottom strip.
|
||||
// Computed off the raw status (not the dismiss-gated `toast`) because the
|
||||
// strip cue isn't dismissible — it just mirrors live connection state.
|
||||
// Gated by postResumeQuiet so a benign background→foreground re-handshake
|
||||
// stays fully silent (the health "Connecting" cue used to flash here for a
|
||||
// few seconds and then clear with no "Connected" toast).
|
||||
val connectionReconnecting =
|
||||
globalConnectionStatus?.active == true && !postResumeQuiet &&
|
||||
!suppressGlobalChrome && !showStartupSphere && !voiceUiState.voiceMode
|
||||
// === END v0.4.1 polish ===
|
||||
|
||||
// Multi-connection switcher has moved into the AgentInfoSheet's
|
||||
@@ -1430,27 +1494,17 @@ fun RelayApp() {
|
||||
DemoModeBanner(onConnect = exitDemoToConnect)
|
||||
}
|
||||
|
||||
// Connection status as a take-space banner (non-error). Replaces the
|
||||
// floating ConnectionStatusToast for the frequent transient/active/
|
||||
// warning states so content slides down instead of being covered.
|
||||
AnimatedVisibility(
|
||||
visible = showConnectionStatusBanner,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(200)),
|
||||
) {
|
||||
ConnectionStatusBanner(
|
||||
status = globalConnectionStatus,
|
||||
includeStatusBarPadding = !showUnattendedBanner && !showDemoBanner,
|
||||
onClick = onConnectionStatusBannerClick,
|
||||
)
|
||||
}
|
||||
// Connection status intentionally has NO top-of-screen surface (no
|
||||
// banner, no strip, no float). Chat/agent status rides the chat header
|
||||
// subtitle; the relay socket rides the bottom RelayStatusStrip cue. See
|
||||
// the note at the top of this composable.
|
||||
|
||||
// Transient info/status banner. Sits below the persistent banners and
|
||||
// owns the status-bar inset only when no banner is above it (otherwise
|
||||
// that banner already padded the top — avoid double padding).
|
||||
MessageBannerHost(
|
||||
includeStatusBarPadding =
|
||||
!showUnattendedBanner && !showDemoBanner && !showConnectionStatusBanner,
|
||||
!showUnattendedBanner && !showDemoBanner,
|
||||
)
|
||||
|
||||
// The update banner AND the connection-status indicator now render as
|
||||
@@ -1491,7 +1545,7 @@ fun RelayApp() {
|
||||
// doesn't occupy space above the Scaffold, so it no longer
|
||||
// participates in the top-inset accounting.
|
||||
if (showUnattendedBanner || showDemoBanner || connectionChipVisible ||
|
||||
showMessageBanner || showConnectionStatusBanner
|
||||
showMessageBanner
|
||||
) {
|
||||
Modifier.consumeWindowInsets(WindowInsets.statusBars)
|
||||
} else {
|
||||
@@ -1552,6 +1606,9 @@ fun RelayApp() {
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// Routine in-progress reconnect surfaces here (amber cue)
|
||||
// instead of a take-space banner or a floating toast.
|
||||
reconnecting = connectionReconnecting,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1616,13 +1673,17 @@ fun RelayApp() {
|
||||
},
|
||||
),
|
||||
) { backStackEntry ->
|
||||
// Responsive bubble width based on screen width
|
||||
// Responsive bubble width based on screen width. The "Blend"
|
||||
// chat look favors wider bubbles: on compact phones the cap is
|
||||
// raised so long turns fill most of the row (binding on the
|
||||
// available width minus the assistant avatar gutter) instead
|
||||
// of wrapping early in a narrow column.
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidthDp = configuration.screenWidthDp.dp
|
||||
val maxBubbleWidth = when {
|
||||
screenWidthDp >= 840.dp -> 600.dp // Expanded (tablet)
|
||||
screenWidthDp >= 600.dp -> 480.dp // Medium (landscape / small tablet)
|
||||
else -> 300.dp // Compact (phone portrait)
|
||||
screenWidthDp >= 840.dp -> 640.dp // Expanded (tablet)
|
||||
screenWidthDp >= 600.dp -> 520.dp // Medium (landscape / small tablet)
|
||||
else -> 340.dp // Compact (phone portrait)
|
||||
}
|
||||
|
||||
// Consume-once semantics: ChatScreen only treats the
|
||||
@@ -1722,15 +1783,13 @@ fun RelayApp() {
|
||||
onNavigateToConnections = {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToChat = {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
// Standard back: return to wherever Manage was opened
|
||||
// from (Settings → Hermes management, the agent sheet,
|
||||
// etc.). The prior forced navigate(Chat) with
|
||||
// saveState/restoreState was a no-op at runtime —
|
||||
// navigating to the start destination with restoreState
|
||||
// restored an equivalent stack and nothing moved.
|
||||
onBack = { navController.popBackStack() },
|
||||
onNavigateToBridge = {
|
||||
rememberBridgeReturn(
|
||||
route = Screen.Manage.route,
|
||||
@@ -1799,14 +1858,12 @@ fun RelayApp() {
|
||||
navController.navigate(Screen.BridgeSafetySettings.route)
|
||||
},
|
||||
onNavigateToChat = {
|
||||
// Standard back. The prior navigate(Chat) with
|
||||
// saveState/restoreState was a no-op — Chat is
|
||||
// the start destination, so restoreState just
|
||||
// restored the same stack and nothing moved.
|
||||
clearBridgeReturn()
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToManage = {
|
||||
clearBridgeReturn()
|
||||
@@ -1835,14 +1892,12 @@ fun RelayApp() {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToChat = {
|
||||
// Standard back. The prior navigate(Chat) with
|
||||
// saveState/restoreState was a no-op — Chat is
|
||||
// the start destination, so restoreState just
|
||||
// restored the same stack and nothing moved.
|
||||
clearBridgeReturn()
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToManage = {
|
||||
clearBridgeReturn()
|
||||
@@ -1967,6 +2022,8 @@ fun RelayApp() {
|
||||
} else {
|
||||
val standardVoiceSignInRouteHint by
|
||||
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
||||
val voiceDashboardUrl by
|
||||
connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
VoiceSettingsScreen(
|
||||
voiceViewModel = voiceViewModel,
|
||||
voiceClient = voiceClient,
|
||||
@@ -1975,6 +2032,10 @@ fun RelayApp() {
|
||||
standardVoiceAvailability = standardVoiceAvailability,
|
||||
standardVoiceSignInRouteHint = standardVoiceSignInRouteHint,
|
||||
relayVoiceReady = relayVoiceReady,
|
||||
dashboardUrl = voiceDashboardUrl,
|
||||
dashboardCookieStoreProvider = {
|
||||
connectionViewModel.activeDashboardCookieStore()
|
||||
},
|
||||
onOpenManage = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
@@ -1998,13 +2059,12 @@ fun RelayApp() {
|
||||
composable(Screen.ProactiveSettings.route) {
|
||||
ProactiveSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onOpenInbox = { navController.navigate(Screen.HermesInbox.route) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Screen.HermesInbox.route) {
|
||||
HermesInboxScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onOpenChat = {
|
||||
navController.navigate(Screen.Chat.route()) {
|
||||
popUpTo(Screen.Chat.route()) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
@@ -2031,13 +2091,9 @@ fun RelayApp() {
|
||||
navController.navigate(Screen.ConnectionsSettings.route)
|
||||
},
|
||||
onNavigateToChat = {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
// popBackStack: navigate(Chat) with restoreState
|
||||
// no-ops (Chat is the start destination).
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToManage = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
@@ -2114,52 +2170,11 @@ fun RelayApp() {
|
||||
connections = connectionsList,
|
||||
activeConnectionId = activeId,
|
||||
activeRelayUiState = activeRelayUiState,
|
||||
onReconnectActive = {
|
||||
connectionViewModel.connectRelay()
|
||||
UiMessageBus.status("Reconnecting to relay…")
|
||||
},
|
||||
// Multi-connection: typed VM helpers (Worker B2)
|
||||
// handle the full mutations — rename persists via
|
||||
// ConnectionStore.updateConnection; revoke issues
|
||||
// the server-side /sessions/{prefix} DELETE and
|
||||
// clears local auth; remove deletes the backing
|
||||
// EncryptedSharedPreferences via ConnectionStore.
|
||||
onRenameConnection = { id, newLabel ->
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.renameConnection(id, newLabel)
|
||||
.onFailure { err ->
|
||||
snackbarHostState.showSnackbar(
|
||||
err.message ?: "Rename failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRepairConnection = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
// Wait for the AuthManager swap before the
|
||||
// scanner can apply a QR payload.
|
||||
connectionViewModel.switchConnection(id).join()
|
||||
navController.navigate(Screen.Pair.route(id))
|
||||
}
|
||||
},
|
||||
onRevokeConnection = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
val result = connectionViewModel.revokeConnection(id)
|
||||
if (result.isFailure) {
|
||||
// v1 constraint: revokeConnection only
|
||||
// works on the active connection.
|
||||
// Surface a snackbar so the user
|
||||
// understands why nothing happened.
|
||||
snackbarHostState.showSnackbar(
|
||||
"Only the active connection can be revoked right now",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRemoveConnection = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.removeConnection(id)
|
||||
}
|
||||
// Tapping a connection card drills into its tabbed
|
||||
// detail (Overview / Routes / Advanced / Security),
|
||||
// where rename / re-pair / revoke / remove now live.
|
||||
onOpenConnection = { id ->
|
||||
navController.navigate(Screen.ConnectionDetail.route(id))
|
||||
},
|
||||
onAddConnection = {
|
||||
connectionSwitchScope.launch {
|
||||
@@ -2176,6 +2191,68 @@ fun RelayApp() {
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
// Pass the VM so the list cards can read live status
|
||||
// for the active connection. Null-safe — if the VM
|
||||
// isn't wired (tests, previews), cards degrade to the
|
||||
// flat layout.
|
||||
connectionViewModel = connectionViewModel,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.ConnectionDetail.route,
|
||||
arguments = listOf(
|
||||
navArgument(Screen.ConnectionDetail.ARG_CONNECTION_ID) {
|
||||
type = NavType.StringType
|
||||
},
|
||||
),
|
||||
) { backStackEntry ->
|
||||
val detailId = backStackEntry.arguments
|
||||
?.getString(Screen.ConnectionDetail.ARG_CONNECTION_ID)
|
||||
.orEmpty()
|
||||
com.hermesandroid.relay.ui.screens.ConnectionDetailScreen(
|
||||
connectionId = detailId,
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onReconnect = {
|
||||
connectionViewModel.connectRelay()
|
||||
UiMessageBus.status("Reconnecting to relay…")
|
||||
},
|
||||
onRename = { id, newLabel ->
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.renameConnection(id, newLabel)
|
||||
.onFailure { err ->
|
||||
snackbarHostState.showSnackbar(
|
||||
err.message ?: "Rename failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRepair = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.switchConnection(id).join()
|
||||
navController.navigate(Screen.Pair.route(id))
|
||||
}
|
||||
},
|
||||
onRevoke = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
val result = connectionViewModel.revokeConnection(id)
|
||||
if (result.isFailure) {
|
||||
snackbarHostState.showSnackbar(
|
||||
"Only the active connection can be revoked right now",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRemove = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.removeConnection(id)
|
||||
}
|
||||
},
|
||||
onSwitchToConnection = { id ->
|
||||
connectionSwitchScope.launch {
|
||||
connectionViewModel.switchConnection(id)
|
||||
}
|
||||
},
|
||||
onNavigateToManage = {
|
||||
navController.navigate(Screen.Manage.route) {
|
||||
launchSingleTop = true
|
||||
@@ -2184,13 +2261,6 @@ fun RelayApp() {
|
||||
onNavigateToPairedDevices = {
|
||||
navController.navigate(Screen.PairedDevices.route)
|
||||
},
|
||||
// Pass the VM so the active card can render the
|
||||
// shared EndpointsCard inline AND the unified
|
||||
// Advanced section (manual URL / insecure toggle /
|
||||
// manual pairing code). Null-safe — if the VM
|
||||
// isn't wired (tests, previews), the active card
|
||||
// degrades to the flat layout.
|
||||
connectionViewModel = connectionViewModel,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
@@ -2427,18 +2497,9 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = showConnectionStatusOverlay,
|
||||
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 },
|
||||
)
|
||||
}
|
||||
// Connection status has no surface here (or anywhere at the top).
|
||||
// Chat/agent status rides the chat header subtitle; the relay socket
|
||||
// rides the bottom RelayStatusStrip cue. Only the update banner floats.
|
||||
}
|
||||
|
||||
// (The ConnectionSwitcherSheet modal that used to live here was
|
||||
|
||||
@@ -62,7 +62,9 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
import com.hermesandroid.relay.data.hasSecureProxy
|
||||
import com.hermesandroid.relay.network.relay.ConnectionState
|
||||
import com.hermesandroid.relay.network.relay.RelayUrlDeriver
|
||||
@@ -1203,6 +1205,275 @@ fun ActiveCardSecurityPosture(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes section for the tabbed connection detail (ADR 24 multi-endpoint).
|
||||
* Current-route panel, Tailscale nudges, Re-check / Auto controls, the
|
||||
* per-route list ([EndpointsCard]) and the add/edit [RouteEditorDialog].
|
||||
*
|
||||
* Relocated from the old inline active-card Route block so behavior is
|
||||
* unchanged; because it now owns a dedicated tab it drops the old
|
||||
* "Show available routes (N)" expander and always shows the list.
|
||||
*/
|
||||
@Composable
|
||||
fun ActiveCardRoutesSection(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
connection: Connection,
|
||||
liveState: RelayUiState?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val endpoints: List<EndpointCandidate> by connectionViewModel.observeDeviceEndpoints()
|
||||
.collectAsState(initial = emptyList())
|
||||
val activeEndpoint by connectionViewModel.activeEndpoint.collectAsState()
|
||||
val isTailscaleDetected by connectionViewModel.isTailscaleDetected.collectAsState()
|
||||
// Plain val (not a delegated property) so the `is Done && .winner` smart
|
||||
// cast below resolves — a `by` delegate would break it.
|
||||
val routeProbeStatus: ConnectionViewModel.RouteProbeStatus =
|
||||
connectionViewModel.routeProbeStatus.collectAsState().value
|
||||
val routeProbeOutcomes by connectionViewModel.routeProbeOutcomes.collectAsState()
|
||||
|
||||
var preferredRole by remember(connection.id) {
|
||||
mutableStateOf(connectionViewModel.getPreferredEndpointRole())
|
||||
}
|
||||
val manualOverrideRole by connectionViewModel.manualRouteOverride.collectAsState()
|
||||
val manualSwitchActive = manualOverrideRole != null &&
|
||||
!manualOverrideRole.equals(preferredRole, ignoreCase = true)
|
||||
var routeEditorOpen by remember(connection.id) { mutableStateOf(false) }
|
||||
var routeEditorOriginal by remember(connection.id) {
|
||||
mutableStateOf<EndpointCandidate?>(null)
|
||||
}
|
||||
val hasTailscaleRoute = endpoints.any { it.role.equals("tailscale", ignoreCase = true) }
|
||||
val tailscalePreferred = preferredRole?.equals("tailscale", ignoreCase = true) == true
|
||||
val routeNeedsAttention = activeEndpoint == null && liveState != RelayUiState.Connected
|
||||
val showTailscaleUnavailableHint =
|
||||
hasTailscaleRoute && !isTailscaleDetected && (tailscalePreferred || routeNeedsAttention)
|
||||
val tailscaleLaunchIntent = remember(context) {
|
||||
context.packageManager.getLaunchIntentForPackage("com.tailscale.ipn")
|
||||
}
|
||||
val isRouteProbing = routeProbeStatus is ConnectionViewModel.RouteProbeStatus.Probing
|
||||
val probeCameUpEmpty = activeEndpoint == null &&
|
||||
routeProbeStatus is ConnectionViewModel.RouteProbeStatus.Done &&
|
||||
routeProbeStatus.winner == null
|
||||
val activeRouteLabel = when {
|
||||
activeEndpoint != null -> activeEndpoint!!.displayLabel()
|
||||
isRouteProbing -> "Checking routes…"
|
||||
probeCameUpEmpty -> "No route reachable"
|
||||
else -> "Resolving"
|
||||
}
|
||||
val activeRouteHost = activeEndpoint?.api?.url
|
||||
?: "Using saved URL: ${connection.apiServerUrl.ifBlank { connection.relayUrl }}"
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Choose how this phone reaches Hermes. Features stay separate " +
|
||||
"from the selected route.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Current: $activeRouteLabel",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (probeCameUpEmpty) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
if (isRouteProbing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = activeRouteHost,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (probeCameUpEmpty) {
|
||||
Text(
|
||||
text = "None of the saved routes answered a health probe. " +
|
||||
"Expand the routes below for per-route reasons.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showTailscaleUnavailableHint) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Tailscale route is not active on this phone",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Connect this phone in Tailscale, then re-check routes.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (tailscaleLaunchIntent != null) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
runCatching { context.startActivity(tailscaleLaunchIntent) }
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text("Open Tailscale")
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = { connectionViewModel.probeNow() },
|
||||
enabled = !isRouteProbing,
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text(if (isRouteProbing) "Checking…" else "Re-check")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTailscaleDetected && !hasTailscaleRoute) {
|
||||
// Phone is on Tailscale but this connection has nothing to roam to —
|
||||
// the strongest signal the user wants remote access but never set it
|
||||
// up. Offer the route editor directly.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Phone is on Tailscale — no Tailscale route yet",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Add your server's Tailscale URL so Hermes keeps " +
|
||||
"working when this phone leaves the server's network.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
routeEditorOriginal = null
|
||||
routeEditorOpen = true
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text("Add Tailscale route")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { connectionViewModel.probeNow() },
|
||||
enabled = !isRouteProbing,
|
||||
) {
|
||||
Text(if (isRouteProbing) "Checking…" else "Re-check")
|
||||
}
|
||||
if (preferredRole != null || manualSwitchActive) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
connectionViewModel.setPreferredEndpointRole(null)
|
||||
preferredRole = null
|
||||
},
|
||||
) {
|
||||
Text("Auto")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EndpointsCard(
|
||||
endpoints = endpoints,
|
||||
activeEndpoint = activeEndpoint,
|
||||
isProbing = isRouteProbing,
|
||||
outcomeFor = { candidate ->
|
||||
routeProbeOutcomes[connectionViewModel.routeOutcomeKey(candidate)]
|
||||
},
|
||||
preferredRole = preferredRole,
|
||||
manualOverrideRole = manualOverrideRole,
|
||||
onUseNow = { candidate -> connectionViewModel.useRouteNow(candidate.role) },
|
||||
onCancelUseNow = { connectionViewModel.useRouteNow(null) },
|
||||
onPreferEndpoint = { candidate ->
|
||||
connectionViewModel.setPreferredEndpointRole(candidate.role)
|
||||
preferredRole = candidate.role
|
||||
},
|
||||
onClearPreferred = {
|
||||
connectionViewModel.setPreferredEndpointRole(null)
|
||||
preferredRole = null
|
||||
},
|
||||
onProbeNow = { connectionViewModel.probeNow() },
|
||||
onViewPin = { candidate -> connectionViewModel.lookupEndpointPin(candidate) },
|
||||
onAddRoute = {
|
||||
routeEditorOriginal = null
|
||||
routeEditorOpen = true
|
||||
},
|
||||
onEditRoute = { candidate ->
|
||||
routeEditorOriginal = candidate
|
||||
routeEditorOpen = true
|
||||
},
|
||||
onRemoveRoute = { candidate -> connectionViewModel.removeExtraRoute(candidate) },
|
||||
)
|
||||
|
||||
if (routeEditorOpen) {
|
||||
RouteEditorDialog(
|
||||
original = routeEditorOriginal,
|
||||
onSave = { role, apiUrl, onResult ->
|
||||
connectionViewModel.saveExtraRoute(
|
||||
role = role,
|
||||
apiUrl = apiUrl,
|
||||
original = routeEditorOriginal,
|
||||
onResult = onResult,
|
||||
)
|
||||
},
|
||||
onDismiss = { routeEditorOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered step row for the Manual pairing code fallback. Tightly
|
||||
* coupled to its Card 3 layout — step badge sizing + content shape —
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -20,7 +19,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
@@ -29,7 +27,6 @@ import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -39,7 +36,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
@@ -54,192 +50,34 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionHandoffStatus
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionHandoffTraceEntry
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStatusSnapshot
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStatusTone
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStepState
|
||||
import com.hermesandroid.relay.viewmodel.asConnectionStatusSnapshot
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ConnectionHandoffBanner(
|
||||
status: ConnectionHandoffStatus?,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = false,
|
||||
) {
|
||||
ConnectionStatusBanner(
|
||||
status = status?.asConnectionStatusSnapshot(),
|
||||
modifier = modifier,
|
||||
includeStatusBarPadding = includeStatusBarPadding,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectionStatusBanner(
|
||||
status: ConnectionStatusSnapshot?,
|
||||
modifier: Modifier = Modifier,
|
||||
includeStatusBarPadding: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val current = status ?: return
|
||||
val containerColor = when {
|
||||
current.tone == ConnectionStatusTone.Error -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.86f)
|
||||
current.tone == ConnectionStatusTone.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.62f)
|
||||
current.success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f)
|
||||
current.active -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.86f)
|
||||
}
|
||||
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
|
||||
}
|
||||
val insetModifier = if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.88f))
|
||||
.then(insetModifier)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
) {
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.animateContentSize(animationSpec = tween(durationMillis = 180)),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 34.dp)
|
||||
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when {
|
||||
current.active -> PulsingSyncIcon(contentColor)
|
||||
current.success -> Icon(
|
||||
imageVector = Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
current.tone == ConnectionStatusTone.Warning ||
|
||||
current.tone == ConnectionStatusTone.Error -> Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
else -> Icon(
|
||||
imageVector = Icons.Filled.Sync,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(16.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.labelMedium,
|
||||
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,
|
||||
)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current.active) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 2.dp, max = 2.dp),
|
||||
color = contentColor.copy(alpha = 0.76f),
|
||||
trackColor = contentColor.copy(alpha = 0.16f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val SWIPE_DISMISS_THRESHOLD_PX = 80f
|
||||
|
||||
/**
|
||||
* Floating, in-theme connection status **toast** for connection switches,
|
||||
* network handoffs, and disconnects.
|
||||
* Floating, in-theme status **toast** with an animated multi-step stepper
|
||||
* (checking → ✓/✕).
|
||||
*
|
||||
* 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.
|
||||
* NOTE: currently **not wired** into the app — connection status now lives in the
|
||||
* chat header subtitle (chat/agent) + the bottom RelayStatusStrip cue (relay
|
||||
* socket), with nothing at the top. This is **intentionally kept as a parked,
|
||||
* general-purpose toast primitive**: it's the only notification surface with a
|
||||
* live multi-step stepper, so it's the natural home for any future "N-step
|
||||
* progress" moment (pairing, long upload, a bridge action sequence). When first
|
||||
* reused, decouple it from [ConnectionStatusSnapshot] and rename to a generic
|
||||
* `StatusToast`. It also anchors [UpdateAvailableBanner]'s visual language + the
|
||||
* shared [ConnectionStepRow]/[StepGlyph] helpers. See TODO.md.
|
||||
*
|
||||
* Rendered as a top-aligned overlay inside a `Box` — it slides down OVER the UI
|
||||
* without shifting layout. 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).
|
||||
@@ -555,21 +393,3 @@ private fun StepGlyph(state: ConnectionStepState, contentColor: Color) {
|
||||
modifier = Modifier.width(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PulsingSyncIcon(color: androidx.compose.ui.graphics.Color) {
|
||||
// Throttled to ~30fps. Reverse ping-pong over 0.9s each way → a 1.8s linear
|
||||
// phase folded into a 0→1→0 triangle. See [rememberAmbientPhase].
|
||||
val phase = rememberAmbientPhase(periodMillis = 1800)
|
||||
val triangle = 1f - kotlin.math.abs(2f * phase - 1f)
|
||||
val alpha = 0.45f + 0.55f * triangle
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Sync,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.alpha(alpha),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1447,12 +1447,16 @@ fun AgentInfoSheet(
|
||||
.extractDefaultLabel(apiServerUrl)
|
||||
.takeIf { it.isNotBlank() }
|
||||
val relayConnected = relayConnectionState == ConnectionState.Connected
|
||||
val threadsActive = connectionViewModel.proactiveEnabled.collectAsState().value &&
|
||||
connectionViewModel.authState.collectAsState().value is
|
||||
com.hermesandroid.relay.auth.AuthState.Paired
|
||||
val sessionCaps = sessionCapabilities(
|
||||
transport = sessionTransport,
|
||||
gatewayAvailability = gatewayAvailability,
|
||||
relayConnected = relayConnected,
|
||||
relayConfigured = relayUrl.isNotBlank(),
|
||||
voiceReady = voiceReady,
|
||||
threadsActive = threadsActive,
|
||||
)
|
||||
SessionPathSummary(
|
||||
transport = sessionTransport,
|
||||
|
||||
@@ -33,9 +33,10 @@ import com.hermesandroid.relay.data.Connection
|
||||
* Each row is a radio selection — tapping commits immediately and dismisses
|
||||
* the sheet so the swap kicks off before the user's finger is off the screen.
|
||||
*
|
||||
* The "Manage connections…" footer button navigates to
|
||||
* [ConnectionsSettingsScreen] for rename / re-pair / revoke / remove —
|
||||
* anything beyond plain switching.
|
||||
* The "Manage connections…" footer button navigates to the Connections list
|
||||
* (`ConnectionsSettingsScreen`); each card there drills into a tabbed detail
|
||||
* screen that owns rename / re-pair / revoke / remove, routes, advanced setup,
|
||||
* and relay sessions — anything beyond plain switching.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
||||
@@ -4,20 +4,41 @@ import androidx.compose.foundation.horizontalScroll
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.ContentCopy
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import com.mikepenz.markdown.compose.components.markdownComponents
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
|
||||
@@ -44,17 +65,73 @@ fun MarkdownContent(
|
||||
Markdown(
|
||||
content = content,
|
||||
modifier = modifier,
|
||||
// Code surfaces must contrast against the bubble (which is itself
|
||||
// surfaceVariant for assistant turns) or code reads as invisible. The
|
||||
// block uses the lowest container (a darker inset in dark themes, a
|
||||
// clean white inset in light), inline code a subtle raised step.
|
||||
colors = markdownColor(
|
||||
text = textColor,
|
||||
codeBackground = MaterialTheme.colorScheme.surfaceVariant,
|
||||
inlineCodeBackground = MaterialTheme.colorScheme.surfaceVariant
|
||||
codeBackground = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
inlineCodeBackground = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
),
|
||||
// Chat-tuned type ramp. Left unset, the mikepenz M3 defaults map headings
|
||||
// to DISPLAY roles (in this app's scale h1=displayLarge 57sp, h2=displayMedium
|
||||
// ~45sp, h3=displaySmall 36sp) — a single `#` becomes a billboard inside the
|
||||
// ~272dp bubble. Here every level derives from bodyLarge/bodyMedium (so the
|
||||
// live font-picker still applies) and is capped so the largest heading is
|
||||
// ~1.4x the 14sp body, matching Discord / GitHub-mobile in-message headings.
|
||||
typography = markdownTypography(
|
||||
h1 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold, color = textColor,
|
||||
),
|
||||
h2 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.Bold, color = textColor,
|
||||
),
|
||||
h3 = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold, color = textColor,
|
||||
),
|
||||
h4 = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 15.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold, color = textColor,
|
||||
),
|
||||
h5 = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.Bold, color = textColor,
|
||||
),
|
||||
h6 = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.4.sp,
|
||||
color = textColor.copy(alpha = 0.85f),
|
||||
),
|
||||
// Prose, list items, and quotes all sit at the 14sp body size so a
|
||||
// paragraph and the bullet list under it share one rhythm — the library
|
||||
// default 'text'/list role is bodyLarge (16sp), 2sp larger than paragraph.
|
||||
paragraph = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
text = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
bullet = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
ordered = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
list = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
quote = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontStyle = FontStyle.Italic, color = textColor.copy(alpha = 0.78f),
|
||||
),
|
||||
// Inline + fenced code at 13sp (one step under body, not two): monospace
|
||||
// + the tinted chip already signal "code" without also shrinking it, and
|
||||
// the loose 0.4sp default tracking is reset to 0 for tighter token runs.
|
||||
code = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
inlineCode = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontSize = 13.sp, letterSpacing = 0.sp,
|
||||
fontFamily = FontFamily.Monospace, color = textColor,
|
||||
),
|
||||
// Links get an accent color + underline so they read as tappable on the
|
||||
// muted assistant bubble (the default textLink is body-colored).
|
||||
textLink = TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
components = markdownComponents(
|
||||
codeBlock = {
|
||||
@@ -108,30 +185,46 @@ fun StreamingMarkdownContent(
|
||||
|
||||
@Composable
|
||||
private fun StreamingCodeBlock(block: StreamingMarkdownBlock.Code) {
|
||||
// Discord-like fenced block: a contrasting inset surface with a thin header
|
||||
// (language label + copy), and a horizontally-scrollable monospace body.
|
||||
// Header is shown whenever there's a language to label or code to copy, so
|
||||
// even a bare ``` fence gets the copy affordance once it has content.
|
||||
val hasHeader = block.language.isNotBlank() || block.code.isNotBlank()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.82f),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
if (block.language.isNotBlank()) {
|
||||
Text(
|
||||
text = block.language,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.68f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column {
|
||||
if (hasHeader) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 12.dp, end = 4.dp, top = 2.dp, bottom = 2.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = block.language.ifBlank { "code" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
CodeCopyButton(code = block.code)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = block.code.ifEmpty { " " },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
@@ -142,6 +235,38 @@ private fun StreamingCodeBlock(block: StreamingMarkdownBlock.Code) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small copy affordance for a code block — copies [code] to the clipboard and
|
||||
* briefly flips to a check for feedback. No-op while [code] is blank.
|
||||
*/
|
||||
@Composable
|
||||
private fun CodeCopyButton(code: String) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(copied) {
|
||||
if (copied) {
|
||||
delay(1500)
|
||||
copied = false
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (code.isNotBlank()) {
|
||||
clipboard.setText(AnnotatedString(code))
|
||||
copied = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
|
||||
contentDescription = if (copied) "Copied" else "Copy code",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface StreamingMarkdownBlock {
|
||||
data class Text(val text: String) : StreamingMarkdownBlock
|
||||
data class Code(val language: String, val code: String) : StreamingMarkdownBlock
|
||||
|
||||
@@ -6,7 +6,9 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -45,9 +47,12 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -55,10 +60,12 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.BlurMode
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.MediaSettingsRepository
|
||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.theme.leftEdgeGlow
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -192,8 +199,15 @@ fun MessageBubble(
|
||||
val blurMode by blurRepo.blurMode.collectAsState(initial = BlurMode.FLAGGED)
|
||||
|
||||
CompositionLocalProvider(LocalMediaBlurMode provides blurMode) {
|
||||
Column(
|
||||
// Identity (the active profile avatar) is shown once in the top bar, so
|
||||
// message bubbles no longer reserve a per-group avatar gutter — that width
|
||||
// is reclaimed for wider bubbles. Outer alignment keeps user bubbles right.
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = alignment
|
||||
) {
|
||||
// Agent name label (above assistant bubbles, only first in group), with
|
||||
@@ -300,6 +314,7 @@ 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) }
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val showEditAction = onEditMessage != null && isUser
|
||||
if (onQuoteMessage != null || showEditAction) {
|
||||
DropdownMenu(
|
||||
@@ -349,6 +364,10 @@ fun MessageBubble(
|
||||
.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = {
|
||||
// Buzz the instant the long-press registers — opening the
|
||||
// action menu is the discoverability moment, so it gets the
|
||||
// same tactile confirm every chat app fires.
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
if (onQuoteMessage != null || showEditAction) {
|
||||
showMessageActions = true
|
||||
} else {
|
||||
@@ -358,7 +377,7 @@ fun MessageBubble(
|
||||
)
|
||||
.semantics { contentDescription = a11yDescription }
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 9.dp)) {
|
||||
SelectionContainer {
|
||||
if (isUser || isSystem) {
|
||||
// Plain text for user and system messages
|
||||
@@ -445,8 +464,12 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming indicator
|
||||
if (message.isStreaming) {
|
||||
// Streaming indicator — only while awaiting the first token. Once
|
||||
// text starts flowing, the growing reply is itself the progress
|
||||
// signal, so the pulsing dots stop (Messenger/Telegram drop the
|
||||
// typing bubble the moment content appears) instead of throbbing
|
||||
// under the text for the whole turn.
|
||||
if (message.isStreaming && message.content.isBlank()) {
|
||||
// After a few seconds with no content yet, escalate the bare
|
||||
// dots to a labeled "Still working…" so a slow first token
|
||||
// never reads as a hang on the SSE / sessions paths.
|
||||
@@ -488,13 +511,35 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamp
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = 0.5f)
|
||||
)
|
||||
// Timestamp — only on the LAST bubble of a same-author run so a
|
||||
// burst of fragments doesn't stack three near-touching time labels.
|
||||
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
|
||||
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
|
||||
if (isLastInGroup) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
// Delivery status — only on agent-Thread reply bubbles (a user
|
||||
// message routed over the relay proactive channel). Null on every
|
||||
// ordinary chat message, which render nothing here.
|
||||
message.deliveryStatus?.takeIf { isUser }?.let { status ->
|
||||
val (label, alpha) = when (status) {
|
||||
MessageDeliveryStatus.SENDING -> "Sending…" to 0.5f
|
||||
MessageDeliveryStatus.DELIVERED -> "Delivered" to 0.5f
|
||||
MessageDeliveryStatus.FAILED -> "Not sent" to 0.7f
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = alpha),
|
||||
)
|
||||
}
|
||||
|
||||
// Token display (assistant messages only)
|
||||
if (!isUser && (message.inputTokens != null || message.outputTokens != null)) {
|
||||
@@ -508,7 +553,8 @@ fun MessageBubble(
|
||||
}
|
||||
} // end Row (bubble + optional leading accent bar)
|
||||
} // end if (showBubble)
|
||||
}
|
||||
} // end content Column
|
||||
} // end Row (avatar gutter + content)
|
||||
} // end CompositionLocalProvider(LocalMediaBlurMode)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -12,19 +13,23 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import com.hermesandroid.relay.ui.theme.relayPanel
|
||||
import kotlin.math.abs
|
||||
|
||||
@Composable
|
||||
fun RelayStatusStrip(
|
||||
@@ -35,6 +40,12 @@ fun RelayStatusStrip(
|
||||
onClick: (() -> Unit)? = null,
|
||||
/** Optional security marker rendered just before the route label. */
|
||||
securityGlyph: (@Composable () -> Unit)? = null,
|
||||
/**
|
||||
* When true, the strip shows an amber "Reconnecting…" cue in place of the
|
||||
* route label. This is where a **routine** in-progress relay reconnect
|
||||
* surfaces — the top chrome stays empty so chat content never shifts.
|
||||
*/
|
||||
reconnecting: Boolean = false,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
@@ -70,8 +81,11 @@ fun RelayStatusStrip(
|
||||
if (securityGlyph != null) {
|
||||
securityGlyph()
|
||||
}
|
||||
if (routeLabel.isNotBlank()) {
|
||||
Text(
|
||||
// Route is in flux mid-reconnect, so the amber cue replaces the
|
||||
// route label rather than stacking beside it in the 22dp strip.
|
||||
when {
|
||||
reconnecting -> ReconnectingCue(modifier = Modifier.weight(1f))
|
||||
routeLabel.isNotBlank() -> Text(
|
||||
text = "· $routeLabel",
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
@@ -92,3 +106,37 @@ fun RelayStatusStrip(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Amber "· Reconnecting…" cue with a softly pulsing dot. This is the *only*
|
||||
* surface for a routine in-progress relay reconnect — the top of the app stays
|
||||
* empty (chat/agent status rides the chat header subtitle) so nothing shifts.
|
||||
* Pulse is frame-throttled via [rememberAmbientPhase] to avoid pinning the
|
||||
* window at panel refresh.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReconnectingCue(modifier: Modifier = Modifier) {
|
||||
val phase = rememberAmbientPhase(periodMillis = 1200)
|
||||
val triangle = 1f - abs(2f * phase - 1f)
|
||||
val dotAlpha = 0.4f + 0.6f * triangle
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.alpha(dotAlpha)
|
||||
.background(RelayRefresh.Amber),
|
||||
)
|
||||
Text(
|
||||
text = "Reconnecting…",
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Amber,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -17,9 +18,13 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
@@ -36,6 +41,7 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -48,6 +54,7 @@ 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.platform.LocalLocale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -60,6 +67,7 @@ import java.util.Locale
|
||||
|
||||
private enum class SessionDrawerFilter(val label: String) {
|
||||
All("All"),
|
||||
Threads("Threads"),
|
||||
Pinned("Pinned"),
|
||||
Archive("Archive"),
|
||||
}
|
||||
@@ -77,9 +85,28 @@ fun SessionDrawerContent(
|
||||
onNewChat: () -> Unit,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onDeleteSession: (String) -> Unit,
|
||||
onRenameSession: (String, String) -> Unit
|
||||
onRenameSession: (String, String) -> Unit,
|
||||
/**
|
||||
* When true, the Threads affordance (header spool + filter chip) shows even with no
|
||||
* Thread sessions present yet — i.e. the relay Threads capability is paired + opted in
|
||||
* (slice 5 wires this from ConnectionViewModel). Until then the affordance is purely
|
||||
* data-driven: it appears whenever at least one `source=phone` session is in the list.
|
||||
*/
|
||||
threadsCapabilityActive: Boolean = false,
|
||||
/**
|
||||
* Create a new agent Thread with the given name (Discord-style "+ New
|
||||
* Thread"). Null hides the affordance; when set it shows in the Threads
|
||||
* filter view. The first message the user types opens the conversation.
|
||||
*/
|
||||
onNewThread: ((String) -> Unit)? = null,
|
||||
/** Gateway sources currently hidden from the drawer (default: cron+webhook). */
|
||||
hiddenSources: Set<String> = emptySet(),
|
||||
/** Toggle a source's visibility (persisted). Null hides the source filter. */
|
||||
onToggleSourceHidden: ((String, Boolean) -> Unit)? = null,
|
||||
) {
|
||||
var renameDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var newThreadDialog by remember { mutableStateOf(false) }
|
||||
var sourceFilterOpen by remember { mutableStateOf(false) }
|
||||
var deleteDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
|
||||
@@ -88,17 +115,43 @@ fun SessionDrawerContent(
|
||||
val listState = rememberLazyListState()
|
||||
var scrollToTopPending by remember { mutableStateOf(false) }
|
||||
val trimmedQuery = query.trim()
|
||||
// Threads affordance shows when the capability is active OR there's already at least one
|
||||
// agent Thread (source=phone) in the list. If the filter is on Threads but they've
|
||||
// vanished, fall back to All so the drawer never gets stuck on an empty hidden filter.
|
||||
val showThreads = threadsCapabilityActive || sessions.any { isThreadSource(it.source) }
|
||||
val activeFilter = if (filter == SessionDrawerFilter.Threads && !showThreads) {
|
||||
SessionDrawerFilter.All
|
||||
} else {
|
||||
filter
|
||||
}
|
||||
// External gateway sources present (discord/telegram/cron/…) for the source
|
||||
// filter dropdown. Own chats (tui/api_server) + phone Threads aren't listed.
|
||||
val presentSources = sessions
|
||||
.mapNotNull { it.source?.trim()?.lowercase()?.takeIf { s -> s.isNotBlank() } }
|
||||
.distinct()
|
||||
.filter { sourceBadge(it) != null }
|
||||
.sorted()
|
||||
val visibleSessions = sessions
|
||||
.asSequence()
|
||||
.filter { session ->
|
||||
when (filter) {
|
||||
when (activeFilter) {
|
||||
SessionDrawerFilter.All -> session.sessionId !in archivedSessionIds
|
||||
SessionDrawerFilter.Threads ->
|
||||
isThreadSource(session.source) &&
|
||||
session.sessionId !in archivedSessionIds
|
||||
SessionDrawerFilter.Pinned ->
|
||||
session.sessionId in pinnedSessionIds &&
|
||||
session.sessionId !in archivedSessionIds
|
||||
SessionDrawerFilter.Archive -> session.sessionId in archivedSessionIds
|
||||
}
|
||||
}
|
||||
.filter { session ->
|
||||
// Source visibility (default hides cron+webhook) — only on the "All"
|
||||
// view; Threads/Pinned/Archive show their full set.
|
||||
if (activeFilter != SessionDrawerFilter.All) return@filter true
|
||||
val src = session.source?.trim()?.lowercase()
|
||||
src == null || src !in hiddenSources
|
||||
}
|
||||
.filter { session ->
|
||||
val needle = trimmedQuery
|
||||
needle.isBlank() ||
|
||||
@@ -155,6 +208,90 @@ fun SessionDrawerContent(
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Source filter — show/hide gateway sources (default hides the
|
||||
// noisy cron+webhook). Only when external sources are present.
|
||||
if (onToggleSourceHidden != null && presentSources.isNotEmpty()) {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { sourceFilterOpen = true },
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.FilterList,
|
||||
contentDescription = "Filter by source",
|
||||
tint = if (presentSources.any { it in hiddenSources }) {
|
||||
RelayRefresh.Relay
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = sourceFilterOpen,
|
||||
onDismissRequest = { sourceFilterOpen = false },
|
||||
) {
|
||||
Text(
|
||||
text = "Show sources",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
presentSources.forEach { src ->
|
||||
val badge = sourceBadge(src)
|
||||
val shown = src !in hiddenSources
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = badge?.label ?: src,
|
||||
color = if (shown) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
if (shown) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = badge?.color ?: RelayRefresh.Relay,
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
},
|
||||
onClick = { onToggleSourceHidden(src, shown) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Threads affordance — a clean thread-spool that toggles the Threads
|
||||
// filter. Shown only when the Threads capability is active (or a Thread is
|
||||
// already present), so an ordinary no-relay drawer is visually unchanged.
|
||||
if (showThreads) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
filter = if (filter == SessionDrawerFilter.Threads) {
|
||||
SessionDrawerFilter.All
|
||||
} else {
|
||||
SessionDrawerFilter.Threads
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
ThreadSpoolGlyph(
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (activeFilter == SessionDrawerFilter.Threads) {
|
||||
RelayRefresh.Relay
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
// Manual re-pull: the server titles a session asynchronously after
|
||||
// the first turn (and never pushes a rename), so a refresh is the
|
||||
// way to pick up a title the auto-reconcile window missed.
|
||||
@@ -205,20 +342,45 @@ fun SessionDrawerContent(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
SessionDrawerFilter.entries.forEach { item ->
|
||||
FilterChip(
|
||||
selected = filter == item,
|
||||
onClick = { filter = item },
|
||||
label = {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = relayMetadataStyle(),
|
||||
)
|
||||
},
|
||||
SessionDrawerFilter.entries
|
||||
.filter { it != SessionDrawerFilter.Threads || showThreads }
|
||||
.forEach { item ->
|
||||
FilterChip(
|
||||
selected = activeFilter == item,
|
||||
onClick = { filter = item },
|
||||
label = {
|
||||
if (item == SessionDrawerFilter.Threads) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(text = item.label, style = relayMetadataStyle())
|
||||
BetaChip()
|
||||
}
|
||||
} else {
|
||||
Text(text = item.label, style = relayMetadataStyle())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
// "+ New Thread" — Discord-style user-created thread, shown when the
|
||||
// Threads filter is active. The first message opens the conversation.
|
||||
if (activeFilter == SessionDrawerFilter.Threads && onNewThread != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(
|
||||
onClick = { newThreadDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ThreadSpoolGlyph(
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("New Thread")
|
||||
}
|
||||
}
|
||||
if (!autoTitlesSupported) {
|
||||
@@ -315,6 +477,40 @@ fun SessionDrawerContent(
|
||||
}
|
||||
}
|
||||
|
||||
// New Thread dialog (Discord-style): name a fresh agent Thread.
|
||||
if (newThreadDialog) {
|
||||
var threadName by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { newThreadDialog = false },
|
||||
title = { Text("New Thread") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = threadName,
|
||||
onValueChange = { threadName = it },
|
||||
label = { Text("Thread name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val name = threadName.trim()
|
||||
if (name.isNotBlank()) {
|
||||
onNewThread?.invoke(name)
|
||||
newThreadDialog = false
|
||||
}
|
||||
}) {
|
||||
Text("Create")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { newThreadDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Rename dialog
|
||||
renameDialogSession?.let { session ->
|
||||
var newTitle by remember(session) { mutableStateOf(session.title ?: "") }
|
||||
@@ -421,6 +617,34 @@ private fun SessionItem(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Agent Thread tag — the clean spool + "Thread", so a source=phone
|
||||
// conversation reads as its own lane in the unified session list (ADR 12).
|
||||
if (isThreadSource(session.source)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(RelayRefresh.Relay.copy(alpha = 0.16f))
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp),
|
||||
) {
|
||||
ThreadSpoolGlyph(
|
||||
modifier = Modifier.size(11.dp),
|
||||
tint = RelayRefresh.Relay,
|
||||
)
|
||||
Text(
|
||||
text = "Thread",
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Relay,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Source badge — external gateway origin (Discord / Telegram /
|
||||
// Cron / Webhook / …); null for own chats + the phone Thread.
|
||||
sourceBadge(session.source)?.let { badge ->
|
||||
SourceChip(badge)
|
||||
}
|
||||
sessionTimestampText(session, locale)?.let { timestamp ->
|
||||
Text(
|
||||
text = timestamp,
|
||||
|
||||
@@ -140,6 +140,7 @@ internal fun sessionCapabilities(
|
||||
relayConnected: Boolean,
|
||||
relayConfigured: Boolean,
|
||||
voiceReady: Boolean,
|
||||
threadsActive: Boolean = false,
|
||||
): List<SessionCapability> {
|
||||
val liveThinkingReason = when {
|
||||
transport.isGateway -> null
|
||||
@@ -177,6 +178,15 @@ internal fun sessionCapabilities(
|
||||
available = voiceReady,
|
||||
reason = if (voiceReady) null else "Voice not ready on this connection.",
|
||||
),
|
||||
SessionCapability(
|
||||
label = "Threads",
|
||||
available = threadsActive,
|
||||
reason = if (threadsActive) {
|
||||
null
|
||||
} else {
|
||||
"Pair the relay and turn on “Let Hermes message me” so the agent can open Threads."
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,7 +255,17 @@ internal fun SessionPathSummary(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
activeCaps.forEach { cap ->
|
||||
CapabilityChip(label = cap.label)
|
||||
if (cap.label == "Threads") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
CapabilityChip(label = cap.label)
|
||||
BetaChip()
|
||||
}
|
||||
} else {
|
||||
CapabilityChip(label = cap.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
|
||||
/** A session's originating gateway/source, classified for a drawer badge. */
|
||||
data class SourceBadge(val label: String, val color: Color)
|
||||
|
||||
/**
|
||||
* The app's own chats — no badge (they're "your" conversations from this app /
|
||||
* desktop / API, not a distinct gateway lane).
|
||||
*/
|
||||
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "")
|
||||
|
||||
/**
|
||||
* Map a session `source` to a drawer badge, or null for the app's own chats and
|
||||
* the phone **Thread** (which renders its own thread-spool chip). External
|
||||
* gateways — discord / telegram / slack / cron / webhook / web / … — each get a
|
||||
* small colored chip so the drawer reads like the desktop's per-channel tags.
|
||||
* Confirmed live sources: tui, cli, api_server, web, discord, telegram, cron,
|
||||
* webhook, phone.
|
||||
*/
|
||||
fun sourceBadge(source: String?): SourceBadge? {
|
||||
val s = source?.trim()?.lowercase() ?: return null
|
||||
if (s.isBlank() || s in OWN_CHAT_SOURCES || s == "phone") return null
|
||||
return when (s) {
|
||||
"discord" -> SourceBadge("Discord", Color(0xFF5865F2))
|
||||
"telegram" -> SourceBadge("Telegram", Color(0xFF229ED9))
|
||||
"slack" -> SourceBadge("Slack", Color(0xFF8E3A93))
|
||||
"cron" -> SourceBadge("Cron", Color(0xFFB78A2E))
|
||||
"webhook" -> SourceBadge("Webhook", Color(0xFF2E9B8F))
|
||||
"web" -> SourceBadge("Web", Color(0xFF6E7787))
|
||||
else -> SourceBadge(s.replaceFirstChar { it.uppercase() }, RelayRefresh.Relay)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small colored source chip (e.g. "Discord", "Cron") for a drawer row. */
|
||||
@Composable
|
||||
fun SourceChip(badge: SourceBadge, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = badge.label,
|
||||
style = relayMetadataStyle(),
|
||||
color = badge.color,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(badge.color.copy(alpha = 0.16f))
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Quiet amber "Beta" chip — Threads is feature-complete enough to use but
|
||||
* gated below a full release (live `/api/ws` foreground transport, unread, etc). */
|
||||
@Composable
|
||||
fun BetaChip(modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = "Beta",
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Amber,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(RelayRefresh.Amber.copy(alpha = 0.18f))
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.StrokeJoin
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
|
||||
/**
|
||||
* Clean "thread-spool" glyph for the agent **Threads** lane — a message trunk that
|
||||
* branches and curls down into a second message (a threaded conversation), drawn with
|
||||
* round-capped strokes so it stays crisp at small sizes.
|
||||
*
|
||||
* Deliberately NOT a phone glyph: a Thread is a source-tagged chat session the agent can
|
||||
* start, not "the phone". Brand-tinted via [tint]. Drawn on a [Canvas] (not an
|
||||
* `ImageVector`) so the geometry is exact and predictable at any size — used both as the
|
||||
* small in-row source tag and the drawer-header affordance.
|
||||
*/
|
||||
@Composable
|
||||
fun ThreadSpoolGlyph(
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = Color.Black,
|
||||
) {
|
||||
Canvas(modifier = modifier) {
|
||||
val s = size.minDimension
|
||||
val strokeW = s * 0.10f
|
||||
val trunkX = s * 0.30f
|
||||
val cap = StrokeCap.Round
|
||||
|
||||
// Upper message — a short bar branching off the top of the trunk.
|
||||
drawLine(
|
||||
color = tint,
|
||||
start = Offset(trunkX, s * 0.33f),
|
||||
end = Offset(s * 0.74f, s * 0.33f),
|
||||
strokeWidth = strokeW,
|
||||
cap = cap,
|
||||
)
|
||||
|
||||
// Trunk + spool curl: straight down, then a rounded quarter-turn to the right.
|
||||
val spool = Path().apply {
|
||||
moveTo(trunkX, s * 0.18f)
|
||||
lineTo(trunkX, s * 0.66f)
|
||||
cubicTo(
|
||||
trunkX, s * 0.77f,
|
||||
trunkX + s * 0.06f, s * 0.83f,
|
||||
trunkX + s * 0.17f, s * 0.83f,
|
||||
)
|
||||
}
|
||||
drawPath(
|
||||
path = spool,
|
||||
color = tint,
|
||||
style = Stroke(width = strokeW, cap = cap, join = StrokeJoin.Round),
|
||||
)
|
||||
|
||||
// Lower message — a short bar off the end of the curl.
|
||||
drawLine(
|
||||
color = tint,
|
||||
start = Offset(trunkX + s * 0.17f, s * 0.83f),
|
||||
end = Offset(s * 0.74f, s * 0.83f),
|
||||
strokeWidth = strokeW,
|
||||
cap = cap,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sources that should surface as a distinct **Thread** lane in the session drawer.
|
||||
*
|
||||
* Slice 1 covers the agent Thread only (`source == "phone"`, the registered platform name
|
||||
* — see ADR 12). The broader "show every chat's source/platform in the drawer" goal
|
||||
* (Discord/Slack/API chips, sort/hide-by-source) is deferred until after the Threads
|
||||
* surface ships; extend this predicate / add a source→label map there.
|
||||
*/
|
||||
internal fun isThreadSource(source: String?): Boolean =
|
||||
source?.trim()?.lowercase() == "phone"
|
||||
@@ -3,8 +3,12 @@ package com.hermesandroid.relay.ui.components
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -26,6 +30,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -75,6 +80,9 @@ import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.util.HumanError
|
||||
import kotlinx.coroutines.delay
|
||||
import com.hermesandroid.relay.viewmodel.BackgroundRunPhase
|
||||
import com.hermesandroid.relay.viewmodel.BackgroundRunState
|
||||
import com.hermesandroid.relay.viewmodel.DestructiveCountdownState
|
||||
import com.hermesandroid.relay.viewmodel.HermesConfirmationState
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
@@ -144,6 +152,9 @@ fun VoiceModeOverlay(
|
||||
// visible if not wired (the chip itself is also gated on
|
||||
// `uiState.permissionDeniedCallout` being non-null).
|
||||
onPermissionDeniedChipTap: (PermissionDeniedCallout) -> Unit = {},
|
||||
// Cancels the promoted/durable background Hermes run from the chip's ✕.
|
||||
// Default no-op so existing call sites/previews keep compiling.
|
||||
onBackgroundRunCancel: () -> Unit = {},
|
||||
onHermesConfirmationAnswer: (String) -> Unit = {},
|
||||
// === END v0.4.1 ===
|
||||
) {
|
||||
@@ -337,27 +348,13 @@ fun VoiceModeOverlay(
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = uiState.backgroundRun != null,
|
||||
enter = fadeIn(tween(140)),
|
||||
exit = fadeOut(tween(180)),
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = uiState.backgroundRun?.message
|
||||
?: "Working on it in the background…",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
BackgroundRunChip(
|
||||
run = uiState.backgroundRun,
|
||||
onCancel = onBackgroundRunCancel,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
@@ -1465,6 +1462,124 @@ private fun DestructiveCountdownRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR 33 live background-run chip.
|
||||
*
|
||||
* With timer-driven spoken progress off by default, this chip is the primary
|
||||
* in-between signal for a promoted/durable Hermes run: a pulsing dot, a
|
||||
* phase-aware title, a live secondary line (active tool · steps · elapsed),
|
||||
* and a ✕ that cancels the run. Phases: RUNNING (normal), RECONNECTING (the
|
||||
* voice socket dropped mid-run — the relay keeps the run alive while the
|
||||
* client retries), DELIVERING (run finished; summary queued behind the floor).
|
||||
*
|
||||
* Takes the nullable state and remembers the last non-null value so the exit
|
||||
* fade shows real content instead of snapping empty (same pattern as
|
||||
* [PermissionDeniedChip]).
|
||||
*/
|
||||
@Composable
|
||||
private fun BackgroundRunChip(
|
||||
run: BackgroundRunState?,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var latest by remember { mutableStateOf<BackgroundRunState?>(null) }
|
||||
run?.let { latest = it }
|
||||
val display = latest ?: return
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = run != null,
|
||||
enter = fadeIn(tween(140)),
|
||||
exit = fadeOut(tween(180)),
|
||||
modifier = modifier,
|
||||
) {
|
||||
// mm:ss ticker — recomposes this chip once a second while visible.
|
||||
var nowMs by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
LaunchedEffect(display.startedAtMs) {
|
||||
while (true) {
|
||||
nowMs = System.currentTimeMillis()
|
||||
delay(1_000L)
|
||||
}
|
||||
}
|
||||
val elapsedSeconds = ((nowMs - display.startedAtMs) / 1000L).coerceAtLeast(0L)
|
||||
val elapsedLabel = "%d:%02d".format(elapsedSeconds / 60, elapsedSeconds % 60)
|
||||
|
||||
val pulse by rememberInfiniteTransition(label = "bgRunPulse").animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
|
||||
label = "bgRunPulseAlpha",
|
||||
)
|
||||
val dotColor = when (display.phase) {
|
||||
BackgroundRunPhase.RECONNECTING -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
val title = when (display.phase) {
|
||||
BackgroundRunPhase.RECONNECTING -> "Reconnecting — your task is still running"
|
||||
BackgroundRunPhase.DELIVERING -> display.message
|
||||
BackgroundRunPhase.RUNNING -> display.message
|
||||
}
|
||||
val detail = buildList {
|
||||
display.statusLine
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { add(it.trimEnd('.', '…')) }
|
||||
if (display.completedToolCount > 0) {
|
||||
add(
|
||||
"${display.completedToolCount} step" +
|
||||
if (display.completedToolCount == 1) "" else "s"
|
||||
)
|
||||
}
|
||||
add(elapsedLabel)
|
||||
}.joinToString(" · ")
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(dotColor.copy(alpha = pulse)),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (detail.isNotBlank()) {
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.75f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Cancel background task",
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.4.1 JIT permission-denied chip.
|
||||
*
|
||||
|
||||
@@ -49,8 +49,10 @@ 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.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
@@ -238,6 +240,68 @@ fun AboutScreen(
|
||||
}
|
||||
// === END PHASE3-flavor-split ===
|
||||
|
||||
// Connected relay's plugin version + a soft "newer release
|
||||
// available" nudge — both flavors, since the relay is
|
||||
// server-side regardless of app track. Source:
|
||||
// ConnectionViewModel.relayUpdateInfo, refreshed on each
|
||||
// auth.ok from the relay's /relay/update-check (the app and
|
||||
// relay version *independently* — this is the relay's own
|
||||
// track, not an app-vs-relay numeric compare). Null until a
|
||||
// paired relay answers, so it simply doesn't render offline.
|
||||
val relayUpdate by connectionViewModel.relayUpdateInfo.collectAsState()
|
||||
relayUpdate?.let { ru ->
|
||||
HorizontalDivider()
|
||||
val clipboard = LocalClipboardManager.current
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Relay",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val subtitle = when {
|
||||
ru.updateAvailable && !ru.latest.isNullOrBlank() ->
|
||||
"Update available — v${ru.current} → v${ru.latest}"
|
||||
ru.current.isNotBlank() -> "On v${ru.current} — up to date"
|
||||
else -> "Connected"
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (ru.updateAvailable) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
if (ru.updateAvailable && !ru.updateCommand.isNullOrBlank()) {
|
||||
TextButton(onClick = {
|
||||
clipboard.setText(AnnotatedString(ru.updateCommand))
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Update command copied",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}) {
|
||||
Text("Copy fix")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ru.updateAvailable && !ru.updateCommand.isNullOrBlank()) {
|
||||
Text(
|
||||
text = "Run on your Hermes host, then restart the gateway:\n${ru.updateCommand}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Sideload-only: in-app update check. googlePlay builds
|
||||
// get updates through the Play Store, so we hide this
|
||||
// row on that track. UpdateViewModel also short-circuits
|
||||
|
||||
@@ -64,6 +64,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.components.LocalAvailableSphereSkins
|
||||
import com.hermesandroid.relay.ui.components.SphereRegistry
|
||||
@@ -76,6 +77,7 @@ import com.hermesandroid.relay.ui.components.avatar.AvatarSource
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAvailableAvatars
|
||||
import com.hermesandroid.relay.ui.components.avatar.SphereAvatar
|
||||
import com.hermesandroid.relay.ui.theme.AppFont
|
||||
import com.hermesandroid.relay.ui.theme.AppTheme
|
||||
import com.hermesandroid.relay.ui.theme.AppThemes
|
||||
import com.hermesandroid.relay.ui.theme.BrandPalette
|
||||
@@ -321,6 +323,50 @@ fun AppearanceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Font section — pick the app-wide body typeface. Each option renders
|
||||
// its own label + sample line IN that font so the choice is legible
|
||||
// before tapping; the selection re-themes every screen live.
|
||||
Text(
|
||||
text = "Font",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
isDarkTheme = isDarkTheme
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
val appFontId by connectionViewModel.appFont.collectAsState()
|
||||
val selectedFont = AppFont.byId(appFontId)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Sets the typeface across the whole app. Code and " +
|
||||
"timestamps stay monospaced.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
AppFont.entries.forEach { font ->
|
||||
FontOptionRow(
|
||||
font = font,
|
||||
selected = font.id == selectedFont.id,
|
||||
onClick = { connectionViewModel.setAppFont(font.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Animation section
|
||||
Text(
|
||||
text = "Animation",
|
||||
@@ -721,6 +767,71 @@ fun AppearanceSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single font option — its name + a sample line, both rendered in the option's
|
||||
* own [AppFont.fontFamily] so the typeface is visible before selecting. Selected
|
||||
* state shows a brand border + check badge. Tapping persists immediately and the
|
||||
* app re-themes live.
|
||||
*/
|
||||
@Composable
|
||||
private fun FontOptionRow(
|
||||
font: AppFont,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val family = font.fontFamily()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.border(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = font.label,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontFamily = family),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = font.preview,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = family),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (selected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(13.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact theme picker chip — a swatch preview of the theme's three signature
|
||||
* colors (background fill + two accent dots) with its label, a selected border,
|
||||
|
||||
@@ -45,9 +45,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayHeroPanel
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayNavTile
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.RelayReturnStrip
|
||||
import com.hermesandroid.relay.ui.components.RelaySectionCaption
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusPill
|
||||
@@ -89,6 +87,13 @@ fun BridgeCoreScreen(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Bridge") },
|
||||
navigationIcon = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back to chat",
|
||||
onClick = onNavigateToChat,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
@@ -119,17 +124,6 @@ fun BridgeCoreScreen(
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Bridge,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> onNavigateToChat()
|
||||
RelayPrimaryMode.Manage -> onNavigateToManage()
|
||||
RelayPrimaryMode.Bridge -> Unit
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 0.dp, vertical = 0.dp),
|
||||
)
|
||||
if (returnTitle != null && onReturn != null) {
|
||||
RelayReturnStrip(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
|
||||
@@ -67,8 +67,6 @@ import com.hermesandroid.relay.ui.components.BridgeMasterToggle
|
||||
import com.hermesandroid.relay.ui.components.BridgePermissionChecklist
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayHeroPanel
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.RelayReturnStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusPill
|
||||
// === v0.4.1 unattended-access ===
|
||||
@@ -215,6 +213,13 @@ fun BridgeScreen(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Bridge") },
|
||||
navigationIcon = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back to chat",
|
||||
onClick = onNavigateToChat,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
@@ -239,16 +244,6 @@ fun BridgeScreen(
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Bridge,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> onNavigateToChat()
|
||||
RelayPrimaryMode.Manage -> onNavigateToManage()
|
||||
RelayPrimaryMode.Bridge -> Unit
|
||||
}
|
||||
},
|
||||
)
|
||||
if (returnTitle != null && onReturn != null) {
|
||||
RelayReturnStrip(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
|
||||
@@ -170,8 +170,6 @@ import com.hermesandroid.relay.ui.components.LocalAgentIconPath
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
|
||||
import java.io.File
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.SphereState
|
||||
import com.hermesandroid.relay.ui.components.LocalThinkingIndicator
|
||||
import com.hermesandroid.relay.ui.components.ThinkingIndicatorConfig
|
||||
@@ -206,6 +204,15 @@ import kotlinx.coroutines.launch
|
||||
|
||||
private const val DEFAULT_CHAR_LIMIT = 4096
|
||||
|
||||
/**
|
||||
* A same-author run breaks into a new visual group once the gap to the
|
||||
* neighboring message exceeds this — so a conversation resumed after a pause
|
||||
* reads as a fresh beat (its own agent-name label, its own timestamp, more air)
|
||||
* instead of one unbroken monologue. Matches the iMessage/Discord convention of
|
||||
* resetting grouping after a short idle.
|
||||
*/
|
||||
private const val GROUP_GAP_MS = 5 * 60_000L
|
||||
|
||||
/**
|
||||
* Snapshot of the streaming-state fields the auto-scroll effect watches.
|
||||
*
|
||||
@@ -1332,6 +1339,16 @@ fun ChatScreen(
|
||||
"Connection: ${activeConnection?.label}"
|
||||
else -> "Active connection"
|
||||
}
|
||||
val threadsProactiveEnabled by connectionViewModel.proactiveEnabled.collectAsState()
|
||||
val threadsAuthState by connectionViewModel.authState.collectAsState()
|
||||
// Threads capability = "Let Hermes message me" on + relay paired.
|
||||
// Shows the drawer's Threads affordance even before the first Thread
|
||||
// arrives (the drawer also self-shows it when a source=phone session
|
||||
// is already present).
|
||||
val threadsCapabilityActive = threadsProactiveEnabled &&
|
||||
threadsAuthState is com.hermesandroid.relay.auth.AuthState.Paired
|
||||
val hiddenSources by connectionViewModel.hiddenSources.collectAsState()
|
||||
|
||||
SessionDrawerContent(
|
||||
sessions = sessions,
|
||||
currentSessionId = currentSessionId,
|
||||
@@ -1354,7 +1371,16 @@ fun ChatScreen(
|
||||
},
|
||||
onRenameSession = { sessionId, title ->
|
||||
chatViewModel.renameSession(sessionId, title)
|
||||
}
|
||||
},
|
||||
threadsCapabilityActive = threadsCapabilityActive,
|
||||
onNewThread = { name ->
|
||||
chatViewModel.startNewThread(name)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
hiddenSources = hiddenSources,
|
||||
onToggleSourceHidden = { source, hidden ->
|
||||
connectionViewModel.setSourceHidden(source, hidden)
|
||||
},
|
||||
)
|
||||
}
|
||||
) {
|
||||
@@ -1380,9 +1406,15 @@ fun ChatScreen(
|
||||
val headerApiReachable = apiReachable || isStreaming
|
||||
val isConnecting = isChatConnecting ||
|
||||
(!headerApiReachable && chatMode != ChatMode.DISCONNECTED)
|
||||
// Once we've been connected this session, a later drop reads as
|
||||
// "Reconnecting…" (we had it, we're getting it back) rather than
|
||||
// a first-time "Connecting…". Honest wording for the WhatsApp-
|
||||
// style subtitle status.
|
||||
var everConnected by remember { mutableStateOf(false) }
|
||||
if (headerApiReachable) everConnected = true
|
||||
val statusText = when {
|
||||
headerApiReachable -> if (isStreaming) "Streaming" else "Connected"
|
||||
isConnecting -> "Connecting..."
|
||||
isConnecting -> if (everConnected) "Reconnecting…" else "Connecting…"
|
||||
else -> "Disconnected"
|
||||
}
|
||||
val statusColor = when {
|
||||
@@ -1677,16 +1709,10 @@ fun ChatScreen(
|
||||
onDismiss = { showContextSheet = false },
|
||||
)
|
||||
}
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Chat,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> Unit
|
||||
RelayPrimaryMode.Manage -> onNavigateToManage()
|
||||
RelayPrimaryMode.Bridge -> onNavigateToBridge()
|
||||
}
|
||||
},
|
||||
)
|
||||
// Chat is the home: the Chat/Manage/Bridge mode strip was removed here
|
||||
// (it spent a chrome band on the most-used screen). Manage and Bridge
|
||||
// are reached from Settings (Settings → Hermes management / Bridge);
|
||||
// Terminal + Settings remain quick icons in the top app bar above.
|
||||
|
||||
// Error banner with retry
|
||||
AnimatedVisibility(visible = error != null) {
|
||||
@@ -1999,7 +2025,7 @@ fun ChatScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
item { Spacer(modifier = Modifier.height(8.dp).animateItem()) }
|
||||
@@ -2017,8 +2043,16 @@ fun ChatScreen(
|
||||
!message.isStreaming
|
||||
) return@items
|
||||
|
||||
val isFirstInGroup = index == 0 || messages[index - 1].role != message.role
|
||||
val isLastInGroup = index == messages.size - 1 || messages[index + 1].role != message.role
|
||||
// Break a same-author run on a role change OR a >5min
|
||||
// gap to the neighbor, so a resumed conversation gets a
|
||||
// fresh agent-name label + its own timestamp instead of
|
||||
// silently merging into the previous burst.
|
||||
val isFirstInGroup = index == 0 ||
|
||||
messages[index - 1].role != message.role ||
|
||||
message.timestamp - messages[index - 1].timestamp > GROUP_GAP_MS
|
||||
val isLastInGroup = index == messages.size - 1 ||
|
||||
messages[index + 1].role != message.role ||
|
||||
messages[index + 1].timestamp - message.timestamp > GROUP_GAP_MS
|
||||
|
||||
// Date separator
|
||||
if (index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp)) {
|
||||
@@ -2797,6 +2831,7 @@ fun ChatScreen(
|
||||
onDismiss = { voiceViewModel.exitVoiceMode() },
|
||||
onModeChange = { voiceViewModel.setInteractionMode(it) },
|
||||
onClearError = { voiceViewModel.clearError() },
|
||||
onBackgroundRunCancel = { voiceViewModel.cancelBackgroundRun() },
|
||||
// Agent B's overlay collects this flow and renders classified
|
||||
// voice errors (mic capture, STT/TTS failures, relay drops).
|
||||
errorEvents = voiceViewModel.errorEvents,
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -67,6 +69,7 @@ import com.hermesandroid.relay.ui.components.toColor
|
||||
import com.hermesandroid.relay.ui.components.ChatTransportTier
|
||||
import com.hermesandroid.relay.ui.components.ChatTransportTone
|
||||
import com.hermesandroid.relay.ui.components.resolveChatTransportStatus
|
||||
import com.hermesandroid.relay.ui.components.sourceBadge
|
||||
import com.hermesandroid.relay.ui.components.textColor
|
||||
import com.hermesandroid.relay.ui.theme.gradientBorder
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
@@ -124,6 +127,10 @@ fun ChatSettingsScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// (Quick Controls — Persistent connection etc. — moved to the
|
||||
// top-level Settings landing, since they're connection-level controls
|
||||
// flipped frequently, not chat-specific. See SettingsScreen's
|
||||
// QuickControlsCard.)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -340,6 +347,46 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Session sources — hide noisy gateway lanes from the drawer.
|
||||
// Edits the same persisted set the drawer source filter uses;
|
||||
// lists the common externals so you can hide one even before
|
||||
// it appears in the list.
|
||||
val hiddenSources by connectionViewModel.hiddenSources.collectAsState()
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Session sources",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Hide a gateway's sessions from the drawer (cron and webhook are hidden by default). Your chats and Threads always show.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
listOf("discord", "telegram", "cron", "webhook", "web").forEach { src ->
|
||||
val badge = sourceBadge(src)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = badge?.label ?: src.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = badge?.color ?: MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Switch(
|
||||
checked = src !in hiddenSources,
|
||||
onCheckedChange = { show ->
|
||||
connectionViewModel.setSourceHidden(src, !show)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
val recentPromptsEnabled by
|
||||
connectionViewModel.chatRecentPromptsEnabled.collectAsState()
|
||||
Row(
|
||||
@@ -808,36 +855,6 @@ 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(
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
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
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardAdvancedSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardFeaturesSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardRoutesSection
|
||||
import com.hermesandroid.relay.ui.components.ActiveCardSecurityPosture
|
||||
import com.hermesandroid.relay.ui.components.ApiServerInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.InsecureConnectionAckDialog
|
||||
import com.hermesandroid.relay.ui.components.RelayInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.SessionInfoSheet
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.viewmodel.RelayUiState
|
||||
import com.hermesandroid.relay.viewmodel.statusText
|
||||
|
||||
/**
|
||||
* Tabbed detail for a single Hermes connection — the level-2 screen the
|
||||
* (slim) [ConnectionsSettingsScreen] list drills into. Replaces the old
|
||||
* "everything crammed into the active card" body.
|
||||
*
|
||||
* Layout:
|
||||
* - TopAppBar: back + connection label + an `Active` badge (active conn) +
|
||||
* an overflow `⋮` menu (Rename / Re-pair / Revoke / Remove).
|
||||
* - When this connection is the **active** one, a 4-tab segmented bar:
|
||||
* **Overview** (status header + the steps/timeline capability list) ·
|
||||
* **Routes** (ADR 24 endpoint management) · **Advanced** (manual URL /
|
||||
* insecure / manual pairing) · **Security** (transport posture + the
|
||||
* prominent Relay sessions entry).
|
||||
* - When this connection is **not** active, only Overview shows — the deep
|
||||
* live content reads the single active-connection VM state, so we surface
|
||||
* a "Switch to this connection" CTA instead of stale/foreign data.
|
||||
*
|
||||
* All deep sections are the existing reusable composables in
|
||||
* `ActiveConnectionSections.kt`; this screen is orchestration + the
|
||||
* screen-scoped info sheets / confirm dialogs (hoisted here so a tab switch
|
||||
* or scroll can't silently dismiss an open sheet).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ConnectionDetailScreen(
|
||||
connectionId: String,
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onReconnect: () -> Unit,
|
||||
onRename: (id: String, newLabel: String) -> Unit,
|
||||
onRepair: (id: String) -> Unit,
|
||||
onRevoke: (id: String) -> Unit,
|
||||
onRemove: (id: String) -> Unit,
|
||||
onSwitchToConnection: (id: String) -> Unit,
|
||||
onNavigateToManage: () -> Unit,
|
||||
onNavigateToPairedDevices: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRow by connectionViewModel.relayRowState.collectAsState()
|
||||
val relayConfigured by connectionViewModel.relayConfigured.collectAsState()
|
||||
val relayEnabled by FeatureFlags.relayEnabled(context)
|
||||
.collectAsState(initial = FeatureFlags.isDevBuild)
|
||||
|
||||
val connection = connections.firstOrNull { it.id == connectionId }
|
||||
// Connection was removed (e.g. via the overflow menu) — leave the screen.
|
||||
LaunchedEffect(connection == null) {
|
||||
if (connection == null) onBack()
|
||||
}
|
||||
if (connection == null) return
|
||||
|
||||
val isActive = connectionId == activeConnectionId
|
||||
|
||||
// Screen-scoped sheet/dialog visibility (survives tab switches + scroll).
|
||||
var showSessionInfoSheet by remember { mutableStateOf(false) }
|
||||
var showApiInfoSheet by remember { mutableStateOf(false) }
|
||||
var showRelayInfoSheet by remember { mutableStateOf(false) }
|
||||
var showInsecureAckDialog by remember { mutableStateOf(false) }
|
||||
var showRenameDialog by remember { mutableStateOf(false) }
|
||||
var showRevokeConfirm by remember { mutableStateOf(false) }
|
||||
var showRemoveConfirm by remember { mutableStateOf(false) }
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val tabs = if (isActive) {
|
||||
listOf(DetailTab.Overview, DetailTab.Routes, DetailTab.Advanced, DetailTab.Security)
|
||||
} else {
|
||||
listOf(DetailTab.Overview)
|
||||
}
|
||||
// Reset selection when the active/non-active shape changes so we never
|
||||
// index past the available tabs.
|
||||
var selectedTab by remember(isActive) { mutableStateOf(0) }
|
||||
val safeIndex = selectedTab.coerceIn(0, tabs.lastIndex)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = connection.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (isActive) {
|
||||
Badge(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
) {
|
||||
Text(
|
||||
text = "Active",
|
||||
modifier = Modifier.padding(horizontal = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MoreVert,
|
||||
contentDescription = "More actions",
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Rename") },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
showRenameDialog = true
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(if (connection.pairedAt == null) "Pair Relay" else "Re-pair")
|
||||
},
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onRepair(connectionId)
|
||||
},
|
||||
)
|
||||
if (connection.pairedAt != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Revoke") },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
showRevokeConfirm = true
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text("Remove", color = MaterialTheme.colorScheme.error)
|
||||
},
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
showRemoveConfirm = true
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
if (tabs.size > 1) {
|
||||
TabRow(selectedTabIndex = safeIndex) {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
selected = safeIndex == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(tab.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
when (tabs[safeIndex]) {
|
||||
DetailTab.Overview -> {
|
||||
if (isActive) {
|
||||
ActiveOverview(
|
||||
connectionViewModel = connectionViewModel,
|
||||
connection = connection,
|
||||
relayConfigured = relayConfigured,
|
||||
relayStatusText = relayRow.statusText(connectedLabel = "Connected"),
|
||||
relayUiState = relayUiState,
|
||||
relayEnabled = relayEnabled,
|
||||
onReconnect = onReconnect,
|
||||
onRepair = { onRepair(connectionId) },
|
||||
onOpenApiInfo = { showApiInfoSheet = true },
|
||||
onOpenDashboard = onNavigateToManage,
|
||||
onOpenRelayInfo = { showRelayInfoSheet = true },
|
||||
onOpenSessionInfo = { showSessionInfoSheet = true },
|
||||
)
|
||||
} else {
|
||||
InactiveOverview(
|
||||
connection = connection,
|
||||
onSwitch = { onSwitchToConnection(connectionId) },
|
||||
onRepair = { onRepair(connectionId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DetailTab.Routes -> ActiveCardRoutesSection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
connection = connection,
|
||||
liveState = relayUiState,
|
||||
)
|
||||
|
||||
DetailTab.Advanced -> ActiveCardAdvancedSection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onInsecureAckRequested = { showInsecureAckDialog = true },
|
||||
)
|
||||
|
||||
DetailTab.Security -> ActiveCardSecurityPosture(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onNavigateToPairedDevices = onNavigateToPairedDevices,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Screen-scope sheets + dialogs ────────────────────────────────────
|
||||
if (showSessionInfoSheet) {
|
||||
SessionInfoSheet(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onDismiss = { showSessionInfoSheet = false },
|
||||
)
|
||||
}
|
||||
if (showApiInfoSheet) {
|
||||
ApiServerInfoSheet(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onDismiss = { showApiInfoSheet = false },
|
||||
)
|
||||
}
|
||||
if (showRelayInfoSheet) {
|
||||
RelayInfoSheet(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onDismiss = { showRelayInfoSheet = false },
|
||||
)
|
||||
}
|
||||
if (showInsecureAckDialog) {
|
||||
InsecureConnectionAckDialog(
|
||||
onConfirm = { reason ->
|
||||
connectionViewModel.setInsecureAckComplete(reason)
|
||||
connectionViewModel.setInsecureMode(true)
|
||||
showInsecureAckDialog = false
|
||||
},
|
||||
onCancel = { showInsecureAckDialog = false },
|
||||
)
|
||||
}
|
||||
if (showRenameDialog) {
|
||||
RenameConnectionDialog(
|
||||
initialLabel = connection.label,
|
||||
onDismiss = { showRenameDialog = false },
|
||||
onConfirm = { newLabel ->
|
||||
onRename(connectionId, newLabel)
|
||||
showRenameDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showRevokeConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRevokeConfirm = false },
|
||||
title = { Text("Revoke this connection?") },
|
||||
text = {
|
||||
Text(
|
||||
"The server session will be invalidated. The connection stays " +
|
||||
"on this device but will need to be re-paired to use again.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onRevoke(connectionId)
|
||||
showRevokeConfirm = false
|
||||
},
|
||||
) { Text("Revoke") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRevokeConfirm = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showRemoveConfirm) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRemoveConfirm = false },
|
||||
title = { Text("Remove this connection?") },
|
||||
text = {
|
||||
Text(
|
||||
"The connection will be deleted from this device along with its " +
|
||||
"saved session token. This cannot be undone.",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// The LaunchedEffect above pops the screen once the
|
||||
// connection disappears from the list.
|
||||
onRemove(connectionId)
|
||||
showRemoveConfirm = false
|
||||
},
|
||||
) {
|
||||
Text("Remove", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRemoveConfirm = false }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class DetailTab(val label: String) {
|
||||
Overview("Overview"),
|
||||
Routes("Routes"),
|
||||
Advanced("Advanced"),
|
||||
Security("Security"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Overview for the **active** connection: a one-line status header followed
|
||||
* by the steps/timeline capability list ([ActiveCardFeaturesSection]) and
|
||||
* quick actions. The timeline is intentionally the hero of this tab.
|
||||
*/
|
||||
@Composable
|
||||
private fun ActiveOverview(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
connection: Connection,
|
||||
relayConfigured: Boolean,
|
||||
relayStatusText: String,
|
||||
relayUiState: RelayUiState,
|
||||
relayEnabled: Boolean,
|
||||
onReconnect: () -> Unit,
|
||||
onRepair: () -> Unit,
|
||||
onOpenApiInfo: () -> Unit,
|
||||
onOpenDashboard: () -> Unit,
|
||||
onOpenRelayInfo: () -> Unit,
|
||||
onOpenSessionInfo: () -> Unit,
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val headerLine = if (relayConfigured) relayStatusText else "Standard · $hostname"
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = headerLine,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = hostname,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "What this connection can do. Routes only choose how the phone " +
|
||||
"reaches Hermes.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
ActiveCardFeaturesSection(
|
||||
connectionViewModel = connectionViewModel,
|
||||
relayEnabled = relayEnabled,
|
||||
onOpenApiInfo = onOpenApiInfo,
|
||||
onOpenDashboard = onOpenDashboard,
|
||||
onOpenRelayInfo = onOpenRelayInfo,
|
||||
onOpenSessionInfo = onOpenSessionInfo,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
Button(onClick = onReconnect) { Text("Reconnect") }
|
||||
}
|
||||
TextButton(onClick = onRepair) {
|
||||
Text(if (connection.pairedAt == null) "Pair Relay" else "Re-pair")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overview for a **non-active** connection. The deep live content reads the
|
||||
* single active-connection VM state, so rather than show stale/foreign data
|
||||
* we surface the path to make this connection active.
|
||||
*/
|
||||
@Composable
|
||||
private fun InactiveOverview(
|
||||
connection: Connection,
|
||||
onSwitch: () -> Unit,
|
||||
onRepair: () -> Unit,
|
||||
) {
|
||||
val hostname = Connection.extractDefaultLabel(connection.apiServerUrl)
|
||||
val statusLine = when {
|
||||
connection.pairedAt != null -> "Paired · relay configured"
|
||||
connection.apiServerUrl.isNotBlank() -> "Standard · relay not paired"
|
||||
else -> "Not configured"
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = hostname,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = statusLine,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text(
|
||||
text = "This connection isn't active. Switch to it to see its live " +
|
||||
"status and manage routes, advanced settings, and relay sessions.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(onClick = onSwitch) { Text("Switch to this connection") }
|
||||
TextButton(onClick = onRepair) {
|
||||
Text(if (connection.pairedAt == null) "Pair Relay" else "Re-pair")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenameConnectionDialog(
|
||||
initialLabel: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
var input by remember { mutableStateOf(initialLabel) }
|
||||
val validation = com.hermesandroid.relay.data.ConnectionValidation.validateLabel(input)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Rename connection") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
singleLine = true,
|
||||
isError = validation != null,
|
||||
supportingText = {
|
||||
if (validation != null) Text(validation)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(input.trim()) },
|
||||
enabled = validation == null && input.trim() != initialLabel,
|
||||
) {
|
||||
Text("Rename")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
@@ -100,9 +101,7 @@ import com.hermesandroid.relay.network.upstream.DashboardStatus
|
||||
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayMetricCard
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayNavTile
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.RelayReturnStrip
|
||||
import com.hermesandroid.relay.ui.components.RelaySectionCaption
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
@@ -286,7 +285,7 @@ private data class PendingDashboardAction(
|
||||
fun DashboardManagementScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onNavigateToConnections: () -> Unit,
|
||||
onNavigateToChat: () -> Unit = {},
|
||||
onBack: () -> Unit = {},
|
||||
onNavigateToBridge: () -> Unit = {},
|
||||
onNavigateToTerminal: () -> Unit = {},
|
||||
onNavigateToSettings: () -> Unit = {},
|
||||
@@ -1034,6 +1033,13 @@ fun DashboardManagementScreen(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Manage") },
|
||||
navigationIcon = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
onClick = onBack,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
@@ -1073,16 +1079,6 @@ fun DashboardManagementScreen(
|
||||
.background(RelayRefresh.Background)
|
||||
.relayGridTexture(alpha = 0.12f)
|
||||
) {
|
||||
RelayModeStrip(
|
||||
selected = RelayPrimaryMode.Manage,
|
||||
onModeSelected = { mode ->
|
||||
when (mode) {
|
||||
RelayPrimaryMode.Chat -> onNavigateToChat()
|
||||
RelayPrimaryMode.Manage -> Unit
|
||||
RelayPrimaryMode.Bridge -> onNavigateToBridge()
|
||||
}
|
||||
},
|
||||
)
|
||||
if (dashboardUrl.isNotBlank()) {
|
||||
ManageDashboardTargetLine(
|
||||
dashboardUrl = dashboardUrl,
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Message
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
/**
|
||||
* The dedicated "Hermes" inbox — agent-initiated messages the agent sent on
|
||||
* its own (the `phone` platform). A flat, newest-first log fed by
|
||||
* [com.hermesandroid.relay.data.ProactiveInboxRepository]; the notification's
|
||||
* tap target and the "View messages" affordance on
|
||||
* [ProactiveSettingsScreen] both land here.
|
||||
*
|
||||
* Rendering is intentionally self-contained (plain cards) rather than reusing
|
||||
* the chat `MessageBubble` — the chat-ux worktree owns those visuals, and this
|
||||
* surface must not depend on or restyle them. Rich markdown rendering can be
|
||||
* layered in later once that component stabilizes (tracked in TODO.md).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HermesInboxScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val messages by connectionViewModel.inboxMessages.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Hermes inbox") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (messages.isNotEmpty()) {
|
||||
IconButton(onClick = { connectionViewModel.clearProactiveInbox() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DeleteSweep,
|
||||
contentDescription = "Clear inbox",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
if (messages.isEmpty()) {
|
||||
EmptyInbox(modifier = Modifier.fillMaxSize().padding(innerPadding))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(messages, key = { it.id }) { entry ->
|
||||
InboxMessageCard(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InboxMessageCard(entry: ProactiveInboxEntry) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = entry.title.ifBlank { "Hermes" },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = entry.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = relativeTime(entry.receivedAt),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyInbox(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Message,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "No messages yet",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = "When your agent reaches out on its own, the messages " +
|
||||
"land here. Enable it under Settings → Hermes messages.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun relativeTime(epochMillis: Long): String {
|
||||
if (epochMillis <= 0L) return ""
|
||||
return DateUtils.getRelativeTimeSpanString(
|
||||
epochMillis,
|
||||
System.currentTimeMillis(),
|
||||
DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
|
||||
/**
|
||||
* "Hermes messages" — the opt-in surface that lets the agent proactively
|
||||
* "Threads" — the opt-in surface that lets the agent proactively
|
||||
* message this phone (the `phone` Hermes platform). Off by default.
|
||||
*
|
||||
* Phase 1d ships just the enablement toggle + notification-permission prompt.
|
||||
@@ -61,7 +61,7 @@ import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
@Composable
|
||||
fun ProactiveSettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onOpenInbox: () -> Unit,
|
||||
onOpenChat: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -88,7 +88,7 @@ fun ProactiveSettingsScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Hermes messages") },
|
||||
title = { Text("Threads") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
@@ -123,7 +123,8 @@ fun ProactiveSettingsScreen(
|
||||
)
|
||||
Text(
|
||||
text = "Your agent can reach out on its own — reminders, " +
|
||||
"finished jobs, alerts — as a notification.",
|
||||
"finished jobs, alerts. Its messages appear as Threads " +
|
||||
"in Chat, where you can reply.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -148,16 +149,16 @@ fun ProactiveSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
ProactiveSectionCard(title = "Inbox") {
|
||||
ProactiveSectionCard(title = "Your Threads") {
|
||||
Text(
|
||||
text = "Messages your agent sends also collect in a dedicated " +
|
||||
"inbox, so you can catch up even after a notification is " +
|
||||
"dismissed.",
|
||||
text = "Each conversation your agent starts shows as a Thread in " +
|
||||
"Chat — open the session list, filter to Threads, and reply " +
|
||||
"right there.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
FilledTonalButton(
|
||||
onClick = onOpenInbox,
|
||||
onClick = onOpenChat,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
@@ -165,7 +166,7 @@ fun ProactiveSettingsScreen(
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("View messages")
|
||||
Text("Open Chat")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,13 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hermesandroid.relay.util.BatteryOptimizations
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -377,6 +383,17 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
// ── Quick Controls ─────────────────────────────────────────
|
||||
// The switches flipped most often, pinned to the top-level Settings
|
||||
// landing instead of buried in a sub-screen. Persistent connection is
|
||||
// connection-level (not chat-specific), so it belongs here beside the
|
||||
// agent / profile cards. Extensible — add more frequently-toggled
|
||||
// switches in QuickControlsCard.
|
||||
QuickControlsCard(
|
||||
connectionViewModel = connectionViewModel,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
// (The "Active Connection quick-look card" that used to live
|
||||
// here — showing API / Relay / Session status rows with a
|
||||
// clickable shortcut into a separate singular-connection
|
||||
@@ -433,8 +450,8 @@ fun SettingsScreen(
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.AutoMirrored.Filled.Message,
|
||||
title = "Hermes messages",
|
||||
subtitle = "Let the agent message you on its own (off by default)",
|
||||
title = "Threads",
|
||||
subtitle = "Let the agent start conversations with you (off by default)",
|
||||
onClick = onNavigateToProactiveSettings,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
@@ -780,6 +797,160 @@ private fun ProfileLockCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick Controls card on the top-level Settings landing — the switches the user
|
||||
* flips most often (Persistent connection, turn-complete alerts), kept out of
|
||||
* the per-feature sub-screens so they're one tap from the Settings root. Wired
|
||||
* straight to the same ConnectionViewModel flows the sub-screens use.
|
||||
*/
|
||||
@Composable
|
||||
private fun QuickControlsCard(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
isDarkTheme: Boolean,
|
||||
) {
|
||||
val gatewayKeepAlive by connectionViewModel.gatewayKeepAlive.collectAsState()
|
||||
val notifyTurnComplete by connectionViewModel.notifyTurnComplete.collectAsState()
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
isDarkTheme = isDarkTheme,
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Quick Controls",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
// Persistent connection — connection-level keep-alive: holds the app
|
||||
// process up via a notification so the gateway chat socket (and, for
|
||||
// relay-paired setups, device control + notification mirroring) stays
|
||||
// reachable in the background. Off by default; uses more battery.
|
||||
QuickControlToggle(
|
||||
title = "Persistent connection",
|
||||
subtitle = if (gatewayKeepAlive) {
|
||||
"Keeping your connection to Hermes open in the background"
|
||||
} else {
|
||||
"Connect on demand only · saves battery"
|
||||
},
|
||||
checked = gatewayKeepAlive,
|
||||
onCheckedChange = { connectionViewModel.setGatewayKeepAlive(it) },
|
||||
)
|
||||
// Doze: even with the keep-alive service running, a specialUse FGS
|
||||
// still gets its network deferred in deep sleep unless the app is
|
||||
// battery-optimization exempt. Nudge for the exemption when the
|
||||
// toggle is on and we're not yet exempt (sideload only — Play
|
||||
// restricts the permission).
|
||||
if (gatewayKeepAlive && BuildFlavor.isSideload) {
|
||||
BatteryOptimizationNudge()
|
||||
}
|
||||
HorizontalDivider()
|
||||
QuickControlToggle(
|
||||
title = "Turn-complete alerts",
|
||||
subtitle = if (notifyTurnComplete) {
|
||||
"Notify when a reply finishes while the app is in the background"
|
||||
} else {
|
||||
"No alert when a backgrounded reply finishes"
|
||||
},
|
||||
checked = notifyTurnComplete,
|
||||
onCheckedChange = { connectionViewModel.setNotifyTurnComplete(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickControlToggle(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Doze allow-list nudge shown under the "Persistent connection" toggle when
|
||||
* it's on but the app isn't battery-optimization exempt (sideload only).
|
||||
* Re-checks on ON_RESUME so it disappears after the user grants the exemption
|
||||
* in the system dialog. See [BatteryOptimizations].
|
||||
*/
|
||||
@Composable
|
||||
private fun BatteryOptimizationNudge() {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var exempt by remember {
|
||||
mutableStateOf(BatteryOptimizations.isIgnoringBatteryOptimizations(context))
|
||||
}
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
exempt = BatteryOptimizations.isIgnoringBatteryOptimizations(context)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
if (exempt) return
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Keep it connected in deep sleep",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = "Android can still pause the connection once the screen's " +
|
||||
"been off a while (Doze). Allow unrestricted battery so " +
|
||||
"Persistent connection keeps working in the background.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { BatteryOptimizations.launchRequest(context) },
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
) {
|
||||
Text("Allow unrestricted battery")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one surface that ALWAYS lists every profile (it never gates on the lock
|
||||
* state — it's how the user picks the target or unlocks). A master "Lock to a
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
@@ -45,6 +46,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -74,9 +76,24 @@ import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||
import com.hermesandroid.relay.network.relay.VoiceConfig
|
||||
import com.hermesandroid.relay.network.relay.VoiceOutputConfig
|
||||
import com.hermesandroid.relay.network.relay.VoiceProviderValidationResponse
|
||||
import com.hermesandroid.relay.network.upstream.ConfigFieldType
|
||||
import com.hermesandroid.relay.network.upstream.ConfigSchemaField
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.ElevenLabsVoices
|
||||
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
|
||||
import com.hermesandroid.relay.network.upstream.applyConfigEdits
|
||||
import com.hermesandroid.relay.network.upstream.configValueAt
|
||||
import com.hermesandroid.relay.network.upstream.parseConfigSchema
|
||||
import com.hermesandroid.relay.network.upstream.voiceConfigFields
|
||||
import com.hermesandroid.relay.ui.LocalSnackbarHost
|
||||
import com.hermesandroid.relay.ui.showHumanError
|
||||
import com.hermesandroid.relay.util.classifyError
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.StandardVoiceAvailability
|
||||
import com.hermesandroid.relay.viewmodel.VoiceConfigUiState
|
||||
@@ -101,8 +118,9 @@ import kotlinx.coroutines.launch
|
||||
* 6. Global voice controls — interaction mode + silence threshold.
|
||||
* 7. Barge-in — interrupt TTS by speaking.
|
||||
* 8. Speech-to-Text — provider/model labels.
|
||||
* 9. Test current engine — voice-output / realtime sample playback.
|
||||
* 10. Coming soon — not-yet-wired controls (Auto-TTS, STT lang).
|
||||
* 9. Server voice config — edit host tts/stt config (Standard path)
|
||||
* plus the ElevenLabs voice picker.
|
||||
* 10. Test current engine — voice-output / realtime sample playback.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -124,6 +142,13 @@ fun VoiceSettingsScreen(
|
||||
* Passed from RelayApp; null degrades to profile-only namespacing.
|
||||
*/
|
||||
connectionId: String? = null,
|
||||
/**
|
||||
* Dashboard base URL + per-connection cookie store provider for the
|
||||
* standard-path server voice-config editor (`/api/config`, cookie auth).
|
||||
* Null on connections with no dashboard — the editor card is then hidden.
|
||||
*/
|
||||
dashboardUrl: String? = null,
|
||||
dashboardCookieStoreProvider: (() -> DashboardCookieStore?)? = null,
|
||||
onOpenManage: (() -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
settingsViewModel: VoiceSettingsViewModel = viewModel(),
|
||||
@@ -142,9 +167,26 @@ fun VoiceSettingsScreen(
|
||||
// just observes it; the editor cards push saves back through the VM.
|
||||
val configState by settingsViewModel.configState.collectAsState()
|
||||
|
||||
// Standard-path server voice-config editor client (dashboard cookie auth).
|
||||
// Built once per (dashboardUrl, connection); shut down on dispose. Null when
|
||||
// the connection has no dashboard, which hides the card entirely.
|
||||
val dashboardConfigClient = remember(dashboardUrl, connectionId) {
|
||||
val url = dashboardUrl?.trim()?.takeIf { it.isNotBlank() } ?: return@remember null
|
||||
DashboardApiClient(
|
||||
baseUrl = url,
|
||||
okHttpClient = DashboardApiClient.defaultClient(
|
||||
cookieStore = dashboardCookieStoreProvider?.invoke() ?: InMemoryDashboardCookieStore(),
|
||||
),
|
||||
)
|
||||
}
|
||||
DisposableEffect(dashboardConfigClient) {
|
||||
onDispose { dashboardConfigClient?.shutdown() }
|
||||
}
|
||||
|
||||
// Global snackbar host — voice/config errors routed through the classifier
|
||||
// are shown here as well as the inline "unavailable" labels.
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// WP-V2/V3: point the screen's prefs repo at the active (connection,
|
||||
// profile) scope so the per-profile engine/route/enhanced toggles read and
|
||||
@@ -274,6 +316,17 @@ fun VoiceSettingsScreen(
|
||||
configState = configState,
|
||||
)
|
||||
|
||||
// --- Server voice config (standard path: edit tts.*/stt.* on the host) ---
|
||||
if (dashboardConfigClient != null) {
|
||||
StandardVoiceServerConfigCard(
|
||||
client = dashboardConfigClient,
|
||||
onOpenManage = onOpenManage,
|
||||
onMessage = { message ->
|
||||
scope.launch { snackbarHost.showSnackbar(message) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// --- Test Current Engine ---
|
||||
TestCurrentEngineCard(
|
||||
currentEngine = currentEngine,
|
||||
@@ -282,12 +335,6 @@ fun VoiceSettingsScreen(
|
||||
selectedProfile = selectedProfile,
|
||||
voiceViewModel = voiceViewModel,
|
||||
)
|
||||
|
||||
// --- Coming soon (not-yet-wired controls) ---
|
||||
ComingSoonCard(
|
||||
voiceSettings = voiceSettings,
|
||||
prefsRepo = prefsRepo,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1880,21 +1927,26 @@ private fun GlobalVoiceControlsCard(
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
text = "Silence threshold: ${voiceSettings.silenceThresholdMs / 1000}s",
|
||||
text = "Silence threshold: " +
|
||||
"%.2fs".format(voiceSettings.silenceThresholdMs / 1000f),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
text = "Auto-stop listening after this much silence",
|
||||
text = "End-of-speech: auto-stop after this much silence once you've " +
|
||||
"spoken (desktop default 1.25s).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// 750 ms..5 s in 250 ms steps (18 stops) so the desktop-matching 1.25s
|
||||
// default lands on a stop. Idle/no-speech (12 s) and the 60 s hard turn
|
||||
// cap are fixed in VoiceViewModel, not exposed here.
|
||||
Slider(
|
||||
value = voiceSettings.silenceThresholdMs.toFloat(),
|
||||
onValueChange = { newValue ->
|
||||
scope.launch { prefsRepo.setSilenceThresholdMs(newValue.toLong()) }
|
||||
},
|
||||
valueRange = 1000f..10000f,
|
||||
steps = 8,
|
||||
valueRange = 750f..5000f,
|
||||
steps = 16,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2196,95 +2248,252 @@ private fun TestCurrentEngineCard(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coming soon — not-yet-wired controls, collapsed by default so they don't
|
||||
// read as broken (WP-V3 item 3).
|
||||
// ---------------------------------------------------------------------------
|
||||
// ===========================================================================
|
||||
// Standard-path server voice config editor.
|
||||
//
|
||||
// Edits the host's tts.*/stt.* config via the dashboard /api/config surface —
|
||||
// the same config.yaml the dashboard's own Audio settings write, and the same
|
||||
// values the standard (no-Relay) /api/audio/* voice path reads. Standard voice
|
||||
// is host-global (see VoiceScopeBanner), so writes target the launch profile's
|
||||
// config (profile = null). Schema (/api/config/schema) drives field rendering;
|
||||
// values (/api/config) seed current state; PUT writes the whole tree back with
|
||||
// the edited leaves merged in. Includes the ElevenLabs voice picker — the one
|
||||
// genuine desktop voice feature the app previously lacked.
|
||||
// ===========================================================================
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ComingSoonCard(
|
||||
voiceSettings: VoiceSettings,
|
||||
prefsRepo: VoicePreferencesRepository,
|
||||
private fun StandardVoiceServerConfigCard(
|
||||
client: DashboardApiClient,
|
||||
onOpenManage: (() -> Unit)?,
|
||||
onMessage: (String) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
SectionCard(title = "Coming soon", badge = "Not wired yet") {
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
var values by remember { mutableStateOf<JsonObject?>(null) }
|
||||
var fields by remember { mutableStateOf<List<ConfigSchemaField>>(emptyList()) }
|
||||
var elevenVoices by remember { mutableStateOf<ElevenLabsVoices?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var signInRequired by remember { mutableStateOf(false) }
|
||||
var saving by remember { mutableStateOf(false) }
|
||||
var edits by remember { mutableStateOf<Map<String, JsonElement>>(emptyMap()) }
|
||||
var reloadNonce by remember { mutableStateOf(0) }
|
||||
|
||||
LaunchedEffect(client, reloadNonce) {
|
||||
loading = true
|
||||
error = null
|
||||
signInRequired = false
|
||||
val cfg = client.getConfig()
|
||||
val sch = client.getConfigSchema()
|
||||
if (cfg.isSuccess && sch.isSuccess) {
|
||||
values = cfg.getOrNull()
|
||||
fields = voiceConfigFields(parseConfigSchema(sch.getOrNull() ?: JsonObject(emptyMap())))
|
||||
edits = emptyMap()
|
||||
// Best-effort; only consulted when the TTS provider is elevenlabs.
|
||||
elevenVoices = client.getElevenLabsVoices().getOrNull()
|
||||
} else {
|
||||
val ex = cfg.exceptionOrNull() ?: sch.exceptionOrNull()
|
||||
val msg = ex?.message.orEmpty()
|
||||
signInRequired = msg.contains("401") || msg.contains("403") ||
|
||||
msg.contains("sign-in", ignoreCase = true)
|
||||
error = if (signInRequired) {
|
||||
"Sign in to Manage to edit the server's voice config."
|
||||
} else {
|
||||
ex?.message ?: "Could not load server voice config"
|
||||
}
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
SectionCard(title = "Server voice config", badge = "Standard") {
|
||||
Text(
|
||||
text = "Controls that are saved but not yet wired end-to-end. Shown for " +
|
||||
"reference; they don't change voice behaviour today.",
|
||||
text = "Edit the host's text-to-speech and speech-to-text settings " +
|
||||
"(config.yaml tts.* / stt.*) — the same values the dashboard's " +
|
||||
"Audio settings write. Applies to new voice turns.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = { expanded = !expanded }) {
|
||||
Text(if (expanded) "Hide" else "Show")
|
||||
|
||||
val tree = values
|
||||
if (tree != null) {
|
||||
// current = pending edit, else the loaded value at the dot-path.
|
||||
fun current(key: String): JsonElement? = edits[key] ?: configValueAt(tree, key)
|
||||
fun currentString(key: String): String =
|
||||
(current(key) as? JsonPrimitive)?.contentOrNull.orEmpty()
|
||||
fun setEdit(key: String, value: JsonElement) { edits = edits + (key to value) }
|
||||
|
||||
val ttsProvider = currentString("tts.provider")
|
||||
val sttProvider = currentString("stt.provider")
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Text-to-Speech", style = MaterialTheme.typography.labelLarge)
|
||||
|
||||
fields.firstOrNull { it.key == "tts.provider" }?.let { field ->
|
||||
ConfigFieldRow(field, current(field.key), null) { setEdit(field.key, it) }
|
||||
}
|
||||
if (ttsProvider.isNotBlank()) {
|
||||
fields.filter { it.key.startsWith("tts.$ttsProvider.") }.forEach { field ->
|
||||
val isElevenVoice = field.key == "tts.elevenlabs.voice_id" &&
|
||||
elevenVoices?.available == true
|
||||
ConfigFieldRow(
|
||||
field = field,
|
||||
current = current(field.key),
|
||||
overrideChoices = if (isElevenVoice) {
|
||||
elevenVoices?.voices?.map { VoiceChoice(value = it.voiceId, label = it.label) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onEdit = { setEdit(field.key, it) },
|
||||
)
|
||||
}
|
||||
if (ttsProvider == "elevenlabs" && elevenVoices?.available == false) {
|
||||
Text(
|
||||
text = "No ElevenLabs API key on the server — set ELEVENLABS_API_KEY " +
|
||||
"in Manage → Keys to pick a voice from a list.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text("Speech-to-Text", style = MaterialTheme.typography.labelLarge)
|
||||
|
||||
fields.firstOrNull { it.key == "stt.enabled" }?.let { field ->
|
||||
ConfigFieldRow(field, current(field.key), null) { setEdit(field.key, it) }
|
||||
}
|
||||
fields.firstOrNull { it.key == "stt.provider" }?.let { field ->
|
||||
ConfigFieldRow(field, current(field.key), null) { setEdit(field.key, it) }
|
||||
}
|
||||
if (sttProvider.isNotBlank()) {
|
||||
fields.filter { it.key.startsWith("stt.$sttProvider.") }.forEach { field ->
|
||||
ConfigFieldRow(field, current(field.key), null) { setEdit(field.key, it) }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
val pending = edits
|
||||
saving = true
|
||||
scope.launch {
|
||||
// GET-merged tree -> PUT whole document (upstream
|
||||
// save_config overwrites; a partial PUT would drop keys).
|
||||
val merged = applyConfigEdits(tree, pending)
|
||||
val result = client.updateConfig(merged, profile = null)
|
||||
saving = false
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
values = merged
|
||||
edits = emptyMap()
|
||||
onMessage("Voice config saved")
|
||||
},
|
||||
onFailure = { onMessage(it.message ?: "Save failed") },
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = edits.isNotEmpty() && !saving,
|
||||
) { Text(if (saving) "Saving…" else "Save") }
|
||||
|
||||
if (edits.isNotEmpty() && !saving) {
|
||||
TextButton(onClick = { edits = emptyMap() }) { Text("Discard") }
|
||||
}
|
||||
}
|
||||
} else if (loading) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
} else if (signInRequired) {
|
||||
Text(
|
||||
text = error ?: "Sign in required.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
onOpenManage?.let { open ->
|
||||
TextButton(onClick = open) { Text("Open Manage to sign in") }
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = error ?: "Could not load server voice config.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
TextButton(onClick = { reloadNonce++ }) { Text("Retry") }
|
||||
}
|
||||
if (expanded) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One editable row for a [ConfigSchemaField]. [overrideChoices] forces a
|
||||
* dropdown regardless of the field's declared type — used for the ElevenLabs
|
||||
* voice picker, where a plain `string` schema field is upgraded to a list when
|
||||
* voices are available. [onEdit] receives the new value as a [JsonElement] for
|
||||
* direct merge into the config tree.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ConfigFieldRow(
|
||||
field: ConfigSchemaField,
|
||||
current: JsonElement?,
|
||||
overrideChoices: List<VoiceChoice>?,
|
||||
onEdit: (JsonElement) -> Unit,
|
||||
) {
|
||||
val label = field.description?.takeIf { it.isNotBlank() } ?: field.key
|
||||
val str = (current as? JsonPrimitive)?.contentOrNull.orEmpty()
|
||||
when {
|
||||
overrideChoices != null -> VoiceChoiceDropdown(
|
||||
label = label,
|
||||
value = str,
|
||||
choices = overrideChoices,
|
||||
onValueChange = { onEdit(JsonPrimitive(it)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
field.type == ConfigFieldType.Select -> VoiceChoiceDropdown(
|
||||
label = label,
|
||||
value = str,
|
||||
choices = field.options.map { VoiceChoice(value = it) },
|
||||
onValueChange = { onEdit(JsonPrimitive(it)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
field.type == ConfigFieldType.Boolean -> {
|
||||
val checked = (current as? JsonPrimitive)?.booleanOrNull ?: false
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Auto-TTS", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = "Read aloud non-voice assistant messages (coming soon)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
// Disabled while Auto-TTS is unimplemented — a live toggle
|
||||
// that does nothing reads as broken. Re-enable when wired.
|
||||
enabled = false,
|
||||
checked = voiceSettings.autoTts,
|
||||
onCheckedChange = { enabled ->
|
||||
scope.launch { prefsRepo.setAutoTts(enabled) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
text = "STT language",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
text = "Stored only — the relay uses its own auto-detect for now",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val languages = listOf(
|
||||
"" to "Auto",
|
||||
"en" to "English",
|
||||
"es" to "Spanish",
|
||||
"fr" to "French",
|
||||
"de" to "German",
|
||||
"ja" to "Japanese",
|
||||
"zh" to "Chinese",
|
||||
)
|
||||
languages.forEach { (code, label) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = voiceSettings.language == code,
|
||||
onClick = {
|
||||
scope.launch { prefsRepo.setLanguage(code) }
|
||||
},
|
||||
)
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(
|
||||
selected = voiceSettings.language == code,
|
||||
onClick = null,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
Switch(checked = checked, onCheckedChange = { onEdit(JsonPrimitive(it)) })
|
||||
}
|
||||
}
|
||||
|
||||
field.type == ConfigFieldType.Number -> OutlinedTextField(
|
||||
value = str,
|
||||
onValueChange = { input ->
|
||||
val parsed = input.toLongOrNull()?.let { JsonPrimitive(it) }
|
||||
?: input.toDoubleOrNull()?.let { JsonPrimitive(it) }
|
||||
?: JsonPrimitive(input)
|
||||
onEdit(parsed)
|
||||
},
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
else -> OutlinedTextField(
|
||||
value = str,
|
||||
onValueChange = { onEdit(JsonPrimitive(it)) },
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.hermesandroid.relay.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.hermesandroid.relay.R
|
||||
|
||||
/**
|
||||
* User-selectable typeface system for the whole app.
|
||||
*
|
||||
* Each [AppFont] maps to a Compose [FontFamily]. The active choice is persisted
|
||||
* in DataStore (ConnectionViewModel.appFont) and threaded into
|
||||
* [HermesRelayTheme] as `appFontId`, which rebuilds the Material [Typography]
|
||||
* from the selected body family. Because that flows straight through
|
||||
* `MaterialTheme(typography = …)`, picking a font re-themes every Text in the
|
||||
* app live — no restart, no per-call-site edits. Code/metadata styles keep
|
||||
* [FontFamily.Monospace] regardless (see [appTypography]).
|
||||
*
|
||||
* Bundled families ship as single variable-font TTFs under res/font and are
|
||||
* weight-instanced via [FontVariation]; [System] uses the platform sans with no
|
||||
* bundle. Only OFL/SIL-licensed fonts are bundled — see the licenses folder.
|
||||
*/
|
||||
enum class AppFont(
|
||||
val id: String,
|
||||
/** Display name in the picker. */
|
||||
val label: String,
|
||||
/** One-line sample rendered in this font in the picker. */
|
||||
val preview: String,
|
||||
) {
|
||||
/** Default — Inter (SIL OFL). Clean, neutral UI sans. */
|
||||
Inter("inter", "Inter", "Sphinx of black quartz, judge my vow."),
|
||||
|
||||
/** Nunito (SIL OFL). Rounded, friendlier sans. */
|
||||
Nunito("nunito", "Nunito", "Sphinx of black quartz, judge my vow."),
|
||||
|
||||
/** Platform default sans — no bundled font, follows the device. */
|
||||
System("system", "System default", "Sphinx of black quartz, judge my vow.");
|
||||
|
||||
/** The Compose [FontFamily] backing this choice, used for all body text. */
|
||||
fun fontFamily(): FontFamily = when (this) {
|
||||
Inter -> InterFontFamily
|
||||
Nunito -> NunitoFontFamily
|
||||
System -> FontFamily.SansSerif
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Inter is the app default when nothing is persisted. */
|
||||
val DEFAULT: AppFont = Inter
|
||||
|
||||
/** Resolve a persisted id back to an [AppFont]; unknown → [DEFAULT]. */
|
||||
fun byId(id: String?): AppFont = entries.firstOrNull { it.id == id } ?: DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bundled variable-font families ──────────────────────────────────────────
|
||||
// One TTF per family carries every weight; each logical weight is a single
|
||||
// resource font pinned to its `wght` axis value via FontVariation. This is the
|
||||
// lighter, Compose-idiomatic path vs. bundling a static instance per weight.
|
||||
|
||||
@OptIn(ExperimentalTextApi::class)
|
||||
private fun variableFont(resId: Int, weight: Int): Font = Font(
|
||||
resId = resId,
|
||||
weight = FontWeight(weight),
|
||||
variationSettings = FontVariation.Settings(FontVariation.weight(weight)),
|
||||
)
|
||||
|
||||
/** Inter — Regular/Medium/SemiBold/Bold pinned off the variable `wght` axis. */
|
||||
val InterFontFamily: FontFamily = FontFamily(
|
||||
variableFont(R.font.inter_variable, 400),
|
||||
variableFont(R.font.inter_variable, 500),
|
||||
variableFont(R.font.inter_variable, 600),
|
||||
variableFont(R.font.inter_variable, 700),
|
||||
)
|
||||
|
||||
/** Nunito — Regular/Medium/SemiBold/Bold pinned off the variable `wght` axis. */
|
||||
val NunitoFontFamily: FontFamily = FontFamily(
|
||||
variableFont(R.font.nunito_variable, 400),
|
||||
variableFont(R.font.nunito_variable, 500),
|
||||
variableFont(R.font.nunito_variable, 600),
|
||||
variableFont(R.font.nunito_variable, 700),
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
|
||||
@@ -21,12 +22,15 @@ import androidx.compose.ui.unit.Density
|
||||
* @param appThemeId id from [AppThemes]; defaults to the Hermes Relay brand.
|
||||
* @param themePreference mode axis — "auto" / "light" / "dark". Only meaningful
|
||||
* for [ThemeMode.BOTH] themes; fixed-mode themes ignore it.
|
||||
* @param appFontId id from [AppFont]; selects the body typeface for the whole
|
||||
* app. Defaults to Inter. Code/metadata styles stay monospaced regardless.
|
||||
*/
|
||||
@Composable
|
||||
fun HermesRelayTheme(
|
||||
appThemeId: String = AppThemes.DEFAULT_ID,
|
||||
themePreference: String = "auto",
|
||||
fontScale: Float = 1.0f,
|
||||
appFontId: String = AppFont.DEFAULT.id,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val appTheme = AppThemes.byId(appThemeId)
|
||||
@@ -34,6 +38,11 @@ fun HermesRelayTheme(
|
||||
val palette = appTheme.paletteFor(useDarkTheme)
|
||||
val colorScheme = palette.toColorScheme()
|
||||
|
||||
// Build the Material typography from the selected font. Remembered per id so
|
||||
// a recomposition (e.g. theme/mode change) doesn't rebuild the FontFamily
|
||||
// graph; a font-pick changes appFontId, which re-themes every Text live.
|
||||
val typography = remember(appFontId) { appTypography(AppFont.byId(appFontId).fontFamily()) }
|
||||
|
||||
// Mirror into the legacy façade after commit so snapshot reads in existing
|
||||
// call sites observe the active palette. SideEffect runs post-composition,
|
||||
// avoiding a state-write-during-composition; the default theme matches the
|
||||
@@ -50,7 +59,7 @@ fun HermesRelayTheme(
|
||||
if (fontScale == 1.0f) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
typography = typography,
|
||||
content = content
|
||||
)
|
||||
} else {
|
||||
@@ -62,7 +71,7 @@ fun HermesRelayTheme(
|
||||
CompositionLocalProvider(LocalDensity provides scaledDensity) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
typography = typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,44 +6,54 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
/**
|
||||
* Build the app [Typography] from a user-selected body [FontFamily].
|
||||
*
|
||||
* Every text role uses [body] except [Typography.labelSmall], which stays on
|
||||
* [FontFamily.Monospace] — it is the app's metadata voice (timestamps, token
|
||||
* counts, path badges, agent-name labels) and is intentionally monospaced
|
||||
* regardless of the chosen UI font. `HermesRelayTheme` calls this with the
|
||||
* active [AppFont]'s family so the whole app re-themes live when the choice
|
||||
* changes; see [AppFont].
|
||||
*/
|
||||
fun appTypography(body: FontFamily): Typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontFamily = body,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontFamily = body,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 34.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontFamily = body,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontFamily = body,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontFamily = body,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontFamily = body,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
@@ -57,3 +67,11 @@ val Typography = Typography(
|
||||
letterSpacing = 0.sp
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Default typography (system sans body) — back-compat for any reader that wants
|
||||
* a ready-made [Typography] without resolving an [AppFont]. The live app builds
|
||||
* its typography from the selected font via [appTypography] inside
|
||||
* `HermesRelayTheme`.
|
||||
*/
|
||||
val Typography = appTypography(FontFamily.SansSerif)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
|
||||
/**
|
||||
* Doze / battery-optimization helpers for the opt-in "Persistent connection"
|
||||
* keep-alive.
|
||||
*
|
||||
* A `specialUse` foreground service holds the app **process** up, but on stock
|
||||
* Android it does NOT exempt the app from Doze's network deferral — in deep
|
||||
* Doze (screen off + stationary) the socket's pings can't fire and the
|
||||
* connection drops until a maintenance window. The one reliable way to keep it
|
||||
* open is the battery-optimization allow-list. These helpers read that state
|
||||
* and launch the system request.
|
||||
*
|
||||
* **Sideload only.** `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` is declared only in
|
||||
* the sideload manifest — Google Play restricts it to a short list of app
|
||||
* categories — so callers gate the prompt on [BuildFlavor.isSideload]. On the
|
||||
* googlePlay flavor the request intent simply won't be granted the permission.
|
||||
*/
|
||||
object BatteryOptimizations {
|
||||
|
||||
/** True if this app is on the OS battery-optimization allow-list. */
|
||||
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return false
|
||||
return runCatching {
|
||||
pm.isIgnoringBatteryOptimizations(context.packageName)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the system "Allow <app> to ignore battery optimizations?" dialog.
|
||||
* Requires `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` (sideload manifest). Falls
|
||||
* back to the battery-optimization settings list if the direct request
|
||||
* can't be launched (permission absent / OEM quirk). Launch from an
|
||||
* Activity context.
|
||||
*/
|
||||
fun launchRequest(context: Context) {
|
||||
val direct = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
runCatching { context.startActivity(direct) }.onFailure {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,23 @@ private fun categoryForContext(context: String?): DiagnosticCategory = when (con
|
||||
* "voice_config", "record", "pair", "save_and_test",
|
||||
* "media_fetch", "send_message", or null for generic)
|
||||
*/
|
||||
/**
|
||||
* True if [t] is a "can't reach the server" connectivity failure — connection
|
||||
* refused, host unresolved, or a network timeout. These are exactly the states
|
||||
* the themed connection banner / startup sphere already surface, so callers on
|
||||
* cold-start / background bootstrap paths can use this to suppress a redundant,
|
||||
* scary snackbar (e.g. "The server isn't accepting connections" on first load)
|
||||
* while still recording the error to diagnostics.
|
||||
*
|
||||
* Deliberately excludes SSL/cert errors (actionable — re-pair) and generic
|
||||
* IOExceptions (could be anything but an unreachable server).
|
||||
*/
|
||||
fun isConnectivityError(t: Throwable?): Boolean = when (t) {
|
||||
is ConnectException, is UnknownHostException, is SocketTimeoutException -> true
|
||||
is IOException -> "timeout" in (t.message?.lowercase() ?: "")
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun classifyError(t: Throwable?, context: String? = null): HumanError {
|
||||
val human = classifyErrorInternal(t, context)
|
||||
if (t != null) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.MediaSettings
|
||||
import com.hermesandroid.relay.data.MediaSettingsRepository
|
||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
@@ -33,6 +34,7 @@ import com.hermesandroid.relay.network.upstream.GatewayAttachment
|
||||
import com.hermesandroid.relay.network.upstream.GatewayRpcException
|
||||
import com.hermesandroid.relay.network.upstream.GatewayTurnCallbacks
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.network.relay.ProactiveMessage
|
||||
import com.hermesandroid.relay.network.relay.RelayHttpClient
|
||||
import com.hermesandroid.relay.network.relay.RealtimeVoiceEvent
|
||||
import com.hermesandroid.relay.network.upstream.SteerResult
|
||||
@@ -55,6 +57,7 @@ import com.hermesandroid.relay.util.MediaCacheWriter
|
||||
import com.hermesandroid.relay.util.PhoneSnapshot
|
||||
import com.hermesandroid.relay.util.buildPromptBlock
|
||||
import com.hermesandroid.relay.util.classifyError
|
||||
import com.hermesandroid.relay.util.isConnectivityError
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
@@ -175,6 +178,77 @@ class ChatViewModel : ViewModel() {
|
||||
/** Callback to persist session ID — set by RelayApp */
|
||||
var onSessionChanged: ((String?) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Send a user message into an agent **Thread** (a `source=phone` session)
|
||||
* over the relay proactive channel instead of the normal chat transport —
|
||||
* set by RelayApp to [ConnectionViewModel.sendProactiveReply]. `(text,
|
||||
* chatId, replyTo, messageId)`; `messageId` is the user bubble's id so the
|
||||
* relay's ack can settle it. Null when no relay/ConnectionViewModel is wired.
|
||||
*/
|
||||
var onProactiveReply: ((String, String?, String?, String) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* A "+ New Thread" the user just created + named, before its first message
|
||||
* is sent. The first send routes to [PendingThread.chatId] (which makes the
|
||||
* gateway create the `source=phone` session keyed by it); we then poll for +
|
||||
* switch to that real session and apply the chosen name.
|
||||
*/
|
||||
private data class PendingThread(val chatId: String, val name: String)
|
||||
private var pendingThread: PendingThread? = null
|
||||
|
||||
/**
|
||||
* A "+ New Thread" whose first message has been sent — we're now polling for
|
||||
* the gateway-created `source=phone` session to switch to it. [knownIds] is
|
||||
* the set of phone-session ids that existed BEFORE the send, so the new one
|
||||
* is found by *difference* (the session `id` is a timestamp and the sessions
|
||||
* API exposes neither `chat_id` nor `session_key`, so we can't match by id).
|
||||
*/
|
||||
private data class CreatingThread(
|
||||
val chatId: String,
|
||||
val name: String,
|
||||
val knownIds: Set<String>,
|
||||
)
|
||||
private var creatingThread: CreatingThread? = null
|
||||
|
||||
/**
|
||||
* `sessionId` → phone-platform `chat_id`, learned for threads this app
|
||||
* created ([switchToCreatedThread]) or received a message in
|
||||
* ([injectThreadMessage]). Routes a reply to the right thread, since the
|
||||
* sessions API doesn't return `chat_id`. Unknown → null → the relay/adapter's
|
||||
* home channel ("phone"). In-memory (lost on restart) — the proper fix
|
||||
* exposes `chat_id` on `/api/sessions` upstream (see TODO).
|
||||
*/
|
||||
private val threadChatIds = mutableMapOf<String, String>()
|
||||
|
||||
/**
|
||||
* Persist a user-chosen Thread name (sessionId → name) — set by RelayApp to
|
||||
* [com.hermesandroid.relay.viewmodel.ConnectionViewModel.saveThreadName].
|
||||
* Null when no relay/ConnectionViewModel is wired.
|
||||
*/
|
||||
var onSaveThreadName: ((String, String) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Latest persisted Thread names, re-applied to the handler on every load and
|
||||
* on [initialize] so a handler created after the DataStore load still picks
|
||||
* them up (the user's name overrides the gateway auto-title in the drawer).
|
||||
*/
|
||||
private var persistedThreadNames: Map<String, String> = emptyMap()
|
||||
|
||||
fun applyPersistedThreadNames(names: Map<String, String>) {
|
||||
persistedThreadNames = names
|
||||
chatHandler?.setUserThreadNames(names)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the reply-routing map from the relay's `/phone/threads` (the
|
||||
* `session_id → chat_id` the API omits). Authoritative over the in-memory
|
||||
* learned map, so any Thread routes its replies to the right conversation —
|
||||
* including one this app didn't create, or any Thread after a restart.
|
||||
*/
|
||||
fun seedThreadChatIds(map: Map<String, String>) {
|
||||
threadChatIds.putAll(map)
|
||||
}
|
||||
|
||||
// --- Human-readable error events ---
|
||||
// One-shot events consumed by ChatScreen via snackbar. Shape mirrors
|
||||
// other VMs for consistency; DROP_OLDEST so a burst of errors never
|
||||
@@ -187,7 +261,22 @@ class ChatViewModel : ViewModel() {
|
||||
val errorEvents: SharedFlow<HumanError> = _errorEvents.asSharedFlow()
|
||||
|
||||
private fun emitError(t: Throwable?, context: String?) {
|
||||
_errorEvents.tryEmit(classifyError(t, context = context))
|
||||
val human = classifyError(t, context = context)
|
||||
// Cold-start / reconnect bootstrap (session-list load, session create)
|
||||
// runs without the user asking and on every reconnect. A "can't reach
|
||||
// the server" failure there is non-actionable noise — the themed
|
||||
// connection banner + startup sphere already surface the unreachable
|
||||
// state. Keep the diagnostics record (classifyError above) but suppress
|
||||
// the redundant, scary "server isn't accepting connections" snackbar
|
||||
// that used to flash from the bottom on first load. Actionable failures
|
||||
// (auth rejected, server error) and all interactive contexts
|
||||
// (send_message, …) still surface normally.
|
||||
if ((context == "load_sessions" || context == "create_session") &&
|
||||
isConnectivityError(t)
|
||||
) {
|
||||
return
|
||||
}
|
||||
_errorEvents.tryEmit(human)
|
||||
}
|
||||
|
||||
// --- Message queue ---
|
||||
@@ -1349,6 +1438,57 @@ class ChatViewModel : ViewModel() {
|
||||
chatHandler?.addProactiveMessage(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a Thread reply bubble when the relay acks it
|
||||
* (`proactive.reply.ack`) — wired by RelayApp to the proactive handler's
|
||||
* `onReplyAck`. [clientMsgId] is the user bubble's id (the app stamped it on
|
||||
* the reply). Any non-"failed" status is treated as DELIVERED (the relay
|
||||
* buffered the reply for the agent).
|
||||
*/
|
||||
fun onProactiveReplyAck(clientMsgId: String, status: String) {
|
||||
val resolved = if (status.equals("failed", ignoreCase = true)) {
|
||||
MessageDeliveryStatus.FAILED
|
||||
} else {
|
||||
MessageDeliveryStatus.DELIVERED
|
||||
}
|
||||
chatHandler?.updateDeliveryStatus(clientMsgId, resolved)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an inbound agent message inline in the open Thread when it belongs
|
||||
* there (the unified-Threads live path) — wired to the proactive handler's
|
||||
* `injectIntoThread`. Returns true when shown in-thread, so the handler
|
||||
* suppresses the notification + inbox entry. Matches the open Thread (or a
|
||||
* pending "+ New Thread" draft) by chat_id, falling back to "accept" when
|
||||
* either side has no parseable chat_id (the single home thread).
|
||||
*/
|
||||
fun injectThreadMessage(msg: ProactiveMessage): Boolean {
|
||||
val handler = chatHandler ?: return false
|
||||
val msgChatId = msg.chatId?.takeIf { it.isNotBlank() }
|
||||
// A freshly-created thread whose real session we're still switching to:
|
||||
// show the agent's first reply in the draft view now (the switch
|
||||
// reconciles it from history). Covers the gap before currentSessionId is
|
||||
// set, so the very first reply doesn't fall through to a notification.
|
||||
creatingThread?.let { creating ->
|
||||
if (msgChatId == null || msgChatId == creating.chatId) {
|
||||
handler.addAgentThreadMessage(msg.text, msg.messageId, msg.title)
|
||||
return true
|
||||
}
|
||||
}
|
||||
val activeId = handler.currentSessionId.value ?: return false
|
||||
val active = handler.sessions.value.firstOrNull { it.sessionId == activeId } ?: return false
|
||||
if (active.source != "phone") return false
|
||||
// Match by the learned chat_id when known; otherwise accept (we can't read
|
||||
// a session's chat_id from the API, so default to showing it in the open
|
||||
// phone thread). Learn the mapping from the message for reply routing.
|
||||
val knownChatId = threadChatIds[activeId]
|
||||
val belongs = knownChatId == null || msgChatId == null || knownChatId == msgChatId
|
||||
if (!belongs) return false
|
||||
if (msgChatId != null) threadChatIds[activeId] = msgChatId
|
||||
handler.addAgentThreadMessage(msg.text, msg.messageId, msg.title)
|
||||
return true
|
||||
}
|
||||
|
||||
fun realtimeAgentContextMessages(maxMessages: Int = 14): List<RealtimeConversationContextMessage> {
|
||||
val handler = chatHandler ?: return emptyList()
|
||||
return handler.messages.value
|
||||
@@ -1384,6 +1524,9 @@ class ChatViewModel : ViewModel() {
|
||||
fun initialize(apiClient: HermesApiClient, chatHandler: ChatHandler) {
|
||||
this.apiClient = apiClient
|
||||
this.chatHandler = chatHandler
|
||||
// A handler created after the persisted Thread names loaded still gets
|
||||
// them, so a named Thread keeps its name across restart / reconnect.
|
||||
chatHandler.setUserThreadNames(persistedThreadNames)
|
||||
fetchSkills()
|
||||
fetchPersonalities()
|
||||
fetchModels()
|
||||
@@ -1880,6 +2023,76 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a user-created agent **Thread** (Discord-style "+ New Thread"): mint
|
||||
* a fresh phone-platform `chat_id`, blank the chat to a draft, and stash it
|
||||
* as [pendingThread]. The first message the user sends opens the conversation
|
||||
* on that `chat_id` (the gateway creates the `source=phone` session keyed by
|
||||
* it), after which [switchToCreatedThread] swaps the draft for the real
|
||||
* session. Gated on relay pairing + "Let Hermes message me" by the caller.
|
||||
*/
|
||||
fun startNewThread(name: String) {
|
||||
val handler = chatHandler ?: return
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
historyLoadGeneration.incrementAndGet()
|
||||
val slug = name.trim().lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').take(24)
|
||||
val chatId = "t-" + slug.ifBlank { "thread" } + "-" +
|
||||
java.util.UUID.randomUUID().toString().take(6)
|
||||
pendingThread = PendingThread(chatId = chatId, name = name.trim())
|
||||
// Blank draft — the first send routes to the new thread (handled in
|
||||
// sendMessageInternal's pendingThread branch).
|
||||
gatewayClient?.clearSession()
|
||||
handler.setSessionId(null)
|
||||
handler.clearMessages()
|
||||
_contextUsage.value = null
|
||||
_contextWindow.value = null
|
||||
_pendingAsk.value = null
|
||||
onSessionChanged?.invoke(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* After a "+ New Thread" first send, poll the session list until the gateway
|
||||
* has created the new `source=phone` session, then switch to it (loading its
|
||||
* history) and apply the user's chosen name. The new session is found by
|
||||
* *difference* — the `source=phone` session id not present before the send —
|
||||
* because the sessions API exposes neither `chat_id` nor `session_key` (the
|
||||
* id is just a timestamp). Records sessionId → chat_id so later replies in
|
||||
* this thread route correctly. Best-effort: if it doesn't appear within the
|
||||
* window the thread still exists and shows in the drawer's Threads filter.
|
||||
*/
|
||||
private fun switchToCreatedThread() {
|
||||
val creating = creatingThread ?: return
|
||||
viewModelScope.launch {
|
||||
for (delayMs in longArrayOf(900L, 1300L, 1800L, 2500L, 3500L, 4500L)) {
|
||||
delay(delayMs)
|
||||
refreshSessions()
|
||||
delay(400L) // let the refresh job land in the sessions flow
|
||||
val match = chatHandler?.sessions?.value?.firstOrNull {
|
||||
it.source == "phone" && it.sessionId !in creating.knownIds
|
||||
}
|
||||
if (match != null) {
|
||||
threadChatIds[match.sessionId] = creating.chatId
|
||||
creatingThread = null
|
||||
// The user's name is authoritative (Discord-style): apply it
|
||||
// as a local override so the server's async auto-titler can't
|
||||
// clobber it, and also best-effort rename the server session
|
||||
// for other surfaces.
|
||||
if (creating.name.isNotBlank()) {
|
||||
chatHandler?.setUserThreadName(match.sessionId, creating.name)
|
||||
onSaveThreadName?.invoke(match.sessionId, creating.name)
|
||||
if (match.title != creating.name) {
|
||||
renameSession(match.sessionId, creating.name)
|
||||
}
|
||||
}
|
||||
switchSession(match.sessionId)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
creatingThread = null // gave up — it still appears in the drawer
|
||||
}
|
||||
}
|
||||
|
||||
fun switchSession(sessionId: String) {
|
||||
apiClient ?: return
|
||||
val handler = chatHandler ?: return
|
||||
@@ -2650,6 +2863,47 @@ class ChatViewModel : ViewModel() {
|
||||
val assistantMessageId = UUID.randomUUID().toString()
|
||||
val sessionId = handler.currentSessionId.value
|
||||
|
||||
// User-created Thread: the first message of a "+ New Thread" opens a new
|
||||
// source=phone gateway session keyed by the minted chat_id. Route it over
|
||||
// the proactive channel, snapshot the existing phone-session ids, then
|
||||
// poll for the NEW one (by difference) and switch to it.
|
||||
pendingThread?.let { pending ->
|
||||
pendingThread = null
|
||||
val send = onProactiveReply
|
||||
if (send != null) {
|
||||
handler.updateDeliveryStatus(messageId, MessageDeliveryStatus.SENDING)
|
||||
val knownIds = handler.sessions.value
|
||||
.filter { it.source == "phone" }
|
||||
.map { it.sessionId }
|
||||
.toSet()
|
||||
creatingThread = CreatingThread(pending.chatId, pending.name, knownIds)
|
||||
send(text.trim(), pending.chatId, null, messageId)
|
||||
switchToCreatedThread()
|
||||
} else {
|
||||
handler.updateDeliveryStatus(messageId, MessageDeliveryStatus.FAILED)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Agent Thread (existing source=phone session): the user is replying
|
||||
// inside a proactive conversation, so route the turn over the relay
|
||||
// proactive channel (continues that thread's gateway session) instead of
|
||||
// a normal chat send. The user bubble was already added above — mark it
|
||||
// SENDING and stamp its id as the reply's message_id so the relay's ack
|
||||
// can settle it. The chat_id comes from the learned map (the API doesn't
|
||||
// expose it); unknown → null → the relay/adapter's home channel ("phone").
|
||||
val activeThread = handler.sessions.value.firstOrNull { it.sessionId == sessionId }
|
||||
if (activeThread?.source == "phone") {
|
||||
val send = onProactiveReply
|
||||
if (send != null) {
|
||||
handler.updateDeliveryStatus(messageId, MessageDeliveryStatus.SENDING)
|
||||
send(text.trim(), threadChatIds[activeThread.sessionId], null, messageId)
|
||||
} else {
|
||||
handler.updateDeliveryStatus(messageId, MessageDeliveryStatus.FAILED)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic drawer preview: a chat created via "New Chat" still reads
|
||||
// "New Chat"/untitled in the drawer until the server auto-titles it after
|
||||
// the turn. Stamp it with the first user message now so the row is
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.ui.theme.AppFont
|
||||
import com.hermesandroid.relay.ui.theme.AppThemes
|
||||
import com.hermesandroid.relay.ui.components.avatar.PetImporter
|
||||
import com.hermesandroid.relay.ui.components.avatar.PetImportResult
|
||||
@@ -38,8 +39,11 @@ import com.hermesandroid.relay.data.SessionTransport
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import com.hermesandroid.relay.data.proactiveEnabledFlow
|
||||
import com.hermesandroid.relay.data.setProactiveEnabled
|
||||
import com.hermesandroid.relay.data.DEFAULT_HIDDEN_SOURCES
|
||||
import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.ProactiveInboxRepository
|
||||
import com.hermesandroid.relay.data.SessionSourcePrefs
|
||||
import com.hermesandroid.relay.data.ThreadNameStore
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
@@ -77,6 +81,7 @@ import com.hermesandroid.relay.network.relay.BridgeCommandHandler
|
||||
import com.hermesandroid.relay.network.relay.ProactiveMessageHandler
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
// === END PHASE3-accessibility ===
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
import com.hermesandroid.relay.util.MediaCacheWriter
|
||||
import com.hermesandroid.relay.viewmodel.connection.PairingController
|
||||
import com.hermesandroid.relay.viewmodel.connection.ProfileController
|
||||
@@ -108,6 +113,8 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private data class RelayUiInputs(
|
||||
val auth: AuthState,
|
||||
@@ -158,6 +165,12 @@ enum class ChatConnectState {
|
||||
class ConnectionViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
// Shared log tag for connection lifecycle + handoff tracing. Pairs with
|
||||
// ConnectionManager's "ConnectionManager" tag so a single
|
||||
// `logcat -s ConnectionVM ConnectionManager` shows the full relay
|
||||
// socket → derived-state → surfaced-banner story.
|
||||
private const val TAG = "ConnectionVM"
|
||||
|
||||
// API Server (direct chat)
|
||||
private val KEY_API_SERVER_URL = stringPreferencesKey("api_server_url")
|
||||
private const val DEFAULT_API_URL = "http://localhost:8642"
|
||||
@@ -175,6 +188,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// reconnect genuinely fails (server down, bad network).
|
||||
private const val RELAY_RECONNECT_GRACE_MS = 5_000L
|
||||
|
||||
// A brief switch-away (glance at another app) doesn't need the full
|
||||
// cache-clearing re-probe [revalidateOnResume] normally does — the
|
||||
// sockets keep 30s pings and the connection was healthy moments ago.
|
||||
// Only pay the re-probe (+ Probing badge flash) when we were away long
|
||||
// enough that the connection could have gone stale, or when it isn't
|
||||
// already healthy. Network *changes* are handled independently by the
|
||||
// ConnectivityObserver/network callbacks, not by revalidate(), so
|
||||
// skipping here can't miss a Wi-Fi↔cellular flip.
|
||||
private const val BRIEF_RESUME_REVALIDATE_MS = 15_000L
|
||||
|
||||
/**
|
||||
* Placeholder label written by [beginAddConnection] before the
|
||||
* user has scanned a QR. The pair-success watcher treats a
|
||||
@@ -190,6 +213,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// Selected app theme id (palette/personality). Orthogonal to KEY_THEME,
|
||||
// which is the light/dark/auto mode axis honored by BOTH-mode themes.
|
||||
private val KEY_APP_THEME = stringPreferencesKey("app_theme")
|
||||
// Selected app font id (body typeface). Resolved against AppFont at the
|
||||
// Compose theme root; defaults to Inter. Orthogonal to KEY_FONT_SCALE
|
||||
// (which scales sizes); this picks the family.
|
||||
private val KEY_APP_FONT = stringPreferencesKey("app_font")
|
||||
// Selected sphere skin id. "auto" (SphereRegistry.AUTO_ID) follows the
|
||||
// active theme's preferred skin; any other id pins a specific skin.
|
||||
private val KEY_SPHERE_SKIN = stringPreferencesKey("sphere_skin")
|
||||
@@ -430,7 +457,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
pairedTokenSnapshot = {
|
||||
// Same synchronous paired-token read the fetch uses, so the media-
|
||||
// capability badge agrees with whether /media/by-path can actually
|
||||
// fetch (the token is wiped on relay restart until we re-pair).
|
||||
// fetch (no current paired token → the fetch can't authenticate).
|
||||
(authManager.authState.value as? AuthState.Paired)?.token
|
||||
},
|
||||
)
|
||||
@@ -496,6 +523,28 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val connectionHandoffStatus: StateFlow<ConnectionHandoffStatus?> =
|
||||
_connectionHandoffStatus.asStateFlow()
|
||||
private var connectionHandoffClearJob: Job? = null
|
||||
// Timestamp of the last app foreground resume (cold start counts). A relay
|
||||
// reconnect within RELAY_RECONNECT_GRACE_MS of this is almost always the
|
||||
// same connection re-handshaking after the OS dropped the socket in the
|
||||
// background — we suppress its transient banner so the user isn't shown a
|
||||
// misleading "Reconnecting"/"Connection changed" flash on every app switch.
|
||||
@Volatile
|
||||
private var lastForegroundResumeAtMs: Long = 0L
|
||||
// True while a just-resumed reconnect's banner is being withheld. Lets the
|
||||
// subsequent "Connection restored" pair stay silent too if we never showed
|
||||
// the reconnecting banner. Touched only from the main-thread state collector.
|
||||
private var suppressedTransientReconnect = false
|
||||
private var transientReconnectJob: Job? = null
|
||||
// True for RELAY_RECONNECT_GRACE_MS right after a foreground resume. The
|
||||
// handoff path suppresses its own "Reconnecting" banner on resume, but the
|
||||
// *health*-derived cue ("Connecting to Hermes", active) leaks the bottom-strip
|
||||
// "Reconnecting…" cue independently — so a benign resume flashed the cue and
|
||||
// then cleared with no "Connected" toast (handoff stayed suppressed). The UI
|
||||
// gates the bottom-strip cue on this so a benign resume is fully silent; if
|
||||
// the socket is still down past the window it un-gates and the cue shows.
|
||||
private val _postResumeQuiet = MutableStateFlow(false)
|
||||
val postResumeQuiet: StateFlow<Boolean> = _postResumeQuiet.asStateFlow()
|
||||
private var postResumeQuietJob: Job? = null
|
||||
private val _serverChatDisplaySettings =
|
||||
MutableStateFlow<DashboardChatDisplaySettings?>(null)
|
||||
|
||||
@@ -1005,6 +1054,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, AppThemes.DEFAULT_ID)
|
||||
|
||||
// Selected app font id (body typeface). Defaults to Inter. Resolved against
|
||||
// AppFont.byId at the Compose theme root, which rebuilds Typography so the
|
||||
// whole app re-themes live when this changes.
|
||||
val appFont: StateFlow<String> = application.relayDataStore.data
|
||||
.map { preferences ->
|
||||
preferences[KEY_APP_FONT] ?: AppFont.DEFAULT.id
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, AppFont.DEFAULT.id)
|
||||
|
||||
// Selected sphere skin id ("auto" follows the theme). Resolved against
|
||||
// SphereRegistry + loaded user skins at the Compose root.
|
||||
val sphereSkin: StateFlow<String> = application.relayDataStore.data
|
||||
@@ -1777,6 +1835,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
title = msg.title ?: "Hermes",
|
||||
text = msg.text,
|
||||
receivedAt = msg.sentAt ?: System.currentTimeMillis(),
|
||||
chatId = msg.chatId,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1787,6 +1846,62 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val proactiveEnabled: StateFlow<Boolean> = application.proactiveEnabledFlow()
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
// Drawer source visibility — which gateway sources are hidden (default:
|
||||
// the noisy automation lanes cron + webhook). Edited from the drawer source
|
||||
// filter + Chat settings; both write the same persisted set.
|
||||
private val sessionSourcePrefs = SessionSourcePrefs(application)
|
||||
val hiddenSources: StateFlow<Set<String>> = sessionSourcePrefs.hiddenSources
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, DEFAULT_HIDDEN_SOURCES)
|
||||
|
||||
fun setSourceHidden(source: String, hidden: Boolean) {
|
||||
viewModelScope.launch { sessionSourcePrefs.setHidden(source, hidden) }
|
||||
}
|
||||
|
||||
// Persisted user-chosen Thread names (sessionId → name) — applied to the
|
||||
// drawer so a named Thread keeps its name across restarts and beats the
|
||||
// gateway's async auto-title. Wired to ChatViewModel via RelayApp.
|
||||
private val threadNameStore = ThreadNameStore(application)
|
||||
val threadNames: StateFlow<Map<String, String>> = threadNameStore.names
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
fun saveThreadName(sessionId: String, name: String) {
|
||||
viewModelScope.launch { threadNameStore.setName(sessionId, name) }
|
||||
}
|
||||
|
||||
// Phone Thread session_id → chat_id, fetched from the relay's /phone/threads
|
||||
// (the gateway store has chat_id but /api/sessions doesn't). Seeds the chat
|
||||
// composer's reply routing so a Thread the app didn't create — or any Thread
|
||||
// after restart — routes to the right conversation. Fail-soft: empty on an
|
||||
// older relay / fetch error, and the client's learned map still applies.
|
||||
private val _phoneThreadChatIds = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val phoneThreadChatIds: StateFlow<Map<String, String>> = _phoneThreadChatIds.asStateFlow()
|
||||
|
||||
fun refreshPhoneThreadChatIds() {
|
||||
viewModelScope.launch {
|
||||
relayHttpClient.fetchPhoneThreads().onSuccess { threads ->
|
||||
val map = threads
|
||||
.filter { it.sessionId.isNotBlank() && it.chatId.isNotBlank() }
|
||||
.associate { it.sessionId to it.chatId }
|
||||
if (map.isNotEmpty()) _phoneThreadChatIds.value = map
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Relay plugin update status from /relay/update-check (the relay compares its
|
||||
// installed version to the latest plugin-v* release, cached an hour). Drives
|
||||
// the Settings version readout + a soft, dismissible "relay is behind" nudge.
|
||||
// Fail-soft: stays null on an older relay (no route) or a fetch error.
|
||||
private val _relayUpdateInfo = MutableStateFlow<RelayHttpClient.RelayUpdateInfo?>(null)
|
||||
val relayUpdateInfo: StateFlow<RelayHttpClient.RelayUpdateInfo?> = _relayUpdateInfo.asStateFlow()
|
||||
|
||||
fun refreshRelayUpdateInfo() {
|
||||
viewModelScope.launch {
|
||||
relayHttpClient.fetchUpdateCheck().onSuccess { info ->
|
||||
if (info != null) _relayUpdateInfo.value = info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendProactiveSubscribe() {
|
||||
multiplexer.send(Envelope(channel = "proactive", type = "proactive.subscribe"))
|
||||
}
|
||||
@@ -1810,6 +1925,46 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
fun clearProactiveInbox() {
|
||||
viewModelScope.launch { proactiveInbox.clear() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reply to a proactive message back to the agent (Phase 2c). The
|
||||
* relay buffers it and the gateway adapter long-polls it, turning it into
|
||||
* an inbound platform message that continues the originating conversation.
|
||||
*
|
||||
* Best-effort over the live relay WS (dropped if disconnected — the same
|
||||
* semantics as [sendProactiveSubscribe]). Used by the Hermes inbox reply
|
||||
* box; the notification inline-reply path goes through
|
||||
* [com.hermesandroid.relay.notifications.ProactiveReplyReceiver] instead.
|
||||
*
|
||||
* @param chatId the conversation to continue (from the original message).
|
||||
* @param replyTo the answered message's id (anchors the reply).
|
||||
*/
|
||||
fun sendProactiveReply(
|
||||
text: String,
|
||||
chatId: String?,
|
||||
replyTo: String?,
|
||||
messageId: String? = null,
|
||||
) {
|
||||
val body = text.trim()
|
||||
if (body.isEmpty()) return
|
||||
multiplexer.send(
|
||||
Envelope(
|
||||
channel = "proactive",
|
||||
type = "proactive.reply",
|
||||
payload = buildJsonObject {
|
||||
put("text", body)
|
||||
if (!chatId.isNullOrBlank()) put("chat_id", chatId)
|
||||
if (!replyTo.isNullOrBlank()) put("reply_to", replyTo)
|
||||
// The app-minted id the relay echoes back in
|
||||
// `proactive.reply.ack`, so a Thread reply bubble can settle
|
||||
// SENDING → DELIVERED. Omitted by the inbox/notification
|
||||
// paths (they don't track per-bubble status).
|
||||
if (!messageId.isNullOrBlank()) put("message_id", messageId)
|
||||
put("ts", System.currentTimeMillis())
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
// === END Proactive ===
|
||||
|
||||
// --- Connection switch orchestration ----------------------------------
|
||||
@@ -1950,6 +2105,14 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
val cleanTitle = title.trim().takeIf { it.isNotBlank() } ?: "Connection changed"
|
||||
val cleanRoute = route?.trim()?.takeIf { it.isNotBlank() }
|
||||
val cleanDetail = detail?.trim()?.takeIf { it.isNotBlank() }?.take(120)
|
||||
// Trace which strip/banner actually surfaced (and why). Best-practice
|
||||
// permanent logging: handoffs are infrequent, and this is the single
|
||||
// line that answers "what made that status appear?" during triage.
|
||||
android.util.Log.i(
|
||||
TAG,
|
||||
"handoff: '$cleanTitle' active=$active success=$success " +
|
||||
"route=${cleanRoute ?: "-"} detail=${cleanDetail ?: "-"}",
|
||||
)
|
||||
val entry = ConnectionHandoffTraceEntry(
|
||||
label = cleanTitle,
|
||||
detail = cleanDetail,
|
||||
@@ -2695,6 +2858,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
viewModelScope.launch {
|
||||
authOkEvents.collect {
|
||||
if (proactiveEnabled.value) sendProactiveSubscribe()
|
||||
// Pull the phone Thread → chat_id map so replies route correctly
|
||||
// (covers Threads the app didn't create + survives restart).
|
||||
refreshPhoneThreadChatIds()
|
||||
// Check whether the relay's plugin is behind the latest release.
|
||||
refreshRelayUpdateInfo()
|
||||
}
|
||||
}
|
||||
// React to the toggle flipping while already connected. drop(1) skips
|
||||
@@ -2805,6 +2973,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
.multiplexer = multiplexer
|
||||
// === END PHASE3-notif-listener-followup ===
|
||||
|
||||
// Proactive notification inline-reply (Phase 2c) — the BroadcastReceiver
|
||||
// lives outside the ViewModel scope, so it reads the live multiplexer
|
||||
// from this static slot (same pattern as the notification companion
|
||||
// above). Replies sent while the relay is disconnected drop best-effort.
|
||||
com.hermesandroid.relay.notifications.ProactiveReplyReceiver
|
||||
.multiplexer = multiplexer
|
||||
|
||||
// Resolve [relayUiState] from the three raw inputs (authState,
|
||||
// relayConnectionState, relayUrl) with a grace-window transition
|
||||
// to Stale. Lifted here from three separate ad-hoc helpers across
|
||||
@@ -2859,9 +3034,37 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp the resume timestamp whenever the process returns to the
|
||||
// foreground (and on the initial start). Read by the reconnect handoff
|
||||
// logic below to decide whether a reconnect is a benign post-resume
|
||||
// re-handshake worth suppressing.
|
||||
viewModelScope.launch {
|
||||
AppForegroundTracker.isForeground.collect { foreground ->
|
||||
if (foreground) {
|
||||
lastForegroundResumeAtMs = System.currentTimeMillis()
|
||||
// Open the quiet window: hide the reconnect cue while a benign
|
||||
// post-resume re-handshake settles. Reopen (un-gate) after the
|
||||
// grace window so a genuine outage still surfaces.
|
||||
_postResumeQuiet.value = true
|
||||
postResumeQuietJob?.cancel()
|
||||
postResumeQuietJob = launch {
|
||||
delay(RELAY_RECONNECT_GRACE_MS)
|
||||
_postResumeQuiet.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
var previousState: ConnectionState? = null
|
||||
var previousRole: String? = null
|
||||
// Role at the last time we surfaced a "connected" handoff. Lets the
|
||||
// single connect message decide "Connected" vs "Connection changed
|
||||
// X → Y" at the moment the socket is actually up — so a route swap is
|
||||
// ONE message, never "Connection changed" followed by a redundant
|
||||
// "Connected". (previousRole can't do this: the endpoint often
|
||||
// republishes the new role mid-swap, before the reconnect completes.)
|
||||
var lastConnectedRole: String? = null
|
||||
combine(
|
||||
relayConnectionState,
|
||||
connectionManager.activeEndpoint,
|
||||
@@ -2874,49 +3077,94 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
previousRole = role
|
||||
if (priorState == null) return@collect
|
||||
|
||||
// Every relay socket-state / endpoint-role transition that
|
||||
// *could* surface a handoff banner. This is the "both sides"
|
||||
// client trace: pair it with the server's connection log to
|
||||
// tell a real drop (server saw a close) from a client-only
|
||||
// role flip (server saw nothing → spurious "route changed").
|
||||
android.util.Log.i(
|
||||
TAG,
|
||||
"relay transition: $priorState→$state " +
|
||||
"role=${priorRole ?: "-"}→${role ?: "-"}",
|
||||
)
|
||||
|
||||
when {
|
||||
state == ConnectionState.Reconnecting -> {
|
||||
recordConnectionHandoff(
|
||||
title = "Connection changed",
|
||||
route = displayEndpointRole(role ?: priorRole),
|
||||
detail = "Trying relay route",
|
||||
active = true,
|
||||
success = false,
|
||||
)
|
||||
}
|
||||
state == ConnectionState.Connected &&
|
||||
priorState == ConnectionState.Reconnecting -> {
|
||||
recordConnectionHandoff(
|
||||
title = "Connection restored",
|
||||
route = displayEndpointRole(role),
|
||||
detail = "Relay path ready",
|
||||
active = false,
|
||||
success = true,
|
||||
)
|
||||
// Same connection re-handshaking — never imply a switch
|
||||
// ("Connection changed" used to mislead here; a genuine
|
||||
// route switch is handled by its own branch below).
|
||||
val reconnectHandoff = {
|
||||
recordConnectionHandoff(
|
||||
title = "Reconnecting",
|
||||
route = displayEndpointRole(role ?: priorRole),
|
||||
detail = "Re-establishing the relay socket",
|
||||
active = true,
|
||||
success = false,
|
||||
)
|
||||
}
|
||||
val resumeAgeMs = System.currentTimeMillis() - lastForegroundResumeAtMs
|
||||
val justResumed = resumeAgeMs in 0 until RELAY_RECONNECT_GRACE_MS
|
||||
transientReconnectJob?.cancel()
|
||||
if (justResumed) {
|
||||
// Withhold the banner: on a foreground resume the OS
|
||||
// commonly drops + re-handshakes the socket. Only
|
||||
// surface "Reconnecting" if it's still down past the
|
||||
// grace window (a real outage, not an app switch).
|
||||
suppressedTransientReconnect = true
|
||||
transientReconnectJob = launch {
|
||||
delay(RELAY_RECONNECT_GRACE_MS)
|
||||
if (relayConnectionState.value == ConnectionState.Reconnecting) {
|
||||
suppressedTransientReconnect = false
|
||||
reconnectHandoff()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
suppressedTransientReconnect = false
|
||||
reconnectHandoff()
|
||||
}
|
||||
}
|
||||
state == ConnectionState.Connected &&
|
||||
priorState != ConnectionState.Connected -> {
|
||||
recordConnectionHandoff(
|
||||
title = "Connected to Hermes",
|
||||
route = displayEndpointRole(role),
|
||||
detail = "Relay path ready",
|
||||
active = false,
|
||||
success = true,
|
||||
)
|
||||
}
|
||||
state == ConnectionState.Connected &&
|
||||
!priorRole.isNullOrBlank() &&
|
||||
!role.isNullOrBlank() &&
|
||||
!priorRole.equals(role, ignoreCase = true) -> {
|
||||
val from = displayEndpointRole(priorRole)
|
||||
val to = displayEndpointRole(role)
|
||||
recordConnectionHandoff(
|
||||
title = "Connection route changed",
|
||||
route = to,
|
||||
detail = listOfNotNull(from, to).joinToString(" -> "),
|
||||
active = false,
|
||||
success = true,
|
||||
)
|
||||
// ONE message for every transition into Connected
|
||||
// (from Reconnecting / Connecting / Disconnected). If
|
||||
// the route changed since we were last connected it's
|
||||
// "Connection changed · LAN → Tailscale" (which itself
|
||||
// implies connected — no redundant "Connected" after);
|
||||
// otherwise just "Connected to Hermes". This single
|
||||
// point replaces the old three positive branches
|
||||
// ("restored" + "connected" + "route changed") that
|
||||
// fired in pairs on a flap or a swap.
|
||||
transientReconnectJob?.cancel()
|
||||
val fromRole = lastConnectedRole
|
||||
if (suppressedTransientReconnect) {
|
||||
// Benign post-resume recovery we kept silent — stay
|
||||
// silent on the restore too.
|
||||
suppressedTransientReconnect = false
|
||||
} else {
|
||||
val routeSwapped = !fromRole.isNullOrBlank() &&
|
||||
!role.isNullOrBlank() &&
|
||||
!fromRole.equals(role, ignoreCase = true)
|
||||
if (routeSwapped) {
|
||||
val from = displayEndpointRole(fromRole)
|
||||
val to = displayEndpointRole(role)
|
||||
recordConnectionHandoff(
|
||||
title = "Connection changed",
|
||||
route = listOfNotNull(from, to).joinToString(" → "),
|
||||
detail = null,
|
||||
active = false,
|
||||
success = true,
|
||||
)
|
||||
} else {
|
||||
recordConnectionHandoff(
|
||||
title = "Connected to Hermes",
|
||||
route = displayEndpointRole(role),
|
||||
detail = "Relay path ready",
|
||||
active = false,
|
||||
success = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
lastConnectedRole = role
|
||||
}
|
||||
state == ConnectionState.Disconnected &&
|
||||
priorState == ConnectionState.Connected -> {
|
||||
@@ -3565,6 +3813,23 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
* the existing one finish. Cheap enough that callers don't need to
|
||||
* debounce themselves.
|
||||
*/
|
||||
/**
|
||||
* Resume-path entry to [revalidate], debounced by how long the app was
|
||||
* away. A quick app-switch with an already-healthy API connection skips
|
||||
* the cache-clearing re-probe (and the Probing badge flash) entirely;
|
||||
* a longer absence — or an unhealthy connection — re-probes as usual.
|
||||
*
|
||||
* @param awayMs milliseconds since the Activity was last paused. Callers
|
||||
* that can't measure it (or want to force a probe) pass [Long.MAX_VALUE].
|
||||
*/
|
||||
fun revalidateOnResume(awayMs: Long) {
|
||||
val healthy = _apiServerHealth.value == HealthStatus.Reachable
|
||||
if (awayMs in 0 until BRIEF_RESUME_REVALIDATE_MS && healthy) {
|
||||
return
|
||||
}
|
||||
revalidate()
|
||||
}
|
||||
|
||||
fun revalidate() {
|
||||
if (isDemoMode.value) return // Demo mode is offline — skip all probes.
|
||||
if (revalidationJob?.isActive == true) return
|
||||
@@ -5397,6 +5662,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the selected body font; the Compose root re-themes live. */
|
||||
fun setAppFont(fontId: String) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences[KEY_APP_FONT] = fontId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSphereSkin(skinId: String) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
|
||||
@@ -152,8 +152,32 @@ data class BackgroundRunState(
|
||||
/** "promoted" (auto-detached long run) or "durable" (explicit mode=background). */
|
||||
val tier: String = "promoted",
|
||||
val message: String = "Working on it in the background…",
|
||||
/**
|
||||
* Live secondary line from the run's progress/tool events (e.g. "Running
|
||||
* command."). With timer-driven spoken progress off by default, this chip
|
||||
* line is the primary in-between signal for a background run.
|
||||
*/
|
||||
val statusLine: String? = null,
|
||||
/** Tools completed so far (from hermes.run.progress). */
|
||||
val completedToolCount: Int = 0,
|
||||
/** Wall-clock at promotion — drives the chip's mm:ss elapsed ticker. */
|
||||
val startedAtMs: Long = System.currentTimeMillis(),
|
||||
val phase: BackgroundRunPhase = BackgroundRunPhase.RUNNING,
|
||||
)
|
||||
|
||||
/** Connection-aware phase for the background-run chip. */
|
||||
enum class BackgroundRunPhase {
|
||||
/** Run in flight; progress events are flowing. */
|
||||
RUNNING,
|
||||
|
||||
/** The voice socket dropped mid-run; the relay keeps the run alive and the
|
||||
* client is retrying the resume — the task is safe, not lost. */
|
||||
RECONNECTING,
|
||||
|
||||
/** The run finished; the spoken summary is queued behind the floor. */
|
||||
DELIVERING,
|
||||
}
|
||||
|
||||
data class VoiceHandoffStatus(
|
||||
val title: String,
|
||||
val route: String? = null,
|
||||
@@ -381,6 +405,21 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
private const val SILENCE_WATCHDOG_POLL_MS: Long = 150L
|
||||
|
||||
/**
|
||||
* Idle/no-speech auto-close for a Listening turn that never hears
|
||||
* speech — parity with hermes-desktop voice_mode `idleSilenceMs`. The
|
||||
* turn is cancelled WITHOUT transcribing (nothing was said), then the
|
||||
* loop is free to re-arm.
|
||||
*/
|
||||
private const val IDLE_NO_SPEECH_MS: Long = 12_000L
|
||||
|
||||
/**
|
||||
* Hard ceiling on a single Listening turn — parity with hermes-desktop
|
||||
* voice_mode's 60 s turn timeout. Whatever was captured is sent to
|
||||
* transcription so a long monologue still completes.
|
||||
*/
|
||||
private const val MAX_LISTEN_TURN_MS: Long = 60_000L
|
||||
|
||||
/**
|
||||
* Explicit allow-list of cancel utterances. Intentionally NOT
|
||||
* fuzzy — ambiguous words like "yes"/"ok" must NOT terminate a
|
||||
@@ -437,6 +476,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// the open path reset them at each turn boundary.
|
||||
private var realtimeSessionJob: Job? = null
|
||||
private var realtimeTurnChannel: kotlinx.coroutines.channels.Channel<RealtimeTurnInput>? = null
|
||||
|
||||
/** Watchdog that clears a DELIVERING background-run chip if no summary
|
||||
* audio ever arrives (visual-only delivery, provider hiccup). */
|
||||
private var deliveringChipClearJob: Job? = null
|
||||
private var rtUserText: String = ""
|
||||
private var rtAssistantMessageId: String = ""
|
||||
private var rtConversationContext: List<RealtimeConversationContextMessage> = emptyList()
|
||||
@@ -1202,6 +1245,40 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_uiState.update {
|
||||
it.copy(voiceMode = true, state = VoiceState.Idle, outputAudioActive = false, error = null)
|
||||
}
|
||||
prewarmRealtimeSession()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the persistent Realtime Agent session at voice-mode entry, before
|
||||
* the first utterance — pulling the session POST + relay websocket +
|
||||
* provider connect out of first-turn latency. Sends no input and touches
|
||||
* no turn state; the first utterance rides [submitRealtimeTurn] exactly
|
||||
* like a follow-up turn. No-op unless the engine is Realtime Agent with
|
||||
* the persistent-session toggle on, or when a session is already open.
|
||||
* A failed warm-up is silent — the first turn just opens fresh.
|
||||
*/
|
||||
private fun prewarmRealtimeSession() {
|
||||
if (voiceEngineMode != VoiceEngineMode.RealtimeAgent) return
|
||||
if (!realtimePersistentSession) return
|
||||
val client = voiceClient ?: return
|
||||
val chatVm = chatViewModel ?: return
|
||||
if (realtimeSessionJob?.isActive == true && realtimeTurnChannel != null) return
|
||||
closeRealtimeSession()
|
||||
realtimeTurnChannel = kotlinx.coroutines.channels.Channel(
|
||||
kotlinx.coroutines.channels.Channel.UNLIMITED,
|
||||
)
|
||||
Log.i(TAG, "Prewarming persistent Realtime Agent session on voice-mode entry")
|
||||
realtimeSessionJob = viewModelScope.launch {
|
||||
runRealtimeAgentTurn(
|
||||
client = client,
|
||||
chatVm = chatVm,
|
||||
userText = "",
|
||||
inputPcm = ByteArray(0),
|
||||
inputSampleRate = 16_000,
|
||||
persistentOpen = true,
|
||||
prewarm = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelRealtimeAgentTurn(reason: String) {
|
||||
@@ -1219,6 +1296,29 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
realtimeConfirmationControl = null
|
||||
}
|
||||
|
||||
/** Apply [transform] to the background-run chip state, if one is showing. */
|
||||
private fun updateBackgroundRun(transform: (BackgroundRunState) -> BackgroundRunState) {
|
||||
_uiState.update { state ->
|
||||
val run = state.backgroundRun ?: return@update state
|
||||
state.copy(backgroundRun = transform(run))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the promoted/durable background run from the overlay chip. The
|
||||
* relay confirms with `hermes.run.cancelled`, which clears the chip; the
|
||||
* message flips immediately so the tap feels acknowledged.
|
||||
*/
|
||||
fun cancelBackgroundRun() {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Background run cancel requested from chip " +
|
||||
"run=${_uiState.value.backgroundRun?.runId ?: "?"}",
|
||||
)
|
||||
updateBackgroundRun { it.copy(message = "Cancelling…", statusLine = null) }
|
||||
cancelRealtimeAgentTurn("background_run_chip")
|
||||
}
|
||||
|
||||
fun exitVoiceMode() {
|
||||
// Idempotence guard — added 2026-04-21 after logcat showed the voice-
|
||||
// exit chime playing on every Add-connection tap.
|
||||
@@ -1498,10 +1598,19 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* watchdog uses, already tuned to reject mic hiss and room tone on
|
||||
* Bailey's devices while catching whispered speech.
|
||||
*
|
||||
* Grace window: auto-stop does NOT fire until we've seen at least one
|
||||
* above-floor frame. If the user taps to start a turn and never
|
||||
* speaks, we wait forever (or until a manual stop) rather than
|
||||
* insta-closing the turn the moment recording begins.
|
||||
* Three timeouts, aligned to hermes-desktop's voice_mode defaults so the
|
||||
* standard-path loop feels like the official desktop:
|
||||
* - **End-of-speech** ([VoiceSettings.silenceThresholdMs], default 1250 ms,
|
||||
* desktop `silenceMs`): after speech is heard, this much silence
|
||||
* auto-stops and transcribes the turn.
|
||||
* - **Idle/no-speech** ([IDLE_NO_SPEECH_MS] = 12 s, desktop `idleSilenceMs`):
|
||||
* a turn that never hears speech is cancelled WITHOUT transcribing.
|
||||
* - **Hard cap** ([MAX_LISTEN_TURN_MS] = 60 s): any turn running this long
|
||||
* is stopped and transcribed.
|
||||
*
|
||||
* Grace window: the end-of-speech timeout does NOT fire until we've seen at
|
||||
* least one above-floor frame. The amplitude floor [RESUME_SILENCE_THRESHOLD]
|
||||
* (0.08) is the analog of desktop's `silenceLevel` (0.075).
|
||||
*
|
||||
* No-op when [voicePreferences] is unwired (pre-fix test call sites)
|
||||
* or when the threshold is <= 0 (reserved for a future "Off" option).
|
||||
@@ -1517,18 +1626,35 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val rec = recorder ?: return@launch
|
||||
var hasSpoken = false
|
||||
var lastVoiceMs = System.currentTimeMillis()
|
||||
val turnStartedMs = System.currentTimeMillis()
|
||||
|
||||
while (isActive && _uiState.value.state == VoiceState.Listening) {
|
||||
val amp = rec.amplitude.value
|
||||
val now = System.currentTimeMillis()
|
||||
if (amp > RESUME_SILENCE_THRESHOLD) {
|
||||
hasSpoken = true
|
||||
lastVoiceMs = now
|
||||
} else if (hasSpoken && (now - lastVoiceMs) >= thresholdMs) {
|
||||
Log.d(TAG, "silence watchdog: ${thresholdMs}ms of silence after speech — auto-stop")
|
||||
// stopListening() is state-guarded (early-returns if
|
||||
// already out of Listening), so a concurrent manual
|
||||
// stop between here and dispatch is a safe no-op.
|
||||
when {
|
||||
amp > RESUME_SILENCE_THRESHOLD -> {
|
||||
hasSpoken = true
|
||||
lastVoiceMs = now
|
||||
}
|
||||
hasSpoken && (now - lastVoiceMs) >= thresholdMs -> {
|
||||
Log.d(TAG, "silence watchdog: ${thresholdMs}ms of silence after speech — auto-stop")
|
||||
// stopListening() is state-guarded (early-returns if
|
||||
// already out of Listening), so a concurrent manual
|
||||
// stop between here and dispatch is a safe no-op.
|
||||
stopListening()
|
||||
return@launch
|
||||
}
|
||||
!hasSpoken && (now - turnStartedMs) >= IDLE_NO_SPEECH_MS -> {
|
||||
Log.d(TAG, "silence watchdog: ${IDLE_NO_SPEECH_MS}ms with no speech — closing idle turn")
|
||||
cancelListeningWithoutProcessing(
|
||||
title = "No speech detected",
|
||||
detail = "No speech within ${IDLE_NO_SPEECH_MS / 1000}s",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
if ((System.currentTimeMillis() - turnStartedMs) >= MAX_LISTEN_TURN_MS) {
|
||||
Log.d(TAG, "silence watchdog: ${MAX_LISTEN_TURN_MS}ms hard turn cap — auto-stop")
|
||||
stopListening()
|
||||
return@launch
|
||||
}
|
||||
@@ -2221,8 +2347,15 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
inputPcm: ByteArray,
|
||||
inputSampleRate: Int,
|
||||
persistentOpen: Boolean = false,
|
||||
/**
|
||||
* Voice-mode-entry warm-up: open the persistent session/socket with no
|
||||
* first turn. All turn-scoped side effects (Thinking state, the chat
|
||||
* assistant placeholder, the turn-active flag) are skipped — the first
|
||||
* real utterance rides [submitRealtimeTurn] like any follow-up turn.
|
||||
*/
|
||||
prewarm: Boolean = false,
|
||||
) {
|
||||
providerRealtimeAgentTurnActive.set(true)
|
||||
if (!prewarm) providerRealtimeAgentTurnActive.set(true)
|
||||
// New turn requested → allow this response's audio through again.
|
||||
realtimeAudioSuppressed = false
|
||||
streamObserverJob?.cancel()
|
||||
@@ -2241,13 +2374,15 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
firstFrameWatchdogJob?.cancel(); firstFrameWatchdogJob = null
|
||||
clearSpokenChunksState()
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
outputAudioActive = false,
|
||||
transcribedText = userText,
|
||||
responseText = "",
|
||||
)
|
||||
if (!prewarm) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
outputAudioActive = false,
|
||||
transcribedText = userText,
|
||||
responseText = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Per-turn event state is hoisted to fields so one session-lived callback
|
||||
@@ -2263,10 +2398,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
spokenStatusCount = 0
|
||||
rtUserText = userText
|
||||
rtConversationContext = chatVm.realtimeAgentContextMessages()
|
||||
rtAssistantMessageId = chatVm.startRealtimeAgentTurn(
|
||||
userText = userText,
|
||||
chatSessionId = chatVm.currentSessionId.value,
|
||||
)
|
||||
if (!prewarm) {
|
||||
rtAssistantMessageId = chatVm.startRealtimeAgentTurn(
|
||||
userText = userText,
|
||||
chatSessionId = chatVm.currentSessionId.value,
|
||||
)
|
||||
}
|
||||
val conversationContext = rtConversationContext
|
||||
val pcmPlayer = realtimePcmPlayer
|
||||
|
||||
@@ -2357,6 +2494,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
onHandoff = ::recordVoiceHandoff,
|
||||
turnInputs = if (persistentOpen) realtimeTurnChannel else null,
|
||||
onTurnComplete = { summary -> onRealtimeTurnComplete(summary) },
|
||||
prewarm = prewarm,
|
||||
) { event, control ->
|
||||
realtimeAgentControl = control
|
||||
chatVm.applyRealtimeAgentEvent(
|
||||
@@ -2421,6 +2559,15 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = "Using $tool...",
|
||||
)
|
||||
}
|
||||
// Live chip: tool starts are the fastest-updating signal for
|
||||
// a background run (progress events only tick every ~5s).
|
||||
updateBackgroundRun { run ->
|
||||
if (run.phase == BackgroundRunPhase.DELIVERING) run
|
||||
else run.copy(
|
||||
statusLine = realtimeToolStatusLine(event.toolName),
|
||||
phase = BackgroundRunPhase.RUNNING,
|
||||
)
|
||||
}
|
||||
}
|
||||
"hermes.tool.delta" -> {
|
||||
realtimeToolProgressLine(event)?.let { line ->
|
||||
@@ -2452,6 +2599,21 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = line,
|
||||
)
|
||||
}
|
||||
// Live chip: active tool + completed-step count. Progress
|
||||
// arriving at all also means the socket is healthy, so a
|
||||
// RECONNECTING chip can flip back to RUNNING here.
|
||||
updateBackgroundRun { run ->
|
||||
if (run.phase == BackgroundRunPhase.DELIVERING) run
|
||||
else run.copy(
|
||||
statusLine = event.activeToolName
|
||||
?.takeIf { name -> name.isNotBlank() }
|
||||
?.let { name -> realtimeToolStatusLine(name) }
|
||||
?: run.statusLine,
|
||||
completedToolCount = event.completedToolCount
|
||||
?: run.completedToolCount,
|
||||
phase = BackgroundRunPhase.RUNNING,
|
||||
)
|
||||
}
|
||||
}
|
||||
"hermes.run.promoted" -> {
|
||||
// The run detached to the background; the provider speaks the
|
||||
@@ -2479,13 +2641,38 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
"hermes.run.background_completed" -> {
|
||||
// Background run finished; the spoken summary follows via the
|
||||
// provider's forced-summary turn. Clear the chip.
|
||||
// provider's forced-summary turn once the floor is clear (up
|
||||
// to ~12s later). Show a "delivering" chip for that gap; the
|
||||
// first summary audio (or the watchdog) clears it.
|
||||
Log.i(
|
||||
TAG,
|
||||
"Realtime background run completed run=${event.runId ?: "?"} " +
|
||||
"ok=${event.success != false}",
|
||||
)
|
||||
_uiState.update { it.copy(backgroundRun = null) }
|
||||
if (event.success == false) {
|
||||
_uiState.update { it.copy(backgroundRun = null) }
|
||||
} else {
|
||||
updateBackgroundRun { run ->
|
||||
run.copy(
|
||||
phase = BackgroundRunPhase.DELIVERING,
|
||||
message = "Done — delivering the answer…",
|
||||
statusLine = null,
|
||||
)
|
||||
}
|
||||
// Watchdog: if no summary audio ever starts (visual-only
|
||||
// delivery, provider hiccup), don't pin a stale chip.
|
||||
deliveringChipClearJob?.cancel()
|
||||
deliveringChipClearJob = viewModelScope.launch {
|
||||
delay(20_000L)
|
||||
_uiState.update { state ->
|
||||
if (state.backgroundRun?.phase == BackgroundRunPhase.DELIVERING) {
|
||||
state.copy(backgroundRun = null)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"hermes.confirmation.requested" -> {
|
||||
val confirmationId = event.confirmationId
|
||||
@@ -2642,7 +2829,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// A persistent session ending in error must drop so the next turn opens
|
||||
// a fresh one rather than submitting into a dead channel.
|
||||
closeRealtimeSession()
|
||||
surfaceError(err, context = "voice_config")
|
||||
// A failed warm-up must stay silent: no user action happened, and the
|
||||
// first real utterance will simply open a fresh session.
|
||||
if (!prewarm) {
|
||||
surfaceError(err, context = "voice_config")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2671,6 +2862,16 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
private fun submitRealtimeTurn(chatVm: ChatViewModel, inputPcm: ByteArray, inputSampleRate: Int) {
|
||||
val channel = realtimeTurnChannel ?: return
|
||||
// A new turn while a background task runs: remind visually that the
|
||||
// earlier task is still going (the agent also says so if the user asks
|
||||
// for another task — the relay answers busy rather than orphaning it).
|
||||
updateBackgroundRun { run ->
|
||||
if (run.phase == BackgroundRunPhase.RUNNING) {
|
||||
run.copy(statusLine = "Still working on the earlier task…")
|
||||
} else {
|
||||
run
|
||||
}
|
||||
}
|
||||
// New turn requested → allow this response's audio through again.
|
||||
realtimeAudioSuppressed = false
|
||||
drainQueuedLocalTts()
|
||||
@@ -3247,6 +3448,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
audioSeen.set(true)
|
||||
audioBytes.addAndGet(audio.size)
|
||||
lastRealtimeAudioDeltaAtMs = System.currentTimeMillis()
|
||||
// The spoken summary started — the DELIVERING chip has done its job.
|
||||
if (_uiState.value.backgroundRun?.phase == BackgroundRunPhase.DELIVERING) {
|
||||
deliveringChipClearJob?.cancel()
|
||||
_uiState.update { it.copy(backgroundRun = null) }
|
||||
}
|
||||
val sampleRate = event.sampleRate ?: 24_000
|
||||
Log.i(
|
||||
TAG,
|
||||
@@ -4077,6 +4283,25 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private fun recordVoiceHandoff(event: VoiceHandoffEvent) {
|
||||
voiceHandoffReporter?.invoke(event)
|
||||
// Background-run chip: reflect the voice socket's health so a mid-run
|
||||
// drop reads as "reconnecting — task still running", not silence. The
|
||||
// relay keeps the run alive across the retry window; progress events
|
||||
// (or the resumed signal) flip the chip back to RUNNING.
|
||||
_uiState.value.backgroundRun?.let { run ->
|
||||
if (run.phase != BackgroundRunPhase.DELIVERING) {
|
||||
when (event.label) {
|
||||
"Connection changed", "Waiting for route", "Trying voice route",
|
||||
"Resume sent", "Route changed",
|
||||
-> updateBackgroundRun { it.copy(phase = BackgroundRunPhase.RECONNECTING) }
|
||||
"Voice reconnected" -> updateBackgroundRun {
|
||||
it.copy(
|
||||
phase = BackgroundRunPhase.RUNNING,
|
||||
statusLine = "Back online — still working…",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val detail = when {
|
||||
!event.previousRoute.isNullOrBlank() && !event.nextRoute.isNullOrBlank() ->
|
||||
"${event.previousRoute} -> ${event.nextRoute}"
|
||||
|
||||
@@ -47,6 +47,15 @@
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<!-- Doze allow-list for the opt-in "Persistent connection" keep-alive.
|
||||
A specialUse FGS holds the process up but does NOT exempt from Doze's
|
||||
network deferral, so the socket still drops in deep sleep unless the
|
||||
app is battery-optimization exempt. Sideload only — Google Play
|
||||
restricts this permission to a short list of app categories, so the
|
||||
googlePlay flavor never declares it (the Settings nudge is flavor-
|
||||
gated to sideload). See util/BatteryOptimizations.kt. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!-- Screen recording — sideload only. googlePlay doesn't declare
|
||||
/screenshot or the MEDIA_PROJECTION FGS type. See the main
|
||||
manifest's comment on why the permission was moved here. -->
|
||||
|
||||
@@ -606,6 +606,103 @@ class DashboardApiClientTest {
|
||||
assertEquals("detailed", settings.toolDisplay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getElevenLabsVoices_parsesAvailableVoices() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{
|
||||
"available": true,
|
||||
"voices": [
|
||||
{"voice_id": "pNInz6obpgDQGcFmaJgB", "name": "Adam", "label": "Adam (premade)"},
|
||||
{"voice_id": "21m00Tcm4TlvDq8ikWAM", "name": "Rachel", "label": "Rachel (premade)"}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val result = client.getElevenLabsVoices().getOrThrow()
|
||||
|
||||
assertEquals("/api/audio/elevenlabs/voices", server.takeRequest().path)
|
||||
assertTrue(result.available)
|
||||
assertEquals(2, result.voices.size)
|
||||
assertEquals("pNInz6obpgDQGcFmaJgB", result.voices[0].voiceId)
|
||||
assertEquals("Adam", result.voices[0].name)
|
||||
assertEquals("Rachel (premade)", result.voices[1].label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getElevenLabsVoices_reportsUnavailableWhenNoApiKey() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"available": false, "voices": []}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val result = client.getElevenLabsVoices().getOrThrow()
|
||||
|
||||
assertFalse(result.available)
|
||||
assertTrue(result.voices.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateConfig_putsWholeTreeWithProfile() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"ok": true}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val tree = Json.parseToJsonElement(
|
||||
"""{"model":"claude-opus-4-8","tts":{"provider":"elevenlabs"}}""",
|
||||
).jsonObject
|
||||
client.updateConfig(tree, profile = "work").getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("PUT", request.method)
|
||||
assertEquals("/api/config", request.path)
|
||||
val body = request.body.readUtf8()
|
||||
// The whole tree must be nested under "config" (upstream ConfigUpdate),
|
||||
// and the profile must ride along.
|
||||
assertTrue(body.contains(""""config":{"""))
|
||||
assertTrue(body.contains(""""model":"claude-opus-4-8""""))
|
||||
assertTrue(body.contains(""""provider":"elevenlabs""""))
|
||||
assertTrue(body.contains(""""profile":"work""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateConfig_omitsProfileWhenBlank() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.updateConfig(Json.parseToJsonElement("""{"stt":{"provider":"local"}}""").jsonObject)
|
||||
.getOrThrow()
|
||||
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertFalse(body.contains("profile"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getConfigAndSchema_hitExpectedPaths() = runTest {
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"tts":{}}"""))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"fields":{},"category_order":[]}"""))
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.getConfig().getOrThrow()
|
||||
client.getConfigSchema().getOrThrow()
|
||||
|
||||
assertEquals("/api/config", server.takeRequest().path)
|
||||
assertEquals("/api/config/schema", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseChatDisplaySettings_mapsToolProgressNoneToOff() {
|
||||
val root = Json.parseToJsonElement(
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
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
|
||||
|
||||
class DashboardConfigEditingTest {
|
||||
|
||||
private fun obj(json: String): JsonObject = Json.parseToJsonElement(json).jsonObject
|
||||
|
||||
private val schema = obj(
|
||||
"""
|
||||
{
|
||||
"fields": {
|
||||
"model": {"type": "string", "description": "Model", "category": "general"},
|
||||
"tts.provider": {"type": "select", "description": "TTS provider", "category": "tts",
|
||||
"options": ["edge", "elevenlabs", "openai"]},
|
||||
"tts.elevenlabs.voice_id": {"type": "string", "description": "Voice", "category": "tts"},
|
||||
"stt.enabled": {"type": "boolean", "description": "STT on", "category": "stt"},
|
||||
"stt.local.model": {"type": "string", "description": "Whisper model", "category": "stt"},
|
||||
"voice.silence_threshold": {"type": "number", "description": "RMS floor", "category": "voice"}
|
||||
},
|
||||
"category_order": ["general", "tts", "stt", "voice"]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun parseConfigSchema_preservesOrderAndTypesAndOptions() {
|
||||
val fields = parseConfigSchema(schema)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"model",
|
||||
"tts.provider",
|
||||
"tts.elevenlabs.voice_id",
|
||||
"stt.enabled",
|
||||
"stt.local.model",
|
||||
"voice.silence_threshold",
|
||||
),
|
||||
fields.map { it.key },
|
||||
)
|
||||
val provider = fields.first { it.key == "tts.provider" }
|
||||
assertEquals(ConfigFieldType.Select, provider.type)
|
||||
assertEquals(listOf("edge", "elevenlabs", "openai"), provider.options)
|
||||
assertEquals(ConfigFieldType.Boolean, fields.first { it.key == "stt.enabled" }.type)
|
||||
assertEquals(ConfigFieldType.Number, fields.first { it.key == "voice.silence_threshold" }.type)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceConfigFields_filtersToTtsAndSttPrefixesOnly() {
|
||||
val voice = voiceConfigFields(parseConfigSchema(schema)).map { it.key }
|
||||
|
||||
assertEquals(
|
||||
listOf("tts.provider", "tts.elevenlabs.voice_id", "stt.enabled", "stt.local.model"),
|
||||
voice,
|
||||
)
|
||||
// The CLI-only `voice.*` category and unrelated `model` are excluded.
|
||||
assertFalse(voice.any { it.startsWith("voice.") })
|
||||
assertFalse(voice.contains("model"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun configValueAt_readsNestedDotPathsAndMissing() {
|
||||
val tree = obj(
|
||||
"""{"model":"opus","tts":{"provider":"elevenlabs","elevenlabs":{"voice_id":"adam"}}}""",
|
||||
)
|
||||
|
||||
assertEquals("opus", (configValueAt(tree, "model") as JsonPrimitive).contentOrNull)
|
||||
assertEquals("elevenlabs", (configValueAt(tree, "tts.provider") as JsonPrimitive).contentOrNull)
|
||||
assertEquals("adam", (configValueAt(tree, "tts.elevenlabs.voice_id") as JsonPrimitive).contentOrNull)
|
||||
assertNull(configValueAt(tree, "tts.openai.voice"))
|
||||
assertNull(configValueAt(tree, "nope.nope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withConfigValue_setsLeafImmutablyAndDoesNotMutateInput() {
|
||||
val tree = obj("""{"model":"opus","tts":{"provider":"edge","elevenlabs":{"voice_id":"adam"}}}""")
|
||||
val updated = withConfigValue(tree, "tts.elevenlabs.voice_id", JsonPrimitive("rachel"))
|
||||
|
||||
// Input untouched; only the target leaf changed in the copy.
|
||||
assertEquals("adam", (configValueAt(tree, "tts.elevenlabs.voice_id") as JsonPrimitive).contentOrNull)
|
||||
assertEquals("rachel", (configValueAt(updated, "tts.elevenlabs.voice_id") as JsonPrimitive).contentOrNull)
|
||||
// Sibling values in the same branch survive.
|
||||
assertEquals("edge", (configValueAt(updated, "tts.provider") as JsonPrimitive).contentOrNull)
|
||||
assertEquals("opus", (configValueAt(updated, "model") as JsonPrimitive).contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withConfigValue_createsMissingIntermediateObjects() {
|
||||
val tree = obj("""{"tts":{"provider":"openai"}}""")
|
||||
val updated = withConfigValue(tree, "tts.openai.voice", JsonPrimitive("nova"))
|
||||
|
||||
assertEquals("nova", (configValueAt(updated, "tts.openai.voice") as JsonPrimitive).contentOrNull)
|
||||
assertEquals("openai", (configValueAt(updated, "tts.provider") as JsonPrimitive).contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withConfigValue_preservesTopLevelKeyOrder() {
|
||||
val tree = obj("""{"a":"1","tts":{"provider":"edge"},"z":"9"}""")
|
||||
val updated = withConfigValue(tree, "tts.provider", JsonPrimitive("openai"))
|
||||
|
||||
assertEquals(listOf("a", "tts", "z"), updated.keys.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun applyConfigEdits_appliesEveryEdit() {
|
||||
val tree = obj("""{"tts":{"provider":"edge"},"stt":{"enabled":true,"provider":"local"}}""")
|
||||
val updated = applyConfigEdits(
|
||||
tree,
|
||||
mapOf(
|
||||
"tts.provider" to JsonPrimitive("elevenlabs"),
|
||||
"stt.provider" to JsonPrimitive("openai"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("elevenlabs", (configValueAt(updated, "tts.provider") as JsonPrimitive).contentOrNull)
|
||||
assertEquals("openai", (configValueAt(updated, "stt.provider") as JsonPrimitive).contentOrNull)
|
||||
assertTrue((configValueAt(updated, "stt.enabled") as JsonPrimitive).contentOrNull == "true")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseConfigSchema_emptyWhenNoFields() {
|
||||
assertTrue(parseConfigSchema(obj("""{"category_order":[]}""")).isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.GraphicEq
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
@@ -31,6 +32,7 @@ import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -62,8 +64,6 @@ import com.hermesandroid.relay.ui.components.ContextMeterBar
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
|
||||
import com.hermesandroid.relay.ui.components.RelayModeStrip
|
||||
import com.hermesandroid.relay.ui.components.RelayPrimaryMode
|
||||
import com.hermesandroid.relay.ui.components.RelayStatusStrip
|
||||
import com.hermesandroid.relay.ui.components.SphereState
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
@@ -125,6 +125,11 @@ class StoreScreenshotTest {
|
||||
@Test fun s01_landing_cyberpunk() = capture("01_landing_cyberpunk", "cyberpunk") { LandingScene() }
|
||||
|
||||
@Test fun s02_chat_brand() = capture("02_chat", "hermes-relay") { ChatScene() }
|
||||
// "Blend" chat surface — wider bubbles + assistant avatar + grouped turns +
|
||||
// a code block, in the Inter default. The self-verification frame for the
|
||||
// chat UI refresh. Rendered in two themes to check dark + light contrast.
|
||||
@Test fun s09_blend_chat() = capture("09_blend_chat", "hermes-relay") { BlendChatScene() }
|
||||
@Test fun s09_blend_chat_light() = capture("09_blend_chat_nous-blue", "nous-blue") { BlendChatScene() }
|
||||
@Test fun s03_voice() = capture("03_voice", "hermes-relay") { VoiceScene() }
|
||||
// Real screen (1:1, auto-updates on layout changes). ConnectionViewModel
|
||||
// takes only an Application; Robolectric provides it. Renders every section
|
||||
@@ -139,6 +144,21 @@ class StoreScreenshotTest {
|
||||
@Test fun s06_manage() = capture("06_manage", "hermes-relay") { ManageScene() }
|
||||
@Test fun s04_sessions() = capture("04_sessions", "hermes-relay") { SessionsScene() }
|
||||
@Test fun s07_connections() = capture("07_connections", "hermes-relay") { ConnectionsScene() }
|
||||
// Real Appearance screen scrolled to the new Font picker — proves the
|
||||
// bundled Inter/Nunito faces load as visibly distinct previews (vs System).
|
||||
@Test fun s10_font_picker() {
|
||||
val vm = ConnectionViewModel(ApplicationProvider.getApplicationContext<Application>())
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
CompositionLocalProvider(LocalSphereSkin provides SphereRegistry.Adaptive) {
|
||||
AppearanceSettingsScreen(connectionViewModel = vm, onBack = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("Nunito").performScrollTo()
|
||||
compose.onRoot().captureRoboImage("build/store-shots/10_font_picker.png")
|
||||
}
|
||||
|
||||
// Same real Appearance screen, scrolled to the avatar + sphere-skin sections.
|
||||
@Test fun s08_appearance() {
|
||||
val vm = ConnectionViewModel(ApplicationProvider.getApplicationContext<Application>())
|
||||
@@ -207,9 +227,34 @@ private fun ChatScene() = StoreCockpit(contextUsage = 0.04f) {
|
||||
}
|
||||
}
|
||||
|
||||
// "Blend" chat — a real grouped thread so the refresh is visible: the assistant
|
||||
// group shows the avatar once (first message), the second assistant message
|
||||
// flat-tops under it with no avatar, a fenced code block exercises the code
|
||||
// surface, and the compact 340dp cap + tightened density match production.
|
||||
@Composable
|
||||
private fun BlendChatScene() = StoreCockpit(contextUsage = 0.06f) {
|
||||
val thread = MockChat.blendThread
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
thread.forEachIndexed { i, m ->
|
||||
val first = i == 0 || thread[i - 1].role != m.role
|
||||
val last = i == thread.lastIndex || thread[i + 1].role != m.role
|
||||
MessageBubble(
|
||||
message = m,
|
||||
modifier = Modifier.padding(top = if (first) 6.dp else 1.dp),
|
||||
maxBubbleWidth = 340.dp,
|
||||
isFirstInGroup = first,
|
||||
isLastInGroup = last,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Real-chrome cockpit — TopAppBar + ContextMeterBar + RelayModeStrip wrap the
|
||||
// content, with ChatInputBar + RelayStatusStrip at the foot. Shared by scenes.
|
||||
// Real-chrome cockpit — TopAppBar + ContextMeterBar wrap the content, with
|
||||
// ChatInputBar + RelayStatusStrip at the foot. Shared by chat-context scenes.
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@@ -217,7 +262,6 @@ private fun ChatScene() = StoreCockpit(contextUsage = 0.04f) {
|
||||
private fun StoreCockpit(
|
||||
contextUsage: Float? = null,
|
||||
showInput: Boolean = true,
|
||||
selectedMode: RelayPrimaryMode = RelayPrimaryMode.Chat,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f
|
||||
@@ -251,7 +295,6 @@ private fun StoreCockpit(
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = RelayRefresh.Background.copy(alpha = 0.96f))
|
||||
)
|
||||
ContextMeterBar(usedFraction = contextUsage, usedTokens = 37_000, maxTokens = 1_050_000)
|
||||
RelayModeStrip(selected = selectedMode, onModeSelected = {})
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) { content() }
|
||||
if (showInput) {
|
||||
ChatInputBar(
|
||||
@@ -334,25 +377,47 @@ private fun VoiceScene() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ManageScene() = StoreCockpit(showInput = false, selectedMode = RelayPrimaryMode.Manage) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
com.hermesandroid.relay.ui.components.RelayHeroPanel(title = "Relay Hub", subtitle = "Dashboard · skills · profiles · models")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
com.hermesandroid.relay.ui.components.RelayMetricCard("123", "skills", Modifier.weight(1f), valueColor = MaterialTheme.colorScheme.onSurface)
|
||||
com.hermesandroid.relay.ui.components.RelayMetricCard("ready", "dashboard", Modifier.weight(1f), valueColor = RelayRefresh.Green)
|
||||
com.hermesandroid.relay.ui.components.RelayMetricCard("0.17.0", "server", Modifier.weight(1f), valueColor = MaterialTheme.colorScheme.onSurface)
|
||||
private fun ManageScene() {
|
||||
// Manage's own chrome (matches DashboardManagementScreen): a back arrow to
|
||||
// Chat + Terminal/Settings/Refresh actions — no hamburger, no context meter,
|
||||
// and no mode strip (removed app-wide; Manage is reached from Settings now).
|
||||
Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
RelayChromeIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back to chat", onClick = {}, modifier = Modifier.padding(start = 4.dp))
|
||||
},
|
||||
title = { Text("Manage", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) },
|
||||
actions = {
|
||||
RelayChromeIconButton(Icons.Filled.Code, "Terminal", onClick = {}, modifier = Modifier.padding(end = 4.dp))
|
||||
RelayChromeIconButton(Icons.Filled.Tune, "Settings", onClick = {}, modifier = Modifier.padding(end = 4.dp))
|
||||
IconButton(onClick = {}) { Icon(Icons.Filled.Refresh, "Refresh", tint = RelayRefresh.Paper) }
|
||||
},
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = RelayRefresh.Background.copy(alpha = 0.96f)),
|
||||
)
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
com.hermesandroid.relay.ui.components.RelayHeroPanel(title = "Relay Hub", subtitle = "Dashboard · skills · profiles · models")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
com.hermesandroid.relay.ui.components.RelayMetricCard("123", "skills", Modifier.weight(1f), valueColor = MaterialTheme.colorScheme.onSurface)
|
||||
com.hermesandroid.relay.ui.components.RelayMetricCard("ready", "dashboard", Modifier.weight(1f), valueColor = RelayRefresh.Green)
|
||||
com.hermesandroid.relay.ui.components.RelayMetricCard("0.17.0", "server", Modifier.weight(1f), valueColor = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
val ic = androidx.compose.material.icons.Icons.Filled
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.Link, "Connections", "Pair, switch, verify routes", {})
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.Person, "Profiles", "SOUL, memory, skills, sessions", {})
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.AutoAwesome, "Skills + Tools", "Browse, enable, configure", {})
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.Schedule, "Automations", "Cron, background runs, delivery", {})
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.Code, "MCP Servers", "Servers, status, tools", {})
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.Tune, "Models", "Pick provider + default model", {})
|
||||
}
|
||||
}
|
||||
val ic = androidx.compose.material.icons.Icons.Filled
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.Link, "Connections", "Pair, switch, verify routes", {})
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.Person, "Profiles", "SOUL, memory, skills, sessions", {})
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.AutoAwesome, "Skills + Tools", "Browse, enable, configure", {})
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.Schedule, "Automations", "Cron, background runs, delivery", {})
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.Code, "MCP Servers", "Servers, status, tools", {})
|
||||
com.hermesandroid.relay.ui.components.RelayNavTile(ic.Tune, "Models", "Pick provider + default model", {})
|
||||
RelayStatusStrip(leading = "⚡ Gateway · LAN", trailing = "gpt-5.5 / profile: default")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +450,47 @@ private object MockChat {
|
||||
inputTokens = 137_200,
|
||||
outputTokens = 206
|
||||
)
|
||||
|
||||
// A grouped thread for the "Blend" capture: user → two-message assistant
|
||||
// group (avatar once, code block in the first) → user follow-up.
|
||||
val blendThread = listOf(
|
||||
ChatMessage(
|
||||
id = "bu1",
|
||||
role = MessageRole.USER,
|
||||
content = "How do I deploy to prod? Walk me through it.",
|
||||
timestamp = 0L,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "ba1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = """
|
||||
Run the deploy script — it stages the build, applies migrations, then ships. It's idempotent, so re-running after a failure is safe:
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh prod --yes
|
||||
```
|
||||
|
||||
Use `--dry-run` first if you want to preview the migration plan.
|
||||
""".trimIndent(),
|
||||
timestamp = 0L,
|
||||
agentName = "Hermes",
|
||||
badges = listOf("Gateway"),
|
||||
),
|
||||
ChatMessage(
|
||||
id = "ba2",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Want me to tail the logs once it's live and ping you if anything errors?",
|
||||
timestamp = 0L,
|
||||
inputTokens = 138_010,
|
||||
outputTokens = 212,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "bu2",
|
||||
role = MessageRole.USER,
|
||||
content = "Yes please — keep an eye on the error rate.",
|
||||
timestamp = 0L,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -447,42 +553,69 @@ private fun SessionRow(title: String, active: String, msgs: String) {
|
||||
|
||||
@Composable
|
||||
private fun ConnectionsScene() = StoreSettings("Connections") {
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), shape = RoundedCornerShape(14.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
// Level-1 list: scannable connection cards, the active one badged, each
|
||||
// with the capability timeline. Tapping opens the tabbed detail.
|
||||
ConnCard(label = "Hermes", active = true, status = "Connected · Tailscale") {
|
||||
FeatureRow("API", "Ready", true)
|
||||
FeatureRow("Dashboard", "Signed in", true)
|
||||
FeatureRow("Voice", "Ready", true)
|
||||
FeatureRow("Relay", "Connected", true)
|
||||
}
|
||||
ConnCard(label = "Lab NAS", active = false, status = "Paired 2 days ago") {
|
||||
FeatureRow("API", "Configured", false)
|
||||
FeatureRow("Dashboard", "Unchecked", false)
|
||||
FeatureRow("Voice", "Optional", false)
|
||||
FeatureRow("Relay", "Paired", true)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnCard(
|
||||
label: String,
|
||||
active: Boolean,
|
||||
status: String,
|
||||
rows: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = if (active) {
|
||||
RelayRefresh.ElectricMuted.copy(alpha = 0.42f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Hermes", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
|
||||
com.hermesandroid.relay.ui.components.RelayStatusPill("Active", active = true)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (active) com.hermesandroid.relay.ui.components.RelayStatusPill("Active", active = true)
|
||||
Icon(
|
||||
androidx.compose.material.icons.Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
Text("Connected · Active: LAN · LAN + Tailscale", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Text("Rename", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge)
|
||||
Text("Re-pair", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge)
|
||||
Text("Revoke", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge)
|
||||
Text("Remove", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelLarge)
|
||||
Text(
|
||||
status,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(vertical = 4.dp), content = rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("Features", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text("What this connection can do. Routes only choose how the phone reaches Hermes.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(vertical = 4.dp)) {
|
||||
FeatureRow("Vanilla Hermes API", "Ready", true)
|
||||
FeatureRow("Dashboard", "Signed in", true)
|
||||
FeatureRow("Vanilla Hermes voice", "Ready", true)
|
||||
FeatureRow("Relay tools", "Ready", true)
|
||||
FeatureRow("Terminal", "Ready", true)
|
||||
FeatureRow("Secure proxy", "Not advertised", false)
|
||||
}
|
||||
}
|
||||
Text("Route", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Current: LAN", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||
Text("192.168.1.50:8642", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("Re-check", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RelayErrorClassifierTest {
|
||||
@Test
|
||||
fun connectivityErrorsAreClassifiedForSnackbarSuppression() {
|
||||
// The "can't reach the server" family the themed banner owns.
|
||||
assertTrue(isConnectivityError(ConnectException("Connection refused")))
|
||||
assertTrue(isConnectivityError(UnknownHostException("host")))
|
||||
assertTrue(isConnectivityError(SocketTimeoutException("timeout")))
|
||||
assertTrue(isConnectivityError(IOException("read timeout")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonConnectivityErrorsAreNotSuppressed() {
|
||||
// Actionable / unrelated errors must still surface a snackbar.
|
||||
assertFalse(isConnectivityError(null))
|
||||
assertFalse(isConnectivityError(IOException("401 Unauthorized")))
|
||||
assertFalse(isConnectivityError(IllegalStateException("not ready")))
|
||||
assertFalse(isConnectivityError(RuntimeException("boom")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiUnauthorizedFromChatPointsAtApiKeyInsteadOfRepairingRelay() {
|
||||
val err = classifyError(
|
||||
|
||||
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 212 KiB |
@@ -73,7 +73,8 @@ const BOOLEAN_FLAGS = new Set([
|
||||
'check',
|
||||
'yes',
|
||||
'new',
|
||||
'watch-editor'
|
||||
'watch-editor',
|
||||
'clear'
|
||||
])
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
@@ -166,7 +167,7 @@ Usage:
|
||||
hermes-relay tools List tools available on the server
|
||||
hermes-relay audit Show what the agent ran on this machine (desktop tools)
|
||||
hermes-relay devices List / revoke / extend server-side paired devices
|
||||
hermes-relay relay Inspect the relay server (info / security / injected context)
|
||||
hermes-relay relay Inspect the relay server (info / security / context / queue)
|
||||
hermes-relay daemon [start|stop|status] Headless tool router — 'start' runs it in the background
|
||||
hermes-relay doctor Diagnostic report: version, paths, sessions, daemon status
|
||||
hermes-relay update Check for and install the latest cli-v* release
|
||||
|
||||
@@ -19,18 +19,21 @@ import { getSession, listSessions } from '../remoteSessions.js'
|
||||
|
||||
const RELAY_USAGE: UsageSpec = {
|
||||
name: 'relay',
|
||||
summary: 'inspect the relay server — info, security toggles, injected agent context',
|
||||
usage: ['relay info', 'relay security', 'relay context'],
|
||||
summary: 'inspect the relay server — info, security toggles, injected context, outbound queue',
|
||||
usage: ['relay info', 'relay security', 'relay context', 'relay queue'],
|
||||
subcommands: [
|
||||
{ verb: 'info', desc: 'Version, uptime, sessions, pending (loopback-only — run on the relay host)' },
|
||||
{ verb: 'security', desc: 'Runtime security toggles (loopback-only)' },
|
||||
{ verb: 'context', desc: 'Audit the system-prompt context the relay injects into the agent' }
|
||||
{ verb: 'context', desc: 'Audit the system-prompt context the relay injects into the agent' },
|
||||
{ verb: 'queue', desc: 'Agent→phone messages queued for an offline phone; cancel with --clear / --cancel <id> (loopback-only)' }
|
||||
],
|
||||
flags: [
|
||||
{ flag: '--remote <url>', desc: 'Relay to query (default: stored/active)' },
|
||||
{ flag: '--clear', desc: 'queue: cancel ALL queued outbound messages' },
|
||||
{ flag: '--cancel <id>', desc: 'queue: cancel one queued message by id' },
|
||||
{ flag: '--json', desc: 'Machine-readable output' }
|
||||
],
|
||||
examples: ['hermes-relay relay context', 'hermes-relay relay info']
|
||||
examples: ['hermes-relay relay queue', 'hermes-relay relay queue --clear', 'hermes-relay relay context']
|
||||
}
|
||||
|
||||
function wsToHttp(url: string): string {
|
||||
@@ -96,6 +99,37 @@ async function getJson(
|
||||
return { status: res.status, body }
|
||||
}
|
||||
|
||||
async function deleteJson(
|
||||
httpUrl: string,
|
||||
token: string
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const res = await fetch(httpUrl, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }
|
||||
})
|
||||
const text = await res.text()
|
||||
let body: unknown
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
body = text
|
||||
}
|
||||
}
|
||||
return { status: res.status, body }
|
||||
}
|
||||
|
||||
function fmtAge(sentAtMs?: number): string {
|
||||
if (!sentAtMs || !Number.isFinite(sentAtMs)) return '?'
|
||||
const secs = Math.max(0, Math.floor((Date.now() - sentAtMs) / 1000))
|
||||
if (secs < 60) return `${secs}s ago`
|
||||
const mins = Math.floor(secs / 60)
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
return `${Math.floor(hrs / 24)}d ago`
|
||||
}
|
||||
|
||||
function fmtUptime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '?'
|
||||
const d = Math.floor(seconds / 86_400)
|
||||
@@ -212,6 +246,79 @@ async function relayContext(args: ParsedArgs, t: Theme): Promise<number> {
|
||||
return 0
|
||||
}
|
||||
|
||||
async function relayQueue(args: ParsedArgs, t: Theme): Promise<number> {
|
||||
const { url, token } = await resolveRemoteAndToken(args)
|
||||
const base = `${wsToHttp(url)}/phone/outbound`
|
||||
|
||||
// Cancel paths: --clear (all) or --cancel <id> (one).
|
||||
const cancelId = typeof args.flags.cancel === 'string' ? args.flags.cancel.trim() : null
|
||||
if (cancelId || args.flags.clear === true) {
|
||||
const target = cancelId ? `${base}?message_id=${encodeURIComponent(cancelId)}` : base
|
||||
const { status, body } = await deleteJson(target, token)
|
||||
if (status === 403) {
|
||||
process.stderr.write(loopbackNote(t, '/phone/outbound') + '\n')
|
||||
return 1
|
||||
}
|
||||
if (status !== 200) {
|
||||
process.stderr.write(t.err(`error: DELETE /phone/outbound returned ${status}`) + '\n')
|
||||
return 1
|
||||
}
|
||||
if (args.flags.json) {
|
||||
process.stdout.write(JSON.stringify(body, null, 2) + '\n')
|
||||
return 0
|
||||
}
|
||||
const n = Number((body as Record<string, unknown> | undefined)?.cancelled ?? 0)
|
||||
process.stdout.write(t.ok(`cancelled ${n} queued message${n === 1 ? '' : 's'}`) + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
// List path.
|
||||
const { status, body } = await getJson(base, token)
|
||||
if (status === 403) {
|
||||
process.stderr.write(loopbackNote(t, '/phone/outbound') + '\n')
|
||||
return 1
|
||||
}
|
||||
if (status === 404) {
|
||||
process.stderr.write(
|
||||
t.muted(`relay at ${url} has no /phone/outbound — server predates outbound buffering.`) + '\n'
|
||||
)
|
||||
return 1
|
||||
}
|
||||
if (status !== 200) {
|
||||
process.stderr.write(t.err(`error: GET /phone/outbound returned ${status}`) + '\n')
|
||||
return 1
|
||||
}
|
||||
if (args.flags.json) {
|
||||
process.stdout.write(JSON.stringify(body, null, 2) + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
const r = (body ?? {}) as {
|
||||
queued?: number
|
||||
messages?: { message_id?: string; chat_id?: string; text?: string; sent_at?: number }[]
|
||||
}
|
||||
const messages = r.messages ?? []
|
||||
process.stdout.write(t.bold(`Phone outbound queue — ${url}`) + '\n')
|
||||
if (messages.length === 0) {
|
||||
process.stdout.write(
|
||||
t.muted(' (nothing queued — the phone is delivering live, or nothing is waiting)') + '\n'
|
||||
)
|
||||
return 0
|
||||
}
|
||||
process.stdout.write(t.muted(` ${messages.length} waiting for the phone to reconnect`) + '\n\n')
|
||||
for (const m of messages) {
|
||||
const id = (m.message_id ?? '').slice(0, 12).padEnd(13)
|
||||
const when = fmtAge(m.sent_at).padEnd(9)
|
||||
const text = (m.text ?? '').replace(/\s+/g, ' ').trim()
|
||||
const preview = text.length > 80 ? text.slice(0, 79) + '…' : text
|
||||
process.stdout.write(` ${t.muted(id)} ${t.muted(when)} ${preview}\n`)
|
||||
}
|
||||
process.stdout.write(
|
||||
'\n' + t.muted(' cancel one: `relay queue --cancel <id>` · clear all: `relay queue --clear`') + '\n'
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
export async function relayCommand(args: ParsedArgs): Promise<number> {
|
||||
const t = makeTheme({ noColor: !!args.flags['no-color'] })
|
||||
if (args.flags.help) {
|
||||
@@ -241,6 +348,10 @@ export async function relayCommand(args: ParsedArgs): Promise<number> {
|
||||
args.positional.shift()
|
||||
return run(relayContext)
|
||||
}
|
||||
if (sub === 'queue') {
|
||||
args.positional.shift()
|
||||
return run(relayQueue)
|
||||
}
|
||||
return unknownSubcommand(RELAY_USAGE, sub, t)
|
||||
}
|
||||
|
||||
|
||||
@@ -252,9 +252,17 @@ Phone (WSS) → Relay Server (:8767) [bridge, terminal]
|
||||
| Voice mode | Depends on Hermes TTS/STT maturity | Phase 6 |
|
||||
| Notification listener | Not needed for app core | Phase 6 |
|
||||
| File transfer | Can use agent tools (terminal) as workaround | Phase 6 |
|
||||
| Android as Hermes platform/channel | See ADR 12 below — relay server as platform adapter | Phase 2+ (after terminal/bridge) |
|
||||
| Android as Hermes platform/channel | See ADR 12 — shipped as the `phone` plugin platform; unified-session "Threads" UI in progress | Shipped 2026-06-28 |
|
||||
|
||||
### 12. Android as a Hermes Platform/Channel (Future)
|
||||
### 12. Android as a Hermes Platform/Channel — "Threads" (Shipped 2026-06-28; unified-session model 2026-06-29)
|
||||
|
||||
> **Status (2026-06-29):** SHIPPED as the `phone` platform plugin (`plugin/phone_platform.py`) — registered via `ctx.register_platform` with **no fork** (the ~16-file upstream change sketched below was avoided; the original research predates the open plugin-platform registry). Two-way reply is device-verified. Agent-facing entry is `send_message target=phone` (the stale `target=mobile:<device_id>` syntax below is superseded); cron `deliver=phone` is the one remaining broken path. See TODO.md "Phone platform — usability roadmap".
|
||||
>
|
||||
> **Decision (2026-06-29) — unified-session "Threads", not a separate surface:** the proactive agent↔phone conversation is **not** a separate app lane/tab/segment. It is a **source-tagged session inside the one Chat surface** — a **Thread** (`source=phone`). The three things distinguishing a Thread from a normal gateway chat are *session properties*, not a separate UI: (a) the agent can initiate a turn, (b) it rides the relay `proactive` transport and is relay-gated, (c) it's a standing/named DM. **Scrollback = the gateway session store** (same read path Chat uses); **live receive = the relay `proactive` push** (→ notification); **send = `proactive.reply`**. The local `ProactiveInboxStore` is demoted to a live-push cache + outbox (no parallel history). The Thread capability is surfaced in the **connection best-path/capability UI** (a relay-tier capability, like terminal/bridge/voice) and as a clean **Threads** entry (thread-spool icon, NOT a phone glyph) pinned atop the session drawer when active — never a connection-wizard step. Degrades cleanly: no relay plugin → no `source=phone` sessions → Chat is unchanged (standard-path-safe). This **supersedes the earlier "separate Agent lane / 4th nav segment" sketch** and folds in the "show chat source/platform attribution in Chat" goal in one stroke.
|
||||
>
|
||||
> **Why unified, not separate:** a separate "agent chat" tab is redundant — a Thread is just a chat session the agent can also start. One Chat surface (sessions tagged by source) matches the Discord/messaging-app model the product targets. **Two distinct senses of "gateway" to keep straight:** the *messaging gateway / platform layer* (`gateway/platforms/*` — phone/Discord/Slack as platforms; this is the Thread's `source`) vs. the *dashboard gateway transport* (`/api/ws` tui_gateway — how live chat bytes flow). A Thread is defined by its **platform/source**, not its transport; the two are orthogonal.
|
||||
|
||||
**Original research (2026-04-07, retained as lineage — the `mobile:<device_id>` syntax and the ~16-file upstream-fork registration below are SUPERSEDED by the no-fork plugin path):**
|
||||
|
||||
**Decision:** Register the Android app as a Hermes platform adapter — like Discord, Telegram, or Slack — so the agent can proactively push messages TO the phone, not just respond to requests.
|
||||
|
||||
|
||||
@@ -838,7 +838,7 @@
|
||||
<td class="relay-c owner">Relay plugin</td>
|
||||
<td>Paired session token</td>
|
||||
<td>QR / 6-char pairing → <code>auth.ok</code></td>
|
||||
<td><strong>In-memory</strong> — re-pair after each relay restart</td>
|
||||
<td><strong>Persisted</strong> — survives relay restart (sessions file + trusted-device refresh); no re-pair needed</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Open-Issue Resolution Batch — 2026-07-06
|
||||
|
||||
Triage of all 13 open GitHub issues (multi-agent code-verified pass + adversarial
|
||||
cross-check), grouped into workstreams that land on `dev` via worktree feature
|
||||
branches before the next releases. Owner-only actions (GitHub comments/closures,
|
||||
labels, Play Console, on-device checks) are tracked in `TODO.md` — automation
|
||||
never posts to GitHub on the owner's behalf.
|
||||
|
||||
## Verdict summary
|
||||
|
||||
| Issue | Verdict | Action |
|
||||
|---|---|---|
|
||||
| #166 hang on "still working…" (local models + delegating skills) | Real bug, **app-side** (mislabeled `area:plugin`) | Fix: SSE stream-death recovery poller (WS-A) |
|
||||
| #165 `hermes relay start` broken under native plugin install / Docker | Real bug, plugin packaging | Fix: relative imports + installer venv autodetect (WS-B), ships as **plugin-v1.3.1** |
|
||||
| #155 / #154 / #146 auto-filed diagnostics reports | Support/user-config, not defects | De-noise the in-app Report flow (WS-C); owner closes all three |
|
||||
| #145 onboarding text overflow, no scroll | Real bug, Android UI | Fix: scrollable slides + compact-height hero (WS-D) |
|
||||
| #144 confusing 4-asset releases | Docs/release UX | Fix: attach 2 assets + rewrite Download block (WS-E) |
|
||||
| #131 crash: label string as URL host | **Fixed** — `3573ba8` (PR #136), shipped android-v1.2.5 | Owner closes |
|
||||
| #129 crash: SocketTimeout on session probe | **Fixed** — `99b9cf1` (PR #128), shipped android-v1.2.4 | Owner closes |
|
||||
| #124 crash: NetworkOnMainThread on TLS teardown | **Fixed** — `802385c` (PR #125), shipped android-v1.2.3 | Owner closes |
|
||||
| #70 v1.0.0 force-close on connect | **Fixed** — keyset crash `48ddba5` (android-v1.1.0); in-thread TLS crash = #124, fixed android-v1.2.3 | Owner closes |
|
||||
| #94 Z Fold7 Play crash tracker | Hardening shipped — `a455e46` (android-v1.2.0); vitals confirmation pending | Owner: Play Console check, then close |
|
||||
| #121 desktop typed stream.event chat renderer | Feature, `cli-v*` track | Scheduled for next CLI release, not this batch |
|
||||
|
||||
All tag-containment claims above were verified with `git merge-base --is-ancestor`,
|
||||
not taken from issue comments.
|
||||
|
||||
## Workstreams (worktree branches → `dev`, `--no-ff` merges)
|
||||
|
||||
### WS-A `fix/chat-stream-recovery` — #166 (Android, M)
|
||||
|
||||
Root cause (verified against upstream source): long turns (slow local model +
|
||||
delegating skills like claude-code) outlive the phone's SSE socket — screen-off /
|
||||
Doze / Wi-Fi power-save drops it (not an OkHttp timeout: client readTimeout is
|
||||
5 min, upstream sends `: keepalive` every 30 s). Upstream
|
||||
`_handle_session_chat_stream` cancels the SSE writer on the write failure, **but
|
||||
the agent run continues in an uncancellable executor thread and the final answer
|
||||
IS persisted** to the session store. The app's `onErrorCb` does one immediate
|
||||
history reload that races the still-running turn, finds nothing, and strands the
|
||||
"Still working…" placeholder forever.
|
||||
|
||||
Fix (client-only; standard path stays unmodified-upstream-compatible): on
|
||||
transport failure for the sessions SSE path (non-user-cancel, IO-class errors),
|
||||
enter a *recovering* state instead of finalizing the turn as dead — poll
|
||||
`GET /api/sessions/{id}/messages` with backoff (5 s → 30 s, capped ~30 min),
|
||||
reconcile persisted messages as they appear, finalize when the last assistant
|
||||
message is stable across consecutive polls; fire the turn-complete notification
|
||||
if backgrounded; cancel the poller on new send / session switch / Stop. Log a
|
||||
Warning diagnostics entry so future reports of this shape are self-explanatory.
|
||||
Upstream PR candidates (optional, not required): intentional detached-run
|
||||
semantics on client disconnect; pollable/resumable session-turn status.
|
||||
|
||||
Docs: troubleshooting entry — long turns with local models. Tests: MockWebServer
|
||||
stream-kill → recovery poller unit tests.
|
||||
|
||||
### WS-B `fix/plugin-native-imports` — #165 (Plugin, M) — **parked until plugin-v1.3.0 is tagged; ships as plugin-v1.3.1**
|
||||
|
||||
Root cause (verified): ~15 runtime files use absolute `from plugin.…` imports.
|
||||
The upstream native installer loads the plugin as `hermes_plugins.hermes_relay`,
|
||||
so no top-level `plugin` package exists → `hermes relay start`, `hermes pair`,
|
||||
and android media registration all break. Separately, `install.sh` hardcodes the
|
||||
classic `hermes-agent/venv` layout and dies on the official Docker image
|
||||
(`/opt/hermes/.venv`). (The reporter's uv-pip gap was already fixed in
|
||||
plugin-v1.1.0+ — that is not what they hit.)
|
||||
|
||||
Fix: convert runtime imports to package-relative form (works identically under
|
||||
editable, native, and `python -m` entry); package bootstrap for the
|
||||
standalone-exec'd dashboard `plugin_api.py`; `install.sh` venv autodetection
|
||||
(classic / uv / Docker) with a graceful steer-to-native-install path on
|
||||
immutable images; `hermes relay doctor` gains an import-chain check so this
|
||||
class of break can never pass doctor again. New `test_native_layout_imports.py`:
|
||||
AST guard (no absolute `plugin.` imports in runtime code) + subprocess smoke
|
||||
test importing the tree under a renamed package dir.
|
||||
|
||||
Sequencing: the import conversion touches the voice/realtime stack that the
|
||||
pending plugin-v1.3.0 e2e validation exercises — so this branch does **not**
|
||||
merge to `dev` until v1.3.0 is cut, then ships as plugin-v1.3.1 with the
|
||||
paired docs (native-install/Docker notes cite the fixed version).
|
||||
|
||||
### WS-C `fix/diagnostics-report-noise` — #155/#154/#146 (Android, M)
|
||||
|
||||
The diagnostics activity log offers an unconditional "Report" button that turns
|
||||
*Info-severity routine probe lines* into `[Bug]:`-titled GitHub issues with a
|
||||
templated body (hardcoded "What happened", unedited connection-mode line).
|
||||
|
||||
Fix: severity-gate the Report flow — Info entries get a pre-flight requiring a
|
||||
"what were you expecting?" answer that replaces the boilerplate; non-Error
|
||||
prefills retitle to `[Diagnostic]:` and carry the existing `question` label
|
||||
instead of `bug`; the body reports the *actual* route role instead of the
|
||||
template line. Add `ServerAddress.loopbackHostWarning()` (+ tests): advisory
|
||||
that `localhost`/`127.0.0.1` on a phone points at the phone itself.
|
||||
**Scope guard:** the wizard/detail-screen wiring of that advisory is deferred —
|
||||
the connections UI is owned by a parallel workstream; only the pure util + the
|
||||
Report-flow changes land here. Docs: troubleshooting gains "No reachable
|
||||
endpoint", localhost-on-phone, and a Tailscale checklist.
|
||||
|
||||
### WS-D `fix/onboarding-scroll` — #145 (Android, S)
|
||||
|
||||
Slides are a fixed-height stack (232 dp hero + cards) in a non-scrolling Column;
|
||||
short viewports / large font scale push content below the fold with no scroll.
|
||||
Fix: `verticalScroll` on the slide column (indicator + Back/Next stay pinned
|
||||
outside the pager), hero shrinks under compact height. Add a compact-height
|
||||
(`w320dp-h480dp`, fontScale 1.3) Roborazzi render test so the regression class
|
||||
is caught host-side.
|
||||
|
||||
### WS-E `chore/release-assets` — #144 (Release infra + docs, S)
|
||||
|
||||
Releases attach 4 artifacts; GitHub sorts alphabetically so the **non-installable
|
||||
`.aab` lists first**, and "Parity/testing artifact" means nothing to users. Note:
|
||||
the issue's suggested primary file is wrong — the correct GitHub download is the
|
||||
**sideload APK** (the googlePlay flavor's install path is the Play Store).
|
||||
|
||||
Fix: attach only `…-sideload-release.apk` + `…-googlePlay-release.aab` +
|
||||
`SHA256SUMS.txt` (parity artifacts remain reproducible from the tag via CI);
|
||||
release-notes template leads with a "Installing on your phone? Download this
|
||||
file" block + an explicit ".aab is not tap-installable" note; RELEASE.md codifies
|
||||
the format. The sideload APK filename never changes (the in-app update checker
|
||||
matches it).
|
||||
|
||||
### WS-F `docs/freshness-pass` — docs truth sweep (S/M)
|
||||
|
||||
Independent of code fixes (docs that ride a code fix land on that branch):
|
||||
- Setup guides say tap "Vanilla Hermes" — the button has been labeled
|
||||
"Hermes" since app v1.2.2; quote UI labels verbatim.
|
||||
- Four broken deep-link anchors into getting-started
|
||||
(`#sideload-apk`, `#relay-server-optional`, `#install-the-server-plugin`).
|
||||
- Smaller accuracy items: README tool count, security.md prototype framing,
|
||||
CLI cert-pin example, index.md SSE wording.
|
||||
- Whether to retire "Vanilla Hermes" as a docs-wide term is an owner decision;
|
||||
this pass only fixes verbatim UI-label quotes.
|
||||
|
||||
## Release sequencing
|
||||
|
||||
1. **plugin-v1.3.0 first** — finish the pending on-device e2e of the ADR-33
|
||||
voice batch, then release-merge and tag. Nothing from this batch rides it.
|
||||
2. **Android batch** — WS-A, WS-C, WS-D converge on `dev` → next `android-v*`
|
||||
tag (with WS-E's release-format change already on `dev` so the new tag
|
||||
demonstrates it). #131/#129/#124/#70/#94 need closure only.
|
||||
3. **plugin-v1.3.1** — WS-B merges after the v1.3.0 tag exists.
|
||||
4. **CLI** — #121 is scheduled for the next `cli-v*` milestone (typed
|
||||
`stream.event` renderer + golden-stream test; relay side already ships the
|
||||
typed passthrough).
|
||||
|
||||
## Verification gates
|
||||
|
||||
- Android: `./gradlew lint` + unit tests green per branch; owner does on-device
|
||||
passes (Doze/screen-off recovery for WS-A, max font/display scale for WS-D).
|
||||
- Plugin: `python -m unittest` suite + new native-layout tests; owner validates
|
||||
on the official Docker image (no local reproduction of the immutable layout).
|
||||
- Docs: VitePress build + anchor check; deploys on the next `dev` → `main`
|
||||
release merge.
|
||||
@@ -146,11 +146,11 @@ This app is a client for a Hermes server the user runs themselves, so a fresh in
|
||||
|
||||
### 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`:
|
||||
The Play build declares `**FOREGROUND_SERVICE_SPECIAL_USE**` for `GatewayKeepAliveService`, backing the opt-in **Persistent connection** 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.
|
||||
- **User control:** off by default; enabled only via *Settings → Quick Controls → Persistent connection*; shows an ongoing notification with a **Turn off** 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.
|
||||
|
||||
@@ -90,8 +90,13 @@
|
||||
# HERMES_RELAY_HOME Target directory (default: ~/.hermes/hermes-relay)
|
||||
# HERMES_RELAY_BRANCH Git branch to install (default: main). Superseded
|
||||
# by --branch when both are provided.
|
||||
# HERMES_VENV_PY Path to hermes-agent venv python
|
||||
# (default: ~/.hermes/hermes-agent/venv/bin/python)
|
||||
# HERMES_VENV_PY Path to hermes-agent venv python. When unset the
|
||||
# installer auto-detects, in order:
|
||||
# ~/.hermes/hermes-agent/venv/bin/python (classic)
|
||||
# ~/.hermes/hermes-agent/.venv/bin/python (uv-managed)
|
||||
# /opt/hermes/.venv/bin/python (official Docker image)
|
||||
# The Docker layout is immutable — the installer
|
||||
# refuses it and steers to the native plugin install.
|
||||
# HERMES_HOME Hermes config home (default: ~/.hermes)
|
||||
# HERMES_RELAY_NO_SYSTEMD Skip step [6/6] even if systemd is available
|
||||
# (set to any non-empty value)
|
||||
@@ -173,7 +178,15 @@ BRANCH="${_BRANCH_FLAG:-${HERMES_RELAY_BRANCH:-main}}"
|
||||
DASHBOARD_PLUGIN="${_DASHBOARD_PLUGIN_FLAG:-${HERMES_RELAY_DASHBOARD_PLUGIN:-yes}}"
|
||||
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
|
||||
RELAY_HOME="${HERMES_RELAY_HOME:-$HERMES_HOME/hermes-relay}"
|
||||
VENV_PY="${HERMES_VENV_PY:-$HERMES_HOME/hermes-agent/venv/bin/python}"
|
||||
# Resolved below (after helpers) — empty means "auto-detect".
|
||||
VENV_PY="${HERMES_VENV_PY:-}"
|
||||
# The official Docker image (nousresearch/hermes-agent) ships hermes-agent
|
||||
# pre-installed here. It is an immutable container layout: no git clone at
|
||||
# $HERMES_HOME/hermes-agent, no user systemd, and site-packages that vanish
|
||||
# on the next `docker pull`. This script's editable-install path cannot work
|
||||
# there, so detection of this interpreter triggers a steer to the native
|
||||
# `hermes plugins install` path instead of dying mid-run.
|
||||
DOCKER_VENV_PY="/opt/hermes/.venv/bin/python"
|
||||
PLUGIN_LINK="$HERMES_HOME/plugins/hermes-relay"
|
||||
SKILLS_DIR_IN_REPO="$RELAY_HOME/skills"
|
||||
HERMES_CONFIG="$HERMES_HOME/config.yaml"
|
||||
@@ -258,6 +271,24 @@ require() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "$1 is required but not installed"
|
||||
}
|
||||
|
||||
# ── Venv autodetection ─────────────────────────────────────────────────────
|
||||
# hermes-agent installs land in several layouts. HERMES_VENV_PY always wins
|
||||
# when set; otherwise probe, in order: classic pip/git venv, uv-managed
|
||||
# .venv, official Docker image.
|
||||
_VENV_AUTODETECTED=""
|
||||
if [ -z "$VENV_PY" ]; then
|
||||
for _cand in \
|
||||
"$HERMES_HOME/hermes-agent/venv/bin/python" \
|
||||
"$HERMES_HOME/hermes-agent/.venv/bin/python" \
|
||||
"$DOCKER_VENV_PY"; do
|
||||
if [ -x "$_cand" ]; then
|
||||
VENV_PY="$_cand"
|
||||
_VENV_AUTODETECTED=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Banner ─────────────────────────────────────────────────────────────────
|
||||
banner() {
|
||||
printf "\n"
|
||||
@@ -268,7 +299,7 @@ banner() {
|
||||
printf "\n"
|
||||
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Repo:" "$REPO_URL ${C_DIM}($BRANCH)${C_RESET}"
|
||||
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Target:" "$RELAY_HOME"
|
||||
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Venv:" "$VENV_PY"
|
||||
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Venv:" "${VENV_PY:-(not found — see below)}"
|
||||
printf " ${C_DIM}%-12s${C_RESET} %s\n" "Hermes:" "$HERMES_CONFIG"
|
||||
}
|
||||
|
||||
@@ -277,12 +308,26 @@ banner
|
||||
require git
|
||||
require python3
|
||||
|
||||
# Immutable/container layout — refuse early with a usable next step instead
|
||||
# of failing partway through the editable install / systemd setup.
|
||||
if [ -n "$_VENV_AUTODETECTED" ] && [ "$VENV_PY" = "$DOCKER_VENV_PY" ]; then
|
||||
printf "\n"
|
||||
warn "Official Hermes Docker image detected ($DOCKER_VENV_PY)."
|
||||
info "This installer's editable install + systemd + shell shims do not"
|
||||
info "apply inside the immutable container image."
|
||||
info "Install the plugin the native way instead:"
|
||||
printf "\n ${C_BOLD}hermes plugins install Codename-11/hermes-relay/plugin${C_RESET}\n\n"
|
||||
info "then enable it when prompted and restart the container."
|
||||
info "(To force this script anyway, set HERMES_VENV_PY explicitly.)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$HERMES_HOME/hermes-agent" ]; then
|
||||
die "hermes-agent not found at $HERMES_HOME/hermes-agent — install Hermes first"
|
||||
fi
|
||||
|
||||
if [ ! -x "$VENV_PY" ]; then
|
||||
die "hermes-agent venv Python not found at $VENV_PY — reinstall hermes-agent or set HERMES_VENV_PY"
|
||||
if [ -z "$VENV_PY" ] || [ ! -x "$VENV_PY" ]; then
|
||||
die "hermes-agent venv Python not found (tried $HERMES_HOME/hermes-agent/venv, $HERMES_HOME/hermes-agent/.venv, and /opt/hermes/.venv) — reinstall hermes-agent or set HERMES_VENV_PY"
|
||||
fi
|
||||
|
||||
# ── 1/6 Clone or update the repo ──────────────────────────────────────────
|
||||
@@ -529,9 +574,18 @@ cat > "$SHIM_PATH" <<SHIM
|
||||
#
|
||||
# Also available: /hermes-relay-pair slash command in any Hermes chat session.
|
||||
#
|
||||
# Override the venv python path with \$HERMES_VENV_PY if needed.
|
||||
# Override the venv python path with \$HERMES_VENV_PY if needed. When unset,
|
||||
# the install-time detected interpreter is tried first, then classic + uv layouts.
|
||||
|
||||
HERMES_VENV_PY="\${HERMES_VENV_PY:-\$HOME/.hermes/hermes-agent/venv/bin/python}"
|
||||
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
|
||||
if [ -z "\$HERMES_VENV_PY" ]; then
|
||||
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
|
||||
if [ -x "\$candidate" ]; then
|
||||
HERMES_VENV_PY="\$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ ! -x "\$HERMES_VENV_PY" ]; then
|
||||
echo "hermes-pair: cannot find hermes venv python at \$HERMES_VENV_PY" >&2
|
||||
echo "hermes-pair: set HERMES_VENV_PY or reinstall hermes-agent" >&2
|
||||
@@ -549,9 +603,18 @@ cat > "$STATUS_SHIM_PATH" <<SHIM
|
||||
#
|
||||
# Also available: /hermes-relay-status slash command in any Hermes chat session.
|
||||
#
|
||||
# Override the venv python path with \$HERMES_VENV_PY if needed.
|
||||
# Override the venv python path with \$HERMES_VENV_PY if needed. When unset,
|
||||
# the install-time detected interpreter is tried first, then classic + uv layouts.
|
||||
|
||||
HERMES_VENV_PY="\${HERMES_VENV_PY:-\$HOME/.hermes/hermes-agent/venv/bin/python}"
|
||||
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
|
||||
if [ -z "\$HERMES_VENV_PY" ]; then
|
||||
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
|
||||
if [ -x "\$candidate" ]; then
|
||||
HERMES_VENV_PY="\$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ ! -x "\$HERMES_VENV_PY" ]; then
|
||||
echo "hermes-status: cannot find hermes venv python at \$HERMES_VENV_PY" >&2
|
||||
echo "hermes-status: set HERMES_VENV_PY or reinstall hermes-agent" >&2
|
||||
@@ -577,7 +640,7 @@ set -eu
|
||||
HERMES_RELAY_HOME="\${HERMES_RELAY_HOME:-\$HOME/.hermes/hermes-relay}"
|
||||
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
|
||||
if [ -z "\$HERMES_VENV_PY" ]; then
|
||||
for candidate in "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
|
||||
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
|
||||
if [ -x "\$candidate" ]; then
|
||||
HERMES_VENV_PY="\$candidate"
|
||||
break
|
||||
@@ -655,9 +718,18 @@ cat > "$TS_SHIM_PATH" <<SHIM
|
||||
# hermes-relay-tailscale enable [--port N] [--api-port N] [--relay-only]
|
||||
# hermes-relay-tailscale disable [--port N] [--api-port N] [--relay-only]
|
||||
#
|
||||
# Override the venv python path with \$HERMES_VENV_PY if needed.
|
||||
# Override the venv python path with \$HERMES_VENV_PY if needed. When unset,
|
||||
# the install-time detected interpreter is tried first, then classic + uv layouts.
|
||||
set -eu
|
||||
HERMES_VENV_PY="\${HERMES_VENV_PY:-\$HOME/.hermes/hermes-agent/venv/bin/python}"
|
||||
HERMES_VENV_PY="\${HERMES_VENV_PY:-}"
|
||||
if [ -z "\$HERMES_VENV_PY" ]; then
|
||||
for candidate in "$VENV_PY" "\$HOME/.hermes/hermes-agent/.venv/bin/python" "\$HOME/.hermes/hermes-agent/venv/bin/python"; do
|
||||
if [ -x "\$candidate" ]; then
|
||||
HERMES_VENV_PY="\$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ ! -x "\$HERMES_VENV_PY" ]; then
|
||||
echo "hermes-relay-tailscale: cannot find hermes venv python at \$HERMES_VENV_PY" >&2
|
||||
echo "hermes-relay-tailscale: set HERMES_VENV_PY or reinstall hermes-agent" >&2
|
||||
@@ -691,8 +763,18 @@ elif [ ! -f "$SERVICE_SRC" ]; then
|
||||
info " Service template not found at $SERVICE_SRC — skipping"
|
||||
else
|
||||
mkdir -p "$SYSTEMD_USER_DIR"
|
||||
cp "$SERVICE_SRC" "$SERVICE_DST"
|
||||
ok "Wrote $SERVICE_DST"
|
||||
# The committed template hardcodes the classic %h/.hermes/hermes-agent/venv
|
||||
# layout. Rewrite that prefix to the venv we actually detected (uv-managed
|
||||
# .venv, a HERMES_VENV_PY override, etc.) so ExecStart / PATH / VIRTUAL_ENV
|
||||
# point at a real interpreter instead of dying with 203/EXEC on non-classic
|
||||
# hosts. $VENV_PY is ".../<venv>/bin/python"; strip "/bin/python" for the dir.
|
||||
# A `|` delimiter avoids clashing with the `/` path separators. We assume the
|
||||
# venv path has no sed metacharacters (& | \) — true for the standard
|
||||
# ~/.hermes/hermes-agent/{venv,.venv} layouts and any sane HERMES_VENV_PY.
|
||||
_venv_bin_dir="${VENV_PY%/*}" # .../<venv>/bin
|
||||
_venv_root_dir="${_venv_bin_dir%/*}" # .../<venv>
|
||||
sed "s|%h/.hermes/hermes-agent/venv|$_venv_root_dir|g" "$SERVICE_SRC" > "$SERVICE_DST"
|
||||
ok "Wrote $SERVICE_DST (venv → $_venv_root_dir)"
|
||||
|
||||
systemctl --user daemon-reload >/dev/null 2>&1 || true
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -83,7 +83,11 @@ def register(ctx):
|
||||
# Tools/CLI above still work.
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("Phone platform registration failed; continuing", exc_info=True)
|
||||
# Don't silently swallow — a real registration failure (e.g. an
|
||||
# upstream PlatformEntry signature drift) should be visible, not lost
|
||||
# at DEBUG. The platform is still additive; tool/CLI registration above
|
||||
# is unaffected.
|
||||
logger.warning("Phone platform registration failed; continuing", exc_info=True)
|
||||
|
||||
# Apply relay-owned host enhancements. This is intentionally guarded so
|
||||
# older Hermes hosts without the system-prompt seam still load tools/CLI.
|
||||
|
||||
@@ -36,6 +36,13 @@ relay voice, desktop tooling, and remote access:
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Official Docker image:** on `nousresearch/hermes-agent` the native
|
||||
`hermes plugins install` path above is the only supported one. The image is
|
||||
immutable (`/opt/hermes/.venv`, no git clone, no user systemd), so
|
||||
`install.sh`'s editable-install/systemd path is not applicable there — the
|
||||
installer detects that layout and steers back to the native path. Start the
|
||||
relay inside the container with `hermes relay start`.
|
||||
|
||||
The optional legacy compatibility hook is managed separately:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -251,6 +251,44 @@ def register_relay_cli(subparser) -> None:
|
||||
insecure.add_argument("--json", action="store_true", help="Print raw JSON")
|
||||
insecure.set_defaults(func=relay_insecure_api_key_command)
|
||||
|
||||
# ── profiles ──────────────────────────────────────────────────────────
|
||||
# Plugin *code* installs once (global symlink); plugin *enablement* is
|
||||
# per-profile (each profile's config.yaml plugins.enabled). This group
|
||||
# lists and bulk-enables hermes-relay across profiles so a multi-agent
|
||||
# user doesn't hand-edit N configs. Pairing is unaffected (one relay).
|
||||
profiles = sub.add_parser(
|
||||
"profiles",
|
||||
help="List or manage which profiles enable the hermes-relay plugin",
|
||||
)
|
||||
profiles_sub = profiles.add_subparsers(dest="profiles_cmd")
|
||||
profiles.set_defaults(func=relay_profiles_command)
|
||||
|
||||
prof_list = profiles_sub.add_parser("list", help="Show hermes-relay enablement per profile")
|
||||
prof_list.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
prof_list.set_defaults(func=relay_profiles_command)
|
||||
|
||||
prof_enable = profiles_sub.add_parser(
|
||||
"enable", help="Enable hermes-relay in profile configs (default: all profiles)"
|
||||
)
|
||||
prof_enable.add_argument("names", nargs="*", help="Profile names to enable (default: all)")
|
||||
prof_enable.add_argument(
|
||||
"--all", action="store_true", help="Enable in the default config + every profile"
|
||||
)
|
||||
prof_enable.add_argument(
|
||||
"--dry-run", action="store_true", help="Show what would change without writing"
|
||||
)
|
||||
prof_enable.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
prof_enable.set_defaults(func=relay_profiles_command)
|
||||
|
||||
# ── update-check ──────────────────────────────────────────────────────
|
||||
update = sub.add_parser(
|
||||
"update-check",
|
||||
help="Check whether a newer hermes-relay plugin release is available",
|
||||
)
|
||||
update.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
update.add_argument("--timeout", type=float, default=4.0, help="GitHub fetch timeout (s)")
|
||||
update.set_defaults(func=relay_update_check_command)
|
||||
|
||||
|
||||
def relay_command(args):
|
||||
"""Dispatch `hermes relay` subcommands when the host CLI calls one handler."""
|
||||
@@ -278,6 +316,91 @@ def relay_compat_command(args) -> None:
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def relay_profiles_command(args) -> None:
|
||||
"""List or bulk-enable per-profile hermes-relay enablement."""
|
||||
from . import profiles as profmod
|
||||
|
||||
configs = profmod.discover_profile_configs()
|
||||
if not configs:
|
||||
print(f"No Hermes config files found under {profmod.hermes_home()}")
|
||||
return
|
||||
|
||||
cmd = getattr(args, "profiles_cmd", None)
|
||||
|
||||
if cmd == "enable":
|
||||
requested = [n.lower() for n in getattr(args, "names", []) or []]
|
||||
want_all = getattr(args, "all", False) or not requested
|
||||
dry = getattr(args, "dry_run", False)
|
||||
results = []
|
||||
for label, path in configs:
|
||||
keys = {label.lower(), label.strip("()").lower()}
|
||||
if not want_all and keys.isdisjoint(requested):
|
||||
continue
|
||||
before = profmod.relay_state(path)
|
||||
changed = profmod.enable_relay(path, dry_run=dry)
|
||||
results.append(
|
||||
{"profile": label, "path": str(path), "was": before, "changed": changed}
|
||||
)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps({"action": "enable", "dry_run": dry, "results": results}, indent=2))
|
||||
return
|
||||
|
||||
verb = "Would enable" if dry else "Enabled"
|
||||
any_changed = False
|
||||
for r in results:
|
||||
if r["changed"]:
|
||||
any_changed = True
|
||||
print(f" {verb} in {r['profile']} ({r['path']})")
|
||||
else:
|
||||
print(f" already enabled in {r['profile']}")
|
||||
if not any_changed:
|
||||
print("hermes-relay is already enabled everywhere — nothing to do.")
|
||||
elif not dry:
|
||||
print(
|
||||
"\nRestart the affected gateway(s) to load it: "
|
||||
"systemctl --user restart hermes-gateway"
|
||||
)
|
||||
return
|
||||
|
||||
# default / "list"
|
||||
states = [(label, str(path), profmod.relay_state(path)) for label, path in configs]
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(
|
||||
{"profiles": [{"profile": l, "path": p, "state": s} for l, p, s in states]},
|
||||
indent=2,
|
||||
))
|
||||
return
|
||||
print("hermes-relay enablement by profile:\n")
|
||||
width = max(len(l) for l, _, _ in states)
|
||||
for label, _path, state in states:
|
||||
mark = {"enabled": "✓", "disabled": "✗", "absent": "·"}.get(state, "?")
|
||||
print(f" {mark} {label.ljust(width)} {state}")
|
||||
if any(s != "enabled" for _, _, s in states):
|
||||
print("\nEnable everywhere with: hermes relay profiles enable --all")
|
||||
|
||||
|
||||
def relay_update_check_command(args) -> None:
|
||||
"""Report whether a newer hermes-relay plugin release is available."""
|
||||
from . import update_check
|
||||
|
||||
result = update_check.check(timeout=getattr(args, "timeout", 4.0))
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(result, indent=2))
|
||||
return
|
||||
cur = result.get("current")
|
||||
if result.get("error"):
|
||||
print(f"hermes-relay {cur} — could not check for updates ({result['error']})")
|
||||
return
|
||||
latest = result.get("latest")
|
||||
if result.get("update_available"):
|
||||
print(f"Update available: {cur} → {latest}")
|
||||
print(f" Run: {result.get('update_command')}")
|
||||
else:
|
||||
suffix = f" (latest: {latest})" if latest else ""
|
||||
print(f"hermes-relay {cur} is up to date{suffix}.")
|
||||
|
||||
|
||||
def relay_start_command(args) -> None:
|
||||
"""Run the relay server in the foreground.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"label": "Relay",
|
||||
"description": "Paired devices, bridge activity, media inspection, and remote access for hermes-relay",
|
||||
"icon": "Activity",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"tab": {
|
||||
"path": "/relay",
|
||||
"position": "after:skills"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"qrcode": "^1.5.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
|
||||
"scripts": {
|
||||
|
||||
@@ -24,9 +24,12 @@ Error translation
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path as FsPath
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
@@ -34,6 +37,45 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, HTTPException, Path, Query
|
||||
|
||||
# ── Plugin-package bootstrap ──────────────────────────────────────────────
|
||||
# hermes-agent's web server loads this file standalone via
|
||||
# ``importlib.util.spec_from_file_location`` (no parent package), so relative
|
||||
# imports cannot work here. The plugin package's import name also varies by
|
||||
# install method: classic editable install = ``plugin``, native
|
||||
# ``hermes plugins install`` = ``hermes_plugins.hermes_relay`` (issue #165).
|
||||
# ``_plugin_module()`` resolves sibling plugin modules in every context:
|
||||
#
|
||||
# 1. Loaded as a submodule of the plugin package (tests, native loader) —
|
||||
# import through the REAL parent package so there is a single module
|
||||
# instance (test monkeypatching of e.g. ``plugin.relay.tailscale`` must
|
||||
# stay effective).
|
||||
# 2. Loaded standalone by the dashboard web server — synthesize the parent
|
||||
# package: a bare ``ModuleType`` whose ``__path__`` points at the plugin
|
||||
# directory, registered in ``sys.modules`` under a stable alias. The
|
||||
# import system then resolves submodules against that path WITHOUT ever
|
||||
# exec'ing ``plugin/__init__.py`` (whose tool registration side effects
|
||||
# must not run inside the web server).
|
||||
|
||||
_PLUGIN_PKG_ALIAS = "_hermes_relay_plugin_pkg"
|
||||
|
||||
|
||||
def _plugin_module(name: str) -> types.ModuleType:
|
||||
"""Import ``<plugin package>.<name>`` in whatever layout we're running."""
|
||||
if __package__ and "." in __package__:
|
||||
# Assumes our parent package IS the plugin package — true for both real
|
||||
# layouts: ``plugin.dashboard`` → ``plugin`` and
|
||||
# ``hermes_plugins.hermes_relay.dashboard`` → ``hermes_plugins.hermes_relay``.
|
||||
parent = __package__.rsplit(".", 1)[0] # strip trailing ".dashboard"
|
||||
return importlib.import_module(f"{parent}.{name}")
|
||||
pkg = sys.modules.get(_PLUGIN_PKG_ALIAS)
|
||||
if pkg is None:
|
||||
plugin_dir = FsPath(__file__).resolve().parent.parent
|
||||
pkg = types.ModuleType(_PLUGIN_PKG_ALIAS)
|
||||
pkg.__path__ = [str(plugin_dir)] # type: ignore[attr-defined]
|
||||
pkg.__package__ = _PLUGIN_PKG_ALIAS
|
||||
sys.modules[_PLUGIN_PKG_ALIAS] = pkg
|
||||
return importlib.import_module(f"{_PLUGIN_PKG_ALIAS}.{name}")
|
||||
|
||||
# Read once at import time — hermes-agent restarts pick up env changes.
|
||||
RELAY_PORT: int = int(os.environ.get("HERMES_RELAY_PORT", "8767"))
|
||||
_RELAY_BASE: str = f"http://127.0.0.1:{RELAY_PORT}"
|
||||
@@ -182,20 +224,85 @@ async def get_media(include_expired: Optional[bool] = Query(default=None)) -> An
|
||||
@router.get("/agent-context")
|
||||
async def get_agent_context() -> dict[str, Any]:
|
||||
"""Return current Agent context flags and the relay audit payload."""
|
||||
from plugin.config import (
|
||||
agent_context_enabled,
|
||||
context_media_sensitivity_enabled,
|
||||
)
|
||||
config = _plugin_module("config")
|
||||
|
||||
return {
|
||||
"settings": {
|
||||
"RELAY_AGENT_CONTEXT_ENABLED": agent_context_enabled(),
|
||||
"RELAY_CONTEXT_MEDIA_SENSITIVITY": context_media_sensitivity_enabled(),
|
||||
"RELAY_AGENT_CONTEXT_ENABLED": config.agent_context_enabled(),
|
||||
"RELAY_CONTEXT_MEDIA_SENSITIVITY": config.context_media_sensitivity_enabled(),
|
||||
},
|
||||
"injected": await _proxy_get("/context/injected"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/phone/config")
|
||||
async def get_phone_config() -> dict[str, Any]:
|
||||
"""Phone-platform home-channel config for the Management tab.
|
||||
|
||||
Reads the same env the adapter resolves, so the dashboard surfaces the
|
||||
*effective* home-channel name + id. The name is editable from the tab via
|
||||
the host ``PUT /api/env`` (``PHONE_HOME_CHANNEL_NAME``); the id is shown
|
||||
read-only because changing it would orphan existing phone Threads (the
|
||||
``chat_id`` keys the gateway session). ``enabled`` reflects the
|
||||
``PHONE_ENABLED`` gate so the tab can hide the card when the platform is
|
||||
off. No relay round-trip — env is process-local.
|
||||
"""
|
||||
phone_platform = _plugin_module("phone_platform")
|
||||
|
||||
return {
|
||||
"enabled": phone_platform._phone_enabled(),
|
||||
"home_channel_id": phone_platform._home_channel(),
|
||||
"home_channel_name": phone_platform._home_channel_name(),
|
||||
"name_env_key": "PHONE_HOME_CHANNEL_NAME",
|
||||
}
|
||||
|
||||
|
||||
# Update-check cache — a GitHub round-trip per dashboard poll would be wasteful
|
||||
# and risks rate-limiting. Cache the resolved latest tag for an hour; the
|
||||
# current/compare/command are recomputed cheaply each call.
|
||||
_UPDATE_CACHE: dict[str, Any] = {"latest": None, "fetched_at": 0.0, "error": None}
|
||||
_UPDATE_CACHE_TTL: float = 3600.0
|
||||
|
||||
|
||||
@router.get("/update-check")
|
||||
async def get_update_check(refresh: Optional[bool] = Query(default=False)) -> dict[str, Any]:
|
||||
"""Report whether a newer hermes-relay plugin release is available.
|
||||
|
||||
Compares the installed ``plugin.relay.__version__`` against the latest
|
||||
``plugin-v*`` GitHub release. The GitHub fetch is cached for an hour
|
||||
(``?refresh=true`` forces it) so a polling dashboard doesn't hammer the
|
||||
releases API. Network failures degrade to ``update_available=false`` with
|
||||
an ``error`` string — never a 5xx — so the card can show "couldn't check".
|
||||
"""
|
||||
update_check = _plugin_module("update_check")
|
||||
|
||||
now = time.time()
|
||||
stale = (now - _UPDATE_CACHE["fetched_at"]) > _UPDATE_CACHE_TTL
|
||||
if refresh or stale or _UPDATE_CACHE["fetched_at"] == 0.0:
|
||||
latest, error = None, None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
resp = await client.get(
|
||||
update_check.GITHUB_RELEASES_URL,
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "hermes-relay-update-check",
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
latest = update_check.pick_latest_plugin_tag(resp.json())
|
||||
else:
|
||||
error = f"GitHub returned {resp.status_code}"
|
||||
except Exception as exc: # network down, rate-limited, bad JSON
|
||||
error = str(exc)
|
||||
_UPDATE_CACHE.update(latest=latest, error=error, fetched_at=now)
|
||||
|
||||
result = update_check.build_result(update_check.current_version(), _UPDATE_CACHE["latest"])
|
||||
result["error"] = _UPDATE_CACHE["error"]
|
||||
result["checked_at"] = int(_UPDATE_CACHE["fetched_at"])
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/push")
|
||||
async def get_push() -> dict[str, Any]:
|
||||
"""Push console stub — no network call until FCM lands."""
|
||||
@@ -296,7 +403,9 @@ async def mint_pairing(body: dict[str, Any] = Body(default_factory=dict)) -> Any
|
||||
# on sys.path (smoke tests, docs render, etc.) still loads the
|
||||
# module. Any failure here becomes a 500 via HTTPException below.
|
||||
try:
|
||||
from plugin.pair import build_endpoint_candidates, read_relay_config
|
||||
pair = _plugin_module("pair")
|
||||
build_endpoint_candidates = pair.build_endpoint_candidates
|
||||
read_relay_config = pair.read_relay_config
|
||||
except ImportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
@@ -317,9 +426,7 @@ async def mint_pairing(body: dict[str, Any] = Body(default_factory=dict)) -> Any
|
||||
# API defaults come from the same config chain ``pair.py`` uses so
|
||||
# the dashboard-minted QR matches what ``hermes-pair --mode auto``
|
||||
# would emit from the CLI.
|
||||
from plugin.pair import read_server_config # local import: see above
|
||||
|
||||
api_cfg = read_server_config()
|
||||
api_cfg = pair.read_server_config()
|
||||
relay_cfg = read_relay_config()
|
||||
api_host = str(body.get("host") or api_cfg.get("host") or "127.0.0.1")
|
||||
api_port = int(body.get("port") or api_cfg.get("port") or 8642)
|
||||
@@ -372,7 +479,7 @@ def _tailscale_status_dict() -> dict[str, Any]:
|
||||
render a "not installed" state without a second round-trip.
|
||||
"""
|
||||
try:
|
||||
from plugin.relay import tailscale
|
||||
tailscale = _plugin_module("relay.tailscale")
|
||||
except ImportError:
|
||||
return {"available": False, "reason": "helper not importable"}
|
||||
try:
|
||||
@@ -386,7 +493,7 @@ def _tailscale_status_dict() -> dict[str, Any]:
|
||||
|
||||
def _canonical_upstream_present() -> bool:
|
||||
try:
|
||||
from plugin.relay import tailscale
|
||||
tailscale = _plugin_module("relay.tailscale")
|
||||
except ImportError:
|
||||
return False
|
||||
try:
|
||||
@@ -424,7 +531,7 @@ async def tailscale_enable(
|
||||
) -> dict[str, Any]:
|
||||
"""Call ``tailscale.enable(port)`` and return its verbatim result."""
|
||||
try:
|
||||
from plugin.relay import tailscale
|
||||
tailscale = _plugin_module("relay.tailscale")
|
||||
except ImportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
@@ -447,7 +554,7 @@ async def tailscale_disable(
|
||||
) -> dict[str, Any]:
|
||||
"""Call ``tailscale.disable(port)`` and return its verbatim result."""
|
||||
try:
|
||||
from plugin.relay import tailscale
|
||||
tailscale = _plugin_module("relay.tailscale")
|
||||
except ImportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
|
||||
@@ -48,6 +48,14 @@ export function getAgentContext() {
|
||||
return fetchJSON("/agent-context");
|
||||
}
|
||||
|
||||
export function getPhoneConfig() {
|
||||
return fetchJSON("/phone/config");
|
||||
}
|
||||
|
||||
export function getUpdateCheck({ refresh = false } = {}) {
|
||||
return fetchJSON(`/update-check${refresh ? "?refresh=true" : ""}`);
|
||||
}
|
||||
|
||||
export function putEnvSetting(key, value) {
|
||||
return fetchHostJSON("/api/env", {
|
||||
method: "PUT",
|
||||
|
||||