Compare commits

..
Author SHA1 Message Date
Bailey Dixon d80b3db329 fix(android): keep passive gateway observation read-only 2026-08-28 23:40:13 -04:00
Bailey Dixon 834763a213 Merge pull request #463 from Codename-11/fix/android-remove-chat-clean-mode
fix(android): remove chat clean focus mode
2026-08-27 20:20:15 -04:00
Bailey Dixon 8ef6b794b2 Merge remote-tracking branch 'origin/dev' into fix/android-remove-chat-clean-mode 2026-08-27 20:08:01 -04:00
Bailey Dixon 17d8a2374a fix(android): remove chat clean focus mode 2026-08-27 20:07:43 -04:00
Bailey Dixon 274ae11160 Merge pull request #462 from Codename-11/docs/pr-intake-external-only
docs: limit PR intake to external contributors
2026-08-27 19:56:52 -04:00
Bailey Dixon 9cebdb67e6 docs: limit PR intake to external contributors 2026-08-27 19:54:49 -04:00
Bailey Dixon ee300b3b15 Merge pull request #460 from Codename-11/docs/triage-assignment-pr-intake
docs: document triage ownership and PR intake
2026-08-27 18:32:20 -04:00
Bailey Dixon 2b75577fcf docs: document triage ownership and PR intake 2026-08-27 18:05:11 -04:00
Bailey Dixon ff2f94922b Merge pull request #459 from Codename-11/docs/pull-request-template
docs: add canonical pull request template
2026-08-27 16:10:29 -04:00
Bailey Dixon 292cc94eb4 docs: add canonical pull request template 2026-08-26 21:47:45 -04:00
Bailey Dixon 621081b633 Merge pull request #458 from Codename-11/docs/triage-bot-attribution
docs: identify automated triage bot
2026-08-26 21:01:18 -04:00
Bailey Dixon da8e822b66 docs: identify automated triage bot 2026-08-26 20:51:32 -04:00
Bailey Dixon 0015a913f1 Merge pull request #457 from Codename-11/feature/android-whats-new-highlights
feat(android): improve What's New release highlights
2026-08-26 20:40:10 -04:00
Bailey Dixon 0f47e464f3 Merge remote-tracking branch 'origin/dev' into feature/android-whats-new-highlights 2026-08-26 20:20:26 -04:00
Bailey Dixon 2411cf19b3 Merge pull request #456 from Codename-11/chore/hermes-issue-triage-docs
docs: document bounded issue triage
2026-08-26 20:13:55 -04:00
Bailey Dixon 51b4edd52a Merge remote-tracking branch 'origin/dev' into feature/android-whats-new-highlights
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/RelayApp.kt
#	docs/localization-status.json
2026-08-26 20:02:24 -04:00
Bailey Dixon d3c4e90696 docs: document bounded issue triage 2026-08-26 19:59:01 -04:00
Bailey Dixon cd7cad03ec Merge pull request #455 from Codename-11/feature/native-git-workspace
feat(android): add first-class native Git workspace
2026-08-26 19:46:35 -04:00
Bailey Dixon bea3021ef0 Merge remote-tracking branch 'origin/dev' into feature/android-whats-new-highlights
# Conflicts:
#	CHANGELOG.md
2026-08-26 19:17:57 -04:00
Bailey Dixon 00a9a7efcb feat(android): improve What's New release highlights 2026-08-26 19:17:09 -04:00
55 changed files with 2313 additions and 1069 deletions
+47
View File
@@ -0,0 +1,47 @@
## Summary
<!-- Describe the user/developer outcome and why this change is needed. Link related issues, e.g. Closes #123. -->
## Changes
<!-- List the focused implementation changes. Avoid repeating the commit log. -->
-
## Verification
<!-- List exact commands and results. Say explicitly when a check was not run or when device/UI proof remains outstanding. -->
-
## Screenshots
<!-- UI change: include screenshots/video plus device, viewport, or emulator details. Otherwise write: No visual change. -->
## Compatibility / risk
<!-- Note Standard Hermes/upstream compatibility, migrations/state changes, security/privacy impact, rollout/rollback, or write N/A. -->
## Lineage / contributor credit
<!-- Preserve prior contributor work when replacing, salvaging, or rebuilding another PR. -->
- Source PR(s): N/A
- Attribution preserved by: N/A
## Checklist
<!-- Check an item when satisfied or when its N/A rationale is stated above. -->
- [ ] Target branch is `dev`, unless this is a `dev` → `main` release PR or a focused production-tag hotfix PR to `main`
- [ ] Scope is focused and related issues/PRs are linked
- [ ] Android changes: lint and focused tests ran, or rationale is listed above
- [ ] Translation changes: locale validation/review ran, or N/A is listed above
- [ ] Server/plugin changes: focused tests ran, or N/A/rationale is listed above
- [ ] Desktop changes: build/tests ran, or N/A/rationale is listed above
- [ ] Docs/site changes: build or link/route checks ran, or N/A/rationale is listed above
- [ ] UI changes were tested on a relevant device/emulator/desktop surface, or the missing proof is stated above
- [ ] Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
- [ ] `CHANGELOG.md` is updated for user-visible changes, or N/A is listed above
- [ ] Public writing hygiene checked: no secrets, private infrastructure, personal names, or AI/process narration
- [ ] Salvaged/replacement work links source PRs and preserves contributor authorship, or N/A is listed above
+2 -1
View File
@@ -16,7 +16,8 @@ function classifyCiPaths(paths) {
android: forceAll || under(['app/', 'relay-core/', 'relay-ui/', 'ui-preview/', 'quest/', 'gradle/']) || exact([
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
'scripts/check-android-collection-apis.py', '.github/workflows/ci-android.yml',
'scripts/check-android-collection-apis.py', 'scripts/check-android-release-notes.py',
'scripts/tests/check_android_release_notes_test.py', '.github/workflows/ci-android.yml',
'.github/workflows/play-preflight-android.yml',
'.github/workflows/approve-release-android.yml',
'.github/workflows/release-android.yml',
@@ -15,6 +15,7 @@ const none = {
assert.deepEqual(classifyCiPaths(['README.md']), none);
assert.deepEqual(classifyCiPaths(['desktop/src/cli.ts']), { ...none, desktop: true });
assert.deepEqual(classifyCiPaths(['relay-core/src/main/kotlin/Wire.kt']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/check-android-release-notes.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['plugin/relay/server.py']), { ...none, plugin: true });
assert.deepEqual(classifyCiPaths(['plugin/dashboard/src/App.tsx']), { ...none, dashboard: true });
assert.deepEqual(classifyCiPaths(['user-docs/index.md']), { ...none, docs: true });
+7
View File
@@ -33,6 +33,8 @@ on:
- "scripts/check-android-locales.py"
- "scripts/android-locale-harness.py"
- "scripts/check-android-collection-apis.py"
- "scripts/check-android-release-notes.py"
- "scripts/tests/check_android_release_notes_test.py"
- ".github/workflows/ci-android.yml"
- ".github/workflows/play-preflight-android.yml"
- ".github/workflows/approve-release-android.yml"
@@ -73,6 +75,11 @@ jobs:
- name: Reject unsafe Android collection APIs
run: python3 scripts/check-android-collection-apis.py
- name: Validate Android release notes
run: |
python3 scripts/check-android-release-notes.py
python3 -m unittest scripts.tests.check_android_release_notes_test
- name: Run Android lint
run: ./gradlew lint --console=plain
+1 -1
View File
@@ -81,10 +81,10 @@ jobs:
- name: Validate release metadata and source compatibility
run: |
python3 scripts/check-version-tracks.py
python3 scripts/check-android-release-notes.py
python3 scripts/check-privacy-policy.py --live
python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py
python3 -m json.tool app/src/main/assets/changelog.json >/dev/null
- name: Decode release keystore
env:
+1
View File
@@ -150,6 +150,7 @@ jobs:
- name: Validate release metadata and Android API compatibility
run: |
python3 scripts/check-version-tracks.py
python3 scripts/check-android-release-notes.py
python3 scripts/check-privacy-policy.py
python3 scripts/check-android-locales.py
python3 scripts/check-android-collection-apis.py
+34
View File
@@ -102,6 +102,40 @@ PR; never resolve those cases by choosing a side automatically.
- Prioritize findings that warrant holding the merge. State the impacted path
and the concrete failure mode.
## Automated public issue triage
New public issues may receive one clearly labeled **Hermes-Relay automated
triage** reply. That first response may classify the report with existing
type/area labels, point to related issues or current code/docs, ask for safe
sanitized diagnostics, and flag the thread for maintainer review.
GitHub attributes that reply to the repository-scoped
`hermes-relay-triage[bot]` App, never to a maintainer's personal account.
The automated lane may assign only the fixed maintainer account `Codename-11`
as follow-up ownership; that assignment does not imply acceptance, priority,
implementation, or a release commitment. It never closes, milestones,
prioritizes, promises a fix/release/timeline, chooses another assignee, or
continues replying after its first response. A related issue is not
automatically a duplicate. Human maintainer comments and decisions remain
authoritative; read the complete live thread before acting on an issue.
## Automated public PR intake
New external-contributor, non-draft pull requests may receive one clearly
labeled **Hermes-Relay automated PR intake** reply from
`hermes-relay-triage[bot]`. Owner-authored `Codename-11` PRs and bot PRs are
dropped before model dispatch. For eligible PRs, the intake compares the live PR
metadata/body and changed-path list with trusted `origin/dev` policy and
`.github/pull_request_template.md` without checking out or executing contributor
code. It may add genuine area labels plus `documentation`, `ci`, or
`needs-maintainer-review` and point out missing intake evidence.
The automated lane never approves, requests changes, merges, closes, assigns,
requests reviewers, milestones, prioritizes, pushes commits, edits PR text,
reruns workflows, applies `review-candidate`, or claims code correctness. Human
maintainer review and CI remain authoritative.
## Public-repo writing hygiene
Everything committed is public. In CHANGELOG, DEVLOG, README, docs, and release
+9
View File
@@ -11,10 +11,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- **Android presents Relay Git as a first-class native workspace.** A compact optional Chat rail opens repository status, line totals, filters, diffs, branches, staging, commits, and remotes; the full workspace remains available from Settings when Chat controls are hidden.
- **Hermes-Relay Plugin provides a bounded Git workspace API for authenticated Dashboard clients.** Configured repository roots, path validation, tracked line totals, scoped write grants, and explicit confirmation protect repository reads and mutations.
### Changed
- **Android What's New leads with one curated release highlight without interrupting startup.** A timed post-update toast can be swiped or closed, previews additional feature/fix counts when a release has meaningful secondary items, expands into the centered highlight view on request, and keeps the full technical history available. Each release can present one plain-language summary, up to three primary benefits, and up to two quieter improvements, while release checks keep the structured entry, fallback, Play copy, and public release records aligned.
### Fixed
- **Opening Android no longer claims or interrupts a turn already running in Hermes Desktop/TUI.** Passive foreground and session browsing now use read-only Gateway status plus profile-scoped history; live-session resume remains reserved for explicit Android actions and exact Android-owned recovery.
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
### Removed
- **Android Chat no longer includes the hidden clean-focus presentation.** The long-press gesture, overlapping instructional pill, reduced composer, and alternate fading transcript were removed so Chat keeps one complete interaction model. Voice Focus remains available.
## [Android 1.13.2] - 2026-08-25
### Added
+35
View File
@@ -104,6 +104,24 @@ The legacy `relay_server/` directory is a thin compatibility shim around `plugin
| **CI/CD** | GitHub Actions (lint, build, test, signed APK artifacts) |
| **Min SDK** | 26 (Android 8.0) / Target SDK 36 |
## Issues and automated triage
New issues may receive one first response headed **Hermes-Relay automated
triage**. It reads the live report against current code, documentation, related
issues, and public release state; it may add existing type/area labels and ask
for a focused, safe diagnostic such as the app version, interaction mode, or a
sanitized log excerpt.
GitHub displays the response as authored by `hermes-relay-triage[bot]`, a
repository-scoped App rather than a maintainer's personal account.
That reply is an acknowledgement and initial analysis, not a maintainer
decision. The automated path may assign `Codename-11` as the fixed owner for
follow-up, but assignment does not mean acceptance, priority, implementation,
or a release commitment. It does not close issues, choose another assignee, set
milestones or priority, promise a fix or release, or continue the conversation.
A maintainer will follow up on the thread.
## Running the Relay Locally
Only needed if you're working on the bridge, voice, notifications, or media features. Chat alone doesn't need the relay.
@@ -145,6 +163,23 @@ documentation fixes.
`main` is release history, not the normal contribution target; it receives
approved release PRs from `dev` and focused hotfix PRs based on production tags.
Pull requests use [the repository template](.github/pull_request_template.md).
Keep the body grounded: describe the outcome and focused changes, list exact
verification, include visual evidence when applicable, state compatibility or
risk, and preserve contributor lineage when replacing or salvaging prior work.
Check an item when it is satisfied or when its N/A rationale is written in the
body; do not use checked boxes as a substitute for evidence.
New external-contributor, non-draft pull requests may receive one
**Hermes-Relay automated PR intake** reply from `hermes-relay-triage[bot]`.
Owner-authored `Codename-11` PRs and bot PRs skip this lane. For eligible PRs,
the bot checks the live body, base branch, changed-path areas, template
completeness, stated verification, visual proof, and lineage without checking
out or executing contributor code. It may add bounded area/intake labels and
identify missing evidence, but it does not review code correctness, approve,
request changes, merge, close, assign, request reviewers, push commits, edit the
PR, rerun workflows, or select review bundles.
`origin/dev` is the canonical integration ref. Keep local `dev` as a clean,
fast-forward-only mirror and create each task in its own branch/worktree from the
current `origin/dev`. Do not accumulate unpublished commits on local `dev`. If a
+48
View File
@@ -1,5 +1,53 @@
# Hermes-Relay — Dev Log
## 2026-08-27 — Fixed issue ownership and bounded PR intake
The automated issue first-response lane now assigns only `Codename-11` when the
maintainer is absent. The assignee is fixed in the write wrapper rather than
chosen by webhook or model output, and represents follow-up ownership only—not
acceptance, priority, implementation, or a release promise.
A separate pull-request route now handles external-contributor, non-bot,
non-draft `opened`, `reopened`, and `ready_for_review` events. Owner-authored
`Codename-11` PRs are dropped before model dispatch. Eligible PRs are read from
live metadata plus trusted `dev` policy/template without executing contributor
code; the route applies only bounded area/intake labels and posts one
App-authored intake reply. It cannot approve, request changes, merge, close,
assign, request reviewers, push, edit PR text, rerun CI, select
`review-candidate`, or claim correctness. The external-contributor production
canary retained its exact head/base/open state and proved marker idempotency.
## 2026-08-26 — Canonical pull request intake contract
The repository now supplies one pull request template derived from the structure
already used by successful Android, plugin, desktop, docs, release, and salvage
PRs. It asks for a concise summary, focused changes, exact verification, visual
evidence when applicable, compatibility/risk notes, contributor lineage, and the
existing cross-surface checklist.
Checkboxes require evidence or an explicit N/A rationale. The template does not
turn every surface into a mandatory test lane, does not replace CI or maintainer
review, and keeps normal work targeting `dev` while preserving the documented
release/hotfix exceptions.
## 2026-08-26 — Bounded public issue triage contract
New GitHub issues may receive one clearly identified Hermes-Relay automated
triage response grounded in the live report, current code and documentation,
related open/closed issues, and verified public release state. The lane may add
existing type/area labels, request focused sanitized diagnostics, and flag a
thread for maintainer review.
The automated path is intentionally not an issue-lifecycle owner: it cannot
close, assign, milestone, prioritize, promise a fix/release/timeline, or continue
replying after the first response. Replies identify themselves as automated and
explicitly hand the remaining decision to a maintainer. Contributor guidance and
agent instructions carry the same boundary.
Public labels and comments are attributed to the repository-scoped
`hermes-relay-triage[bot]` GitHub App rather than a maintainer's personal
account. The App identity does not expand the bounded action contract.
## 2026-08-24 — Single dev integration authority
`origin/dev` is the sole integration authority. Primary local `dev` checkouts are
+60 -11
View File
@@ -530,10 +530,16 @@ the new app version and a higher `appVersionCode`.
run Android's retrace tool with the matching flavor mapping:
`retrace <mapping.txt> <obfuscated-trace.txt>`. Play reports can additionally
use the mapping bundled into the uploaded AAB through Play Console.
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
shown in the settings/about screen. Update with the version number
and a brief feature summary. Gets stale silently if forgotten
(v0.4.0 shipped with 0.1.0 content until caught post-release).
- `app/src/main/assets/changelog.json` — curated source for the in-app
**What's New** dialog and Android release history. Prepend the newest entry
with one explicit `highlight` (`title`, plain-language `summary`, and 1–3
user-benefit bullets), up to two quieter `improvements`, Android-only
`playNotes`, a `toastDigest` with counts and 0–2 short previews for noteworthy
items beyond the hero, and the existing technical `sections` used by older clients.
Do not derive the highlight mechanically from `CHANGELOG.md`; choosing the
release's main reason to care is an editorial release-prep decision.
- `app/src/main/assets/whats_new.txt` — legacy in-app fallback generated from
the newest structured entry. Do not edit it independently.
- `app/src/googlePlay/play/release-notes/en-US/default.txt` — the Play
Console **"What's new"** text, which gradle-play-publisher reads at
upload to fill the Production-draft release notes. This is **separate**
@@ -541,19 +547,60 @@ the new app version and a higher `appVersionCode`.
this file is missing or stale, the Play draft ships with empty/wrong
notes (shipped empty in v1.1.0 until caught post-release). Keep it
**≤500 chars per language**, user-facing, Android-only.
- `docs/play-store-listing.md` — Play Store listing copy. Update
the version reference and the "Release Notes" section that gets
pasted into the Play Console "What's new" field. Keep the Play
"What's new" within **500 characters** and framed around the
release's themes, not a feature dump. Compare its **Foreground service
- `docs/play-store-listing.md` — Play Store listing copy. Its release-note
block and the Gradle Play Publisher note are generated from `playNotes`.
After editing the newest structured entry, run
`python scripts/check-android-release-notes.py --write`, then run it again
without `--write` to validate the 1–3 / 0–2 editorial limits, current Android
version, GitHub-release/changelog headings, derived files, and Play's
**500-character** limit. Frame Play copy around the release's themes, not a
feature dump. Compare its **Foreground service
permissions** section with the merged `googlePlayRelease` manifest and
complete Play Console declarations for every declared service type before
approval; the Publisher API can upload a draft and still reject promotion
when an App content declaration is missing.
#### Generate release copy from the verified changes
When release copy is generated with an agent, this section is the canonical
authoring contract; do not maintain a separate prompt file.
1. Read the exact Android version/SHA, the Android-only entries selected from
`[Unreleased]`, the implemented behavior, and any compatibility or security
boundary that users must understand. Do not generate from commit titles or
a mixed-surface changelog block alone.
2. Before editing release files, show a temporary coverage ledger in the task
output. Map every selected source change to exactly one placement:
`hero`, `secondary`, or `full-only`. Include the change kind (`feature`,
`change`, or `fix`) and a short reason. The ledger is review evidence, not a
committed public artifact; no selected source item may disappear silently.
3. Choose exactly one `hero`: the strongest user-facing reason to care about
the release. Its summary is one plain-language outcome, and its 1–3 bullets
are distinct user benefits rather than implementation steps or filler.
4. Use `secondary` for other important user-visible features and fixes. The
`toastDigest` counts only these items, excluding the hero. Preview the
strongest 1–2 secondary items in short phrases. If there are no legitimate
secondary items, set both counts to `0` and `preview` to `[]`; the app hides
the footer. Never invent an item to satisfy the layout.
5. Use `full-only` for technically relevant details that belong in
`RELEASE_NOTES.md` or `CHANGELOG.md` but would make the collapsed update card
noisy. Preserve user-relevant trust and compatibility limits; omit branches,
worktrees, CI mechanics, debugging history, and private/operator context.
6. Write each surface for its audience:
- `RELEASE_NOTES.md`: concise Summary plus Added/Changed/Fixed; keep the
deterministic Download and Install/Verify scaffolding intact.
- `CHANGELOG.md`: complete, crisp public history for the released surface.
- `changelog.json`: curated hero, optional improvements, digest, Play copy,
and compatibility `sections` for older clients.
- `playNotes`: Android-only themes within the rendered 500-character limit.
7. Before presenting the draft, check that wording begins with user outcomes,
avoids unexplained implementation terminology, uses exact public product
names, makes no unverified device claim, and passes the public-distribution
scrub below.
#### Scrub for public distribution
This is a **public repo** and these four files are user-facing. Before
This is a **public repo** and these release-note files are user-facing. Before
promoting the `[Unreleased]` block and writing the notes, scrub the
versioned CHANGELOG block and all three release-notes artifacts for
wording that shouldn't ship publicly. The CHANGELOG accumulates in a
@@ -639,7 +686,9 @@ git checkout dev
git pull --ff-only origin dev
git add gradle/libs.versions.toml RELEASE_NOTES.md CHANGELOG.md \
app/src/main/assets/whats_new.txt docs/play-store-listing.md
app/src/main/assets/changelog.json app/src/main/assets/whats_new.txt \
app/src/googlePlay/play/release-notes/en-US/default.txt \
docs/play-store-listing.md
git commit -m "release(android): android-v0.6.2"
git push origin dev
+8 -3
View File
@@ -26,6 +26,9 @@ model device-certified:
renders as Working.
- Run a background process that outlives its parent turn and verify Background
work remains separate from the conversation's Idle state.
- On a physical phone, open and repeatedly foreground Android while the same
session is working in official Desktop/TUI; verify Android sends no live
attach/interrupt RPC, the producer completes, and final history appears.
- Pursue an upstream `session.active_list` profile field/filter or an aggregate
activity route with explicit profile ownership so multi-profile clients do
not need to resolve process-wide rows from durable keys.
@@ -756,8 +759,10 @@ cancels). Ranked next increments, in value-per-complexity order:
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:
This historical batch remains owner-controlled. The bounded new-issue triage
lane may post one clearly identified first response and basic type/area labels,
but it does not execute backlog actions. Every comment, close, relabel, and
milestone below remains 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
@@ -1452,5 +1457,5 @@ Follow-ups:
- **On-device import/delete smoke.** Import `/sdcard/Download/lucy.zip` via Add a pet → confirm Lucy appears, selects, and animates all states; then remove it and confirm the avatar falls back to the Sphere.
- **Pet behavior model — richer state association (spec'd 2026-06-19, `docs/pet-spec.md` "Agent states &amp; pet behavior").** Shipped: the honesty clamp (declared reactivity ∩ `PET_RENDERER_CAPABILITIES`), the friendly `writing` alias, the `**working`/tool-use overlay** (pet-local sub-state from `toolCallBurst`; opt-in `working` clip drives both the swap and the Tools badge), the **one-shot reaction layer** (`greet`/`wake` on appear, `done`/`celebrate` on turn-finish — opt-in, play-once-then-revert, transition-derived; `ONE_SHOT_MAX_MS` backstop), and `**intensity` modulation** (opt-in `reactive.intensity` → live playback speedup ≤1.6× via `rememberUpdatedState`; un-clamps the Activity badge). Voice · Tools · Activity reactivity is now complete. Remaining:
- `**attention` one-shot (only deferred behavior).** A reaction on notification arrival — needs a host event the avatar doesn't yet receive (unlike `greet`/`done`, which ride state transitions). Would plumb a notification edge into `AvatarRenderState` (or a side channel) + a `PetOneShot.Attention`. Low priority: the avatar is rarely on-screen when notifications land (backgrounded) — see the value analysis; revisit only if the avatar becomes an always-on surface (persistent overlay / Quest port).
- **On-device verification (working + one-shots + intensity).** Best seen in clean mode (`AgentTextFlow` feeds `toolCallBurst` + `streamingIntensity` + state transitions). Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Confirm each decoded clip swap holds the previous complete visual until the new state is ready.
- **On-device verification (working + one-shots + intensity).** Use the normal Chat background visualization, which receives `toolCallBurst`, `streamingIntensity`, and state transitions. Confirm: a `working` clip swaps in during a tool run and releases ~600ms after (`WORKING_BURST_THRESHOLD` 0.5); a `done` clip plays once on reply completion then returns to idle; a `greet` clip plays once when the avatar appears; with `intensity:true`, a writing/working loop visibly quickens while streaming. Confirm each decoded clip swap holds the previous complete visual until the new state is ready.
- **Undecodable-but-present image appears valid (audit 2026-06-19).** A file that exists but isn't a decodable image passes the loader's `isFile` check, so the pet shows in the picker but renders blank. Documented as a caveat; consider a cheap header sniff at load time if false-valid pets become a support issue.
@@ -239,6 +239,60 @@ class GatewayForegroundRecoveryInstrumentedTest {
assertEquals(0, fixture.requestsTo("/v1/chat/completions"))
}
@Test
fun desktopOwnedTurn_remainsReadOnlyAcrossAndroidForegroundLifecycle() {
viewModel.setChatVisible(false)
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
val controlMethods = setOf(
"session.resume",
"session.activate",
"session.interrupt",
"prompt.submit",
)
val baseline = controlMethods.associateWith(fixture::rpcCount)
val baselineActiveList = fixture.rpcCount("session.active_list")
fixture.activeSessionStatus = "working"
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val okHttp = OkHttpClient()
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
okHttpClient = okHttp,
),
okHttpClient = okHttp,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
viewModel.setChatTurnCheckpointStore(null)
viewModel.updateGatewayClient(gatewayClient)
viewModel.setChatVisible(true)
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
viewModel.setChatVisible(false)
viewModel.setChatVisible(true)
fixture.awaitRpcCount("session.active_list", baselineActiveList + 1)
controlMethods.forEach { method ->
assertEquals(
"passive lifecycle sent $method",
baseline.getValue(method),
fixture.rpcCount(method),
)
}
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
assertEquals(
"observer teardown interrupted the Desktop turn",
baseline.getValue("session.interrupt"),
fixture.rpcCount("session.interrupt"),
)
}
private companion object {
const val STORED_SESSION_ID = "20260821_120000_fixture"
const val LIVE_SESSION_ID = "fixture-live-1"
@@ -263,6 +317,9 @@ internal class AndroidGatewayContractFixture {
@Volatile
var recoveryRunning = false
@Volatile
var activeSessionStatus: String? = null
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
sockets.add(webSocket)
@@ -282,6 +339,18 @@ internal class AndroidGatewayContractFixture {
"session.activate" -> sessionSnapshot(
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "fixture-live-1",
)
"session.active_list" -> buildJsonObject {
put("sessions", kotlinx.serialization.json.buildJsonArray {
activeSessionStatus?.let { status ->
add(buildJsonObject {
put("id", LIVE_SESSION_ID)
put("session_key", STORED_SESSION_ID)
put("status", status)
put("last_active", 1.0)
})
}
})
}
"prompt.submit", "session.interrupt" -> buildJsonObject { put("ok", true) }
else -> JsonObject(emptyMap())
}
@@ -345,6 +414,15 @@ internal class AndroidGatewayContractFixture {
error("Gateway RPC $method not observed; saw ${rpcLog.map { it.first }}")
}
fun awaitRpcCount(method: String, count: Int) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (System.nanoTime() < deadline) {
if (rpcCount(method) >= count) return
Thread.sleep(20)
}
error("Gateway RPC $method count $count not observed; saw ${rpcLog.map { it.first }}")
}
fun requestsTo(path: String): Int = requestPaths.count { it.startsWith(path) }
fun rpcCount(method: String): Int = rpcLog.count { it.first == method }
@@ -353,4 +431,9 @@ internal class AndroidGatewayContractFixture {
allSockets.forEach { socket -> runCatching { socket.close(1001, "teardown") } }
runCatching { server.shutdown() }
}
private companion object {
const val STORED_SESSION_ID = "20260821_120000_fixture"
const val LIVE_SESSION_ID = "fixture-live-1"
}
}
@@ -1 +1,3 @@
Supervised Mode adds a parent-configured, profile-pinned chat view with device-authenticated settings. Parents can limit attachments, Standard voice, generated media, history, actions, and technical details. Session rows stay neutral while live activity is unavailable, and returning from parent settings no longer blanks Supervised Chat.
v1.13.2 - Supervised Mode and clearer activity
Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.
+22
View File
@@ -1,9 +1,31 @@
{
"schema": 2,
"versions": [
{
"version": "1.13.2",
"title": "Supervised Mode and clearer activity",
"date": "2026-08-25",
"highlight": {
"title": "Supervised Mode",
"summary": "Create a simpler, profile-focused chat and choose which features remain available.",
"bullets": [
"Protect parent settings with your phone's device authentication.",
"Choose access to attachments, Standard voice, generated media, history, actions, and technical details.",
"Keep Supervised Chat open when returning from parent settings."
]
},
"improvements": [
"Activity indicators now appear only while Hermes is genuinely working."
],
"toastDigest": {
"additionalFeatureCount": 0,
"fixCount": 2,
"preview": [
"Accurate activity",
"safer return"
]
},
"playNotes": "Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.",
"sections": [
{
"header": "Use a supervised chat",
+7 -3
View File
@@ -1,5 +1,9 @@
v1.13.2 - Supervised Mode and clearer activity
* Configure a profile-pinned Supervised Mode with device-authenticated parent settings.
* Keep Supervised Chat visible when parent access relocks after full settings.
* Keep uncertain session activity neutral until live work is confirmed.
Supervised Mode
* Protect parent settings with your phone's device authentication.
* Choose access to attachments, Standard voice, generated media, history, actions, and technical details.
* Keep Supervised Chat open when returning from parent settings.
Also improved
* Activity indicators now appear only while Hermes is genuinely working.
@@ -906,6 +906,30 @@ class GatewayChatClient(
scope.launch { prewarmAwait(storedSessionId) }
}
/**
* Establish only the shared Gateway socket for read-only observation.
*
* `session.resume` and `session.activate` attach a live runtime to this
* transport. Opening Chat, foreground restoration, and selecting a saved
* transcript must not claim a turn that another Desktop/TUI client owns,
* so those paths use this socket-only warmup and observe through REST
* history plus `session.active_list` instead.
*/
fun observe(onReady: (() -> Unit)? = null) {
scope.launch {
if (observeAwait() && onReady != null) callbackDispatcher(onReady)
}
}
/** Suspending [observe]; returns true once the read-only socket is ready. */
suspend fun observeAwait(): Boolean = try {
connectMutex.withLock { ensureConnected() }
true
} catch (e: Exception) {
Log.d(TAG, "Gateway observation warmup skipped: ${e.message}")
false
}
/**
* Suspending [prewarm]: establishes the socket and (when [storedSessionId]
* is non-null) resumes the existing session, returning only once that work
@@ -39,6 +39,7 @@ import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -67,6 +68,8 @@ import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraph.Companion.findStartDestination
@@ -126,6 +129,7 @@ import com.hermesandroid.relay.ui.components.HostResourcePressureBanner
import com.hermesandroid.relay.ui.components.rememberUpdateAvailability
import com.hermesandroid.relay.ui.components.resolveChatTransportStatus
import com.hermesandroid.relay.ui.components.WhatsNewDialog
import com.hermesandroid.relay.ui.components.WhatsNewToast
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.BridgePreferencesRepository
import com.hermesandroid.relay.data.BridgeSafetyPreferencesRepository
@@ -164,6 +168,7 @@ import com.hermesandroid.relay.ui.screens.BotModeScreen
// === END PHASE3-safety-rails ===
import com.hermesandroid.relay.ui.screens.ChatScreen
import com.hermesandroid.relay.ui.screens.ChatSettingsScreen
import com.hermesandroid.relay.ui.screens.ChangelogScreen
import com.hermesandroid.relay.ui.screens.DashboardManagementScreen
import com.hermesandroid.relay.ui.screens.DashboardSignInScreen
import com.hermesandroid.relay.ui.screens.DeveloperSettingsScreen
@@ -935,11 +940,39 @@ fun RelayApp() {
buildChatGitWorkspaceSummary(repo, gitDetailState)
}
// What's New auto-show
// Post-update What's New starts as a non-blocking toast and expands only
// when requested; manual Settings/About entry points retain the full view.
val showWhatsNew by connectionViewModel.showWhatsNew.collectAsState()
var showWhatsNewExpanded by rememberSaveable { mutableStateOf(false) }
var showWhatsNewHistory by rememberSaveable { mutableStateOf(false) }
if (showWhatsNew) {
WhatsNewDialog(onDismiss = { connectionViewModel.dismissWhatsNew() })
if (showWhatsNew && !showWhatsNewExpanded) {
WhatsNewToast(
onDismiss = { connectionViewModel.dismissWhatsNew() },
onExpand = {
connectionViewModel.dismissWhatsNew()
showWhatsNewExpanded = true
},
)
}
if (showWhatsNewExpanded) {
WhatsNewDialog(
onDismiss = { showWhatsNewExpanded = false },
onViewHistory = {
showWhatsNewExpanded = false
showWhatsNewHistory = true
},
)
}
if (showWhatsNewHistory) {
Dialog(
onDismissRequest = { showWhatsNewHistory = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(modifier = Modifier.fillMaxSize()) {
ChangelogScreen(onClose = { showWhatsNewHistory = false })
}
}
}
// Mark version as seen on first launch (when there's no previous version)
@@ -0,0 +1,53 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import android.provider.Settings
import android.view.accessibility.AccessibilityManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
/** Shared OS motion/accessibility posture for animated UI affordances. */
internal data class AccessibleMotionState(
/** OS animator scale is non-zero (i.e. system animations are on). */
val osAnimations: Boolean,
/** TalkBack-style touch exploration is active. */
val touchExploration: Boolean,
)
@Composable
internal fun rememberAccessibleMotionState(): AccessibleMotionState {
val context = LocalContext.current
val osAnimations = remember {
runCatching {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
) != 0f
}.getOrDefault(true)
}
val accessibilityManager = remember {
context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
}
var touchExploration by remember {
mutableStateOf(accessibilityManager?.isTouchExplorationEnabled == true)
}
DisposableEffect(accessibilityManager) {
val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled ->
touchExploration = enabled
}
accessibilityManager?.addTouchExplorationStateChangeListener(listener)
onDispose {
accessibilityManager?.removeTouchExplorationStateChangeListener(listener)
}
}
return AccessibleMotionState(
osAnimations = osAnimations,
touchExploration = touchExploration,
)
}
@@ -1,665 +0,0 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import android.provider.Settings
import android.view.accessibility.AccessibilityManager
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalAgentAvatar
import com.hermesandroid.relay.ui.components.avatar.LocalBackgroundVisualizationEnabled
import kotlinx.coroutines.delay
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.RelayRefresh
// --- Text-flow tuning constants -------------------------------------------
//
// All time-based numbers stay inside the ranges WP-C1 prescribes so the
// "clean text flowing in and fading out" reads calm rather than frantic.
/** Soft word-wrap width for a flow line — keeps each buffer entry to ~one
* visual line so the bounded buffer maps cleanly to "≤6 lines". */
private const val FLOW_MAX_CHARS = 42
/** Soft-wrap target only — the visible buffer is now bounded by the
* scrollable viewport height + scroll, not a hard line count. */
private const val FLOW_MAX_LINES = 6
/** Memory ceiling for the persistent line buffer. Lines past this (already
* scrolled well above the faded top edge) are dropped silently so a very long
* turn can't grow the list without bound. */
private const val FLOW_BUFFER_MAX = 80
/** How long a settled line lingers after it stops growing, before it begins
* fading. Inside the 2.5–4s band from the spec. */
private const val FLOW_DWELL_MS = 3_000L
private const val FLOW_FADE_IN_MS = 180
private const val FLOW_FADE_OUT_MS = 600
/** Buffer maintenance cadence. Cheap list bookkeeping only — it mutates
* observed state (and so triggers recomposition) only when something
* actually changes, so an idle clean mode does not churn the UI. */
private const val FLOW_TICK_MS = 80L
/**
* One ephemeral line in the text flow.
*
* [text] and [visibility] are snapshot-observed so a growing tail or a
* fade-out re-renders just that line. [settledAt]/[hiddenAt] are plain
* bookkeeping read only by the maintenance loop, so they intentionally do
* NOT trigger recomposition.
*
* [visibility] starts `currentState = false, targetState = true`; handing
* that to `AnimatedVisibility(visibleState = …)` plays the enter transition
* the first time the line is composed — the idiomatic "animate on appear".
*/
private class FlowLine(val key: Int, initialText: String) {
var text by mutableStateOf(initialText)
val visibility = MutableTransitionState(false).apply { targetState = true }
/** Wall-clock millis at which the line stopped growing (null while it is
* still the active streaming tail). Starts the dwell countdown. */
var settledAt: Long? = null
/** Wall-clock millis at which the fade-out was requested. */
var hiddenAt: Long? = null
}
/**
* Split [text] into short, append-only flow segments.
*
* Explicit newlines hard-break; long paragraphs greedily soft-wrap at word
* boundaries to [maxChars]. Because the source content only ever grows
* (streaming appends), every segment except the last is final the moment the
* next word/line exists — which is exactly what lets the caller treat the
* last segment as the "growing tail" and everything before it as settled,
* and key each line by its stable index.
*/
private fun segmentFlowLines(text: String, maxChars: Int): List<String> {
if (text.isBlank()) return emptyList()
val out = ArrayList<String>()
for (rawLine in text.split('\n')) {
val line = rawLine.trim()
if (line.isEmpty()) continue
val current = StringBuilder()
for (word in line.split(' ')) {
if (word.isEmpty()) continue
val candidate = if (current.isEmpty()) word.length else current.length + 1 + word.length
if (candidate > maxChars && current.isNotEmpty()) {
out.add(current.toString())
current.setLength(0)
current.append(word)
} else {
if (current.isNotEmpty()) current.append(' ')
current.append(word)
}
}
if (current.isNotEmpty()) out.add(current.toString())
}
return out
}
/**
* Soft fade on the TOP edge so lines that scroll up dissolve cleanly into the
* background instead of hard-clipping — the "slides up and clears" look — while
* the avatar above stays unobstructed. Renders the content into an offscreen
* layer and masks the top [fade] dp with a transparent->opaque gradient.
*/
private fun Modifier.topFadeEdge(fade: Dp = 28.dp): Modifier = this
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
.drawWithContent {
drawContent()
val fadePx = fade.toPx().coerceAtMost(size.height)
if (fadePx <= 0f) return@drawWithContent
drawRect(
brush = Brush.verticalGradient(
0f to Color.Transparent,
(fadePx / size.height) to Color.Black,
),
blendMode = BlendMode.DstIn,
)
}
/** Shared OS motion/accessibility posture for animated chat affordances. */
internal data class AccessibleMotionState(
/** OS animator scale is non-zero (i.e. system animations are ON). */
val osAnimations: Boolean,
/** TalkBack-style touch exploration is active — faded text is unreadable
* to it, so the text path must fall back to a static, announced mirror. */
val touchExploration: Boolean,
)
@Composable
internal fun rememberAccessibleMotionState(): AccessibleMotionState {
val context = LocalContext.current
// ANIMATOR_DURATION_SCALE == 0 is the platform "remove animations" / many
// OEM "reduce motion" toggles. Read once on entry; a mid-mode toggle is
// rare and recovered by leaving + re-entering the mode.
val osAnimations = remember {
runCatching {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
) != 0f
}.getOrDefault(true)
}
val a11y = remember {
context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
}
var touchExploration by remember {
mutableStateOf(a11y?.isTouchExplorationEnabled == true)
}
DisposableEffect(a11y) {
val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled ->
touchExploration = enabled
}
a11y?.addTouchExplorationStateChangeListener(listener)
onDispose { a11y?.removeTouchExplorationStateChangeListener(listener) }
}
return AccessibleMotionState(
osAnimations = osAnimations,
touchExploration = touchExploration,
)
}
/**
* Ephemeral, themed text flow bound to the agent's streaming reply.
*
* New segments materialize with `fadeIn + slideInVertically`; a settled line
* dwells ~[FLOW_DWELL_MS], then `fadeOut`s and is **removed from the buffer**
* (it leaves the composition tree, so it stops composing — not merely
* alpha-0). The still-growing tail never fades; its dwell starts only once
* [streaming] flips false. The buffer is hard-capped at [FLOW_MAX_LINES].
*
* Accessibility: when [motionEnabled] is false (animations disabled, OS
* reduce-motion, or TalkBack touch exploration) the flow renders the recent
* lines **statically** inside a polite live region — never gating the
* conversation on animation. Even on the animated path a visually-hidden
* polite mirror carries the readable words, since faded glyphs are
* unreadable to assistive tech.
*
* @param content the last assistant message's (streaming) content.
* @param streaming whether that message is still growing this turn.
* @param messageId stable id of the bound message; a new id resets the buffer.
*/
@Composable
fun AgentTextFlow(
content: String,
streaming: Boolean,
messageId: String?,
motionEnabled: Boolean,
modifier: Modifier = Modifier,
) {
val flowStyle = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
lineHeight = 21.sp,
)
val flowColor = MaterialTheme.colorScheme.onSurface
// Readable, non-faded mirror of the visible tail — used as the live-region
// text on both paths so assistive tech hears the words.
val mirrorText = remember(content) {
segmentFlowLines(content, FLOW_MAX_CHARS).takeLast(FLOW_MAX_LINES).joinToString(" ")
}
// --- Static / reduced-motion path -------------------------------------
if (!motionEnabled) {
val staticLines = remember(content) {
segmentFlowLines(content, FLOW_MAX_CHARS).takeLast(FLOW_BUFFER_MAX)
}
val staticScroll = rememberScrollState()
// Pin the latest line to the bottom of the bounded viewport.
LaunchedEffect(staticLines.size) { staticScroll.scrollTo(staticScroll.maxValue) }
// No contentDescription — the merged child Text content IS the readable
// content; liveRegion announces it on change. Lines persist + scroll
// (bounded + top-faded like the animated path) — they never vanish.
Column(
modifier = modifier
.semantics { liveRegion = LiveRegionMode.Polite }
// Fade the top edge ONLY when there's content scrolled above it —
// a message that fits shows its first line crisply (no cut-off look).
.topFadeEdge(fade = if (staticScroll.canScrollBackward) 28.dp else 0.dp)
.verticalScroll(staticScroll),
verticalArrangement = Arrangement.Bottom,
) {
staticLines.forEach { line ->
Text(
text = line,
style = flowStyle,
color = flowColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(),
)
}
}
return
}
// --- Animated path ----------------------------------------------------
val flowLines = remember(messageId) { mutableStateListOf<FlowLine>() }
val currentContent by rememberUpdatedState(content)
LaunchedEffect(messageId) {
flowLines.clear()
// Largest segment index ever materialized — guards against re-adding a
// line that was dropped from the front by the memory cap.
var maxKeyAdded = -1
var lastText: String? = null
while (true) {
val text = currentContent
// Re-diff only when the transcript changed, so an idle clean mode
// (no streaming, no new turn) doesn't churn. We never permanently
// exit: a new turn appended to the transcript must still slide in.
if (text != lastText) {
lastText = text
val segs = segmentFlowLines(text, FLOW_MAX_CHARS)
// Add new lines (they slide in); update a changed tail in place.
// Lines PERSIST — older ones simply scroll up within the bounded,
// scrollable viewport and dissolve at the top fade edge.
segs.forEachIndexed { i, s ->
val existing = flowLines.firstOrNull { it.key == i }
if (existing == null) {
if (i > maxKeyAdded) {
flowLines.add(FlowLine(key = i, initialText = s))
maxKeyAdded = i
}
} else if (existing.text != s) {
existing.text = s
}
}
// Memory guard: drop the oldest lines once well past the viewport.
while (flowLines.size > FLOW_BUFFER_MAX) flowLines.removeAt(0)
}
delay(FLOW_TICK_MS)
}
}
val scrollState = rememberScrollState()
// Pin the latest line to the bottom as content streams in / lines slide up.
LaunchedEffect(flowLines.size, flowLines.lastOrNull()?.text) {
scrollState.scrollTo(scrollState.maxValue)
}
Box(modifier = modifier) {
// Visually-hidden, readable, politely-announced mirror. Present even
// with motion on, so non-touch assistive tech still receives the words
// the faded glyphs can't convey. The Text's own content is its
// semantics text, so liveRegion alone announces it on change.
Text(
text = mirrorText,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 1.dp)
.alpha(0f)
.semantics { liveRegion = LiveRegionMode.Polite },
style = flowStyle,
)
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
// Fade the top edge ONLY when content is scrolled above it, so a
// reply that fits the viewport shows its first line crisply.
.topFadeEdge(fade = if (scrollState.canScrollBackward) 28.dp else 0.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.Bottom,
) {
flowLines.forEach { line ->
androidx.compose.runtime.key(line.key) {
AnimatedVisibility(
visibleState = line.visibility,
enter = fadeIn(tween(FLOW_FADE_IN_MS)) +
slideInVertically(tween(FLOW_FADE_IN_MS)) { it / 6 },
exit = fadeOut(tween(FLOW_FADE_OUT_MS)),
) {
Text(
text = line.text,
style = flowStyle,
color = flowColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
// The visible glyphs fade; the mirror above owns
// accessibility, so keep AT off these duplicates.
modifier = Modifier
.fillMaxWidth()
.clearAndSetSemantics {},
)
}
}
}
}
}
}
/**
* Thin single-line composer for clean mode.
*
* Deliberately stripped: no model/effort pills, no attachments, no slash
* palette — just a pill field plus a send affordance, calling [onSend] with
* the same [com.hermesandroid.relay.viewmodel.ChatViewModel.sendMessage]
* contract the full composer uses. Internal text state is UI-local.
*/
@Composable
private fun CleanModeComposer(
enabled: Boolean,
onSend: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var text by remember { mutableStateOf("") }
val canSend = enabled && text.isNotBlank()
val submit = {
val trimmed = text.trim()
if (enabled && trimmed.isNotEmpty()) {
onSend(trimmed)
text = ""
}
}
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(start = 18.dp, end = 6.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
BasicTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier
.weight(1f)
.heightIn(min = 40.dp)
.padding(vertical = 8.dp),
enabled = enabled,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { submit() }),
decorationBox = { inner ->
Box(contentAlignment = Alignment.CenterStart) {
if (text.isEmpty()) {
Text(
text = stringResource(R.string.agent_text_placeholder),
style = MaterialTheme.typography.bodyLarge,
color = RelayRefresh.Dim,
)
}
inner()
}
},
)
IconButton(
onClick = submit,
enabled = canSend,
modifier = Modifier.size(44.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.agent_text_send_cd),
tint = if (canSend) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
},
)
}
}
}
}
/**
* Clean text-flow chat mode — a full-screen, minimalist third presentation of
* the agent surface (alongside normal chat and the voice overlay).
*
* Centered morphing sphere, a calm themed text flow ([AgentTextFlow]) instead
* of a persistent transcript, and a thin composer. Mirrors the voice overlay's
* centered-sphere + bottom-content skeleton (`VoiceModeOverlay.kt:262-280`).
*
* Exit is an **explicit control** (top-corner dismiss + system back) — never
* any-tap, because the in-mode composer needs taps. All mode state lives in
* the caller as plain UI-local state; this is a presentation over the same
* conversation, not new ViewModel state.
*
* Honors the ambient-visualization preference independently from motion.
* When visible, [animationEnabled] and OS reduce-motion pause the sphere on a
* static frame; TalkBack keeps the text readable and announced.
*/
@Composable
fun CleanChatMode(
messages: List<ChatMessage>,
isStreaming: Boolean,
sphereState: SphereState,
streamingIntensity: Float,
toolCallBurst: Float,
animationEnabled: Boolean,
enabled: Boolean,
onSend: (String) -> Unit,
onExit: () -> Unit,
modifier: Modifier = Modifier,
) {
val motion = rememberAccessibleMotionState()
val backgroundVisualizationEnabled = LocalBackgroundVisualizationEnabled.current
val sphereAnimated = animationEnabled && motion.osAnimations
// Faded text is unreadable to touch exploration, so the text path goes
// static (readable + announced) whenever TalkBack is exploring.
val textMotionEnabled = sphereAnimated && !motion.touchExploration
val lastAssistant = remember(messages) {
messages.lastOrNull { it.role == MessageRole.ASSISTANT }
}
// Clean mode shows the recent CONVERSATION (not just the last reply) as one
// faded, scrollable flow, so scrolling up brings history into view. The flow
// is append-only across turns; user turns get a subtle "›" so the
// back-and-forth stays legible. How far back it retains is bounded by the
// flow's line buffer (FLOW_BUFFER_MAX).
val flowContent = remember(messages) {
messages
.filter { it.role == MessageRole.USER || it.role == MessageRole.ASSISTANT }
.joinToString("\n\n") { msg ->
val body = msg.content.trim()
if (msg.role == MessageRole.USER) "› $body" else body
}
}
// Stable per-conversation key so the flow buffer accumulates across turns and
// resets only on a new conversation (the oldest message's id changes).
val conversationKey = messages.firstOrNull()?.id
val flowStreaming = lastAssistant?.isStreaming == true && isStreaming
// The sphere + text are a vertically-centered group (equal spacers above and
// below). The sphere is a fixed size so the group grows via the TEXT: a short
// reply sits centered, and as the reply lengthens the centered group gets
// taller — sliding the sphere up toward the top third while the text fills
// down toward the composer.
val sphereHeight = (LocalConfiguration.current.screenHeightDp * 0.34f).dp
val maxFlowHeight = (LocalConfiguration.current.screenHeightDp * 0.5f).dp
BackHandler(enabled = true) { onExit() }
val sphereDescLabel = stringResource(R.string.agent_text_sphere_desc)
val sphereDescription = remember(sphereState) {
"$sphereDescLabel ${sphereState.name.lowercase()}"
}
Box(
modifier = modifier
.fillMaxSize()
// Opaque so the chat underneath is fully hidden — this is a mode,
// not a translucent overlay.
.background(RelayRefresh.Background)
// Consume any pointer event the children (composer, exit button, text
// scroll) didn't handle, so stray taps/swipes in the empty areas don't
// fall through to the chat + session drawer behind this mode. Children
// run leaf-first on the same Main pass, so this only catches the gaps
// (mirrors the voice overlay's focus-mode scrim).
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent().changes.forEach { it.consume() }
}
}
},
) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.imePadding()
.padding(horizontal = 20.dp),
) {
// Explicit dismiss — the only way out besides system back.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = onExit) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.agent_text_exit_clean),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Flexible top spacer — with the bottom one it vertically centers the
// sphere + text group; as the text grows the spacers yield and the
// sphere rises toward the top third.
Spacer(modifier = Modifier.weight(1f))
// The ambient sphere is optional. Hiding it collapses the visual's
// slot so the conversation remains centered instead of leaving a
// large blank region above the text.
if (backgroundVisualizationEnabled) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(sphereHeight),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxSize()
.semantics { contentDescription = sphereDescription },
) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
state = sphereState,
intensity = streamingIntensity,
toolCallBurst = toolCallBurst,
// Pin to a still frame when motion is suppressed.
paused = !sphereAnimated,
),
modifier = Modifier.fillMaxSize(),
)
}
}
}
AgentTextFlow(
content = flowContent,
streaming = flowStreaming,
messageId = conversationKey,
motionEnabled = textMotionEnabled,
// Content-sized reading area (capped ~half the screen) directly
// below the sphere — no gap between them. Grows + scrolls with the
// reply, which is what lifts the centered group (and the sphere).
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 560.dp)
.heightIn(min = 96.dp, max = maxFlowHeight)
.padding(bottom = 12.dp),
)
// Flexible bottom spacer — balances the top one to keep the
// sphere + text group vertically centered.
Spacer(modifier = Modifier.weight(1f))
CleanModeComposer(
enabled = enabled,
onSend = onSend,
modifier = Modifier.padding(bottom = 12.dp),
)
}
}
}
@@ -1,7 +1,14 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
@@ -20,13 +27,20 @@ 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.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.rememberCoroutineScope
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.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -37,10 +51,12 @@ import androidx.compose.ui.unit.dp
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* "What's New" sheet, shown automatically on a version bump (RelayApp) and from
* the About screen.
* Expanded "What's New" sheet, opened from the non-blocking post-update toast
* or directly from the About screen.
*
* As of the multi-version changelog work, the single source of truth is the
* bundled [changelog.json] asset (see [ChangelogStore]). This dialog renders the
@@ -60,6 +76,9 @@ fun WhatsNewDialog(
onViewHistory: (() -> Unit)? = null,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var visible by remember { mutableStateOf(false) }
var exiting by remember { mutableStateOf(false) }
// Prefer the structured changelog's latest entry; fall back to the legacy
// text asset so a missing/garbled JSON never leaves the dialog empty.
val latest = remember { ChangelogStore.load(context).versions.firstOrNull() }
@@ -67,20 +86,39 @@ fun WhatsNewDialog(
latest?.toNotes() ?: parseWhatsNew(loadWhatsNew(context))
}
fun leave(afterExit: () -> Unit) {
if (exiting) return
exiting = true
scope.launch {
visible = false
delay(180)
afterExit()
}
}
LaunchedEffect(Unit) {
if (!exiting) visible = true
}
Dialog(
onDismissRequest = onDismiss,
onDismissRequest = { leave(onDismiss) },
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 32.dp)
.widthIn(max = 560.dp)
.heightIn(max = 680.dp),
shape = appearanceRoundedCornerShape(24.dp),
tonalElevation = 6.dp,
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(220)) + scaleIn(tween(220), initialScale = 0.92f),
exit = fadeOut(tween(180)) + scaleOut(tween(180), targetScale = 0.96f),
) {
Column(modifier = Modifier.fillMaxWidth()) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 32.dp)
.widthIn(max = 560.dp)
.heightIn(max = 680.dp),
shape = appearanceRoundedCornerShape(24.dp),
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -99,7 +137,7 @@ fun WhatsNewDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onDismiss) {
IconButton(onClick = { leave(onDismiss) }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.changelog_close),
@@ -146,20 +184,48 @@ fun WhatsNewDialog(
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
onViewHistory?.let { action ->
androidx.compose.material3.OutlinedButton(
onClick = action,
modifier = Modifier.weight(1f),
) { Text(stringResource(R.string.whats_new_full_history)) }
WhatsNewActions(
onDismiss = { leave(onDismiss) },
onViewHistory = onViewHistory?.let { action -> { leave(action) } },
)
}
}
}
}
}
@Composable
private fun WhatsNewActions(
onDismiss: () -> Unit,
onViewHistory: (() -> Unit)?,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
) {
val stackActions = LocalDensity.current.fontScale >= 1.3f || maxWidth < 320.dp
if (stackActions) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
onViewHistory?.let { action ->
OutlinedButton(onClick = action, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.whats_new_full_history))
}
Button(onClick = onDismiss, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.whats_new_got_it))
}
Button(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.whats_new_got_it))
}
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
onViewHistory?.let { action ->
OutlinedButton(onClick = action, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.whats_new_full_history))
}
}
Button(onClick = onDismiss, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.whats_new_got_it))
}
}
}
}
@@ -222,15 +288,55 @@ fun ColumnScope.VersionNotesBody(groups: List<WhatsNewGroup>) {
* followed by [VersionNotesBody]. Used by ChangelogScreen for each release.
*/
@Composable
fun VersionNotesBlock(entry: ChangelogVersion) {
fun VersionNotesBlock(
entry: ChangelogVersion,
showVersionLine: Boolean = true,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text = entry.subtitle(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
VersionNotesBody(entry.toGroups())
val highlight = entry.highlight
if (highlight == null) {
if (showVersionLine) {
Text(
text = entry.subtitle(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
}
VersionNotesBody(entry.toGroups())
} else {
if (showVersionLine) {
Text(
text = entry.versionLine(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = highlight.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
highlight.summary?.takeIf { it.isNotBlank() }?.let { summary ->
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
VersionNotesBody(listOf(WhatsNewGroup(header = null, bullets = highlight.bullets)))
if (entry.improvements.isNotEmpty()) {
VersionNotesBody(
listOf(
WhatsNewGroup(
header = stringResource(R.string.changelog_also_improved),
bullets = entry.improvements,
),
),
)
}
}
}
}
@@ -245,12 +351,32 @@ data class ChangelogSection(
val bullets: List<String> = emptyList(),
)
/** Editorially selected reason to care about a release. */
@Serializable
data class ChangelogHighlight(
val title: String,
val summary: String? = null,
val bullets: List<String> = emptyList(),
)
/** Compact preview of noteworthy items beyond the primary highlight. */
@Serializable
data class ChangelogToastDigest(
val additionalFeatureCount: Int = 0,
val fixCount: Int = 0,
val preview: List<String> = emptyList(),
)
/** A single released version's user-facing notes. */
@Serializable
data class ChangelogVersion(
val version: String,
val title: String? = null,
val date: String? = null,
val highlight: ChangelogHighlight? = null,
val improvements: List<String> = emptyList(),
val toastDigest: ChangelogToastDigest? = null,
val playNotes: String? = null,
val sections: List<ChangelogSection> = emptyList(),
) {
/** "v1.2.0 — Make it yours · 2026-06-20" (each token optional but version). */
@@ -261,8 +387,21 @@ data class ChangelogVersion(
return head + titlePart + datePart
}
/** Compact metadata line used when a curated highlight owns the headline. */
fun versionLine(): String {
val datePart = date?.takeIf { it.isNotBlank() }?.let { " · $it" } ?: ""
return "v$version$datePart"
}
fun toGroups(): List<WhatsNewGroup> =
sections.map { WhatsNewGroup(it.header?.takeIf { h -> h.isNotBlank() }, it.bullets) }
highlight?.let { curated ->
buildList {
add(WhatsNewGroup(curated.title.takeIf { it.isNotBlank() }, curated.bullets))
if (improvements.isNotEmpty()) {
add(WhatsNewGroup("Also improved", improvements))
}
}
} ?: sections.map { WhatsNewGroup(it.header?.takeIf { h -> h.isNotBlank() }, it.bullets) }
/** Adapt this version into the dialog's [WhatsNewNotes] shape. */
fun toNotes(): WhatsNewNotes = WhatsNewNotes(
@@ -0,0 +1,341 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
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.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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalAccessibilityManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
private const val DEFAULT_AUTO_DISMISS_MILLIS = 10_000L
private const val EXIT_ANIMATION_MILLIS = 180L
private const val SWIPE_DISMISS_FRACTION = 0.28f
/**
* Non-blocking post-update notice. It stays outside the dialog window so the
* app remains usable, then hands off to the full [WhatsNewDialog] only when the
* user chooses to expand it.
*/
@Composable
fun WhatsNewToast(
onDismiss: () -> Unit,
onExpand: () -> Unit,
autoDismissMillis: Long = DEFAULT_AUTO_DISMISS_MILLIS,
) {
val context = LocalContext.current
val entry = remember { ChangelogStore.load(context).versions.firstOrNull() } ?: return
val progress = remember { Animatable(1f) }
val horizontalOffset = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
val accessibilityManager = LocalAccessibilityManager.current
var visible by remember { mutableStateOf(false) }
var widthPx by remember { mutableIntStateOf(1) }
fun leave(afterExit: () -> Unit) {
if (!visible) return
scope.launch {
visible = false
delay(EXIT_ANIMATION_MILLIS)
afterExit()
}
}
LaunchedEffect(Unit) {
visible = true
val recommendedTimeout = accessibilityManager?.calculateRecommendedTimeoutMillis(
originalTimeoutMillis = autoDismissMillis,
containsIcons = true,
containsText = true,
containsControls = true,
) ?: autoDismissMillis
progress.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = recommendedTimeout.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(),
easing = LinearEasing,
),
)
leave(onDismiss)
}
Popup(
alignment = Alignment.TopCenter,
properties = PopupProperties(focusable = false),
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(220)) + slideInVertically(tween(220)) { -it / 2 },
exit = fadeOut(tween(EXIT_ANIMATION_MILLIS.toInt())) +
slideOutVertically(tween(EXIT_ANIMATION_MILLIS.toInt())) { -it / 3 },
) {
val swipeProgress = (abs(horizontalOffset.value) / widthPx).coerceIn(0f, 1f)
Box(
modifier = Modifier
.windowInsetsPadding(WindowInsets.statusBars)
.padding(horizontal = 12.dp, vertical = 10.dp)
.widthIn(max = 560.dp)
.fillMaxWidth()
.offset { IntOffset(horizontalOffset.value.roundToInt(), 0) }
.alpha(1f - (swipeProgress * 0.7f))
.onSizeChanged { widthPx = it.width.coerceAtLeast(1) }
.pointerInput(onDismiss) {
var dragTarget = horizontalOffset.value
detectHorizontalDragGestures(
onDragStart = { dragTarget = horizontalOffset.value },
onHorizontalDrag = { change, amount ->
change.consume()
dragTarget += amount
scope.launch { horizontalOffset.snapTo(dragTarget) }
},
onDragEnd = {
if (abs(horizontalOffset.value) >= widthPx * SWIPE_DISMISS_FRACTION) {
scope.launch {
horizontalOffset.animateTo(
targetValue = if (horizontalOffset.value < 0) -widthPx.toFloat() else widthPx.toFloat(),
animationSpec = tween(EXIT_ANIMATION_MILLIS.toInt()),
)
onDismiss()
}
} else {
scope.launch { horizontalOffset.animateTo(0f, tween(160)) }
}
},
)
},
) {
WhatsNewToastContent(
entry = entry,
progress = progress.value,
onExpand = { leave(onExpand) },
onDismiss = { leave(onDismiss) },
)
}
}
}
}
@Composable
internal fun WhatsNewToastContent(
entry: ChangelogVersion,
progress: Float,
onExpand: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = stringResource(R.string.whats_new_title)
val highlight = entry.highlight
Surface(
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = appearanceRoundedCornerShape(18.dp),
tonalElevation = 6.dp,
shadowElevation = 10.dp,
modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 6.dp, top = 10.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
.clickable(
role = Role.Button,
onClickLabel = title,
onClick = onExpand,
)
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "$title · v${entry.version}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = highlight?.title ?: entry.title.orEmpty(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
highlight?.summary?.takeIf(String::isNotBlank)?.let { summary ->
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.changelog_close),
)
}
}
entry.toastDigest?.takeIf {
it.additionalFeatureCount > 0 || it.fixCount > 0
}?.let { digest ->
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.32f),
)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
role = Role.Button,
onClickLabel = stringResource(R.string.changelog_view_all),
onClick = onExpand,
)
.padding(horizontal = 16.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
val counts = buildList {
if (digest.additionalFeatureCount > 0) {
add(
pluralStringResource(
R.plurals.changelog_additional_feature_count,
digest.additionalFeatureCount,
digest.additionalFeatureCount,
),
)
}
if (digest.fixCount > 0) {
add(
pluralStringResource(
R.plurals.changelog_fix_count,
digest.fixCount,
digest.fixCount,
),
)
}
}
Text(
text = stringResource(
R.string.changelog_toast_also,
counts.joinToString(" · "),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = digest.preview.joinToString(", ") + "…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.changelog_view_all),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
}
}
}
LinearProgressIndicator(
progress = { progress.coerceIn(0f, 1f) },
modifier = Modifier
.fillMaxWidth()
.clearAndSetSemantics { },
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
}
@@ -3,13 +3,13 @@ package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
@@ -21,6 +21,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -33,12 +34,21 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import com.hermesandroid.relay.BuildConfig
import com.hermesandroid.relay.R
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.ui.components.ChangelogStore
import com.hermesandroid.relay.ui.components.ChangelogVersion
import com.hermesandroid.relay.ui.components.VersionNotesBody
import com.hermesandroid.relay.ui.components.VersionNotesBlock
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* Full release history, sourced from the bundled `changelog.json` (the same
@@ -95,19 +105,26 @@ fun ChangelogScreen(
return@Scaffold
}
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 16.dp),
.padding(innerPadding),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
versions.forEachIndexed { index, entry ->
itemsIndexed(
items = versions,
key = { _, entry -> entry.version },
) { index, entry ->
// Latest version is expanded; older ones start collapsed.
ChangelogVersionCard(
entry = entry,
initiallyExpanded = index == 0,
installed = isInstalledRelease(
releaseVersion = entry.version,
buildVersion = BuildConfig.VERSION_NAME,
flavor = BuildConfig.FLAVOR,
),
)
}
}
@@ -122,21 +139,26 @@ fun ChangelogScreen(
private fun ChangelogVersionCard(
entry: ChangelogVersion,
initiallyExpanded: Boolean,
installed: Boolean,
) {
var expanded by remember { mutableStateOf(initiallyExpanded) }
var expanded by remember(entry.version) { mutableStateOf(initiallyExpanded) }
val expansionState = stringResource(
if (expanded) R.string.changelog_state_expanded else R.string.changelog_state_collapsed,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(12.dp),
shape = appearanceRoundedCornerShape(12.dp),
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.clickable(role = Role.Button) { expanded = !expanded }
.semantics { stateDescription = expansionState }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -148,12 +170,20 @@ private fun ChangelogVersionCard(
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
entry.date?.takeIf { it.isNotBlank() }?.let { date ->
Text(
text = date,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
entry.date?.takeIf { it.isNotBlank() }?.let { date ->
Text(
text = formatReleaseDate(date),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (installed) {
InstalledVersionBadge()
}
}
}
Icon(
@@ -162,7 +192,7 @@ private fun ChangelogVersionCard(
} else {
Icons.Filled.ExpandMore
},
contentDescription = if (expanded) stringResource(R.string.changelog_collapse) else stringResource(R.string.changelog_expand),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -174,9 +204,43 @@ private fun ChangelogVersionCard(
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
VersionNotesBody(entry.toGroups())
VersionNotesBlock(entry, showVersionLine = false)
}
}
}
}
}
@Composable
private fun InstalledVersionBadge() {
Surface(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
shape = appearanceRoundedCornerShape(10.dp),
) {
Text(
text = stringResource(R.string.changelog_installed),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
internal fun formatReleaseDate(raw: String, locale: Locale = Locale.getDefault()): String =
runCatching {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale)
.format(LocalDate.parse(raw))
}.getOrDefault(raw)
internal fun isInstalledRelease(
releaseVersion: String,
buildVersion: String,
flavor: String,
): Boolean {
val canonicalBuildVersion = flavor
.takeIf { it.isNotBlank() }
?.let { buildVersion.removeSuffix("-$it") }
?: buildVersion
return releaseVersion == canonicalBuildVersion
}
@@ -37,10 +37,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
@@ -202,7 +200,6 @@ import com.hermesandroid.relay.viewmodel.ChatFailureRoute
import com.hermesandroid.relay.viewmodel.ChatFailureNotice
import com.hermesandroid.relay.viewmodel.scopedChatFailure
import com.hermesandroid.relay.ui.components.ConversationVoiceDock
import com.hermesandroid.relay.ui.components.CleanChatMode
import com.hermesandroid.relay.ui.components.ChatInputPickerControl
import com.hermesandroid.relay.ui.components.ChatInputPickerOption
import com.hermesandroid.relay.ui.components.ChatInputTrailing
@@ -1122,12 +1119,16 @@ fun ChatScreen(
}
// Recover any durable in-flight chat checkpoint whenever Chat returns to
// the foreground. On Gateway this also pre-warms/re-attaches the socket;
// sessions-SSE falls back to bounded persisted-history reconciliation.
// the foreground. setChatVisible owns that edge; an ordinary Gateway open
// warms only the observation socket and never attaches a saved session.
val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState()
LaunchedEffect(isGatewayTransport, appForeground, chatReady) {
chatViewModel.setChatVisible(appForeground && chatReady)
if (appForeground && chatReady) {
val chatVisible = appForeground && chatReady
val visibilityChanged = chatViewModel.setChatVisible(chatVisible)
if (isGatewayTransport && chatVisible && !visibilityChanged) {
// Gateway availability can settle after Chat was already visible.
// Repeat the socket-only warmup for that edge; ordinary observation
// still cannot resume or activate a session.
chatViewModel.prewarmGateway()
}
if (isGatewayTransport && appForeground && chatReady) {
@@ -1187,12 +1188,6 @@ fun ChatScreen(
}
}
}
var ambientMode by remember { mutableStateOf(false) } // clean text-flow mode, hides chat
// Clean-mode discoverability hint: a persistent pill shown ONLY on the
// empty / new-chat view (no messages) — it teaches the long-press entry
// without nagging mid-conversation. Derived directly from the message list
// at the pill below; no one-shot timer, no auto-dismiss.
// Sphere state with debounced Thinking→Streaming (min 1.5s in Thinking)
val rawSphereState by remember {
derivedStateOf {
@@ -1428,7 +1423,6 @@ fun ChatScreen(
PetInteractionLayer(
owner = "chat-interaction-layer",
active = shouldHideChatPet(
ambientMode = ambientMode,
voiceMode = voiceUiState.voiceMode,
drawerOpenOrMoving =
drawerState.currentValue != DrawerValue.Closed ||
@@ -3120,11 +3114,6 @@ fun ChatScreen(
}
}
}
// Clean text-flow mode has no top-bar toggle — it's a quiet
// gesture: long-press the conversation background to enter.
// Exit is the in-mode dismiss control or system back (not
// any-tap, since the mode carries its own composer). A
// first-run hint teaches the entry.
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = RelayRefresh.Background.copy(alpha = 0.96f)
@@ -3203,11 +3192,6 @@ fun ChatScreen(
}
}
// Clean text-flow mode is rendered as a full-screen overlay (a
// sibling of this Column, below) so it can own its own minimal
// composer and explicit-dismiss control. The Column always renders
// the normal chat underneath; the overlay covers it when active.
// Message list or empty state
if (messages.isEmpty() && !isStreaming && (!isLoadingHistory || isChatConnecting)) {
AnimatedContent(
@@ -3444,28 +3428,12 @@ fun ChatScreen(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
// Quiet entry to clean mode: long-press the
// conversation background. Bubbles keep their own
// long-press (copy) — they consume the gesture first,
// so only presses on empty space land here. Enters
// regardless of animationEnabled — clean mode degrades
// to static text when motion is suppressed, so the
// conversation is never gated on animation.
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
ambientMode = true
},
)
}
) {
// Ambient avatar behind messages
if (
LocalBackgroundVisualizationEnabled.current &&
(!supervised || supervisedVisibility.showAgentIdentity) &&
animationBehindChat &&
!ambientMode
animationBehindChat
) {
LocalAgentAvatar.current.Render(
state = AvatarRenderState(
@@ -4810,62 +4778,6 @@ fun ChatScreen(
}
}
// Clean-mode discoverability hint — a quiet, persistent pill teaching
// the long-press entry. Shown ONLY on the empty / new-chat view; it
// yields the bottom area whenever clean or voice mode owns it.
AnimatedVisibility(
visible = shouldShowCleanViewHint(
hasMessages = messages.isNotEmpty(),
ambientMode = ambientMode,
voiceMode = voiceUiState.voiceMode,
),
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 96.dp),
) {
Text(
text = stringResource(R.string.chat_clean_view_hint),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f))
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
// Clean text-flow mode — full-screen overlay covering the whole Box.
// Owns its own minimal composer + explicit dismiss; exit is never
// any-tap. Renders static text when motion is suppressed.
AnimatedVisibility(
visible = ambientMode,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
CleanChatMode(
messages = messages,
isStreaming = isStreaming,
sphereState = sphereState,
streamingIntensity = streamingIntensity,
toolCallBurst = toolCallBurst,
animationEnabled = animationEnabled,
enabled = chatReady,
onSend = { text ->
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
userScrolledAwayState.value = reduceUserScrolledAway(
current = userScrolledAwayState.value,
event = ChatFollowEvent.UserSend,
)
chatViewModel.sendMessage(text)
},
onExit = { ambientMode = false },
)
}
// Voice mode overlay — covers the whole Box when voiceUiState.voiceMode
AnimatedVisibility(
visible = voiceUiState.voiceMode,
@@ -5574,15 +5486,8 @@ private fun createCameraCaptureUri(context: android.content.Context): Uri {
)
}
internal fun shouldShowCleanViewHint(
hasMessages: Boolean,
ambientMode: Boolean,
voiceMode: Boolean,
): Boolean = !hasMessages && !ambientMode && !voiceMode
/** Chat overlays that temporarily own input and must not compete with the root pet host. */
internal fun shouldHideChatPet(
ambientMode: Boolean = false,
voiceMode: Boolean = false,
drawerOpenOrMoving: Boolean = false,
commandPaletteVisible: Boolean = false,
@@ -5591,8 +5496,7 @@ internal fun shouldHideChatPet(
contextSheetVisible: Boolean = false,
backgroundProcessesVisible: Boolean = false,
agentInfoVisible: Boolean = false,
): Boolean = ambientMode ||
voiceMode ||
): Boolean = voiceMode ||
drawerOpenOrMoving ||
commandPaletteVisible ||
modelSheetVisible ||
@@ -408,6 +408,9 @@ class ChatViewModel : ViewModel() {
private val sessionActivityGeneration = AtomicLong(0L)
private val sessionActivityPollMutex = Mutex()
private var sessionActivityPollJob: Job? = null
private var passiveGatewayHistoryRefreshJob: Job? = null
private var passivelyObservedGatewaySessionId: String? = null
private var passiveObservationCatchupPendingSessionId: String? = null
private var sessionActivityDirectory: Set<SessionActivityOwner> = emptySet()
private var lastProjectedProcessIds: Set<String> = emptySet()
private var lastProjectedProcessOwner: SessionActivityOwner? = null
@@ -2252,6 +2255,8 @@ class ChatViewModel : ViewModel() {
}
private suspend fun pollSessionActivity(client: GatewayChatClient) {
var hasPassiveCurrentLiveWork = false
var hasPassiveCatchupPending = false
sessionActivityPollMutex.withLock {
if (gatewayClient !== client || !chatVisible || streamingEndpoint != "gateway") return
val generation = sessionActivityGeneration.get()
@@ -2272,6 +2277,43 @@ class ChatViewModel : ViewModel() {
when (val result = client.listActiveSessions()) {
is GatewayActiveSessionsResult.Success -> {
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
val currentStoredId = currentOwner?.storedSessionId
val passiveCurrentRows = if (currentStoredId == null) {
emptyList()
} else {
result.sessions.filter { row ->
row.storedSessionId == currentStoredId &&
client.knownSessionOwner(row.runtimeSessionId) == null
}
}
hasPassiveCurrentLiveWork = passiveCurrentRows.any { row ->
row.status != GatewayActiveSessionStatus.Idle
}
val initialCatchupPending =
passiveObservationCatchupPendingSessionId == currentStoredId
val needsFinalPassiveRefresh =
passivelyObservedGatewaySessionId == currentStoredId &&
!hasPassiveCurrentLiveWork
if (hasPassiveCurrentLiveWork) {
currentStoredId?.let(::refreshPassivelyObservedGatewayHistory)
if (initialCatchupPending) {
passiveObservationCatchupPendingSessionId = null
}
} else if (needsFinalPassiveRefresh || initialCatchupPending) {
val scheduled = currentStoredId?.let { storedId ->
refreshPassivelyObservedGatewayHistory(
storedSessionId = storedId,
retryUntilChanged = true,
)
} == true
if (scheduled && initialCatchupPending) {
passiveObservationCatchupPendingSessionId = null
}
}
passivelyObservedGatewaySessionId =
currentStoredId?.takeIf { hasPassiveCurrentLiveWork }
hasPassiveCatchupPending =
passiveObservationCatchupPendingSessionId == currentStoredId
val resolved = resolveGatewayActiveSessions(
sessions = result.sessions,
directory = directory,
@@ -2333,6 +2375,18 @@ class ChatViewModel : ViewModel() {
GatewayActiveSessionsResult.Unsupported,
is GatewayActiveSessionsResult.TransientFailure -> {
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
val currentStoredId = currentOwner?.storedSessionId
if (passiveObservationCatchupPendingSessionId == currentStoredId) {
val scheduled = currentStoredId?.let { storedId ->
refreshPassivelyObservedGatewayHistory(
storedSessionId = storedId,
retryUntilChanged = true,
)
} == true
if (scheduled) passiveObservationCatchupPendingSessionId = null
}
hasPassiveCatchupPending =
passiveObservationCatchupPendingSessionId == currentStoredId
val scopes = directory.mapTo(mutableSetOf()) {
SessionActivityScope.of(it.connectionId, it.profile)
}.apply { add(currentScope) }
@@ -2351,7 +2405,9 @@ class ChatViewModel : ViewModel() {
record.freshness == SessionActivityFreshness.Confirmed &&
record.phase(System.currentTimeMillis()) != SessionActivityPhase.Idle
}
val delayMs = if (hasConfirmedLiveWork) 1_500L else 30_000L
val delayMs = if (
hasConfirmedLiveWork || hasPassiveCurrentLiveWork || hasPassiveCatchupPending
) 1_500L else 30_000L
sessionActivityPollJob = viewModelScope.launch {
delay(delayMs)
if (gatewayClient === client && chatVisible) pollSessionActivity(client)
@@ -2424,6 +2480,10 @@ class ChatViewModel : ViewModel() {
clearProjectedBackgroundProcesses()
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
passiveGatewayHistoryRefreshJob?.cancel()
passiveGatewayHistoryRefreshJob = null
passivelyObservedGatewaySessionId = null
passiveObservationCatchupPendingSessionId = null
sessionActivityGeneration.incrementAndGet()
sessionActivityDirectory = emptySet()
lastLocalActivityOwner = null
@@ -2543,8 +2603,9 @@ class ChatViewModel : ViewModel() {
// Foreground can race OkHttp's delayed close callback:
// the first prewarm sees the old socket as Ready, then
// the callback moves it to Idle. Re-run from this exact
// client transition so the visible durable session is
// resumed and its authoritative history reconciled.
// client transition so the observation socket is
// restored; only an exact Android checkpoint may
// resume/activate a live runtime.
prewarmGateway()
}
}
@@ -2999,6 +3060,103 @@ class ChatViewModel : ViewModel() {
}
}
/**
* Refresh a Desktop/TUI-owned turn through the profile-scoped history
* surface without attaching its live runtime. `session.active_list` drives
* the bounded cadence; one final read follows Working/Waiting -> Idle.
*/
private fun refreshPassivelyObservedGatewayHistory(
storedSessionId: String,
retryUntilChanged: Boolean = false,
): Boolean {
if (passiveGatewayHistoryRefreshJob?.isActive == true) return false
if (_isLoadingHistory.value) return false
val handler = chatHandler ?: return false
val contextKey = activeProfileContextKey
val profileName = currentSessionProfileName()
val refreshJob = viewModelScope.launch(start = CoroutineStart.LAZY) {
try {
repeat(if (retryUntilChanged) 8 else 1) { attempt ->
val serverMessages = runCatching {
loadGatewaySessionHistory(
sessionId = storedSessionId,
requireProfileScope = true,
profileName = profileName,
)
}.getOrNull() ?: return@launch
if (
chatHandler !== handler ||
activeProfileContextKey != contextKey ||
currentSessionProfileName() != profileName ||
handler.currentSessionId.value != storedSessionId ||
_isLoadingHistory.value ||
activeStream != null ||
handler.isStreaming.value
) return@launch
val visibleSignature = handler.messages.value
.filterNot { it.clientOnly }
.map { message ->
Triple(
message.role.name.lowercase(),
message.content,
message.thinkingContent,
)
}
val serverSignature = serverMessages.map { message ->
Triple(
message.role.lowercase(),
message.contentText.orEmpty(),
message.resolvedReasoning.orEmpty(),
)
}
if (visibleSignature != serverSignature) {
handler.loadMessageHistory(serverMessages)
refreshSessions()
scheduleTitleReconcile(storedSessionId)
return@launch
}
if (attempt < 7 && retryUntilChanged) delay(250L)
}
} finally {
if (passiveGatewayHistoryRefreshJob === coroutineContext[Job]) {
passiveGatewayHistoryRefreshJob = null
}
}
}
passiveGatewayHistoryRefreshJob = refreshJob
refreshJob.start()
return true
}
/** Open the read-only socket off Main, then publish observation ownership on Main. */
private fun observeGatewaySession(
client: GatewayChatClient?,
handler: ChatHandler,
storedSessionId: String,
) {
val observer = client ?: return
val contextKey = activeProfileContextKey
val profileName = currentSessionProfileName()
observer.observe {
if (
chatVisible &&
gatewayClient === observer &&
chatHandler === handler &&
activeProfileContextKey == contextKey &&
currentSessionProfileName() == profileName &&
handler.currentSessionId.value == storedSessionId
) {
passiveObservationCatchupPendingSessionId = storedSessionId
refreshPassivelyObservedGatewayHistory(
storedSessionId = storedSessionId,
retryUntilChanged = true,
)
requestSessionActivityRefresh()
}
}
}
/** One-shot `config.get personality` over a ready socket → drives the collector. */
private fun seedServerPersonality(client: GatewayChatClient) {
viewModelScope.launch {
@@ -3009,11 +3167,11 @@ class ChatViewModel : ViewModel() {
}
/**
* Warm the gateway socket (and resume the current session) when the chat
* surface is visible and the gateway is the resolved transport, so the
* first send is warm instead of paying the cold connect + `session.resume`
* on the send path. No-op without a gateway client; idempotent when warm.
* Driven by a foreground/visibility effect in ChatScreen.
* Warm the Gateway socket when Chat is visible without claiming a runtime
* that may belong to Desktop/TUI. Exact Android-owned checkpoints recover
* through `session.activate`/`session.resume`; an ordinary open observes
* through REST history and `session.active_list` until the user performs
* an explicit action that needs session ownership.
*/
fun prewarmGateway() {
val client = gatewayClient
@@ -3021,17 +3179,16 @@ class ChatViewModel : ViewModel() {
val sessionId = handler.currentSessionId.value
selectBackgroundProcessSession(sessionId)
if (sessionId == null) {
client?.prewarm(null)
client?.observe()
} else {
// Preserve the original warm-up path before persistence wiring is
// available (early composition and JVM tests). Production installs
// the store from initializeMedia before Chat becomes ready.
if (chatTurnCheckpointStore == null) {
val gateway = client ?: return
// GatewayChatClient owns an IO scope, so this can progress even
// while a paused/blocked UI dispatcher is being recreated.
// Its cold-ready listener performs history/process refresh.
gateway.prewarm(sessionId)
// GatewayChatClient owns the socket IO scope, so the dial can
// progress while a paused UI dispatcher is being recreated.
observeGatewaySession(gateway, handler, sessionId)
return
}
if (activeStream == null && (streamRecovery == null || client != null)) {
@@ -3043,26 +3200,29 @@ class ChatViewModel : ViewModel() {
chatHandler === handler &&
handler.currentSessionId.value == sessionId
) {
if (client?.prewarmAwait(sessionId) == true) {
gatewayProcessController.sessionReady(sessionId)
}
observeGatewaySession(client, handler, sessionId)
}
checkpointRecoveryJob = null
}
return
}
// prewarm() only emits the existing "cold ready" callback when it
// had to resume. An already-live session still needs its initial
// process snapshot when Chat opens, so confirm it explicitly.
viewModelScope.launch {
if (
client?.prewarmAwait(sessionId) == true &&
gatewayClient === client &&
chatHandler === handler &&
handler.currentSessionId.value == sessionId
) {
gatewayProcessController.sessionReady(sessionId)
// A locally-owned live mapper may revalidate its existing binding.
// A passive transcript must remain socket-only: resuming it here
// can replace another client's transport and turn Android teardown
// into a later session.interrupt.
if (client?.hasActiveTurnForSession(sessionId) == true) {
viewModelScope.launch {
if (client.prewarmAwait(sessionId) &&
gatewayClient === client &&
chatHandler === handler &&
handler.currentSessionId.value == sessionId
) {
gatewayProcessController.sessionReady(sessionId)
requestSessionActivityRefresh()
}
}
} else {
observeGatewaySession(client, handler, sessionId)
}
}
}
@@ -3072,7 +3232,7 @@ class ChatViewModel : ViewModel() {
* Gateway chat owns automatic idle-socket reattachment; other tabs and a
* backgrounded app retain the normal no-reconnect behavior.
*/
fun setChatVisible(visible: Boolean) {
fun setChatVisible(visible: Boolean): Boolean {
val changed = chatVisible != visible
chatVisible = visible
if (visible && changed) {
@@ -3081,7 +3241,12 @@ class ChatViewModel : ViewModel() {
} else if (!visible) {
sessionActivityPollJob?.cancel()
sessionActivityPollJob = null
passiveGatewayHistoryRefreshJob?.cancel()
passiveGatewayHistoryRefreshJob = null
passivelyObservedGatewaySessionId = null
passiveObservationCatchupPendingSessionId = null
}
return changed
}
// === Gateway desktop-parity state ===
@@ -4465,7 +4630,9 @@ class ChatViewModel : ViewModel() {
)
if (stillCurrent()) {
handler.loadMessageHistory(messages)
if (streamingEndpoint == "gateway") gatewayClient?.prewarm(sessionId)
if (streamingEndpoint == "gateway") {
observeGatewaySession(gatewayClient, handler, sessionId)
}
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
@@ -4976,7 +5143,9 @@ class ChatViewModel : ViewModel() {
handler.currentSessionId.value == sessionId
) {
handler.loadMessageHistory(messages)
if (streamingEndpoint == "gateway") gatewayClient?.prewarm(sessionId)
if (streamingEndpoint == "gateway") {
observeGatewaySession(gatewayClient, handler, sessionId)
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
@@ -6566,6 +6735,10 @@ class ChatViewModel : ViewModel() {
* SSE cannot, so it retains the existing interrupt/cancel behavior.
*/
private fun releaseTurnForNavigation(handler: ChatHandler) {
passiveGatewayHistoryRefreshJob?.cancel()
passiveGatewayHistoryRefreshJob = null
passivelyObservedGatewaySessionId = null
passiveObservationCatchupPendingSessionId = null
val gateway = gatewayClient
val canBackground = streamingEndpoint == "gateway" &&
activeStreamIsGateway && activeStream != null && gateway != null
+15 -9
View File
@@ -136,7 +136,6 @@
<string name="chat_editing_notice">Editando — a conversa será retrocedida</string>
<string name="chat_mic_perm_needed">Permissão do microfone necessária</string>
<string name="chat_mic_perm_body">Toque em Abrir Configurações e permita o acesso ao microfone para usar o modo de voz.</string>
<string name="chat_clean_view_hint">Mantenha o chat pressionado para uma visualização limpa e focada</string>
<string name="chat_manage_connections">Gerenciar conexões</string>
<string name="chat_no_clipboard_image">Nenhuma imagem na área de transferência</string>
<string name="chat_copied_to_clipboard">Copiado para a área de transferência</string>
@@ -566,6 +565,20 @@
<string name="changelog_no_notes">Nenhuma nota de versão disponível.</string>
<string name="changelog_collapse">Recolher</string>
<string name="changelog_expand">Expandir</string>
<string name="changelog_state_expanded">Expandido</string>
<string name="changelog_state_collapsed">Recolhido</string>
<string name="changelog_installed">Instalado</string>
<string name="changelog_also_improved">Também melhorado</string>
<string name="changelog_toast_also">Também: %1$s</string>
<string name="changelog_view_all">Ver tudo</string>
<plurals name="changelog_additional_feature_count">
<item quantity="one">%1$d recurso</item>
<item quantity="other">%1$d recursos</item>
</plurals>
<plurals name="changelog_fix_count">
<item quantity="one">%1$d correção</item>
<item quantity="other">%1$d correções</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">Diagnóstico</string>
<string name="diag_back">Voltar</string>
@@ -668,7 +681,7 @@
<string name="settings_developer_options">Opções do desenvolvedor</string>
<string name="settings_developer_options_desc">Flags de recursos, gerenciamento de dados e opções experimentais</string>
<string name="settings_whats_new">Novidades</string>
<string name="settings_whats_new_desc">Notas de versão e changelog completo</string>
<string name="settings_whats_new_desc">Histórico de versões do Android</string>
<string name="settings_about">Sobre</string>
<string name="settings_about_desc">Versão, créditos e documentação</string>
<string name="settings_profile_lock">Bloqueio de perfil</string>
@@ -2503,8 +2516,6 @@
<string name="power_gate_explanation_expired">Sua sessão do relay não é mais aceita pelo servidor. Abra a conexão para reparar.</string>
<string name="power_gate_explanation_unavailable">Este servidor Hermes não oferece a superfície necessária para este recurso.</string>
<string name="power_gate_explanation_signin">Este recurso padrão do painel exige login no painel antes de ser usado.</string>
<!-- AgentTextFlow / CleanModeComposer -->
<string name="agent_text_placeholder">Mensagem</string>
<!-- ExtraKeysToolbar -->
<string name="extra_keys_esc">ESC</string>
<string name="extra_keys_tab">TAB</string>
@@ -2892,7 +2903,6 @@
<!-- InsecureConnectionAckDialog -->
<!-- SessionTtlPickerDialog -->
<!-- PowerFeatureGate -->
<!-- AgentTextFlow / CleanModeComposer -->
<!-- ExtraKeysToolbar -->
<!-- ToolProgressCard -->
<!-- AgentIconRow -->
@@ -2948,10 +2958,6 @@
<string name="chat_relay_sessions_summary">Veja e revogue os dispositivos pareados com este relay.</string>
<string name="chat_relay_sessions_title">Sessões do Relay</string>
<string name="chat_rename_failed">Falha ao renomear</string>
<!-- AgentTextFlow -->
<string name="agent_text_send_cd">Enviar</string>
<string name="agent_text_exit_clean">Sair do modo limpo</string>
<string name="agent_text_sphere_desc">Agente</string>
<!-- UpdateBanner format strings (with version placeholders) -->
<string name="update_banner_available_fmt">Atualização disponível — v%1$s</string>
<string name="update_banner_body_fmt">Você está na %1$s. Toque em Atualizar para baixar.</string>
+13 -10
View File
@@ -150,7 +150,6 @@
<string name="chat_editing_notice">编辑中——将回退对话</string>
<string name="chat_mic_perm_needed">需要麦克风权限</string>
<string name="chat_mic_perm_body">点击“打开设置”并授予麦克风权限以使用语音模式。</string>
<string name="chat_clean_view_hint">长按聊天进入清爽专注视图</string>
<string name="chat_manage_connections">管理连接</string>
<string name="chat_no_clipboard_image">剪贴板中没有图片</string>
<string name="chat_copied_to_clipboard">已复制到剪贴板</string>
@@ -603,6 +602,18 @@
<string name="changelog_no_notes">暂无发行说明。</string>
<string name="changelog_collapse">收起</string>
<string name="changelog_expand">展开</string>
<string name="changelog_state_expanded">已展开</string>
<string name="changelog_state_collapsed">已收起</string>
<string name="changelog_installed">已安装</string>
<string name="changelog_also_improved">其他改进</string>
<string name="changelog_toast_also">另外:%1$s</string>
<string name="changelog_view_all">查看全部</string>
<plurals name="changelog_additional_feature_count">
<item quantity="other">%1$d 项功能</item>
</plurals>
<plurals name="changelog_fix_count">
<item quantity="other">%1$d 项修复</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">诊断</string>
@@ -709,7 +720,7 @@
<string name="settings_developer_options">开发者选项</string>
<string name="settings_developer_options_desc">功能标志、数据管理、实验性</string>
<string name="settings_whats_new">新功能</string>
<string name="settings_whats_new_desc">发行说明和完整变更日志</string>
<string name="settings_whats_new_desc">Android 版本历史</string>
<string name="settings_about">关于</string>
<string name="settings_about_desc">版本、致谢、文档</string>
<string name="settings_profile_lock">配置文件锁定</string>
@@ -2610,9 +2621,6 @@
<string name="power_gate_explanation_unavailable">此 Hermes 服务器未暴露此功能所需的接口。</string>
<string name="power_gate_explanation_signin">此标准仪表盘功能需要在登录后才能使用。</string>
<!-- AgentTextFlow / CleanModeComposer -->
<string name="agent_text_placeholder">消息</string>
<!-- ExtraKeysToolbar -->
<string name="extra_keys_esc">ESC</string>
<string name="extra_keys_tab">TAB</string>
@@ -3053,11 +3061,6 @@
<string name="chat_rename_failed">重命名失败</string>
<string name="chat_only_active_revoke">现在只能撤销活动连接</string>
<!-- AgentTextFlow -->
<string name="agent_text_send_cd">发送</string>
<string name="agent_text_exit_clean">退出简洁模式</string>
<string name="agent_text_sphere_desc">代理</string>
<!-- UpdateBanner (format strings with version placeholders) -->
<string name="update_banner_available_fmt">更新可用 &#8212; v%1$s</string>
+15 -12
View File
@@ -150,7 +150,6 @@
<string name="chat_editing_notice">Bearbeitung — setzt die Unterhaltung zurück</string>
<string name="chat_mic_perm_needed">Mikrofonberechtigung erforderlich</string>
<string name="chat_mic_perm_body">Tippe auf Einstellungen öffnen und erteile Mikrofonzugriff, um den Sprachmodus zu nutzen.</string>
<string name="chat_clean_view_hint">Halte den Chat gedrückt für eine aufgeräumte, fokussierte Ansicht</string>
<string name="chat_manage_connections">Verbindungen verwalten</string>
<string name="chat_no_clipboard_image">Kein Bild in der Zwischenablage</string>
<string name="chat_copied_to_clipboard">In die Zwischenablage kopiert</string>
@@ -603,6 +602,20 @@
<string name="changelog_no_notes">Keine Versionshinweise verfügbar.</string>
<string name="changelog_collapse">Einklappen</string>
<string name="changelog_expand">Ausklappen</string>
<string name="changelog_state_expanded">Ausgeklappt</string>
<string name="changelog_state_collapsed">Eingeklappt</string>
<string name="changelog_installed">Installiert</string>
<string name="changelog_also_improved">Außerdem verbessert</string>
<string name="changelog_toast_also">Außerdem: %1$s</string>
<string name="changelog_view_all">Alle anzeigen</string>
<plurals name="changelog_additional_feature_count">
<item quantity="one">%1$d Funktion</item>
<item quantity="other">%1$d Funktionen</item>
</plurals>
<plurals name="changelog_fix_count">
<item quantity="one">%1$d Fehlerbehebung</item>
<item quantity="other">%1$d Fehlerbehebungen</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">Diagnose</string>
@@ -709,7 +722,7 @@
<string name="settings_developer_options">Entwickleroptionen</string>
<string name="settings_developer_options_desc">Funktionsschalter, Datenverwaltung, Experimente</string>
<string name="settings_whats_new">Neuigkeiten</string>
<string name="settings_whats_new_desc">Versionshinweise und vollständiges Änderungsprotokoll</string>
<string name="settings_whats_new_desc">Android-Versionsverlauf</string>
<string name="settings_about">Info</string>
<string name="settings_about_desc">Version, Mitwirkende, Dokumentation</string>
<string name="settings_profile_lock">Profilsperre</string>
@@ -2613,9 +2626,6 @@
<string name="power_gate_explanation_unavailable">Dieser Hermes-Server stellt die für diese Funktion benötigte Oberfläche nicht bereit.</string>
<string name="power_gate_explanation_signin">Diese Standard-Dashboard-Funktion erfordert vor der Nutzung eine Dashboard-Anmeldung.</string>
<!-- AgentTextFlow / CleanModeComposer -->
<string name="agent_text_placeholder">Nachricht</string>
<!-- ExtraKeysToolbar -->
<string name="extra_keys_esc">ESC</string>
<string name="extra_keys_tab">TAB</string>
@@ -3032,8 +3042,6 @@
<!-- PowerFeatureGate -->
<!-- AgentTextFlow / CleanModeComposer -->
<!-- ExtraKeysToolbar -->
<!-- ToolProgressCard -->
@@ -3116,11 +3124,6 @@
<string name="chat_rename_failed">Umbenennen fehlgeschlagen</string>
<!-- AgentTextFlow -->
<string name="agent_text_send_cd">Senden</string>
<string name="agent_text_exit_clean">Aufgeräumten Modus beenden</string>
<string name="agent_text_sphere_desc">Agent</string>
<!-- UpdateBanner format strings (with version placeholders) -->
<string name="update_banner_available_fmt">Aktualisierung verfügbar &#8212; v%1$s</string>
+15 -6
View File
@@ -127,7 +127,6 @@
<string name="chat_editing_notice">Editando: rebobinará la conversación</string>
<string name="chat_mic_perm_needed">Se necesita permiso para el micrófono</string>
<string name="chat_mic_perm_body">Toque Abrir configuración y otorgue acceso al micrófono para usar el modo de voz.</string>
<string name="chat_clean_view_hint">Mantenga el chat para una vista clara y enfocada</string>
<string name="chat_manage_connections">Administrar conexiones</string>
<string name="chat_no_clipboard_image">No hay ninguna imagen en el portapapeles</string>
<string name="chat_copied_to_clipboard">Copiado al portapapeles</string>
@@ -538,6 +537,20 @@
<string name="changelog_no_notes">No hay notas de versión disponibles.</string>
<string name="changelog_collapse">Colapsar</string>
<string name="changelog_expand">Expandir</string>
<string name="changelog_state_expanded">Expandido</string>
<string name="changelog_state_collapsed">Contraído</string>
<string name="changelog_installed">Instalada</string>
<string name="changelog_also_improved">También mejorado</string>
<string name="changelog_toast_also">También: %1$s</string>
<string name="changelog_view_all">Ver todo</string>
<plurals name="changelog_additional_feature_count">
<item quantity="one">%1$d función</item>
<item quantity="other">%1$d funciones</item>
</plurals>
<plurals name="changelog_fix_count">
<item quantity="one">%1$d corrección</item>
<item quantity="other">%1$d correcciones</item>
</plurals>
<string name="diag_title">Diagnóstico</string>
<string name="diag_back">Atrás</string>
<string name="diag_status">Estado</string>
@@ -636,7 +649,7 @@
<string name="settings_developer_options">Opciones de desarrollador</string>
<string name="settings_developer_options_desc">Indicadores de funciones, gestión de datos, experimental.</string>
<string name="settings_whats_new">Novedades</string>
<string name="settings_whats_new_desc">Notas de la versión y registro de cambios completo</string>
<string name="settings_whats_new_desc">Historial de versiones de Android</string>
<string name="settings_about">Acerca de</string>
<string name="settings_about_desc">Versión, créditos, documentos.</string>
<string name="settings_profile_lock">Bloqueo de perfil</string>
@@ -2391,7 +2404,6 @@
<string name="power_gate_explanation_expired">El servidor ya no acepta su sesión relay. Abra la conexión para reparar.</string>
<string name="power_gate_explanation_unavailable">Este servidor Hermes no expone la superficie necesaria para esta función.</string>
<string name="power_gate_explanation_signin">Esta función estándar del panel necesita iniciar sesión en el panel antes de poder usarse.</string>
<string name="agent_text_placeholder">Mensaje</string>
<string name="extra_keys_esc">ESC</string>
<string name="extra_keys_tab">PESTAÑA</string>
<string name="extra_keys_ctrl">CONTROL</string>
@@ -2779,9 +2791,6 @@
<string name="chat_relay_sessions_summary">Ver y revocar dispositivos emparejados con este relay.</string>
<string name="chat_relay_sessions_title">Sesiones Relay</string>
<string name="chat_rename_failed">Error al cambiar el nombre</string>
<string name="agent_text_send_cd">Enviar</string>
<string name="agent_text_exit_clean">Salir del modo limpio</string>
<string name="agent_text_sphere_desc">Agente</string>
<string name="update_banner_available_fmt">Actualización disponible: v%1$s</string>
<string name="update_banner_body_fmt">Estás en %1$s. Toque Actualizar para descargar.</string>
<string name="attach_viewer_cd_close">Cerrar</string>
+13 -12
View File
@@ -150,7 +150,6 @@
<string name="chat_editing_notice">編集中 — 会話を巻き戻します</string>
<string name="chat_mic_perm_needed">マイクの許可が必要です</string>
<string name="chat_mic_perm_body">[設定を開く] をタップし、音声モードを使用するためのマイクへのアクセスを許可します。</string>
<string name="chat_clean_view_hint">チャットを保留すると、すっきりとした集中的なビューが得られます</string>
<string name="chat_manage_connections">接続を管理する</string>
<string name="chat_no_clipboard_image">クリップボードに画像がありません</string>
<string name="chat_copied_to_clipboard">クリップボードにコピーされました</string>
@@ -603,6 +602,18 @@
<string name="changelog_no_notes">リリースノートはありません。</string>
<string name="changelog_collapse">崩壊</string>
<string name="changelog_expand">拡大する</string>
<string name="changelog_state_expanded">展開済み</string>
<string name="changelog_state_collapsed">折りたたみ済み</string>
<string name="changelog_installed">インストール済み</string>
<string name="changelog_also_improved">その他の改善</string>
<string name="changelog_toast_also">その他: %1$s</string>
<string name="changelog_view_all">すべて表示</string>
<plurals name="changelog_additional_feature_count">
<item quantity="other">%1$d 件の機能</item>
</plurals>
<plurals name="changelog_fix_count">
<item quantity="other">%1$d 件の修正</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">診断</string>
@@ -709,7 +720,7 @@
<string name="settings_developer_options">開発者向けオプション</string>
<string name="settings_developer_options_desc">機能フラグ、データ管理、実験的</string>
<string name="settings_whats_new">新着情報</string>
<string name="settings_whats_new_desc">リリースノートと完全な変更ログ</string>
<string name="settings_whats_new_desc">Android のリリース履歴</string>
<string name="settings_about">について</string>
<string name="settings_about_desc">バージョン、クレジット、ドキュメント</string>
<string name="settings_profile_lock">プロファイルロック</string>
@@ -2624,9 +2635,6 @@
<string name="power_gate_explanation_unavailable">この Hermes サーバーは、この機能に必要なサーフェスを公開しません。</string>
<string name="power_gate_explanation_signin">この標準のダッシュボード機能を使用するには、ダッシュボードにサインインする必要があります。</string>
<!-- AgentTextFlow / CleanModeComposer -->
<string name="agent_text_placeholder">メッセージ</string>
<!-- ExtraKeysToolbar -->
<string name="extra_keys_esc">ESC</string>
<string name="extra_keys_tab">タブ</string>
@@ -3042,8 +3050,6 @@
<!-- PowerFeatureGate -->
<!-- AgentTextFlow / CleanModeComposer -->
<!-- ExtraKeysToolbar -->
<!-- ToolProgressCard -->
@@ -3126,11 +3132,6 @@
<string name="chat_rename_failed">名前の変更に失敗しました</string>
<!-- AgentTextFlow -->
<string name="agent_text_send_cd">送信</string>
<string name="agent_text_exit_clean">クリーンモードを終了する</string>
<string name="agent_text_sphere_desc">エージェント</string>
<!-- UpdateBanner format strings (with version placeholders) -->
<string name="update_banner_available_fmt">利用可能なアップデート &#8212; v%1$s</string>
+19 -6
View File
@@ -144,7 +144,6 @@
<string name="chat_editing_notice">Редактирование — перемотает беседу</string>
<string name="chat_mic_perm_needed">Требуется разрешение на микрофон</string>
<string name="chat_mic_perm_body">Нажмите Открыть настройки и предоставьте доступ к микрофону для использования голосового режима.</string>
<string name="chat_clean_view_hint">Удерживайте чат для чистого, сфокусированного представления</string>
<string name="chat_manage_connections">Управление подключениями</string>
<string name="chat_no_clipboard_image">Нет изображения в буфере обмена</string>
<string name="chat_copied_to_clipboard">Скопировано в буфер обмена</string>
@@ -581,6 +580,24 @@
<string name="changelog_no_notes">Заметки о выпуске недоступны.</string>
<string name="changelog_collapse">Свернуть</string>
<string name="changelog_expand">Развернуть</string>
<string name="changelog_state_expanded">Развернуто</string>
<string name="changelog_state_collapsed">Свернуто</string>
<string name="changelog_installed">Установлено</string>
<string name="changelog_also_improved">Также улучшено</string>
<string name="changelog_toast_also">Также: %1$s</string>
<string name="changelog_view_all">Показать все</string>
<plurals name="changelog_additional_feature_count">
<item quantity="one">%1$d функция</item>
<item quantity="few">%1$d функции</item>
<item quantity="many">%1$d функций</item>
<item quantity="other">%1$d функции</item>
</plurals>
<plurals name="changelog_fix_count">
<item quantity="one">%1$d исправление</item>
<item quantity="few">%1$d исправления</item>
<item quantity="many">%1$d исправлений</item>
<item quantity="other">%1$d исправления</item>
</plurals>
<string name="diag_title">Диагностика</string>
<string name="diag_back">Назад</string>
<string name="diag_status">Статус</string>
@@ -679,7 +696,7 @@
<string name="settings_developer_options">Настройки разработчика</string>
<string name="settings_developer_options_desc">Флаги функций, управление данными, экспериментальные</string>
<string name="settings_whats_new">Что нового</string>
<string name="settings_whats_new_desc">Примечания к выпуску и полный журнал изменений</string>
<string name="settings_whats_new_desc">История выпусков Android</string>
<string name="settings_about">О приложении</string>
<string name="settings_about_desc">Версия, кредиты, документация</string>
<string name="settings_profile_lock">Блокировка профиля</string>
@@ -2602,7 +2619,6 @@
<string name="power_gate_explanation_expired">Ваша сессия Relay больше не принимается сервером. Откройте соединение для восстановления.</string>
<string name="power_gate_explanation_unavailable">Этот сервер Гермеса не предоставляет необходимую поверхность для этой функции.</string>
<string name="power_gate_explanation_signin">Эта стандартная функция панели управления требует входа в панель управления перед использованием.</string>
<string name="agent_text_placeholder">Сообщение</string>
<string name="extra_keys_esc">ESC</string>
<string name="extra_keys_tab">TAB</string>
<string name="extra_keys_ctrl">CTRL</string>
@@ -2996,9 +3012,6 @@
<string name="chat_relay_sessions_summary">Просмотр и отзыв устройств, сопряженных с этим Relay.</string>
<string name="chat_relay_sessions_title">Сессии Relay</string>
<string name="chat_rename_failed">Переименование не удалось</string>
<string name="agent_text_send_cd">Отправить</string>
<string name="agent_text_exit_clean">Выход из чистого режима</string>
<string name="agent_text_sphere_desc">Агент</string>
<string name="update_banner_available_fmt">Доступно обновление \&amp;#8212; v%1$s</string>
<string name="update_banner_body_fmt">Вы используете %1$s. Нажмите Обновить, чтобы скачать.</string>
<string name="attach_viewer_cd_close">Закрыть</string>
+15 -12
View File
@@ -154,7 +154,6 @@
<string name="chat_editing_notice">Editing — will rewind the conversation</string>
<string name="chat_mic_perm_needed">Microphone permission needed</string>
<string name="chat_mic_perm_body">Tap Open Settings and grant Microphone access to use voice mode.</string>
<string name="chat_clean_view_hint">Hold the chat for a clean, focused view</string>
<string name="chat_manage_connections">Manage connections</string>
<string name="chat_no_clipboard_image">No image on the clipboard</string>
<string name="chat_copied_to_clipboard">Copied to clipboard</string>
@@ -641,6 +640,20 @@
<string name="changelog_no_notes">No release notes available.</string>
<string name="changelog_collapse">Collapse</string>
<string name="changelog_expand">Expand</string>
<string name="changelog_state_expanded">Expanded</string>
<string name="changelog_state_collapsed">Collapsed</string>
<string name="changelog_installed">Installed</string>
<string name="changelog_also_improved">Also improved</string>
<string name="changelog_toast_also">Also: %1$s</string>
<string name="changelog_view_all">View all</string>
<plurals name="changelog_additional_feature_count">
<item quantity="one">%1$d feature</item>
<item quantity="other">%1$d features</item>
</plurals>
<plurals name="changelog_fix_count">
<item quantity="one">%1$d fix</item>
<item quantity="other">%1$d fixes</item>
</plurals>
<!-- DiagnosticsScreen -->
<string name="diag_title">Diagnostics</string>
@@ -747,7 +760,7 @@
<string name="settings_developer_options">Developer options</string>
<string name="settings_developer_options_desc">Feature flags, data management, experimental</string>
<string name="settings_whats_new">What\'s New</string>
<string name="settings_whats_new_desc">Release notes and full changelog</string>
<string name="settings_whats_new_desc">Android release history</string>
<string name="settings_about">About</string>
<string name="settings_about_desc">Version, credits, docs</string>
<string name="settings_profile_lock">Profile lock</string>
@@ -2928,9 +2941,6 @@
<string name="power_gate_explanation_unavailable">This Hermes server does not expose the surface needed for this feature.</string>
<string name="power_gate_explanation_signin">This standard dashboard feature needs dashboard sign-in before it can be used.</string>
<!-- AgentTextFlow / CleanModeComposer -->
<string name="agent_text_placeholder">Message</string>
<!-- ExtraKeysToolbar -->
<string name="extra_keys_esc">ESC</string>
<string name="extra_keys_tab">TAB</string>
@@ -3353,8 +3363,6 @@
<!-- PowerFeatureGate -->
<!-- AgentTextFlow / CleanModeComposer -->
<!-- ExtraKeysToolbar -->
<!-- ToolProgressCard -->
@@ -3437,11 +3445,6 @@
<string name="chat_rename_failed">Rename failed</string>
<!-- AgentTextFlow -->
<string name="agent_text_send_cd">Send</string>
<string name="agent_text_exit_clean">Exit clean mode</string>
<string name="agent_text_sphere_desc">Agent</string>
<!-- UpdateBanner format strings (with version placeholders) -->
<string name="update_banner_available_fmt">Update available &#8212; v%1$s</string>
@@ -231,11 +231,14 @@ class GatewayClientHarness(
val suppressAckMethods: MutableSet<String> = ConcurrentHashMap.newKeySet()
val pendingAcks = LinkedBlockingQueue<PendingAck>()
@Volatile
var suppressGatewayReady: Boolean = false
private val wsListener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
serverSockets.add(webSocket)
allServerSockets.add(webSocket)
webSocket.send(eventFrame("gateway.ready", null, null))
if (!suppressGatewayReady) sendGatewayReady(webSocket)
}
override fun onMessage(webSocket: WebSocket, text: String) {
@@ -627,6 +630,10 @@ class GatewayClientHarness(
fun awaitServerSocket(): WebSocket =
serverSockets.poll(5, TimeUnit.SECONDS) ?: error("server socket never opened")
fun sendGatewayReady(webSocket: WebSocket) {
webSocket.send(eventFrame("gateway.ready", null, null))
}
fun awaitRpc(method: String): JsonObject {
val deadline = System.currentTimeMillis() + 5_000
while (System.currentTimeMillis() < deadline) {
@@ -1407,6 +1414,27 @@ class GatewayChatClientTest {
assertEquals(listOf("stored-session"), resumedSessions.toList())
}
@Test
fun `observation warmup never claims or interrupts a foreign runtime`() = runBlocking {
val registrations = AtomicInteger(0)
client.setUnsolicitedTurnProvider {
registrations.incrementAndGet()
GatewayInboundTurnRegistration(Recorder().callbacks) { true }
}
assertTrue(client.observeAwait())
val serverWs = harness.awaitServerSocket()
serverWs.send(harness.eventFrame("message.start", null, "foreign-runtime"))
delay(100)
client.shutdown()
assertEquals(0, registrations.get())
assertFalse(harness.rpcLog.any { it.first == "session.resume" })
assertFalse(harness.rpcLog.any { it.first == "session.activate" })
assertFalse(harness.rpcLog.any { it.first == "session.interrupt" })
assertFalse(harness.rpcLog.any { it.first == "prompt.submit" })
}
@Test
fun `newer prewarm selection wins when an older resume completes late`() = runBlocking {
harness.suppressAckMethods += "session.resume"
@@ -0,0 +1,32 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.ui.screens.ChangelogScreen
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h640dp-xhdpi")
class ChangelogHistoryScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test fun latestReleaseIsCuratedAndInstalled() {
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
ChangelogScreen(onClose = {})
}
}
compose.onNodeWithText("Supervised Mode").assertExists()
compose.onNodeWithText("Installed").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/changelog-history.png")
}
}
@@ -0,0 +1,114 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.ui.components.ChangelogStore
import com.hermesandroid.relay.ui.components.WhatsNewToast
import com.hermesandroid.relay.ui.components.WhatsNewToastContent
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Rule
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h640dp-xhdpi")
class WhatsNewToastScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test fun compactNoticeRemainsClearAtLargeText() {
compose.setContent {
val context = LocalContext.current
val density = LocalDensity.current
val entry = ChangelogStore.load(context).versions.first()
CompositionLocalProvider(LocalDensity provides Density(density.density, 1.35f)) {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Box(
modifier = Modifier
.fillMaxSize()
.background(androidx.compose.material3.MaterialTheme.colorScheme.background)
.padding(12.dp),
contentAlignment = Alignment.TopCenter,
) {
WhatsNewToastContent(
entry = entry,
progress = 0.62f,
onExpand = {},
onDismiss = {},
)
}
}
}
}
compose.onNodeWithText("Supervised Mode").assertExists()
compose.onNodeWithText("Also: 2 fixes").assertExists()
compose.onNodeWithText("Accurate activity, safer return…").assertExists()
compose.onNodeWithText("View all").assertExists()
compose.onNodeWithContentDescription("Close").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/whats-new-toast-large-text.png")
}
@Test fun exposesExpandAndCloseActions() {
var expanded = false
var dismissed = false
compose.setContent {
val entry = ChangelogStore.load(LocalContext.current).versions.first()
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
WhatsNewToastContent(
entry = entry,
progress = 1f,
onExpand = { expanded = true },
onDismiss = { dismissed = true },
)
}
}
compose.onNodeWithText("Supervised Mode").performClick()
expanded = false
compose.onNodeWithText("View all").performClick()
compose.onNodeWithContentDescription("Close").performClick()
assertTrue(expanded)
assertTrue(dismissed)
}
@Test fun horizontalSwipeDismissesTheNotice() {
var dismissed = false
compose.mainClock.autoAdvance = false
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
WhatsNewToast(
onDismiss = { dismissed = true },
onExpand = {},
autoDismissMillis = 60_000L,
)
}
}
compose.mainClock.advanceTimeBy(300L)
compose.onNodeWithText("Supervised Mode").performTouchInput { swipeLeft(durationMillis = 300L) }
compose.mainClock.advanceTimeBy(300L)
assertTrue(dismissed)
}
}
@@ -36,6 +36,47 @@ class ChangelogParserTest {
assertEquals(listOf("a", "b"), changelog.versions[0].sections.first().bullets)
}
@Test
fun parsesCuratedHighlightAndImprovements() {
val raw = """
{
"schema": 2,
"versions": [
{
"version": "1.2.0",
"title": "A useful release",
"date": "2026-06-20",
"highlight": {
"title": "The main reason to care",
"summary": "A plain-language explanation.",
"bullets": ["a", "b", "c"]
},
"improvements": ["d", "e"],
"toastDigest": {
"additionalFeatureCount": 1,
"fixCount": 2,
"preview": ["Secondary feature", "Important fix"]
},
"playNotes": "Concise Play copy."
}
]
}
""".trimIndent()
val entry = ChangelogStore.parse(raw).versions.single()
assertEquals("The main reason to care", entry.highlight?.title)
assertEquals("A plain-language explanation.", entry.highlight?.summary)
assertEquals(listOf("a", "b", "c"), entry.highlight?.bullets)
assertEquals(listOf("d", "e"), entry.improvements)
assertEquals(1, entry.toastDigest?.additionalFeatureCount)
assertEquals(2, entry.toastDigest?.fixCount)
assertEquals(listOf("Secondary feature", "Important fix"), entry.toastDigest?.preview)
assertEquals("Concise Play copy.", entry.playNotes)
assertEquals("v1.2.0 · 2026-06-20", entry.versionLine())
assertEquals(listOf("The main reason to care", "Also improved"), entry.toGroups().map { it.header })
}
@Test
fun blankInputYieldsEmptyChangelog() {
assertTrue(ChangelogStore.parse("").versions.isEmpty())
@@ -0,0 +1,24 @@
package com.hermesandroid.relay.ui.screens
import java.util.Locale
import org.junit.Assert.assertEquals
import org.junit.Test
class ChangelogScreenTest {
@Test
fun formatsReleaseDateForTheCurrentLocale() {
assertEquals("Aug 25, 2026", formatReleaseDate("2026-08-25", Locale.US))
}
@Test
fun preservesAnUnparseableReleaseDate() {
assertEquals("coming soon", formatReleaseDate("coming soon", Locale.US))
}
@Test
fun matchesInstalledReleaseAcrossFlavorSuffixes() {
assertEquals(true, isInstalledRelease("1.13.2", "1.13.2-sideload", "sideload"))
assertEquals(true, isInstalledRelease("1.13.2-rc.1", "1.13.2-rc.1-googlePlay", "googlePlay"))
assertEquals(false, isInstalledRelease("1.13.1", "1.13.2-sideload", "sideload"))
}
}
@@ -1,66 +0,0 @@
package com.hermesandroid.relay.ui.screens
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatCleanViewHintTest {
@Test
fun petRemainsVisibleOnUnobstructedChat() {
assertFalse(shouldHideChatPet())
}
@Test
fun everyModalChatSurfaceSuppressesPet() {
assertTrue(shouldHideChatPet(ambientMode = true))
assertTrue(shouldHideChatPet(voiceMode = true))
assertTrue(shouldHideChatPet(drawerOpenOrMoving = true))
assertTrue(shouldHideChatPet(commandPaletteVisible = true))
assertTrue(shouldHideChatPet(modelSheetVisible = true))
assertTrue(shouldHideChatPet(effortSheetVisible = true))
assertTrue(shouldHideChatPet(contextSheetVisible = true))
assertTrue(shouldHideChatPet(backgroundProcessesVisible = true))
assertTrue(shouldHideChatPet(agentInfoVisible = true))
}
@Test
fun emptyTextChatShowsCleanViewHint() {
assertTrue(
shouldShowCleanViewHint(
hasMessages = false,
ambientMode = false,
voiceMode = false,
),
)
}
@Test
fun voiceModeOwnsBottomAreaOnEmptyChat() {
assertFalse(
shouldShowCleanViewHint(
hasMessages = false,
ambientMode = false,
voiceMode = true,
),
)
}
@Test
fun existingConversationOrCleanModeSuppressesHint() {
assertFalse(
shouldShowCleanViewHint(
hasMessages = true,
ambientMode = false,
voiceMode = false,
),
)
assertFalse(
shouldShowCleanViewHint(
hasMessages = false,
ambientMode = true,
voiceMode = false,
),
)
}
}
@@ -0,0 +1,25 @@
package com.hermesandroid.relay.ui.screens
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatPetVisibilityTest {
@Test
fun petRemainsVisibleOnUnobstructedChat() {
assertFalse(shouldHideChatPet())
}
@Test
fun everyModalChatSurfaceSuppressesPet() {
assertTrue(shouldHideChatPet(voiceMode = true))
assertTrue(shouldHideChatPet(drawerOpenOrMoving = true))
assertTrue(shouldHideChatPet(commandPaletteVisible = true))
assertTrue(shouldHideChatPet(modelSheetVisible = true))
assertTrue(shouldHideChatPet(effortSheetVisible = true))
assertTrue(shouldHideChatPet(contextSheetVisible = true))
assertTrue(shouldHideChatPet(backgroundProcessesVisible = true))
assertTrue(shouldHideChatPet(agentInfoVisible = true))
}
}
@@ -992,11 +992,12 @@ class ChatViewModelGatewayInboundTurnTest {
awaitCondition { handler.messages.value.any { it.content == "Partial A" } }
viewModel.switchSession(secondSession)
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition { handler.currentSessionId.value == secondSession && !handler.isStreaming.value }
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "session.resume" })
assertTrue(gatewayHarness.rpcLog.none { it.first == "session.interrupt" })
viewModel.sendMessage("Run task B")
gatewayHarness.awaitRpcCount("session.resume", 2)
gatewayHarness.awaitRpcCount("prompt.submit", 2)
serverWs.send(
gatewayHarness.eventFrame(
@@ -1824,11 +1825,12 @@ class ChatViewModelGatewayInboundTurnTest {
awaitCondition { viewModel.queuedMessages.value == listOf("Follow up A") }
viewModel.switchSession(secondSession)
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition { handler.currentSessionId.value == secondSession && !handler.isStreaming.value }
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "session.resume" })
assertTrue("session B must not show A's queue", viewModel.queuedMessages.value.isEmpty())
viewModel.sendMessage("Run task B")
gatewayHarness.awaitRpcCount("session.resume", 2)
gatewayHarness.awaitRpcCount("prompt.submit", 2)
serverWs.send(
gatewayHarness.eventFrame(
@@ -2031,13 +2033,16 @@ class ChatViewModelGatewayInboundTurnTest {
serverWs.close(1012, "test disconnect")
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Idle }
viewModel.setChatVisible(true)
viewModel.prewarmGateway()
gatewayHarness.awaitServerSocket()
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition {
handler.messages.value.singleOrNull()?.content == BACKGROUND_ANSWER
}
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "session.resume" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.activate" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.interrupt" })
assertFalse(handler.isStreaming.value)
}
@@ -2048,16 +2053,19 @@ class ChatViewModelGatewayInboundTurnTest {
// Foreground arrives while OkHttp still reports the old socket ready,
// so the one-shot prewarm is an intentional no-op. The delayed close
// callback must itself trigger an exact-session reattach.
// callback must itself restore the observation socket and catch up
// history without attaching the live session.
viewModel.prewarmGateway()
serverWs.close(1012, "late background close")
awaitCondition { gatewayHarness.ticketMints.get() >= 2 }
serverWs = gatewayHarness.awaitServerSocket()
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition {
handler.messages.value.singleOrNull()?.content == BACKGROUND_ANSWER
}
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "session.resume" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.activate" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.interrupt" })
assertFalse(handler.isStreaming.value)
}
@@ -2366,36 +2374,189 @@ class ChatViewModelGatewayInboundTurnTest {
}
@Test
fun reconnectAfterMissedStartRecoversOnExactSessionCompletion() {
fun reconnectCatchupClosesCompletionBetweenFirstReadAndIdleSnapshot() {
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
awaitCondition { !viewModel.isLoadingHistory.value }
val firstReadStarted = CompletableDeferred<Unit>()
val releaseFirstRead = CompletableDeferred<Unit>()
val readCount = AtomicInteger(0)
viewModel.setProfileMessageLoader {
if (readCount.incrementAndGet() == 1) {
firstReadStarted.complete(Unit)
releaseFirstRead.await()
Result.success(emptyList())
} else {
Result.success(persistedHistory)
}
}
serverWs.close(1012, "missed start")
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Idle }
viewModel.setChatVisible(true)
viewModel.prewarmGateway()
serverWs = gatewayHarness.awaitServerSocket()
gatewayHarness.awaitRpcCount("session.resume", 2)
// Reconnected midway through the synthetic turn: no message.start is
// replayed, so the delta is intentionally ignored and completion drives
// authoritative history recovery.
serverWs.send(
gatewayHarness.eventFrame(
"message.delta",
buildJsonObject { put("text", BACKGROUND_ANSWER) },
"live-resumed",
),
)
awaitCondition { firstReadStarted.isCompleted }
// Completion persists after the reconnect's first catch-up read began,
// while the first active-list snapshot is already empty/idle. The
// pending final-read marker must close this exact ordering window.
persistedHistory = persistedAnswerHistory()
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", BACKGROUND_ANSWER) },
"live-resumed",
),
)
releaseFirstRead.complete(Unit)
awaitCondition { handler.messages.value.any { it.content == BACKGROUND_ANSWER } }
assertEquals(1, gatewayHarness.rpcLog.count { it.first == "session.resume" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.activate" })
assertEquals(0, gatewayHarness.rpcLog.count { it.first == "session.interrupt" })
assertFalse(handler.isStreaming.value)
}
@Test
fun passiveForegroundObservationNeverClaimsOrInterruptsDesktopTurn() {
val observerProfile = Profile(
name = "observer",
model = "model-a",
description = "Observer",
)
viewModel.setSelectedProfileProvider { observerProfile }
viewModel.setSessionProfileNameProvider { observerProfile.name }
viewModel.setProfileMessageLoaderWithMode { profileName, sessionId, _ ->
assertEquals(STORED_SESSION_ID, sessionId)
Result.success(
if (profileName == observerProfile.name) {
persistedHistory
} else {
listOf(
MessageItem(
id = "wrong-profile",
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive("Wrong profile history"),
),
)
},
)
}
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("connection-a", observerProfile.name),
STORED_SESSION_ID,
)
awaitCondition { !viewModel.isLoadingHistory.value }
viewModel.setChatVisible(false)
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
val ownershipMethods = setOf(
"session.resume",
"session.activate",
"session.interrupt",
"prompt.submit",
)
val baseline = ownershipMethods.associateWith { method ->
gatewayHarness.rpcLog.count { it.first == method }
}
val baselineActiveList = gatewayHarness.rpcLog.count { it.first == "session.active_list" }
gatewayHarness.activeSessionListPayload = activeSessionPayload("working")
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = gatewayHarness.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
),
okHttpClient = OkHttpClient(),
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
)
viewModel.setChatTurnCheckpointStore(MemoryCheckpointStore())
viewModel.updateGatewayClient(gatewayClient)
viewModel.setChatVisible(true)
viewModel.prewarmGateway()
awaitCondition {
gatewayHarness.rpcLog.count { it.first == "session.active_list" } > baselineActiveList
}
persistedHistory = listOf(
MessageItem(
id = "desktop-answer",
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive("Desktop completed without Android attachment."),
),
)
awaitCondition {
handler.messages.value.singleOrNull()?.content ==
"Desktop completed without Android attachment."
}
ownershipMethods.forEach { method ->
assertEquals(
"passive foreground sent $method",
baseline.getValue(method),
gatewayHarness.rpcLog.count { it.first == method },
)
}
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
assertEquals(
"observer teardown interrupted the Desktop turn",
baseline.getValue("session.interrupt"),
gatewayHarness.rpcLog.count { it.first == "session.interrupt" },
)
}
@Test
fun observerReadyAfterChatHidesCannotRestartPassiveWork() {
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
awaitCondition { !viewModel.isLoadingHistory.value }
viewModel.setChatVisible(false)
viewModel.updateGatewayClient(null)
gatewayClient.shutdown()
gatewayScope.cancel()
val historyReads = AtomicInteger(0)
viewModel.setProfileMessageLoader {
historyReads.incrementAndGet()
Result.success(persistedHistory)
}
val controlMethods = setOf(
"session.resume",
"session.activate",
"session.interrupt",
"prompt.submit",
)
val baseline = controlMethods.associateWith { method ->
gatewayHarness.rpcLog.count { it.first == method }
}
val baselineActiveList = gatewayHarness.rpcLog.count { it.first == "session.active_list" }
gatewayHarness.suppressGatewayReady = true
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
gatewayClient = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
baseUrl = gatewayHarness.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
),
okHttpClient = OkHttpClient(),
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
)
viewModel.setChatTurnCheckpointStore(MemoryCheckpointStore())
viewModel.updateGatewayClient(gatewayClient)
viewModel.setChatVisible(true)
val delayedSocket = gatewayHarness.awaitServerSocket()
viewModel.setChatVisible(false)
gatewayHarness.sendGatewayReady(delayedSocket)
shadowOf(Looper.getMainLooper()).idleFor(500, TimeUnit.MILLISECONDS)
Thread.sleep(100)
assertEquals(0, historyReads.get())
assertEquals(
baselineActiveList,
gatewayHarness.rpcLog.count { it.first == "session.active_list" },
)
controlMethods.forEach { method ->
assertEquals(baseline.getValue(method), gatewayHarness.rpcLog.count { it.first == method })
}
}
@Test
fun staleHistoryReadCannotEraseATurnCompletedDuringTheFetch() {
val loadCount = AtomicInteger(0)
+33
View File
@@ -3977,3 +3977,36 @@ disappearance, client-side profile isolation, and method-not-found; physical
and current-host certification remains tracked in `TODO.md`. An upstream
profile field/filter or explicitly owned aggregate activity route would remove
the remaining ambiguity for multi-profile clients.
---
## ADR 69 — Passive Android observation never attaches another client's Gateway turn
**Status:** Accepted (2026-08-28).
**Context.** `session.resume` and `session.activate` are live-runtime attachment
operations, not read-only subscriptions. Android previously called
`session.resume` while opening or foregrounding Chat and after loading a saved
session's history. When Desktop/TUI already owned a running turn, that passive
prewarm could rebind the runtime transport to Android. A later Android socket,
route, or client teardown could then strand the producer or promote the foreign
turn into an Android `GatewayTurn` whose cancellation sends `session.interrupt`.
The issue was distinct from the earlier stale-view and missing-terminal recovery
paths, which concern exact Android-owned checkpoints.
**Decision.** Ordinary visibility, foreground restoration, Idle-socket recovery,
and saved-session selection establish only the shared Gateway socket. They use
profile-scoped REST history plus process-wide `session.active_list`; while an
unowned row with the selected durable id is live, Android performs bounded
history refreshes and one final read after settlement. These observer paths send
no `session.resume`, `session.activate`, `prompt.submit`, or `session.interrupt`.
Exact Android-owned checkpoints retain `session.activate` with durable-resume
fallback, and explicit send or session-config actions may resume because the user
is intentionally taking control of that destination.
**Consequences.** Opening Android cannot replace, stop, or later cancel a turn
already running in Desktop/TUI. Live token frames remain with the producing
client; Android observes durable progress and final history without inventing a
multi-subscriber Gateway contract. The first explicit Android mutation may pay
the resume latency that passive prewarm previously hid. Cross-client fixtures
and Android lifecycle coverage enforce the no-control-RPC observation boundary.
+1
View File
@@ -41,6 +41,7 @@ the upstream contract identifiers it depends on.
| `active_status_lifecycle` | `session.active_list` reports starting, working, waiting, and idle, then a complete empty process-wide snapshot permits removal of unambiguously owned prior rows |
| `active_status_profile_scope` | A row has no profile metadata and a caller profile hint has no effect; the client must use exact client-held ownership and reject invented attribution |
| `active_status_unsupported` | An older Gateway returns JSON-RPC method-not-found; the client retains Unknown rather than inventing Idle or Working |
| `cross_client_observation` | A second client observes a Desktop-owned working session through active status and history without resume, activate, submit, or interrupt; the producing client receives the terminal event |
Fixture evidence is a bounded metadata-only ring. It records sequence,
connection number, RPC method, event type, scope classification, and outcome.
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+2 -2
View File
@@ -141,7 +141,7 @@ The companion has a durable home and an optional autonomous roaming mode:
dragging itself remains finger-driven.
- Roaming requires an idle agent, the foreground app, enabled animation, and an
available safe rail. Agent activity, scrolling, dragging, an open pet menu,
startup, voice, clean/ambient mode, Android animator scale 0, or TalkBack touch
startup, voice, Android animator scale 0, or TalkBack touch
exploration stops autonomous travel. During scrolling, the pet follows its
current measured or synthetic habitat rail while it remains valid; it does
not re-dock or teleport. The IME/short-screen compact layout uses 50 dp base
@@ -676,7 +676,7 @@ fallback checks with a pack missing one or more optional rows:
opening and closing the keyboard moves that dock with the input rail without
hiding the pet or placing it over transcript content.
- [ ] Opening or closing the session drawer, command palette, model/reasoning/
context/process/agent sheets, clean mode, or either voice presentation keeps
context/process/agent sheets or either voice presentation keeps
the pet absent until the owning surface has fully closed. Slash autocomplete
and recent-prompt chips remain usable and never become walkable terrain.
- [ ] After a plain assistant response, it approaches through the outer gutter,
@@ -1,6 +1,8 @@
# Clean mode, Sphere background, and floating pets — design record
# Sphere background and floating pets — design record
**Status:** Implemented; interaction model revised 2026-08-01 for #267
**Status:** Implemented; interaction model revised 2026-08-01 for #267. The
separate Chat clean-focus presentation was removed 2026-08-27; the background
visualization and floating-pet contracts remain current.
**Owner surface:** Android app shell, Chat, Terminal, and Appearance settings
## Decision
@@ -51,7 +53,7 @@ Desktop and Codex while adapting it to a phone-sized viewport.
landing squash. Idle variety cycles through hops, waves, and rests.
- Roaming runs only while Hermes is idle and the app is foregrounded. Thinking,
streaming, tool work, errors, transcript scrolling, dragging, the pet menu,
voice, clean/ambient mode, startup, and loss of a safe rail pause or dock it as
voice, startup, and loss of a safe rail pause or dock it as
appropriate. The director's fixed priority is direct interaction, agent
activity, response visit, roaming, then idle.
- App animation-off, Android animator scale 0, and TalkBack touch exploration
@@ -114,8 +116,6 @@ identity data is unchanged.
[`../sphere-spec.md`](../sphere-spec.md).
- `MorphingSphereCore` and its web parity harness remain the source of truth for
Sphere rendering.
- Clean text-flow mode remains a presentation of the active conversation, not a
separate chat or profile state.
## Out of scope
+1 -1
View File
@@ -93,7 +93,7 @@ Paste into Play Console → **What's new** (≤500 characters):
```
v1.13.2 - Supervised Mode and clearer activity
Supervised Mode adds a parent-configured, profile-pinned chat view with device-authenticated settings. Parents can limit attachments, Standard voice, generated media, history, actions, and technical details. Session rows stay neutral while live activity is unavailable, and returning from parent settings no longer blanks Supervised Chat.
Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.
```
## Category
+2 -2
View File
@@ -481,7 +481,7 @@ Bottom navigation bar with 4 tabs:
- **Bot group projection** — Android merges the bounded `ui_meta["hermes-bots-groups"]` v3 projection across gateways by durable room identity and newest revision. Rooms and recent messages are visibly read-only; Android does not create, rename, disband, join, send, coordinate member turns, or become a second room-log authority. Binary room images are ignored at this metadata boundary.
- **Session drawer** (swipe from left or hamburger icon) — session list with title, timestamp, message count. Create, switch, rename, delete, pin/unpin, and archive/restore. The process-owned conversation binding is the single connection/profile/session identity for Chat; selecting an All Profiles row atomically makes its owner the selected agent and persists that profile/session, while merely browsing All Profiles changes no agent state. Lifecycle or locale-driven Activity recreation cannot replace an explicit binding with stale persisted state, and asynchronous list/history/mutation work is accepted only for the binding's exact namespace. A profile lock hides All Profiles and rejects stale/deep-linked cross-profile opens. The All Profiles browser mode otherwise survives Activity state restoration and refetches its rows after recreation. Pin and archive are durable upstream session fields loaded and patched through the owning connection/profile's Dashboard session API; Android does not keep a second local flag registry. Archived rows are requested explicitly so they remain restorable after recreation. Failed mutations roll back the optimistic row, while refresh and deletion reconcile from server truth. When a persisted title is absent, use upstream's first-user-message `preview`, matching the Hermes Desktop session picker; show "Untitled" only when neither value exists.
- **Authoritative session activity** — one composite registry keyed by connection, normalized profile, and durable session id drives the drawer, filters, grouping, animation, accessibility, and the visible composer. Exact pending approval/clarify/sudo/secret/MCP requests produce **Needs input**; the Gateway's process-wide `session.active_list` supplies **Starting**, **Working**, and **Idle**; exact terminal or `session.info {running:false}` can settle the matching generation. Because active-list rows normally have no profile metadata, Android assigns a row only through exact foreground/detached ownership already held by that client, or explicit profile metadata if a future upstream sends it. A bounded REST directory never proves global uniqueness. Unresolved rows create no status. Resolved rows from a partial snapshot may update their exact owners, but disappearance settles a scope only when the successful process-wide snapshot was completely and unambiguously resolved for it. Restart/checkpoint recovery is **Checking**; a failed or unsupported live refresh is **Unavailable**, never inferred Idle. REST `is_active` remains recency metadata only. `process.list` may add a separate **Background work** indicator and never keeps the parent conversation Working. Old socket generations, bare session ids from another profile, and delayed snapshots cannot revive newer settled state.
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible turn without sending `session.interrupt`; each running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Explicit Stop still interrupts. SSE fallback stays single-stream and cancels on navigation.
- **Concurrent Gateway chats** — switching sessions, profiles, drafts, or Threads detaches the visible Android-owned turn without sending `session.interrupt`; each Android-owned running chat keeps a connection/profile/session-scoped checkpoint and reattaches to its live Gateway session when reopened. Opening, foregrounding, or selecting a saved session without that exact checkpoint is read-only observation: Android warms only the socket, reads profile-scoped history, and polls `session.active_list` without `session.resume`, `session.activate`, `prompt.submit`, or `session.interrupt`. A Desktop/TUI-owned turn therefore remains owned by its producing client; Android refreshes persisted progress and performs one final history read when the runtime settles. Explicit send/config actions may resume the destination session, explicit Stop still interrupts, and SSE fallback stays single-stream and cancels on navigation.
- **Queued Gateway follow-ups** — every local queued item is immutably scoped to its originating connection, profile, stored session, transport, and run generation; only that run's completion can make it eligible, and switching sessions shows only that session's queue. Restored text queues retain the same scope, while unavailable/deleted destinations and non-restorable attachment queues fail visibly instead of following the current composer. Drained messages add `queued: true` to `prompt.submit`; ordinary sends omit the field. Authoritative submit rejections (`4004`, `4018`, `4028`, `4029`, `4030`, `4090`, `5008`, `5070`, and `5071`) preserve the server message and never fall through to API-server SSE.
- **Durable composer drafts** — each connection/profile/session owns one app-private draft containing text, quote/edit context, and pending attachment bytes. Metadata and content-addressed blobs live under Android's no-backup directory, are capped at 64 drafts and 128 MB of retained blobs outside the active draft, flush when Chat backgrounds, and are removed after a successful send. Session/profile/connection navigation saves the previous owner before restoring the destination; an opened cross-profile session uses its actual owning profile rather than the global picker.
- **Large paste review** — a default-on Chat setting converts any single insertion of at least 5,000 characters into a visible `pasted-text.txt` attachment before the normal message-length limit rejects it. Gateway uses upstream `file.attach`; API-server SSE and proactive Thread paths materialize the same UTF-8 text into the outgoing prompt and remove only the synthetic attachment from that transport, so the behavior never requires Relay or silently drops content.
@@ -544,7 +544,7 @@ The bridge UI drives — and is driven by — Tier 5 safety-rails (`BridgeSafety
- **Data** — Android-local backup, restore, and reset with confirmation dialogs. Manage → Operations also uses the authenticated upstream dashboard to create and download server backups and to upload an explicitly confirmed zip to the guarded import staging route. Manage → Learning edits full node content and confirms deletion (Hermes archives skill nodes; memory-node deletion is permanent). Manage → Memory uses upstream discovery/config/setup and provider activation, scoped to the selected profile. Manage → Channels includes upstream WhatsApp QR onboarding, status polling, apply/cancel, and the resulting gateway restart.
- **Automation** — Manage → Cron lists and controls jobs through upstream Dashboard routes. A current authenticated Gateway also exposes **New schedule**, including an optional 1–999 run cap through native `cron.manage`; blank retains the schedule's upstream one-shot/forever default. Android never turns invalid finite counts into unlimited work and does not introduce a Relay scheduler.
- **Local reset evidence** — immediately before New chat or Thread entry replaces the visible context, Android stores a bounded app-private checkpoint containing only transport, structural counts, and lifecycle booleans. No conversation content, identifiers, profile names, URLs, paths, media, tool payloads, secrets, or telemetry leaves the device; the record is available only through the existing review-before-share Diagnostics bundle.
- **About** — logo on dark background, dynamic version from BuildConfig, Source + Docs link buttons, credits. What's New dialog.
- **About** — logo on dark background, dynamic version from BuildConfig, Source + Docs link buttons, credits. Post-update What's New uses a non-blocking timed/swipeable toast with a compact secondary feature/fix digest, then expands into the centered highlight view; About and Settings retain intentional access to the full release history.
---
+5 -4
View File
@@ -1,9 +1,10 @@
# Sphere skin spec
The agent sphere (the ASCII "orb" on the empty chat screen, ambient mode, and
the voice overlay) is a **hot-swappable component**. Built-in skins ship with the
app, an **Adaptive** skin recolors to match your theme, and you can side-load your
own skins from a small JSON file. This document is the authoring reference.
The agent sphere (the ASCII "orb" on the empty chat screen, behind the chat
transcript, and in the voice overlay) is a **hot-swappable component**. Built-in
skins ship with the app, an **Adaptive** skin recolors to match your theme, and
you can side-load your own skins from a small JSON file. This document is the
authoring reference.
> **See also** [`pet-spec.md`](./pet-spec.md) — appearance is two-level: the
> background visualization and floating pets are independent; skins apply only
+3
View File
@@ -30,13 +30,16 @@ FOCUSED_TESTS = (
"com.hermesandroid.relay.ui.components.BackgroundTaskCardTest",
"com.hermesandroid.relay.ui.components.DotMatrixIndicatorTest",
"com.hermesandroid.relay.ui.components.AttachmentGalleryLayoutTest",
"com.hermesandroid.relay.ui.components.ChangelogParserTest",
"com.hermesandroid.relay.ui.components.MarkdownStreamingParserTest",
"com.hermesandroid.relay.ui.screens.ChangelogScreenTest",
"com.hermesandroid.relay.ui.screens.ChatUnreadStateTest",
)
REPOSITORY_CHECKS = (
"check-android-locales.py",
"check-user-docs-locales.py",
"check-android-collection-apis.py",
"check-android-release-notes.py",
"check-version-tracks.py",
)
+11 -10
View File
@@ -66,16 +66,17 @@ git diff --no-color "$LIBS" || true
echo " --------------------------------------------------------"
echo ""
echo " Next steps:"
echo " 1. Update CHANGELOG.md, RELEASE_NOTES.md, app/src/main/assets/whats_new.txt,"
echo " app/src/main/assets/changelog.json, and"
echo " app/src/googlePlay/play/release-notes/en-US/default.txt"
echo " - whats_new.txt holds ONLY the latest version (auto-dialog fallback +"
echo " Play Console 'What's new' source; gradle-play-publisher reads default.txt; <=500 chars/lang)"
echo " - changelog.json: PREPEND a new {version,title,date,sections[]} block (newest first)"
echo " to versions[]; it backs the in-app What's New dialog + full Settings changelog"
echo " 2. Commit on dev:"
echo " git add $LIBS CHANGELOG.md RELEASE_NOTES.md app/src/main/assets/whats_new.txt app/src/main/assets/changelog.json app/src/googlePlay/play/release-notes/en-US/default.txt"
echo " 1. Update CHANGELOG.md, RELEASE_NOTES.md, and prepend the curated Android"
echo " release entry to app/src/main/assets/changelog.json. The newest entry"
echo " requires highlight {title,summary,bullets[1..3]}, improvements[0..2],"
echo " toastDigest {additionalFeatureCount,fixCount,preview[0..2]}, playNotes,"
echo " and the technical sections retained for history."
echo " 2. Refresh and validate the derived in-app fallback + Play notes:"
echo " python scripts/check-android-release-notes.py --write"
echo " python scripts/check-android-release-notes.py"
echo " 3. Commit on a release-prep branch targeting dev:"
echo " git add $LIBS CHANGELOG.md RELEASE_NOTES.md app/src/main/assets/whats_new.txt app/src/main/assets/changelog.json app/src/googlePlay/play/release-notes/en-US/default.txt docs/play-store-listing.md"
echo " git commit -m \"release(android): android-v$NEW_VERSION\""
echo " 3. Merge dev -> main, then tag main:"
echo " 4. After the approved dev -> main release PR, tag the new main tip:"
echo " git tag android-v$NEW_VERSION"
echo " git push origin android-v$NEW_VERSION"
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Validate and synchronize Android release-note surfaces.
The newest ``changelog.json`` entry is the curated source for the in-app
What's New experience. ``--write`` refreshes the legacy text fallback, Google
Play notes, and the matching operator copy in ``docs/play-store-listing.md``.
The technical CHANGELOG and GitHub release body remain fuller records, but
their version headings are checked so release prep cannot silently drift.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import sys
import tomllib
from typing import Any
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
CHANGELOG_JSON = pathlib.Path("app/src/main/assets/changelog.json")
WHATS_NEW = pathlib.Path("app/src/main/assets/whats_new.txt")
PLAY_NOTES = pathlib.Path("app/src/googlePlay/play/release-notes/en-US/default.txt")
PLAY_LISTING = pathlib.Path("docs/play-store-listing.md")
PLAY_SECTION_RE = re.compile(
r"(## Release Notes\s+Paste into Play Console → \*\*What's new\*\* "
r"\(≤500 characters\):\s+```\s*\n)(.*?)(\n```\s*\n## Category)",
re.DOTALL,
)
def _read_text(relative: pathlib.Path) -> str:
return (REPO_ROOT / relative).read_text(encoding="utf-8")
def _android_version() -> str:
versions = tomllib.loads(_read_text(pathlib.Path("gradle/libs.versions.toml"))).get(
"versions", {}
)
return str(versions.get("appVersionName", ""))
def _latest_entry() -> dict[str, Any]:
data = json.loads(_read_text(CHANGELOG_JSON))
if not isinstance(data, dict) or data.get("schema") != 2:
raise ValueError("changelog.json must use schema 2")
versions = data.get("versions")
if not isinstance(versions, list) or not versions or not isinstance(versions[0], dict):
raise ValueError("changelog.json must contain at least one version")
return versions[0]
def validate_curated_entry(entry: dict[str, Any], expected_version: str) -> list[str]:
errors: list[str] = []
version = entry.get("version")
if version != expected_version:
errors.append(
f"latest changelog version is {version!r}, expected Android {expected_version!r}"
)
for field in ("title", "date"):
if not isinstance(entry.get(field), str) or not entry[field].strip():
errors.append(f"latest changelog entry requires non-empty {field}")
highlight = entry.get("highlight")
if not isinstance(highlight, dict):
errors.append("latest changelog entry requires a highlight object")
highlight = {}
for field in ("title", "summary"):
if not isinstance(highlight.get(field), str) or not highlight[field].strip():
errors.append(f"latest changelog highlight requires non-empty {field}")
bullets = highlight.get("bullets")
if not isinstance(bullets, list):
errors.append("latest changelog highlight bullets must be an array")
bullets = []
elif not 1 <= len(bullets) <= 3:
errors.append("latest changelog highlight must contain 1-3 bullets")
if any(not isinstance(item, str) or not item.strip() for item in bullets):
errors.append("latest changelog highlight bullets must be non-empty strings")
improvements = entry.get("improvements", [])
if not isinstance(improvements, list):
errors.append("latest changelog improvements must be an array")
improvements = []
elif len(improvements) > 2:
errors.append("latest changelog may contain at most 2 improvements")
if any(not isinstance(item, str) or not item.strip() for item in improvements):
errors.append("latest changelog improvements must be non-empty strings")
if len(bullets) + len(improvements) > 5:
errors.append("latest changelog may expose at most 5 total list items")
digest = entry.get("toastDigest")
if not isinstance(digest, dict):
errors.append("latest changelog entry requires a toastDigest object")
digest = {}
digest_counts: list[int] = []
for field in ("additionalFeatureCount", "fixCount"):
count = digest.get(field)
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
errors.append(f"latest changelog toastDigest {field} must be a non-negative integer")
else:
digest_counts.append(count)
digest_preview = digest.get("preview")
if not isinstance(digest_preview, list):
errors.append("latest changelog toastDigest preview must be an array")
digest_preview = []
elif any(not isinstance(item, str) or not item.strip() for item in digest_preview):
errors.append("latest changelog toastDigest preview items must be non-empty strings")
if len(digest_counts) == 2:
if sum(digest_counts) == 0 and digest_preview:
errors.append("latest changelog toastDigest preview must be empty when counts are zero")
elif sum(digest_counts) > 0 and not 1 <= len(digest_preview) <= 2:
errors.append("latest changelog toastDigest preview must contain 1-2 items when secondary items exist")
play_notes = entry.get("playNotes")
if not isinstance(play_notes, str) or not play_notes.strip():
errors.append("latest changelog entry requires non-empty playNotes")
elif len(render_play_notes(entry)) > 500:
errors.append("rendered Google Play notes exceed 500 characters")
return errors
def render_whats_new(entry: dict[str, Any]) -> str:
highlight = entry["highlight"]
lines = [
f"v{entry['version']} - {entry['title']}",
"",
highlight["title"],
*(f"* {bullet}" for bullet in highlight["bullets"]),
]
improvements = entry.get("improvements", [])
if improvements:
lines.extend(("", "Also improved", *(f"* {item}" for item in improvements)))
return "\n".join(lines) + "\n"
def render_play_notes(entry: dict[str, Any]) -> str:
return f"v{entry['version']} - {entry['title']}\n\n{entry['playNotes'].strip()}\n"
def _replace_play_listing(current: str, rendered: str) -> str:
match = PLAY_SECTION_RE.search(current)
if not match:
raise ValueError("docs/play-store-listing.md has no recognized Release Notes block")
return current[: match.start()] + match.group(1) + rendered.rstrip() + match.group(3) + current[match.end() :]
def _check_exact(relative: pathlib.Path, expected: str, errors: list[str]) -> None:
actual = _read_text(relative).replace("\r\n", "\n")
if actual != expected:
errors.append(f"{relative.as_posix()} is stale; run this script with --write")
def _validate_record_versions(version: str, errors: list[str]) -> None:
changelog = _read_text(pathlib.Path("CHANGELOG.md"))
if f"## [Android {version}]" not in changelog:
errors.append(f"CHANGELOG.md has no [Android {version}] release heading")
release_notes = _read_text(pathlib.Path("RELEASE_NOTES.md"))
if not release_notes.startswith(f"# Hermes-Relay Android v{version}\n"):
errors.append(f"RELEASE_NOTES.md does not describe Android v{version}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--write",
action="store_true",
help="Refresh derived in-app fallback and Play release-note files",
)
args = parser.parse_args()
try:
version = _android_version()
entry = _latest_entry()
errors = validate_curated_entry(entry, version)
if errors:
raise ValueError("\n".join(errors))
whats_new = render_whats_new(entry)
play_notes = render_play_notes(entry)
listing = _replace_play_listing(_read_text(PLAY_LISTING), play_notes)
except (OSError, ValueError, json.JSONDecodeError, tomllib.TOMLDecodeError) as exc:
print(f"Android release-note check failed: {exc}", file=sys.stderr)
return 1
if args.write:
(REPO_ROOT / WHATS_NEW).write_text(whats_new, encoding="utf-8", newline="\n")
(REPO_ROOT / PLAY_NOTES).write_text(play_notes, encoding="utf-8", newline="\n")
(REPO_ROOT / PLAY_LISTING).write_text(listing, encoding="utf-8", newline="\n")
print("Android release-note derivatives refreshed")
return 0
errors = []
_check_exact(WHATS_NEW, whats_new, errors)
_check_exact(PLAY_NOTES, play_notes, errors)
if _read_text(PLAY_LISTING).replace("\r\n", "\n") != listing:
errors.append("docs/play-store-listing.md release notes are stale; run this script with --write")
_validate_record_versions(version, errors)
if errors:
print("Android release-note validation failed:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
return 1
print(f"Android release notes are aligned for v{version}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,97 @@
from __future__ import annotations
import importlib.util
import pathlib
import unittest
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "check-android-release-notes.py"
SPEC = importlib.util.spec_from_file_location("check_android_release_notes", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
release_notes = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(release_notes)
def entry(*, bullets: int = 3, improvements: int = 2) -> dict[str, object]:
return {
"version": "1.2.3",
"title": "A useful release",
"date": "2026-08-26",
"highlight": {
"title": "The main reason to care",
"summary": "A short explanation in plain language.",
"bullets": [f"Benefit {index}" for index in range(bullets)],
},
"improvements": [f"Improvement {index}" for index in range(improvements)],
"toastDigest": {
"additionalFeatureCount": 1,
"fixCount": 2,
"preview": ["Secondary feature", "Important fix"],
},
"playNotes": "A concise Play Store summary.",
}
class AndroidReleaseNotesTest(unittest.TestCase):
def test_allows_three_highlights_and_two_improvements(self) -> None:
self.assertEqual([], release_notes.validate_curated_entry(entry(), "1.2.3"))
def test_rejects_more_than_three_highlights(self) -> None:
errors = release_notes.validate_curated_entry(entry(bullets=4, improvements=0), "1.2.3")
self.assertIn("latest changelog highlight must contain 1-3 bullets", errors)
def test_rejects_more_than_five_visible_items(self) -> None:
errors = release_notes.validate_curated_entry(entry(bullets=3, improvements=3), "1.2.3")
self.assertIn("latest changelog may contain at most 2 improvements", errors)
self.assertIn("latest changelog may expose at most 5 total list items", errors)
def test_renders_fallback_with_curated_groups(self) -> None:
rendered = release_notes.render_whats_new(entry(bullets=1, improvements=1))
self.assertIn("The main reason to care\n* Benefit 0", rendered)
self.assertIn("Also improved\n* Improvement 0", rendered)
def test_play_notes_include_version_and_stay_bounded(self) -> None:
rendered = release_notes.render_play_notes(entry())
self.assertTrue(rendered.startswith("v1.2.3 - A useful release"))
self.assertLessEqual(len(rendered), 500)
def test_allows_a_hero_only_release_without_secondary_filler(self) -> None:
release = entry()
release["toastDigest"] = {
"additionalFeatureCount": 0,
"fixCount": 0,
"preview": [],
}
self.assertEqual([], release_notes.validate_curated_entry(release, "1.2.3"))
def test_rejects_preview_when_secondary_counts_are_zero(self) -> None:
release = entry()
release["toastDigest"] = {
"additionalFeatureCount": 0,
"fixCount": 0,
"preview": ["Invented filler"],
}
errors = release_notes.validate_curated_entry(release, "1.2.3")
self.assertIn("latest changelog toastDigest preview must be empty when counts are zero", errors)
def test_requires_a_bounded_preview_for_secondary_items(self) -> None:
release = entry()
release["toastDigest"] = {
"additionalFeatureCount": 1,
"fixCount": 2,
"preview": ["one", "two", "three"],
}
errors = release_notes.validate_curated_entry(release, "1.2.3")
self.assertIn(
"latest changelog toastDigest preview must contain 1-2 items when secondary items exist",
errors,
)
if __name__ == "__main__":
unittest.main()
@@ -100,6 +100,47 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
self.assertEqual(["user", "assistant"], [row["role"] for row in history["messages"]])
self.assertEqual(2, history["pagination"]["returned"])
async def test_cross_client_observer_never_claims_or_interrupts_producer(self) -> None:
fixture, base_url = await self.start("cross_client_observation")
producer, _ = await self.connect(base_url)
await self.rpc(producer, 1, "session.resume", {"session_id": fixture.scenario.stored_session_id})
await producer.receive_json()
await self.rpc(producer, 2, "prompt.submit", {"text": "producer-only content"})
producer_frames = await self.frames_until(
producer,
lambda frame: frame.get("params", {}).get("type") == "message.delta",
)
observer, _ = await self.connect(base_url)
await self.rpc(observer, 3, "session.active_list")
active = (await observer.receive_json())["result"]["sessions"]
self.assertEqual("working", active[0]["status"])
async with self.session.get(
f"{base_url}/api/sessions/{fixture.scenario.stored_session_id}/messages",
params={"profile": "default", "limit": 500, "offset": 0, "order": "asc"},
) as response:
self.assertEqual(200, response.status)
self.assertIsInstance((await response.json())["messages"], list)
await observer.close()
producer_frames += await self.frames_until(
producer,
lambda frame: frame.get("params", {}).get("type") == "message.complete",
)
self.assertIn(
"message.complete",
[frame.get("params", {}).get("type") for frame in producer_frames],
)
async with self.session.get(f"{base_url}/__fixture__/evidence") as response:
evidence = await response.json()
observer_methods = [
entry.get("method")
for entry in evidence["entries"]
if entry.get("kind") == "rpc" and entry.get("connection") == 2
]
self.assertEqual(["session.active_list"], observer_methods)
self.assertNotIn("session.interrupt", observer_methods)
async def test_rapid_chunks_tools_and_interims_keep_wire_order(self) -> None:
_, base_url = await self.start("rapid_tools_interims")
ws, _ = await self.connect(base_url)
@@ -265,6 +306,7 @@ class ScenarioTestCase(unittest.TestCase):
"active_status_lifecycle",
"active_status_profile_scope",
"active_status_unsupported",
"cross_client_observation",
"ordinary_turn",
"rapid_tools_interims",
"terminal_gap_activate",
@@ -304,6 +346,10 @@ class ScenarioTestCase(unittest.TestCase):
("gateway.settled_session_info",),
load_scenario("terminal_gap_session_info").contract_requirements,
)
self.assertEqual(
("gateway.message_complete", "gateway.session_active_list"),
load_scenario("cross_client_observation").contract_requirements,
)
def test_tls_arguments_must_be_paired(self) -> None:
with contextlib.redirect_stderr(io.StringIO()):
@@ -0,0 +1,40 @@
{
"name": "cross_client_observation",
"live_session_id": "fixture-desktop-live",
"stored_session_id": "fixture-shared-session",
"profile": "default",
"contract_requirements": [
"gateway.message_complete",
"gateway.session_active_list"
],
"initial_history": [],
"turns": [
{
"steps": [
{"op": "event", "type": "message.start"},
{"op": "event", "type": "message.delta", "payload": {"text": "Desktop still owns this turn."}},
{"op": "sleep", "milliseconds": 250},
{
"op": "persist",
"messages": [
{"id": 1, "role": "user", "content": "Desktop prompt.", "timestamp": 1.0},
{"id": 2, "role": "assistant", "content": "Desktop still owns this turn.", "timestamp": 2.0, "finish_reason": "stop"}
]
},
{"op": "set_running", "value": false},
{"op": "event", "type": "message.complete", "payload": {"text": "Desktop still owns this turn.", "status": "complete"}}
]
}
],
"active_list": {
"supported": true,
"snapshots": [
[
{"id": "fixture-desktop-live", "session_key": "fixture-shared-session", "status": "working", "current": false}
],
[
{"id": "fixture-desktop-live", "session_key": "fixture-shared-session", "status": "idle", "current": false}
]
]
}
}
+3 -3
View File
@@ -17,8 +17,8 @@ Sphere and phone-local pet controls live under **Settings → Appearance**.
The background and floating-pet selections are saved independently. You can use
the same installed pack in either role, choose different packs, or turn either
one off. A background animation reacts on the central Chat, clean/ambient, and
voice surfaces; it never roams, uses floating-pet placement, or inherits pet
one off. A background animation reacts on the central Chat and voice surfaces;
it never roams, uses floating-pet placement, or inherits pet
temperament.
## Profile badge image
@@ -234,7 +234,7 @@ normal Hermes Relay screens.
move actions remain available on screens that use the saved free-form edge home,
so dragging is never required.
- Chat temporarily hides the pet while the session drawer, a picker or info
sheet, clean mode, or voice mode owns the screen. Inline slash suggestions and
sheet, or voice mode owns the screen. Inline slash suggestions and
recent-prompt chips stay visible, but are measured as blocked controls so a
roaming pet routes around them.
- On every other screen, opening a dialog or modal sheet temporarily hides the