Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a894da5fda | ||
|
|
80e636bc30 | ||
|
|
834763a213 | ||
|
|
8ef6b794b2 | ||
|
|
17d8a2374a | ||
|
|
274ae11160 | ||
|
|
9cebdb67e6 | ||
|
|
ee300b3b15 | ||
|
|
2b75577fcf | ||
|
|
ff2f94922b | ||
|
|
292cc94eb4 | ||
|
|
621081b633 | ||
|
|
da8e822b66 | ||
|
|
0015a913f1 | ||
|
|
0f47e464f3 | ||
|
|
2411cf19b3 | ||
|
|
51b4edd52a | ||
|
|
d3c4e90696 | ||
|
|
cd7cad03ec | ||
|
|
bea3021ef0 | ||
|
|
00a9a7efcb | ||
|
|
7a97b71844 | ||
|
|
ae51ae3ab1 | ||
|
|
1bdcab8fc0 | ||
|
|
0c77d010fa | ||
|
|
dc18209c3c | ||
|
|
69347adb34 | ||
|
|
43a809e41a | ||
|
|
a5cc0104bf | ||
|
|
86a0bebc0d | ||
|
|
04d9421c74 | ||
|
|
14401aa3c3 | ||
|
|
99897274c6 | ||
|
|
20c5b690a8 | ||
|
|
dc86c043bc | ||
|
|
d5cce4e390 |
@@ -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
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
name: CI — Upstream Contract
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
@@ -40,45 +43,64 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
env:
|
||||
REQUESTED_REF: ${{ github.event.inputs.upstream_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# PR/push runs use a known-good NousResearch/hermes-agent commit so
|
||||
# normal CI is stable. The weekly schedule below intentionally tracks
|
||||
# main as the upstream-drift siren.
|
||||
DEFAULT_REF="ef4b897a1843cd32c4f141f55db60f0f0602cc98"
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
REF="main" # weekly drift siren
|
||||
elif [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
REF="${{ github.event.inputs.upstream_ref }}" # manual override
|
||||
elif [ -n "$REQUESTED_REF" ]; then
|
||||
REF="$REQUESTED_REF" # manual override
|
||||
else
|
||||
REF="$DEFAULT_REF"
|
||||
fi
|
||||
|
||||
# The ref is passed to git below, so reject option-like or malformed
|
||||
# values before it reaches that boundary. Full commit IDs and normal
|
||||
# branch/tag names remain supported for manual contract checks.
|
||||
if [[ "$REF" == -* ]] ||
|
||||
! git check-ref-format --allow-onelevel "$REF" >/dev/null; then
|
||||
echo "FAIL: invalid upstream branch or tag name." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
echo "Checking standard-path route contract against upstream ref: $REF"
|
||||
|
||||
- name: Checkout vanilla upstream (no plugin, no bootstrap)
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
path: _upstream
|
||||
fetch-depth: 1
|
||||
- name: Extract trusted upstream contract sources
|
||||
env:
|
||||
UPSTREAM_REF: ${{ steps.ref.outputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
UPSTREAM_GIT="$RUNNER_TEMP/hermes-agent-contract.git"
|
||||
git init --bare "$UPSTREAM_GIT"
|
||||
git -C "$UPSTREAM_GIT" remote add origin \
|
||||
"https://github.com/NousResearch/hermes-agent.git"
|
||||
git -C "$UPSTREAM_GIT" fetch --no-tags --depth=1 origin -- "$UPSTREAM_REF"
|
||||
UPSTREAM_COMMIT="$(git -C "$UPSTREAM_GIT" rev-parse 'FETCH_HEAD^{commit}')"
|
||||
|
||||
mkdir -p _upstream/gateway/platforms _upstream/hermes_cli
|
||||
git -C "$UPSTREAM_GIT" show \
|
||||
"$UPSTREAM_COMMIT:gateway/platforms/api_server.py" \
|
||||
> _upstream/gateway/platforms/api_server.py
|
||||
git -C "$UPSTREAM_GIT" show \
|
||||
"$UPSTREAM_COMMIT:hermes_cli/web_server.py" \
|
||||
> _upstream/hermes_cli/web_server.py
|
||||
echo "Extracted contract sources from upstream commit: $UPSTREAM_COMMIT"
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Assert upstream checkout is vanilla (no relay bootstrap/plugin)
|
||||
run: |
|
||||
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
|
||||
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
|
||||
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
|
||||
echo "FAIL: upstream checkout contains a relay bootstrap — not vanilla."; exit 1
|
||||
fi
|
||||
echo "OK: upstream checkout carries no relay plugin/bootstrap."
|
||||
|
||||
- name: Run route-surface contract
|
||||
run: python scripts/check-upstream-route-contract.py "_upstream"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+24
-1
@@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Android can preview delegated agent work without leaving the parent chat.** The current-chat activity sheet shows bounded lifecycle, progress, and tool previews for concurrent children, opens vanilla Hermes child history read-only when the Gateway exposes it, and stays explicit when reconnect gaps or older routes leave details unavailable.
|
||||
- **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
|
||||
|
||||
- **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
|
||||
|
||||
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android session rows stay neutral when optional live activity is unavailable or still loading.** Directory refreshes no longer restore a persistent Checking state, and full-row activity borders are reserved for actual Starting or Working turns.
|
||||
@@ -24,7 +48,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Provider usage and limits are available from top-level Settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden presentation modes. Provider credentials remain on the Hermes host.
|
||||
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
|
||||
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
|
||||
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -159,6 +159,11 @@ manual fallbacks when QR or clipboard transfer is unavailable.
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/screenshots/supplemental/15_git_workspace.png" alt="Native Git workspace showing repository changes, an inline diff, and staging controls" width="260"><br>
|
||||
<sub><b>Native Git workspace</b> — optional Hermes-Relay plugin</sub>
|
||||
</p>
|
||||
|
||||
### Simplified Chinese
|
||||
|
||||
<table>
|
||||
|
||||
+60
-11
@@ -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
|
||||
|
||||
|
||||
+10
-7
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.13.1
|
||||
# Hermes-Relay Android v1.13.2
|
||||
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.13.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.13.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,17 +12,20 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch makes Android session activity follow live Hermes runtime state instead of a five-minute recency estimate. It keeps Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work accurate while preserving stale state until a complete, unambiguous snapshot can safely replace it.
|
||||
This release adds a parent-configured Supervised Mode and improves its return from full settings. It also keeps session rows neutral until live activity is confirmed.
|
||||
|
||||
## Added
|
||||
|
||||
- Use a profile-pinned Supervised Mode with parent-controlled attachments, Standard voice, generated media, history, actions, and technical details. Device authentication protects full settings; this remains a client-side restricted view rather than a server-enforced account boundary.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Derive session activity from the authoritative live runtime snapshot rather than Dashboard recency.
|
||||
- Preserve prior activity when a refresh is incomplete, unsupported, or ambiguously scoped.
|
||||
- Keep session drawer labels, timestamps, and active-turn ownership aligned with the exact profile and session.
|
||||
- Keep session rows neutral while optional live activity is unavailable or still loading, and reserve full-row activity borders for actual Starting or Working turns.
|
||||
- Keep Supervised Chat rendered when parent access relocks after visiting full settings.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.13.1** (versionCode **50**).
|
||||
- App version: **1.13.2** (versionCode **51**).
|
||||
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
|
||||
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
|
||||
@@ -756,8 +756,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 +1454,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 & 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.
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
会话活动现在依据 Hermes 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。
|
||||
新增监督模式:家长可配置并固定到指定配置文件,设置受设备身份验证保护。家长可限制附件、标准语音、生成媒体、历史记录、操作和技术详情。实时活动不可用时会话行保持中性显示,从家长设置返回时监督聊天也不再空白。
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
{
|
||||
"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",
|
||||
"bullets": [
|
||||
"Configure a profile-pinned restricted chat with parent-controlled attachments, voice, media, history, actions, and technical details.",
|
||||
"Protect full settings with device authentication and keep Supervised Chat visible when parent access relocks."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Show only confirmed activity",
|
||||
"bullets": [
|
||||
"Keep session rows neutral while optional live activity is unavailable or still loading.",
|
||||
"Show full-row activity borders only during actual Starting or Working turns."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.13.1",
|
||||
"title": "Accurate session activity",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
v1.13.1 - Accurate session activity
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
|
||||
* Follow live Hermes runtime state for Working, Starting, Needs input, and Idle.
|
||||
* Keep stale activity visible until a complete, unambiguous snapshot clears it.
|
||||
* Distinguish Checking, Unavailable, and Background work in the session drawer.
|
||||
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.
|
||||
|
||||
@@ -10,6 +10,16 @@ package com.hermesandroid.relay.data
|
||||
*/
|
||||
object AgentDisplay {
|
||||
const val SERVER_DEFAULT_PROFILE_KEY: String = "__server_default__"
|
||||
private const val PROFILE_CONTEXT_SEPARATOR = "::"
|
||||
|
||||
data class ProfileContextIdentity(
|
||||
val connectionId: String,
|
||||
val profileKey: String,
|
||||
) {
|
||||
/** Null means the upstream request must inherit Server Default. */
|
||||
val requestProfileName: String?
|
||||
get() = profileRequestName(profileKey)
|
||||
}
|
||||
private val GENERIC_MODEL_ALIASES = setOf(
|
||||
"hermes-agent",
|
||||
"hermes_agent",
|
||||
@@ -163,7 +173,25 @@ object AgentDisplay {
|
||||
profileRequestName(profileName) ?: SERVER_DEFAULT_PROFILE_KEY
|
||||
|
||||
fun profileContextKey(connectionId: String?, profileName: String?): String =
|
||||
"${connectionId.orEmpty()}::${profileSessionKey(profileName)}"
|
||||
"${connectionId.orEmpty()}$PROFILE_CONTEXT_SEPARATOR${profileSessionKey(profileName)}"
|
||||
|
||||
/**
|
||||
* Parse the canonical profile/context identity used by persisted chat state.
|
||||
*
|
||||
* Legacy or malformed opaque keys deliberately return null: recovery may
|
||||
* still use the exact key for ownership, but must not invent an upstream
|
||||
* profile override from it. The first separator is authoritative so legal
|
||||
* profile names containing `::` remain round-trippable.
|
||||
*/
|
||||
fun parseProfileContextKey(contextKey: String?): ProfileContextIdentity? {
|
||||
val raw = contextKey?.trim().orEmpty()
|
||||
val separator = raw.indexOf(PROFILE_CONTEXT_SEPARATOR)
|
||||
if (separator <= 0 || separator + PROFILE_CONTEXT_SEPARATOR.length >= raw.length) return null
|
||||
val connectionId = raw.substring(0, separator).trim()
|
||||
val profileKey = raw.substring(separator + PROFILE_CONTEXT_SEPARATOR.length).trim()
|
||||
if (connectionId.isEmpty() || profileKey.isEmpty()) return null
|
||||
return ProfileContextIdentity(connectionId, profileKey)
|
||||
}
|
||||
|
||||
fun localDisplayAlias(value: String?): String? =
|
||||
value
|
||||
|
||||
@@ -33,6 +33,8 @@ class ChatInputPreferencesRepository(
|
||||
stringPreferencesKey("physical_keyboard_enter_behavior")
|
||||
internal val KEY_CONVERT_LARGE_PASTES =
|
||||
booleanPreferencesKey("convert_large_pastes_to_attachments")
|
||||
internal val KEY_SHOW_GIT_WORKSPACE_IN_CHAT =
|
||||
booleanPreferencesKey("show_git_workspace_in_chat")
|
||||
}
|
||||
|
||||
val physicalKeyboardEnterBehavior: Flow<PhysicalKeyboardEnterBehavior> = dataStore.data
|
||||
@@ -47,6 +49,10 @@ class ChatInputPreferencesRepository(
|
||||
.map { preferences -> preferences[KEY_CONVERT_LARGE_PASTES] ?: true }
|
||||
.distinctUntilChanged()
|
||||
|
||||
val showGitWorkspaceInChat: Flow<Boolean> = dataStore.data
|
||||
.map { preferences -> preferences[KEY_SHOW_GIT_WORKSPACE_IN_CHAT] ?: true }
|
||||
.distinctUntilChanged()
|
||||
|
||||
suspend fun setPhysicalKeyboardEnterBehavior(behavior: PhysicalKeyboardEnterBehavior) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[KEY_PHYSICAL_KEYBOARD_ENTER] = behavior.storedValue
|
||||
@@ -58,4 +64,10 @@ class ChatInputPreferencesRepository(
|
||||
preferences[KEY_CONVERT_LARGE_PASTES] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setShowGitWorkspaceInChat(enabled: Boolean) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[KEY_SHOW_GIT_WORKSPACE_IN_CHAT] = enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import kotlinx.serialization.json.Json
|
||||
data class ChatTurnCheckpoint(
|
||||
val schemaVersion: Int = CURRENT_SCHEMA,
|
||||
val contextKey: String,
|
||||
/** Explicit persisted profile identity; null only for legacy checkpoints. */
|
||||
val profileKey: String? = null,
|
||||
val sessionId: String,
|
||||
val liveSessionId: String? = null,
|
||||
val transport: String,
|
||||
|
||||
@@ -28,11 +28,17 @@ data class GitStatusCounts(
|
||||
val staged: Int = 0,
|
||||
val modified: Int = 0,
|
||||
val untracked: Int = 0,
|
||||
/** Unique changed paths. -1 means an older plugin did not provide it. */
|
||||
val changes: Int = -1,
|
||||
val additions: Int = 0,
|
||||
val deletions: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitStatusEntry(
|
||||
val path: String,
|
||||
val additions: Int? = null,
|
||||
val deletions: Int? = null,
|
||||
)
|
||||
|
||||
/** A branch from /git/branches. */
|
||||
|
||||
@@ -1006,6 +1006,56 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound the ephemeral, read-only child-watch projection. This is stricter
|
||||
* than the main transcript: system rows and tool results are not part of
|
||||
* the preview contract, and one live child must not retain unbounded text.
|
||||
*/
|
||||
internal fun boundReadOnlyPreview(
|
||||
maxMessages: Int = 100,
|
||||
maxTotalChars: Int = 32_000,
|
||||
maxFieldChars: Int = 8_000,
|
||||
maxToolChars: Int = 1_000,
|
||||
): Boolean {
|
||||
var truncated = false
|
||||
_messages.update { current ->
|
||||
val visible = current.filterNot { it.role == MessageRole.SYSTEM }
|
||||
if (visible.size != current.size || visible.size > maxMessages) truncated = true
|
||||
var remaining = maxTotalChars
|
||||
val kept = mutableListOf<ChatMessage>()
|
||||
visible.takeLast(maxMessages).asReversed().forEach { message ->
|
||||
if (remaining <= 0) {
|
||||
truncated = true
|
||||
return@forEach
|
||||
}
|
||||
fun bounded(value: String, limit: Int): String {
|
||||
val allowed = minOf(limit, remaining)
|
||||
val next = value.takeLast(allowed)
|
||||
if (next.length != value.length) truncated = true
|
||||
remaining -= next.length
|
||||
return next
|
||||
}
|
||||
val content = bounded(message.content, maxFieldChars)
|
||||
val thinking = bounded(message.thinkingContent, maxFieldChars)
|
||||
val tools = message.toolCalls.takeLast(50).map { tool ->
|
||||
if (message.toolCalls.size > 50) truncated = true
|
||||
tool.copy(
|
||||
args = tool.args?.let { bounded(it, maxToolChars) },
|
||||
result = null,
|
||||
error = tool.error?.let { bounded(it, maxToolChars) },
|
||||
)
|
||||
}
|
||||
kept += message.copy(
|
||||
content = content,
|
||||
thinkingContent = thinking,
|
||||
toolCalls = tools,
|
||||
)
|
||||
}
|
||||
kept.asReversed()
|
||||
}
|
||||
return truncated
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate the last client-owned state of an unfinished turn.
|
||||
*
|
||||
@@ -3030,7 +3080,9 @@ class ChatHandler {
|
||||
fun onSubagentEvent(messageId: String, event: GatewaySubagentEvent) {
|
||||
val label = event.goal.trim().take(60).ifBlank { null }
|
||||
when (event.phase) {
|
||||
GatewaySubagentEvent.Phase.START -> {
|
||||
GatewaySubagentEvent.Phase.SPAWN_REQUESTED,
|
||||
GatewaySubagentEvent.Phase.START,
|
||||
-> {
|
||||
if (label != null) subagentLabels[event.taskIndex] = label
|
||||
event.subagentId?.takeIf(String::isNotBlank)?.let {
|
||||
subagentIds[event.taskIndex] = it
|
||||
|
||||
+343
-26
@@ -209,6 +209,9 @@ class GatewayChatClient(
|
||||
private const val INBOUND_BIND_TIMEOUT_MS = 2_000L
|
||||
private const val CANCELLED_TURN_SUBMIT_WAIT_MS = 2_000L
|
||||
private const val MAX_RECOVERY_BUFFERED_EVENTS = 256
|
||||
internal const val MAX_CHILD_WATCH_HISTORY_ITEMS = 200
|
||||
internal const val MAX_CHILD_WATCH_HISTORY_CHARS = 64_000
|
||||
private const val MAX_PENDING_CHILD_WATCH_EVENTS = 256
|
||||
|
||||
/** Distinct socket-loss (flap) events per turn we'll try to recover from. */
|
||||
private const val MAX_TURN_REJOINS = 4
|
||||
@@ -381,6 +384,15 @@ class GatewayChatClient(
|
||||
private val prewarmRequestGeneration = AtomicLong(0)
|
||||
private val pendingRpcs = ConcurrentHashMap<Long, CompletableDeferred<JsonObject>>()
|
||||
|
||||
/** Monotonic client-local fence for lazy child watch open/close races. */
|
||||
private val childWatchGeneration = AtomicLong(0)
|
||||
|
||||
/** Live child runtime id -> exact watcher that owns its callbacks. */
|
||||
private val childWatches = ConcurrentHashMap<String, ChildWatchRegistration>()
|
||||
|
||||
/** Events that race a lazy `session.resume` acknowledgement. */
|
||||
private val pendingChildWatchOpens = ConcurrentHashMap<Long, PendingChildWatchOpen>()
|
||||
|
||||
/** Live (per-connection) session id ←→ the stored DB id it was resumed/created from. */
|
||||
@Volatile
|
||||
private var liveSessionId: String? = null
|
||||
@@ -447,6 +459,62 @@ class GatewayChatClient(
|
||||
@Volatile var pendingAsk: GatewayAsk? = null,
|
||||
)
|
||||
|
||||
private class ChildWatchRegistration(
|
||||
val storedSessionId: String,
|
||||
val liveSessionId: String,
|
||||
val profile: String?,
|
||||
val generation: Long,
|
||||
val callbacks: GatewayTurnCallbacks,
|
||||
) {
|
||||
lateinit var mapper: GatewayEventMapper
|
||||
}
|
||||
|
||||
private data class ChildWatchEvent(
|
||||
val sessionId: String,
|
||||
val type: String,
|
||||
val payload: JsonObject?,
|
||||
)
|
||||
|
||||
private data class PendingChildWatchReplay(
|
||||
val events: List<ChildWatchEvent>,
|
||||
val truncated: Boolean,
|
||||
)
|
||||
|
||||
private class PendingChildWatchOpen {
|
||||
private val lock = Any()
|
||||
private val events = mutableListOf<ChildWatchEvent>()
|
||||
private var closed = false
|
||||
private var truncated = false
|
||||
|
||||
fun capture(event: ChildWatchEvent): Boolean = synchronized(lock) {
|
||||
if (closed) return@synchronized false
|
||||
if (events.size >= MAX_PENDING_CHILD_WATCH_EVENTS) {
|
||||
events.removeAt(0)
|
||||
truncated = true
|
||||
}
|
||||
events += event
|
||||
true
|
||||
}
|
||||
|
||||
fun closeAndTake(sessionId: String): PendingChildWatchReplay = synchronized(lock) {
|
||||
closed = true
|
||||
PendingChildWatchReplay(
|
||||
events = events.filter { it.sessionId == sessionId },
|
||||
truncated = truncated,
|
||||
).also { events.clear() }
|
||||
}
|
||||
|
||||
fun close() = synchronized(lock) {
|
||||
closed = true
|
||||
events.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private data class BoundedChildHistory(
|
||||
val messages: List<MessageItem>,
|
||||
val truncated: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Upstream may emit the interrupted turn's tail and terminal event after
|
||||
* `session.interrupt` returns. Keep a short exact-session tombstone so that
|
||||
@@ -947,6 +1015,200 @@ class GatewayChatClient(
|
||||
return sessionReady
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the vanilla-upstream child-session watcher advertised by
|
||||
* `subagent.*.child_session_id`. This RPC deliberately does not mutate
|
||||
* [liveSessionId], [storedSessionId], or [liveSessionProfile]: the parent
|
||||
* conversation keeps owning the main mapper while the returned short live
|
||||
* id routes a second, read-only event stream on the same socket.
|
||||
*
|
||||
* Returned history is bounded locally even when an upstream gateway sends
|
||||
* the child's entire transcript in the resume acknowledgement. The server
|
||||
* may still enforce its own larger resume safety limit before replying.
|
||||
*/
|
||||
suspend fun openChildWatch(
|
||||
childSessionId: String,
|
||||
profile: String? = currentSessionProfile(),
|
||||
callbacks: GatewayTurnCallbacks,
|
||||
historyLimit: Int = MAX_CHILD_WATCH_HISTORY_ITEMS,
|
||||
): Result<GatewayChildWatch> = runCatching {
|
||||
val storedChildId = childSessionId.trim()
|
||||
require(storedChildId.isNotEmpty()) { "child session id is required" }
|
||||
val requestedProfile = profile?.trim()?.takeIf(String::isNotEmpty)
|
||||
connectMutex.withLock {
|
||||
// Allocate and register under the same mutex as the resume RPC so
|
||||
// concurrent opens complete in generation order; an older caller
|
||||
// can never `put` after a newer one for the same live child id.
|
||||
val generation = childWatchGeneration.incrementAndGet()
|
||||
val pending = PendingChildWatchOpen()
|
||||
pendingChildWatchOpens[generation] = pending
|
||||
try {
|
||||
ensureConnected()
|
||||
val result = rpc(
|
||||
"session.resume",
|
||||
buildJsonObject {
|
||||
put("session_id", storedChildId)
|
||||
put("cols", DEFAULT_COLS)
|
||||
put("source", sessionSource)
|
||||
put("lazy", true)
|
||||
put("close_on_disconnect", true)
|
||||
requestedProfile?.let { put("profile", it) }
|
||||
},
|
||||
).getOrElse { error ->
|
||||
throw GatewayPreflightException(
|
||||
"child session resume failed: ${error.message}",
|
||||
)
|
||||
}
|
||||
val liveChildId = result.stringField("session_id")?.takeIf(String::isNotBlank)
|
||||
?: throw GatewayPreflightException(
|
||||
"child session resume returned no live session id",
|
||||
)
|
||||
try {
|
||||
requireConfirmedSessionProfile(result, requestedProfile)
|
||||
} catch (error: GatewayPreflightException) {
|
||||
// The wrong profile must not leave an unowned lazy watcher behind.
|
||||
rpc(
|
||||
"session.close",
|
||||
buildJsonObject { put("session_id", liveChildId) },
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
val registration = ChildWatchRegistration(
|
||||
storedSessionId = storedChildId,
|
||||
liveSessionId = liveChildId,
|
||||
profile = requestedProfile,
|
||||
generation = generation,
|
||||
callbacks = callbacks,
|
||||
)
|
||||
val dispatchedCallbacks = dispatchOn(callbacks) {
|
||||
childWatches[liveChildId] === registration
|
||||
}
|
||||
registration.mapper = GatewayEventMapper(
|
||||
dispatchedCallbacks,
|
||||
dedupeAdjacentMessageStarts = true,
|
||||
)
|
||||
childWatches.put(liveChildId, registration)?.let { prior ->
|
||||
if (prior.generation != generation) {
|
||||
notifyChildWatchFailure(
|
||||
prior,
|
||||
"Child watch was replaced by a newer view",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Replay only frames tagged with the exact live id returned by
|
||||
// this resume. Unknown gateway sessions captured during the
|
||||
// narrow ack race remain foreign and are discarded.
|
||||
val replay = pending.closeAndTake(liveChildId)
|
||||
if (replay.truncated) dispatchedCallbacks.onReconcileRequired()
|
||||
replay.events.forEach { event ->
|
||||
if (childWatches[liveChildId] === registration) {
|
||||
registration.mapper.onEvent(event.type, event.payload)
|
||||
}
|
||||
}
|
||||
val replayedTerminal = replay.events.any {
|
||||
it.type == "message.complete" || it.type == "error"
|
||||
}
|
||||
|
||||
val history = parseChildWatchMessages(result, historyLimit)
|
||||
GatewayChildWatch(
|
||||
storedSessionId = storedChildId,
|
||||
liveSessionId = liveChildId,
|
||||
profile = requestedProfile,
|
||||
generation = generation,
|
||||
messages = history.messages,
|
||||
historyTruncated = history.truncated,
|
||||
running = !replayedTerminal && result.booleanField("running") == true,
|
||||
status = if (replayedTerminal) "idle" else result.stringField("status"),
|
||||
)
|
||||
} finally {
|
||||
pendingChildWatchOpens.remove(generation, pending)
|
||||
pending.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close only the exact lazy watcher represented by [watch]. A stale handle
|
||||
* is a no-op so it can never close a newer watcher whose live id was reused.
|
||||
* The parent session and delegated child continue running server-side.
|
||||
*/
|
||||
suspend fun closeChildWatch(watch: GatewayChildWatch): Result<Unit> =
|
||||
connectMutex.withLock {
|
||||
val registration = childWatches[watch.liveSessionId]
|
||||
?: return@withLock Result.success(Unit)
|
||||
if (
|
||||
registration.generation != watch.generation ||
|
||||
registration.storedSessionId != watch.storedSessionId ||
|
||||
registration.profile != watch.profile ||
|
||||
!childWatches.remove(watch.liveSessionId, registration)
|
||||
) {
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
if (webSocket == null || readySignal?.isCompleted != true) {
|
||||
return@withLock Result.success(Unit)
|
||||
}
|
||||
val result = rpc(
|
||||
"session.close",
|
||||
buildJsonObject { put("session_id", watch.liveSessionId) },
|
||||
)
|
||||
result.fold(
|
||||
onSuccess = { Result.success(Unit) },
|
||||
onFailure = { error ->
|
||||
// Permit an exact-handle retry. Opens share connectMutex,
|
||||
// so no newer registration can race this restoration.
|
||||
childWatches.putIfAbsent(watch.liveSessionId, registration)
|
||||
Result.failure(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseChildWatchMessages(
|
||||
result: JsonObject,
|
||||
requestedLimit: Int,
|
||||
): BoundedChildHistory {
|
||||
val limit = requestedLimit.coerceIn(1, MAX_CHILD_WATCH_HISTORY_ITEMS)
|
||||
val all = (result["messages"] as? JsonArray).orEmpty()
|
||||
val raw = all.takeLast(limit)
|
||||
var retainedChars = 0
|
||||
var truncated = all.size > raw.size
|
||||
val newestFirst = raw.asReversed().mapNotNull { element ->
|
||||
val message = element as? JsonObject ?: run {
|
||||
truncated = true
|
||||
return@mapNotNull null
|
||||
}
|
||||
// Gateway display history uses `text`; the shared session DTO uses
|
||||
// `content`. Normalize only that projection boundary.
|
||||
val normalized = JsonObject(message.toMutableMap().apply {
|
||||
if (!containsKey("content")) {
|
||||
put("content", message["text"] ?: message["context"] ?: JsonNull)
|
||||
}
|
||||
if (!containsKey("tool_name") && message.containsKey("name")) {
|
||||
put("tool_name", message["name"] ?: JsonNull)
|
||||
}
|
||||
})
|
||||
val serializedChars = normalized.toString().length
|
||||
if (serializedChars > MAX_CHILD_WATCH_HISTORY_CHARS - retainedChars) {
|
||||
truncated = true
|
||||
return@mapNotNull null
|
||||
}
|
||||
val decoded = runCatching {
|
||||
json.decodeFromJsonElement(MessageItem.serializer(), normalized)
|
||||
}.onFailure {
|
||||
Log.w(TAG, "child watch returned an unreadable history row", it)
|
||||
}.getOrNull()
|
||||
if (decoded == null) {
|
||||
truncated = true
|
||||
null
|
||||
} else {
|
||||
retainedChars += serializedChars
|
||||
decoded
|
||||
}
|
||||
}
|
||||
return BoundedChildHistory(newestFirst.asReversed(), truncated)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a session-scoped target before a model-selection `config.set`.
|
||||
*
|
||||
@@ -3022,6 +3284,9 @@ class GatewayChatClient(
|
||||
val reason = payload?.stringField("reason")
|
||||
val supportedReason = reason in setOf("idle_timeout", "lru_evict", "ws_orphan_reap")
|
||||
if (!reclaimedLiveId.isNullOrBlank() && supportedReason) {
|
||||
childWatches.remove(reclaimedLiveId)?.let { registration ->
|
||||
notifyChildWatchFailure(registration, "Gateway reclaimed the child watch")
|
||||
}
|
||||
val background = backgroundTurns.remove(reclaimedLiveId)
|
||||
if (background != null) {
|
||||
callbackDispatcher {
|
||||
@@ -3073,6 +3338,23 @@ class GatewayChatClient(
|
||||
return
|
||||
}
|
||||
|
||||
// A lazy child watcher is a second session on this shared socket. Route
|
||||
// it before the main-session recovery/foreign-session gates and require
|
||||
// the exact live id returned by its own session.resume acknowledgement.
|
||||
val childWatch = eventSessionId?.let(childWatches::get)
|
||||
if (childWatch != null) {
|
||||
if (childWatches[eventSessionId] === childWatch) {
|
||||
childWatch.mapper.onEvent(type, payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val capturedForPendingChildWatch = !eventSessionId.isNullOrBlank() &&
|
||||
eventSessionId != liveSessionId &&
|
||||
!backgroundTurns.containsKey(eventSessionId) &&
|
||||
capturePendingChildWatchEvent(ChildWatchEvent(eventSessionId, type, payload))
|
||||
if (capturedForPendingChildWatch) return
|
||||
|
||||
// A cold session.resume may schedule auto-continue before its RPC
|
||||
// response reaches Android. The recovery buffer is an ownership gate,
|
||||
// not an observational copy: an event is either claimed here for
|
||||
@@ -3315,6 +3597,7 @@ class GatewayChatClient(
|
||||
it.completeExceptionally(GatewayRpcException("gateway connection lost"))
|
||||
}
|
||||
pendingRpcs.clear()
|
||||
failChildWatches("Child watch disconnected from the gateway")
|
||||
val turn = activeTurn
|
||||
if (turn == null) {
|
||||
if (backgroundTurns.isNotEmpty() && !backgroundRejoinInProgress) {
|
||||
@@ -3497,6 +3780,7 @@ class GatewayChatClient(
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
failChildWatches("Child watch closed with the gateway socket")
|
||||
}
|
||||
|
||||
private fun scheduleBackgroundClose() {
|
||||
@@ -3506,7 +3790,7 @@ class GatewayChatClient(
|
||||
backgroundCloseJob?.cancel()
|
||||
backgroundCloseJob = scope.launch {
|
||||
delay(BACKGROUND_CLOSE_GRACE_MS)
|
||||
if (activeTurn == null && backgroundTurns.isEmpty() &&
|
||||
if (activeTurn == null && backgroundTurns.isEmpty() && childWatches.isEmpty() &&
|
||||
!AppForegroundTracker.isForeground.value
|
||||
) {
|
||||
closeSocket("app backgrounded")
|
||||
@@ -3514,6 +3798,28 @@ class GatewayChatClient(
|
||||
}
|
||||
}
|
||||
|
||||
private fun failChildWatches(message: String) {
|
||||
if (childWatches.isEmpty()) return
|
||||
val registrations = childWatches.values.toSet()
|
||||
childWatches.clear()
|
||||
registrations.forEach { notifyChildWatchFailure(it, message) }
|
||||
}
|
||||
|
||||
private fun notifyChildWatchFailure(
|
||||
registration: ChildWatchRegistration,
|
||||
message: String,
|
||||
) {
|
||||
callbackDispatcher { registration.callbacks.onResumeFailure(message) }
|
||||
}
|
||||
|
||||
private fun capturePendingChildWatchEvent(event: ChildWatchEvent): Boolean {
|
||||
var captured = false
|
||||
pendingChildWatchOpens.values.forEach { pending ->
|
||||
if (pending.capture(event)) captured = true
|
||||
}
|
||||
return captured
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// JSON-RPC
|
||||
// ------------------------------------------------------------------
|
||||
@@ -4062,44 +4368,55 @@ class GatewayChatClient(
|
||||
}
|
||||
|
||||
/** Wrap callbacks so every invocation lands on the callback dispatcher (main thread). */
|
||||
private fun dispatchOn(callbacks: GatewayTurnCallbacks) = GatewayTurnCallbacks(
|
||||
onSessionId = { v -> callbackDispatcher { callbacks.onSessionId(v) } },
|
||||
onStart = { callbackDispatcher { callbacks.onStart() } },
|
||||
onTextDelta = { v -> callbackDispatcher { callbacks.onTextDelta(v) } },
|
||||
private fun dispatchOn(
|
||||
callbacks: GatewayTurnCallbacks,
|
||||
stillCurrent: () -> Boolean = { true },
|
||||
) = GatewayTurnCallbacks(
|
||||
onSessionId = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onSessionId(v) } },
|
||||
onStart = { dispatchIfCurrent(stillCurrent) { callbacks.onStart() } },
|
||||
onTextDelta = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onTextDelta(v) } },
|
||||
onInterimMessage = { text, alreadyStreamed ->
|
||||
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
|
||||
dispatchIfCurrent(stillCurrent) { callbacks.onInterimMessage(text, alreadyStreamed) }
|
||||
},
|
||||
onInterimReconciled = { text ->
|
||||
callbackDispatcher { callbacks.onInterimReconciled(text) }
|
||||
dispatchIfCurrent(stillCurrent) { callbacks.onInterimReconciled(text) }
|
||||
},
|
||||
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
|
||||
onThinkingDelta = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onThinkingDelta(v) } },
|
||||
onToolCallStart = { id, name, args ->
|
||||
callbackDispatcher { callbacks.onToolCallStart(id, name, args) }
|
||||
dispatchIfCurrent(stillCurrent) { callbacks.onToolCallStart(id, name, args) }
|
||||
},
|
||||
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
|
||||
onToolCallFailed = { a, b -> callbackDispatcher { callbacks.onToolCallFailed(a, b) } },
|
||||
onToolOutputRisk = { v -> callbackDispatcher { callbacks.onToolOutputRisk(v) } },
|
||||
onTurnComplete = { callbackDispatcher { callbacks.onTurnComplete() } },
|
||||
onReconcileRequired = { callbackDispatcher { callbacks.onReconcileRequired() } },
|
||||
onComplete = { callbackDispatcher { callbacks.onComplete() } },
|
||||
onUsage = { v -> callbackDispatcher { callbacks.onUsage(v) } },
|
||||
onError = { v -> callbackDispatcher { callbacks.onError(v) } },
|
||||
onToolGenerating = { v -> callbackDispatcher { callbacks.onToolGenerating(v) } },
|
||||
onSubagentEvent = { v -> callbackDispatcher { callbacks.onSubagentEvent(v) } },
|
||||
onMoaReference = { v -> callbackDispatcher { callbacks.onMoaReference(v) } },
|
||||
onInteractionRequest = { v -> callbackDispatcher { callbacks.onInteractionRequest(v) } },
|
||||
onInteractionExpired = { v -> callbackDispatcher { callbacks.onInteractionExpired(v) } },
|
||||
onResumeFailure = { v -> callbackDispatcher { callbacks.onResumeFailure(v) } },
|
||||
onFailure = { v -> callbackDispatcher { callbacks.onFailure(v) } },
|
||||
onToolCallDone = { a, b -> dispatchIfCurrent(stillCurrent) { callbacks.onToolCallDone(a, b) } },
|
||||
onToolCallFailed = { a, b -> dispatchIfCurrent(stillCurrent) { callbacks.onToolCallFailed(a, b) } },
|
||||
onToolOutputRisk = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onToolOutputRisk(v) } },
|
||||
onTurnComplete = { dispatchIfCurrent(stillCurrent) { callbacks.onTurnComplete() } },
|
||||
onReconcileRequired = { dispatchIfCurrent(stillCurrent) { callbacks.onReconcileRequired() } },
|
||||
onComplete = { dispatchIfCurrent(stillCurrent) { callbacks.onComplete() } },
|
||||
onUsage = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onUsage(v) } },
|
||||
onError = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onError(v) } },
|
||||
onToolGenerating = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onToolGenerating(v) } },
|
||||
onSubagentEvent = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onSubagentEvent(v) } },
|
||||
onMoaReference = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onMoaReference(v) } },
|
||||
onInteractionRequest = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onInteractionRequest(v) } },
|
||||
onInteractionExpired = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onInteractionExpired(v) } },
|
||||
onResumeFailure = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onResumeFailure(v) } },
|
||||
onFailure = { v -> dispatchIfCurrent(stillCurrent) { callbacks.onFailure(v) } },
|
||||
// MUST be wrapped like every other member: GatewayTurnCallbacks gives
|
||||
// onStatusUpdate a default no-op, so omitting it here silently swallows
|
||||
// EVERY gateway status line — the ❌ terminal-error lifecycle update
|
||||
// included. Without it markError never fires, the turn isn't badged
|
||||
// "Error", and onComplete's history reload wipes the error bubble (the
|
||||
// "reply appears then vanishes" bug).
|
||||
onStatusUpdate = { kind, text -> callbackDispatcher { callbacks.onStatusUpdate(kind, text) } },
|
||||
onStatusClear = { kind -> callbackDispatcher { callbacks.onStatusClear(kind) } },
|
||||
onStatusUpdate = { kind, text ->
|
||||
dispatchIfCurrent(stillCurrent) { callbacks.onStatusUpdate(kind, text) }
|
||||
},
|
||||
onStatusClear = { kind -> dispatchIfCurrent(stillCurrent) { callbacks.onStatusClear(kind) } },
|
||||
)
|
||||
|
||||
private fun dispatchIfCurrent(stillCurrent: () -> Boolean, callback: () -> Unit) {
|
||||
callbackDispatcher {
|
||||
if (stillCurrent()) callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseGatewayPersonalityOptions(result: JsonObject): List<String> =
|
||||
|
||||
@@ -281,11 +281,12 @@ class GatewayEventMapper(
|
||||
callbacks.onError(payload.string("message") ?: "Gateway error")
|
||||
}
|
||||
|
||||
"subagent.start", "subagent.thinking", "subagent.tool",
|
||||
"subagent.spawn_requested", "subagent.start", "subagent.thinking", "subagent.tool",
|
||||
"subagent.progress", "subagent.complete",
|
||||
-> {
|
||||
clearActivityStatuses()
|
||||
val phase = when (type) {
|
||||
"subagent.spawn_requested" -> GatewaySubagentEvent.Phase.SPAWN_REQUESTED
|
||||
"subagent.start" -> GatewaySubagentEvent.Phase.START
|
||||
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
|
||||
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
|
||||
@@ -306,6 +307,10 @@ class GatewayEventMapper(
|
||||
preview = payload.string("tool_preview") ?: payload.string("text"),
|
||||
durationSeconds = payload.double("duration_seconds"),
|
||||
subagentId = payload.string("subagent_id"),
|
||||
childSessionId = payload.string("child_session_id"),
|
||||
parentId = payload.string("parent_id"),
|
||||
depth = payload.int("depth"),
|
||||
model = payload.string("model"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.UsageInfo
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -257,7 +258,8 @@ data class GatewayToolOutputRisk(
|
||||
|
||||
/**
|
||||
* One `subagent.*` lifecycle event, emitted on the PARENT session. Lifecycle
|
||||
* per task: START → (THINKING | TOOL | PROGRESS)* → COMPLETE. Field
|
||||
* per task: SPAWN_REQUESTED → START → (THINKING | TOOL | PROGRESS)* →
|
||||
* COMPLETE. Field
|
||||
* availability varies by phase — [toolName]/[preview] ride TOOL,
|
||||
* [status]/[summary]/[durationSeconds] ride COMPLETE — and older emitters
|
||||
* omit everything beyond the three defaults-bearing fields.
|
||||
@@ -273,10 +275,36 @@ data class GatewaySubagentEvent(
|
||||
val preview: String? = null,
|
||||
val durationSeconds: Double? = null,
|
||||
val subagentId: String? = null,
|
||||
/** Durable child session id accepted by `session.resume {lazy:true}`. */
|
||||
val childSessionId: String? = null,
|
||||
/** Owning subagent id for nested delegation; null for first-level children. */
|
||||
val parentId: String? = null,
|
||||
/** Zero-based depth used by the upstream spawn-tree renderer. */
|
||||
val depth: Int? = null,
|
||||
/** Effective child model, when the emitter exposes it. */
|
||||
val model: String? = null,
|
||||
) {
|
||||
enum class Phase { START, THINKING, TOOL, PROGRESS, COMPLETE }
|
||||
enum class Phase { SPAWN_REQUESTED, START, THINKING, TOOL, PROGRESS, COMPLETE }
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile-pinned, read-only child-session watch opened through the vanilla
|
||||
* upstream Gateway. [storedSessionId] is the durable child id from
|
||||
* `subagent.*`; [liveSessionId] is the short runtime id that tags subsequent
|
||||
* mirror events on this socket. The bounded [messages] snapshot is child-only.
|
||||
*/
|
||||
data class GatewayChildWatch(
|
||||
val storedSessionId: String,
|
||||
val liveSessionId: String,
|
||||
val profile: String?,
|
||||
val generation: Long,
|
||||
val messages: List<MessageItem>,
|
||||
/** True when Android retained only a bounded recent tail of the response. */
|
||||
val historyTruncated: Boolean,
|
||||
val running: Boolean,
|
||||
val status: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* One session-owned background process returned by the upstream gateway's
|
||||
* `process.list` RPC. The registry calls its process id `session_id`; Android
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.ui.components.ChatGitWorkspaceSummary
|
||||
import com.hermesandroid.relay.viewmodel.GitRepoDetailState
|
||||
|
||||
internal fun selectGitRepoForWorkspace(
|
||||
repos: List<GitRepo>,
|
||||
selectedRepoId: String?,
|
||||
sessionRepoRoot: String?,
|
||||
sessionWorkingDirectory: String?,
|
||||
): GitRepo? {
|
||||
if (repos.isEmpty()) return null
|
||||
|
||||
fun normalized(path: String): String =
|
||||
path.trim().replace('\\', '/').trimEnd('/')
|
||||
|
||||
val exactRoot = sessionRepoRoot?.let(::normalized).orEmpty()
|
||||
val workingDirectory = sessionWorkingDirectory?.let(::normalized).orEmpty()
|
||||
val matched = exactRoot.takeIf { it.isNotBlank() }?.let { root ->
|
||||
repos.firstOrNull { normalized(it.root).equals(root, ignoreCase = true) }
|
||||
} ?: workingDirectory.takeIf { it.isNotBlank() }?.let { cwd ->
|
||||
repos.filter { repo ->
|
||||
val root = normalized(repo.root)
|
||||
cwd.equals(root, ignoreCase = true) ||
|
||||
cwd.startsWith("$root/", ignoreCase = true)
|
||||
}.maxByOrNull { normalized(it.root).length }
|
||||
}
|
||||
if (matched != null) return matched
|
||||
if (repos.any { it.id == selectedRepoId }) return null
|
||||
return repos.singleOrNull()
|
||||
}
|
||||
|
||||
internal fun buildChatGitWorkspaceSummary(
|
||||
repo: GitRepo?,
|
||||
detail: GitRepoDetailState,
|
||||
): ChatGitWorkspaceSummary? {
|
||||
val ready = detail as? GitRepoDetailState.Ready ?: return null
|
||||
repo ?: return null
|
||||
val status = ready.status
|
||||
val changedPaths = buildSet {
|
||||
status.staged.forEach { add(it.path) }
|
||||
status.modified.forEach { add(it.path) }
|
||||
status.untracked.forEach { add(it.path) }
|
||||
}
|
||||
val branch = ready.branches.firstOrNull { it.isCurrent }?.name
|
||||
?: repo.currentBranch.orEmpty()
|
||||
if (branch.isBlank()) return null
|
||||
return ChatGitWorkspaceSummary(
|
||||
branch = branch,
|
||||
changeCount = status.counts.changes.takeIf { it >= 0 } ?: changedPaths.size,
|
||||
additions = status.counts.additions,
|
||||
deletions = status.counts.deletions,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -181,6 +186,7 @@ import com.hermesandroid.relay.ui.screens.PluginsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginPageScreen
|
||||
import com.hermesandroid.relay.ui.screens.GitStateScreen
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import com.hermesandroid.relay.viewmodel.GitStateUiState
|
||||
import com.hermesandroid.relay.ui.screens.TerminalScreen
|
||||
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProactiveSettingsScreen
|
||||
@@ -898,11 +904,75 @@ fun RelayApp() {
|
||||
gitStateViewModel.setWriteGrant(gitOwnerKey, granted)
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
val showWhatsNew by connectionViewModel.showWhatsNew.collectAsState()
|
||||
val gitReposState by gitStateViewModel.repos.collectAsState()
|
||||
val gitDetailState by gitStateViewModel.detail.collectAsState()
|
||||
val selectedGitRepoId by gitStateViewModel.selectedRepoId.collectAsState()
|
||||
val chatSessions by chatViewModel.sessions.collectAsState()
|
||||
val activeChatSession = remember(chatSessions, currentChatSessionId) {
|
||||
chatSessions.firstOrNull { it.sessionId == currentChatSessionId }
|
||||
}
|
||||
|
||||
if (showWhatsNew) {
|
||||
WhatsNewDialog(onDismiss = { connectionViewModel.dismissWhatsNew() })
|
||||
// Bind Git to the active coding session when upstream supplies its exact
|
||||
// workspace metadata. CWD fallback only matches a path-segment descendant;
|
||||
// an ambiguous multi-repo catalog stays unselected until the user chooses.
|
||||
LaunchedEffect(gitReposState, activeChatSession, selectedGitRepoId) {
|
||||
val repos = (gitReposState as? GitStateUiState.Ready)?.repos.orEmpty()
|
||||
val target = selectGitRepoForWorkspace(
|
||||
repos = repos,
|
||||
selectedRepoId = selectedGitRepoId,
|
||||
sessionRepoRoot = activeChatSession?.gitRepoRoot,
|
||||
sessionWorkingDirectory = activeChatSession?.workingDirectory,
|
||||
)
|
||||
if (target != null && target.id != selectedGitRepoId) {
|
||||
gitStateViewModel.selectRepo(target.id)
|
||||
}
|
||||
}
|
||||
|
||||
val gitWorkspaceAvailable = gitReposState is GitStateUiState.Ready
|
||||
val gitWorkspaceSummary = remember(
|
||||
gitReposState,
|
||||
gitDetailState,
|
||||
selectedGitRepoId,
|
||||
) {
|
||||
val repo = (gitReposState as? GitStateUiState.Ready)
|
||||
?.repos
|
||||
?.firstOrNull { it.id == selectedGitRepoId }
|
||||
buildChatGitWorkspaceSummary(repo, gitDetailState)
|
||||
}
|
||||
|
||||
// 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 && !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)
|
||||
@@ -2295,6 +2365,11 @@ fun RelayApp() {
|
||||
onNavigateToBotMode = {
|
||||
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
|
||||
},
|
||||
gitWorkspaceAvailable = gitWorkspaceAvailable,
|
||||
gitWorkspaceSummary = gitWorkspaceSummary,
|
||||
onNavigateToGitWorkspace = {
|
||||
navController.navigate(Screen.GitState.route) { launchSingleTop = true }
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.BotMode.route) {
|
||||
@@ -2650,6 +2725,9 @@ fun RelayApp() {
|
||||
onNavigateToPlugins = {
|
||||
navController.navigate(Screen.Plugins.route)
|
||||
},
|
||||
onNavigateToGitWorkspace = {
|
||||
navController.navigate(Screen.GitState.route)
|
||||
},
|
||||
onNavigateToChatSettings = {
|
||||
navController.navigate(Screen.ChatSettings.route)
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
|
||||
/** Small, read-only Git projection supplied by the native workspace owner. */
|
||||
data class ChatGitWorkspaceSummary(
|
||||
val branch: String,
|
||||
val changeCount: Int,
|
||||
val additions: Int? = null,
|
||||
val deletions: Int? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ChatGitContextButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.AccountTree,
|
||||
contentDescription = stringResource(R.string.chat_git_open_workspace),
|
||||
onClick = onClick,
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(9.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
shape = CircleShape,
|
||||
color = RelayRefresh.Green,
|
||||
border = BorderStroke(1.5.dp, RelayRefresh.Background),
|
||||
content = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatGitWorkspaceRail(
|
||||
summary: ChatGitWorkspaceSummary,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val branch = summary.branch.trim()
|
||||
val changeCount = summary.changeCount.coerceAtLeast(0)
|
||||
val changeLabel = pluralStringResource(
|
||||
R.plurals.chat_git_change_count,
|
||||
changeCount,
|
||||
changeCount,
|
||||
)
|
||||
val additions = summary.additions?.coerceAtLeast(0)
|
||||
val deletions = summary.deletions?.coerceAtLeast(0)
|
||||
val a11yLabel = buildList {
|
||||
add(stringResource(R.string.chat_git_branch, branch))
|
||||
add(changeLabel)
|
||||
additions?.let { add(stringResource(R.string.chat_git_additions, it)) }
|
||||
deletions?.let { add(stringResource(R.string.chat_git_deletions, it)) }
|
||||
add(stringResource(R.string.chat_git_open_workspace))
|
||||
}.joinToString(". ")
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(38.dp)
|
||||
.clearAndSetSemantics { contentDescription = a11yLabel },
|
||||
shape = appearanceRoundedCornerShape(12.dp),
|
||||
color = RelayRefresh.Background.copy(alpha = 0.72f),
|
||||
border = BorderStroke(1.dp, RelayRefresh.LineStrong),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 11.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.AccountTree,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Cyan,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
Text(
|
||||
text = branch,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Paper,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 92.dp),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(16.dp)
|
||||
.background(RelayRefresh.LineStrong),
|
||||
)
|
||||
Text(
|
||||
text = changeLabel,
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Muted,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
additions?.let {
|
||||
Text(
|
||||
text = "+$it",
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Green,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
deletions?.let {
|
||||
Text(
|
||||
text = "-$it",
|
||||
style = relayMetadataStyle(),
|
||||
color = RelayRefresh.Danger,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Muted,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+138
-22
@@ -20,13 +20,17 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.PauseCircleOutline
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -39,9 +43,13 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -49,6 +57,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -56,6 +65,10 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivity
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
|
||||
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Composer-adjacent summary of upstream Hermes processes for the active chat.
|
||||
@@ -63,8 +76,10 @@ import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
* in the global session/navigation drawer.
|
||||
*/
|
||||
@Composable
|
||||
fun GatewayBackgroundProcessStrip(
|
||||
internal fun GatewayBackgroundProcessStrip(
|
||||
processes: List<GatewayProcess>,
|
||||
subagentActivities: List<SubagentActivity>,
|
||||
subagentPreviewVisibility: SubagentPreviewVisibility,
|
||||
loading: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -72,16 +87,26 @@ fun GatewayBackgroundProcessStrip(
|
||||
// Initial/switch refreshes are silent. The strip appears only after the
|
||||
// session actually owns a process, avoiding a transient "Checking" row on
|
||||
// every ordinary chat open.
|
||||
if (processes.isEmpty()) return
|
||||
val visibleActivities = subagentActivities.takeIf { subagentPreviewVisibility.showLifecycle }.orEmpty()
|
||||
if (processes.isEmpty() && visibleActivities.isEmpty()) return
|
||||
|
||||
val running = processes.count { it.isRunning }
|
||||
val runningAgents = visibleActivities.count { !it.isTerminal }
|
||||
val failed = processes.count { !it.isRunning && (it.exitCode ?: 0) != 0 }
|
||||
val displayedCount = if (running > 0) running else processes.size
|
||||
val failedAgents = visibleActivities.count { it.phase == SubagentActivityPhase.FAILED }
|
||||
val interruptedAgents = visibleActivities.count {
|
||||
it.phase == SubagentActivityPhase.INTERRUPTED ||
|
||||
it.phase == SubagentActivityPhase.ENDED_WITH_PARENT
|
||||
}
|
||||
val failureCount = failed + failedAgents
|
||||
val status = when {
|
||||
runningAgents > 0 -> stringResource(R.string.subagent_lane_running_count, runningAgents)
|
||||
running > 0 -> "$running ${stringResource(R.string.bg_processes_running)}"
|
||||
failed > 0 -> "$failed ${stringResource(R.string.task_status_failed)}"
|
||||
failureCount > 0 -> "$failureCount ${stringResource(R.string.task_status_failed)}"
|
||||
interruptedAgents > 0 -> stringResource(R.string.agent_activity_status_interrupted)
|
||||
else -> stringResource(R.string.task_status_complete)
|
||||
}
|
||||
val openDescription = stringResource(R.string.current_chat_activity_open)
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
@@ -90,11 +115,11 @@ fun GatewayBackgroundProcessStrip(
|
||||
.heightIn(min = 48.dp)
|
||||
.semantics {
|
||||
contentDescription =
|
||||
"Background processes, $status. Open current chat activity."
|
||||
"$openDescription, $status"
|
||||
stateDescription = status
|
||||
}
|
||||
.clickable(
|
||||
onClickLabel = stringResource(R.string.bg_processes_open),
|
||||
onClickLabel = openDescription,
|
||||
onClick = onClick,
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
@@ -105,37 +130,41 @@ fun GatewayBackgroundProcessStrip(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (running > 0 || loading) {
|
||||
if (running > 0 || runningAgents > 0 || loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (failed > 0) Icons.Filled.ErrorOutline else Icons.Filled.CheckCircle,
|
||||
imageVector = when {
|
||||
failureCount > 0 -> Icons.Filled.ErrorOutline
|
||||
interruptedAgents > 0 -> Icons.Filled.PauseCircleOutline
|
||||
else -> Icons.Filled.CheckCircle
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(17.dp),
|
||||
tint = if (failed > 0) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
tint = when {
|
||||
failureCount > 0 -> MaterialTheme.colorScheme.error
|
||||
interruptedAgents > 0 -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(9.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.background_process_count, displayedCount),
|
||||
text = stringResource(R.string.current_chat_activity_title),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (failed > 0 && running == 0) {
|
||||
color = if (failureCount > 0 && running == 0) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ExpandLess,
|
||||
imageVector = Icons.Filled.Visibility,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
@@ -149,18 +178,56 @@ fun GatewayBackgroundProcessStrip(
|
||||
/** Mobile analogue of Hermes Desktop's composer process stack + terminal viewer. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GatewayBackgroundProcessSheet(
|
||||
internal fun GatewayBackgroundProcessSheet(
|
||||
processes: List<GatewayProcess>,
|
||||
subagentActivities: List<SubagentActivity>,
|
||||
subagentChildPreview: SubagentChildPreview?,
|
||||
subagentPreviewVisibility: SubagentPreviewVisibility,
|
||||
loading: Boolean,
|
||||
stoppingProcessIds: Set<String>,
|
||||
onRefresh: () -> Unit,
|
||||
onStop: (String) -> Unit,
|
||||
onDismissProcess: (String) -> Unit,
|
||||
onOpenSubagentChild: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var expandedAgentKeys by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val running = processes.filter { it.isRunning }
|
||||
val recent = processes.filterNot { it.isRunning }
|
||||
val visibleActivities = subagentActivities.takeIf { subagentPreviewVisibility.showLifecycle }.orEmpty()
|
||||
val followTarget = subagentActivityFollowTarget(
|
||||
visibleActivities,
|
||||
expandedAgentKeys,
|
||||
subagentPreviewVisibility,
|
||||
subagentChildPreview,
|
||||
)
|
||||
val activityRevision = visibleActivities.sumOf { it.revision } +
|
||||
subagentChildPreview?.messages.orEmpty().sumOf { message ->
|
||||
message.content.length + message.thinkingContent.length +
|
||||
message.toolCalls.sumOf { (it.args?.length ?: 0) + it.name.length }
|
||||
}
|
||||
val nearActivityTail by remember(followTarget, listState) {
|
||||
derivedStateOf {
|
||||
val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
followTarget >= 0 && last in maxOf(0, followTarget - 2)..followTarget
|
||||
}
|
||||
}
|
||||
var followAgentTail by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(listState, followTarget) {
|
||||
snapshotFlow { listState.isScrollInProgress to nearActivityTail }.collect { (scrolling, nearTail) ->
|
||||
if (scrolling) followAgentTail = nearTail
|
||||
else if (nearTail) followAgentTail = true
|
||||
}
|
||||
}
|
||||
LaunchedEffect(activityRevision, followTarget) {
|
||||
if (followAgentTail && followTarget >= 0) {
|
||||
listState.scrollToItem(followTarget)
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -178,23 +245,48 @@ fun GatewayBackgroundProcessSheet(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.background_processes_title), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
stringResource(R.string.background_processes_subtitle),
|
||||
stringResource(R.string.current_chat_activity_title),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.current_chat_activity_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh, enabled = !loading) {
|
||||
if (processes.isNotEmpty()) IconButton(onClick = onRefresh, enabled = !loading) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.background_processes_refresh_a11y))
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.current_chat_activity_close),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!followAgentTail && followTarget >= 0) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
followAgentTail = true
|
||||
scope.launch { listState.animateScrollToItem(followTarget) }
|
||||
}) {
|
||||
Text(stringResource(R.string.current_chat_activity_latest))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processes.isEmpty() && !loading) {
|
||||
if (processes.isEmpty() && visibleActivities.isEmpty() && !loading) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -202,23 +294,47 @@ fun GatewayBackgroundProcessSheet(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Terminal,
|
||||
Icons.Filled.AccountTree,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(30.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.bg_processes_empty),
|
||||
stringResource(R.string.current_chat_activity_empty),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 560.dp),
|
||||
) {
|
||||
subagentActivityItems(
|
||||
activities = visibleActivities,
|
||||
expandedKeys = expandedAgentKeys,
|
||||
visibility = subagentPreviewVisibility,
|
||||
childPreview = subagentChildPreview,
|
||||
onToggle = { key ->
|
||||
expandedAgentKeys = if (key in expandedAgentKeys) {
|
||||
expandedAgentKeys - key
|
||||
} else {
|
||||
expandedAgentKeys + key
|
||||
}
|
||||
},
|
||||
onOpenChild = onOpenSubagentChild,
|
||||
)
|
||||
if (visibleActivities.isNotEmpty() && processes.isNotEmpty()) {
|
||||
item { HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) }
|
||||
item {
|
||||
ProcessSectionLabel(
|
||||
stringResource(R.string.background_processes_title),
|
||||
processes.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (running.isNotEmpty()) {
|
||||
item { ProcessSectionLabel(stringResource(R.string.bg_processes_running), running.size) }
|
||||
items(running, key = { it.id }) { process ->
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -26,8 +25,6 @@ import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* ASCII morphing sphere — the visual embodiment of the AI agent.
|
||||
@@ -56,19 +53,13 @@ import kotlin.math.sin
|
||||
private const val SPHERE_TIME_UNITS_PER_SEC = 1f
|
||||
private const val SPHERE_TWO_PI = 6.2832f
|
||||
private const val SPHERE_COLOR_RADIANS_PER_SEC = 0.7854f
|
||||
private const val SPHERE_IDLE_BREATH_RADIANS_PER_SEC = 0.72f
|
||||
private const val SPHERE_IDLE_BREATH_SCALE = 0.012f
|
||||
private const val SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS = 184L
|
||||
|
||||
internal enum class SphereMotionMode {
|
||||
Still,
|
||||
AmbientLayer,
|
||||
Procedural,
|
||||
}
|
||||
|
||||
internal fun sphereMotionMode(
|
||||
state: SphereState,
|
||||
voiceMode: Boolean,
|
||||
motionVisible: Boolean,
|
||||
fixedTime: Float?,
|
||||
fixedColorPhase: Float?,
|
||||
@@ -76,11 +67,7 @@ internal fun sphereMotionMode(
|
||||
if (!motionVisible || fixedTime != null || fixedColorPhase != null) {
|
||||
return SphereMotionMode.Still
|
||||
}
|
||||
return if (state == SphereState.Idle && !voiceMode) {
|
||||
SphereMotionMode.AmbientLayer
|
||||
} else {
|
||||
SphereMotionMode.Procedural
|
||||
}
|
||||
return SphereMotionMode.Procedural
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -134,15 +121,12 @@ fun MorphingSphere(
|
||||
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
|
||||
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
|
||||
|
||||
// Active states retain the full procedural animation. Visible Idle uses a
|
||||
// lightweight graphics-layer breath: redrawing the 58x34 glyph grid just
|
||||
// for ambient drift was the measured screen-on hotspot, while transforming
|
||||
// its cached layer preserves the intended living Sphere at far lower cost.
|
||||
// Every visible Sphere uses the same display-synced procedural loop so
|
||||
// startup, chat, and voice all feel equally smooth. Backgrounded and
|
||||
// explicitly paused/reduced-motion renderers still pin a static frame.
|
||||
val animatedTime = remember { mutableFloatStateOf(0f) }
|
||||
val animatedColorPhase = remember { mutableFloatStateOf(0f) }
|
||||
val motionMode = sphereMotionMode(
|
||||
state = state,
|
||||
voiceMode = effVoiceMode,
|
||||
motionVisible = motionVisible,
|
||||
fixedTime = fixedTime,
|
||||
fixedColorPhase = fixedColorPhase,
|
||||
@@ -162,25 +146,6 @@ fun MorphingSphere(
|
||||
}
|
||||
}
|
||||
}
|
||||
val idleBreathPhase = remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(motionMode) {
|
||||
if (motionMode != SphereMotionMode.AmbientLayer) {
|
||||
idleBreathPhase.floatValue = 0f
|
||||
return@LaunchedEffect
|
||||
}
|
||||
var lastNanos = withFrameNanos { it }
|
||||
while (true) {
|
||||
val now = withFrameNanos { it }
|
||||
val dtSec = (now - lastNanos).coerceAtLeast(0L) / 1_000_000_000f
|
||||
lastNanos = now
|
||||
idleBreathPhase.floatValue =
|
||||
(idleBreathPhase.floatValue + dtSec * SPHERE_IDLE_BREATH_RADIANS_PER_SEC) %
|
||||
SPHERE_TWO_PI
|
||||
// The frame wait plus this delay caps the gentle layer-only pulse
|
||||
// near 5fps while active procedural states retain display-rate motion.
|
||||
delay(SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
val time = fixedTime ?: animatedTime.floatValue
|
||||
val colorPhase = fixedColorPhase ?: animatedColorPhase.floatValue
|
||||
@@ -192,18 +157,7 @@ fun MorphingSphere(
|
||||
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
|
||||
val glyphStrings = remember { HashMap<Char, String>(32) }
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
if (motionMode == SphereMotionMode.AmbientLayer) {
|
||||
val scale = 1f + sin(idleBreathPhase.floatValue) * SPHERE_IDLE_BREATH_SCALE
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
}
|
||||
.clipToBounds(),
|
||||
) {
|
||||
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
|
||||
val canvasW = size.width
|
||||
val canvasH = size.height
|
||||
val cellW = canvasW / cols
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.HourglassTop
|
||||
import androidx.compose.material.icons.filled.PauseCircleOutline
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivity
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEvent
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEventKind
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
|
||||
|
||||
internal data class SubagentPreviewVisibility(
|
||||
val showLifecycle: Boolean = true,
|
||||
val showReasoning: Boolean = true,
|
||||
val showToolNames: Boolean = true,
|
||||
val showToolDetails: Boolean = true,
|
||||
val showChildHistory: Boolean = true,
|
||||
)
|
||||
|
||||
internal fun LazyListScope.subagentActivityItems(
|
||||
activities: List<SubagentActivity>,
|
||||
expandedKeys: Set<String>,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
childPreview: SubagentChildPreview?,
|
||||
onToggle: (String) -> Unit,
|
||||
onOpenChild: (String) -> Unit,
|
||||
) {
|
||||
if (activities.isEmpty() || !visibility.showLifecycle) return
|
||||
item(key = "subagent-section") {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.agent_activity_section),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.agent_activity_disclosure),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
activities.forEach { activity ->
|
||||
val key = activity.stableKey
|
||||
item(key = "subagent-header-$key") {
|
||||
SubagentActivityHeader(
|
||||
activity = activity,
|
||||
expanded = key in expandedKeys,
|
||||
visibility = visibility,
|
||||
onClick = {
|
||||
if (key !in expandedKeys && visibility.showChildHistory) onOpenChild(key)
|
||||
onToggle(key)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (key in expandedKeys) {
|
||||
if (activity.truncated) {
|
||||
item(key = "subagent-truncated-$key") {
|
||||
SubagentMetaRow(stringResource(R.string.agent_activity_older_omitted))
|
||||
}
|
||||
}
|
||||
activity.events.forEach { event ->
|
||||
item(key = "subagent-event-$key-${event.sequence}") {
|
||||
SubagentEventRow(event, visibility)
|
||||
}
|
||||
}
|
||||
if (activity.partialAfterGap) {
|
||||
item(key = "subagent-gap-$key") {
|
||||
SubagentMetaRow(stringResource(R.string.agent_activity_partial))
|
||||
}
|
||||
}
|
||||
childPreview?.takeIf { visibility.showChildHistory && it.activityKey == key }?.let { preview ->
|
||||
when (preview.childWatchAvailable) {
|
||||
null -> item(key = "subagent-child-loading-$key") {
|
||||
SubagentMetaRow(stringResource(R.string.agent_activity_child_loading))
|
||||
}
|
||||
false -> item(key = "subagent-child-unavailable-$key") {
|
||||
SubagentMetaRow(
|
||||
preview.error?.takeIf(String::isNotBlank)
|
||||
?: stringResource(R.string.agent_activity_child_unavailable),
|
||||
)
|
||||
}
|
||||
true -> {
|
||||
item(key = "subagent-child-heading-$key") {
|
||||
SubagentMetaRow(
|
||||
if (preview.running) {
|
||||
stringResource(R.string.agent_activity_child_live)
|
||||
} else {
|
||||
stringResource(R.string.agent_activity_child_history)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (preview.historyTruncated) {
|
||||
item(key = "subagent-child-truncated-$key") {
|
||||
SubagentMetaRow(stringResource(R.string.agent_activity_child_truncated))
|
||||
}
|
||||
}
|
||||
preview.messages.filterNot { it.role == MessageRole.SYSTEM }.forEach { message ->
|
||||
item(key = "subagent-child-message-$key-${message.uiKey}") {
|
||||
SubagentChildMessageRow(message, visibility)
|
||||
}
|
||||
}
|
||||
preview.error?.takeIf(String::isNotBlank)?.let { error ->
|
||||
item(key = "subagent-child-error-$key") { SubagentMetaRow(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "subagent-child-tail-$key") {
|
||||
Spacer(Modifier.height(1.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun subagentActivityItemCount(
|
||||
activities: List<SubagentActivity>,
|
||||
expandedKeys: Set<String>,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
childPreview: SubagentChildPreview?,
|
||||
): Int {
|
||||
if (activities.isEmpty() || !visibility.showLifecycle) return 0
|
||||
return 1 + activities.sumOf { activity ->
|
||||
val expanded = activity.stableKey in expandedKeys
|
||||
val preview = childPreview?.takeIf {
|
||||
visibility.showChildHistory && it.activityKey == activity.stableKey
|
||||
}
|
||||
val previewRows = when (preview?.childWatchAvailable) {
|
||||
null -> if (preview != null) 1 else 0
|
||||
false -> 1
|
||||
true -> 1 + preview.messages.count { it.role != MessageRole.SYSTEM } +
|
||||
(if (preview.historyTruncated) 1 else 0) +
|
||||
(if (preview.error.isNullOrBlank()) 0 else 1)
|
||||
} + if (preview != null) 1 else 0 // explicit bottom anchor for growing rows
|
||||
1 + if (!expanded) 0 else activity.events.size +
|
||||
(if (activity.truncated) 1 else 0) +
|
||||
(if (activity.partialAfterGap) 1 else 0) + previewRows
|
||||
}
|
||||
}
|
||||
|
||||
internal fun subagentActivityFollowTarget(
|
||||
activities: List<SubagentActivity>,
|
||||
expandedKeys: Set<String>,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
childPreview: SubagentChildPreview?,
|
||||
): Int {
|
||||
if (activities.isEmpty() || !visibility.showLifecycle) return -1
|
||||
var index = 0 // section heading
|
||||
var selectedTarget = -1
|
||||
activities.forEach { activity ->
|
||||
index += 1 // lane header
|
||||
if (activity.stableKey in expandedKeys) {
|
||||
if (activity.truncated) index += 1
|
||||
index += activity.events.size
|
||||
if (activity.partialAfterGap) index += 1
|
||||
if (visibility.showChildHistory && childPreview?.activityKey == activity.stableKey) {
|
||||
index += when (childPreview.childWatchAvailable) {
|
||||
null, false -> 1
|
||||
true -> 1 + childPreview.messages.count { it.role != MessageRole.SYSTEM } +
|
||||
(if (childPreview.historyTruncated) 1 else 0) +
|
||||
(if (childPreview.error.isNullOrBlank()) 0 else 1)
|
||||
}
|
||||
index += 1 // explicit bottom anchor for growing child content
|
||||
selectedTarget = index
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (selectedTarget >= 0) selectedTarget else index
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentActivityHeader(
|
||||
activity: SubagentActivity,
|
||||
expanded: Boolean,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val title = activity.goal.takeIf { visibility.showReasoning && it.isNotBlank() }
|
||||
?: stringResource(R.string.agent_activity_fallback, activity.taskIndex + 1)
|
||||
val phaseLabel = phaseLabel(activity.phase)
|
||||
val description = stringResource(
|
||||
R.string.agent_activity_lane_a11y,
|
||||
title,
|
||||
phaseLabel,
|
||||
activity.taskIndex + 1,
|
||||
activity.taskCount,
|
||||
)
|
||||
val icon: ImageVector = when (activity.phase) {
|
||||
SubagentActivityPhase.COMPLETED -> Icons.Filled.CheckCircle
|
||||
SubagentActivityPhase.FAILED -> Icons.Filled.ErrorOutline
|
||||
SubagentActivityPhase.INTERRUPTED,
|
||||
SubagentActivityPhase.ENDED_WITH_PARENT,
|
||||
-> Icons.Filled.PauseCircleOutline
|
||||
SubagentActivityPhase.STARTED,
|
||||
SubagentActivityPhase.THINKING,
|
||||
SubagentActivityPhase.TOOL,
|
||||
SubagentActivityPhase.PROGRESS,
|
||||
-> Icons.Filled.HourglassTop
|
||||
}
|
||||
val tint = when (activity.phase) {
|
||||
SubagentActivityPhase.FAILED -> MaterialTheme.colorScheme.error
|
||||
SubagentActivityPhase.COMPLETED -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 3.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.semantics {
|
||||
contentDescription = description
|
||||
stateDescription = phaseLabel
|
||||
liveRegion = LiveRegionMode.Polite
|
||||
}
|
||||
.clickable(onClick = onClick),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(9.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.agent_activity_task_position,
|
||||
activity.taskIndex + 1,
|
||||
activity.taskCount,
|
||||
phaseLabel,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
activity.durationSeconds?.takeIf { activity.isTerminal }?.let { seconds ->
|
||||
Text(
|
||||
stringResource(R.string.agent_activity_duration, seconds),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Icon(
|
||||
if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = stringResource(
|
||||
if (expanded) R.string.cd_subagent_collapse else R.string.cd_subagent_expand,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentEventRow(
|
||||
event: SubagentActivityEvent,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
) {
|
||||
val label = when (event.kind) {
|
||||
SubagentActivityEventKind.STARTED -> stringResource(R.string.agent_activity_event_started)
|
||||
SubagentActivityEventKind.UPDATE -> stringResource(R.string.agent_activity_event_update)
|
||||
SubagentActivityEventKind.TOOL -> stringResource(R.string.agent_activity_event_tool)
|
||||
SubagentActivityEventKind.COMPLETED -> phaseLabel(event.phase)
|
||||
}
|
||||
val showText = when (event.kind) {
|
||||
SubagentActivityEventKind.STARTED -> false
|
||||
SubagentActivityEventKind.UPDATE,
|
||||
SubagentActivityEventKind.COMPLETED,
|
||||
-> visibility.showReasoning
|
||||
SubagentActivityEventKind.TOOL -> visibility.showToolDetails
|
||||
}
|
||||
val toolName = event.toolName?.takeIf { visibility.showToolNames || visibility.showToolDetails }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 38.dp, end = 20.dp, top = 5.dp, bottom = 5.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.AccountTree,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelSmall)
|
||||
toolName?.let {
|
||||
Text(
|
||||
" · $it",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
event.text?.takeIf { showText && it.isNotBlank() }?.let { text ->
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = if (event.kind == SubagentActivityEventKind.TOOL) {
|
||||
FontFamily.Monospace
|
||||
} else {
|
||||
FontFamily.Default
|
||||
},
|
||||
maxLines = 8,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentMetaRow(text: String) {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier.padding(start = 38.dp, end = 20.dp, top = 4.dp, bottom = 6.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubagentChildMessageRow(
|
||||
message: ChatMessage,
|
||||
visibility: SubagentPreviewVisibility,
|
||||
) {
|
||||
if (message.role == MessageRole.SYSTEM) return
|
||||
val role = when (message.role) {
|
||||
MessageRole.USER -> stringResource(R.string.agent_activity_child_role_task)
|
||||
MessageRole.ASSISTANT -> stringResource(R.string.agent_activity_child_role_agent)
|
||||
MessageRole.SYSTEM -> stringResource(R.string.agent_activity_child_role_system)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 38.dp, end = 20.dp, top = 6.dp, bottom = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
role,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
message.thinkingContent.takeIf { visibility.showReasoning && it.isNotBlank() }?.let { thought ->
|
||||
Text(
|
||||
thought,
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 10,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
message.content.takeIf(String::isNotBlank)?.let { content ->
|
||||
Text(
|
||||
content,
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 20,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (visibility.showToolNames || visibility.showToolDetails) {
|
||||
message.toolCalls.forEach { tool ->
|
||||
Text(
|
||||
buildString {
|
||||
append(tool.name)
|
||||
if (visibility.showToolDetails) {
|
||||
tool.args?.takeIf(String::isNotBlank)?.let { append(" · ").append(it.take(500)) }
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun phaseLabel(phase: SubagentActivityPhase): String = stringResource(
|
||||
when (phase) {
|
||||
SubagentActivityPhase.STARTED -> R.string.agent_activity_status_started
|
||||
SubagentActivityPhase.THINKING -> R.string.agent_activity_status_thinking
|
||||
SubagentActivityPhase.TOOL -> R.string.agent_activity_status_tool
|
||||
SubagentActivityPhase.PROGRESS -> R.string.agent_activity_status_progress
|
||||
SubagentActivityPhase.COMPLETED -> R.string.agent_activity_status_completed
|
||||
SubagentActivityPhase.FAILED -> R.string.agent_activity_status_failed
|
||||
SubagentActivityPhase.INTERRUPTED -> R.string.agent_activity_status_interrupted
|
||||
SubagentActivityPhase.ENDED_WITH_PARENT -> R.string.agent_activity_status_unavailable
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -193,13 +191,15 @@ import com.hermesandroid.relay.ui.components.BackgroundTaskCard
|
||||
import com.hermesandroid.relay.ui.components.LocalRelayServerImageResolver
|
||||
import com.hermesandroid.relay.ui.components.RelayServerImageResolver
|
||||
import com.hermesandroid.relay.ui.components.ChatInputBar
|
||||
import com.hermesandroid.relay.ui.components.ChatGitContextButton
|
||||
import com.hermesandroid.relay.ui.components.ChatGitWorkspaceRail
|
||||
import com.hermesandroid.relay.ui.components.ChatGitWorkspaceSummary
|
||||
import com.hermesandroid.relay.ui.components.ChatFailureDetailsDialog
|
||||
import com.hermesandroid.relay.ui.components.ChatFailurePanel
|
||||
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
|
||||
@@ -216,6 +216,7 @@ import com.hermesandroid.relay.ui.components.CHAT_PET_STEP_MESSAGE_MARKER
|
||||
import com.hermesandroid.relay.ui.components.CHAT_PET_USER_MESSAGE_PERCH_PREFIX
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
|
||||
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
|
||||
import com.hermesandroid.relay.ui.components.InjectedContextSheet
|
||||
import com.hermesandroid.relay.ui.components.InlineAutocomplete
|
||||
import com.hermesandroid.relay.ui.components.loadedContentTransform
|
||||
@@ -719,6 +720,9 @@ fun ChatScreen(
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
supervisedPolicy: SupervisedModePolicy = SupervisedModePolicy(),
|
||||
onNavigateToBotMode: () -> Unit = {},
|
||||
gitWorkspaceSummary: ChatGitWorkspaceSummary? = null,
|
||||
gitWorkspaceAvailable: Boolean = gitWorkspaceSummary != null,
|
||||
onNavigateToGitWorkspace: () -> Unit = {},
|
||||
) {
|
||||
val supervised = supervisedPolicy.enabled
|
||||
val supervisedVisibility = supervisedPolicy.visibility.resolved()
|
||||
@@ -925,6 +929,8 @@ fun ChatScreen(
|
||||
val backgroundProcesses by chatViewModel.backgroundProcesses.collectAsState()
|
||||
val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState()
|
||||
val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState()
|
||||
val subagentActivities by chatViewModel.subagentActivities.collectAsState()
|
||||
val subagentChildPreview by chatViewModel.subagentChildPreview.collectAsState()
|
||||
val isLoadingHistory by chatViewModel.isLoadingHistory.collectAsState()
|
||||
val isLoadingSessions by chatViewModel.isLoadingSessions.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
@@ -1059,6 +1065,13 @@ fun ChatScreen(
|
||||
supervisedVisibility.showToolNames -> "compact"
|
||||
else -> "off"
|
||||
}
|
||||
val subagentPreviewVisibility = SubagentPreviewVisibility(
|
||||
showLifecycle = !supervised || supervisedVisibility.showWorkingStatus,
|
||||
showReasoning = showThinking,
|
||||
showToolNames = toolDisplay == "compact" || toolDisplay == "detailed",
|
||||
showToolDetails = toolDisplay == "detailed",
|
||||
showChildHistory = !supervised,
|
||||
)
|
||||
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
|
||||
val closeDrawerOnSend by connectionViewModel.closeDrawerOnSend.collectAsState()
|
||||
val keepComposerFocusedOnSend by
|
||||
@@ -1067,6 +1080,13 @@ fun ChatScreen(
|
||||
connectionViewModel.physicalKeyboardEnterBehavior.collectAsState()
|
||||
val convertLargePastesToAttachments by
|
||||
connectionViewModel.convertLargePastesToAttachments.collectAsState()
|
||||
val showGitWorkspaceInChat by
|
||||
connectionViewModel.showGitWorkspaceInChat.collectAsState()
|
||||
val visibleGitWorkspaceSummary = gitWorkspaceSummary?.takeIf {
|
||||
!supervised && showGitWorkspaceInChat && it.branch.isNotBlank()
|
||||
}
|
||||
val showGitWorkspaceContextEntry =
|
||||
!supervised && showGitWorkspaceInChat && gitWorkspaceAvailable
|
||||
|
||||
val availableSkills by chatViewModel.availableSkills.collectAsState()
|
||||
val queuedMessages by chatViewModel.queuedMessages.collectAsState()
|
||||
@@ -1174,12 +1194,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 {
|
||||
@@ -1358,6 +1372,7 @@ fun ChatScreen(
|
||||
// A process inventory is scoped to one gateway session. Never leave a
|
||||
// sheet opened onto a different chat after a drawer/profile switch.
|
||||
LaunchedEffect(currentSessionId, selectedProfile?.name, activeConnection?.id) {
|
||||
chatViewModel.closeSubagentChildPreview()
|
||||
showBackgroundProcesses = false
|
||||
}
|
||||
|
||||
@@ -1415,7 +1430,6 @@ fun ChatScreen(
|
||||
PetInteractionLayer(
|
||||
owner = "chat-interaction-layer",
|
||||
active = shouldHideChatPet(
|
||||
ambientMode = ambientMode,
|
||||
voiceMode = voiceUiState.voiceMode,
|
||||
drawerOpenOrMoving =
|
||||
drawerState.currentValue != DrawerValue.Closed ||
|
||||
@@ -2996,6 +3010,12 @@ fun ChatScreen(
|
||||
// info. Dropping it here declutters the actions row and frees
|
||||
// width for the title subtitle.)
|
||||
if (!supervised) {
|
||||
if (showGitWorkspaceContextEntry) {
|
||||
ChatGitContextButton(
|
||||
onClick = onNavigateToGitWorkspace,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = stringResource(R.string.cd_terminal),
|
||||
@@ -3101,11 +3121,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)
|
||||
@@ -3184,11 +3199,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(
|
||||
@@ -3425,28 +3435,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(
|
||||
@@ -3971,6 +3965,8 @@ fun ChatScreen(
|
||||
if (isGatewayTransport) {
|
||||
GatewayBackgroundProcessStrip(
|
||||
processes = backgroundProcesses,
|
||||
subagentActivities = subagentActivities,
|
||||
subagentPreviewVisibility = subagentPreviewVisibility,
|
||||
loading = backgroundProcessesLoading,
|
||||
onClick = { showBackgroundProcesses = true },
|
||||
)
|
||||
@@ -4457,6 +4453,14 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
visibleGitWorkspaceSummary?.let { summary ->
|
||||
ChatGitWorkspaceRail(
|
||||
summary = summary,
|
||||
onClick = onNavigateToGitWorkspace,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
ChatInputBar(
|
||||
value = inputText,
|
||||
onValueChange = { inputText = it },
|
||||
@@ -4783,62 +4787,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,
|
||||
@@ -4928,12 +4876,19 @@ fun ChatScreen(
|
||||
if (showBackgroundProcesses) {
|
||||
GatewayBackgroundProcessSheet(
|
||||
processes = backgroundProcesses,
|
||||
subagentActivities = subagentActivities,
|
||||
subagentChildPreview = subagentChildPreview,
|
||||
subagentPreviewVisibility = subagentPreviewVisibility,
|
||||
loading = backgroundProcessesLoading,
|
||||
stoppingProcessIds = stoppingProcessIds,
|
||||
onRefresh = chatViewModel::refreshBackgroundProcesses,
|
||||
onStop = chatViewModel::stopBackgroundProcess,
|
||||
onDismissProcess = chatViewModel::dismissBackgroundProcess,
|
||||
onDismiss = { showBackgroundProcesses = false },
|
||||
onOpenSubagentChild = chatViewModel::openSubagentChildPreview,
|
||||
onDismiss = {
|
||||
chatViewModel.closeSubagentChildPreview()
|
||||
showBackgroundProcesses = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5547,15 +5502,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,
|
||||
@@ -5564,8 +5512,7 @@ internal fun shouldHideChatPet(
|
||||
contextSheetVisible: Boolean = false,
|
||||
backgroundProcessesVisible: Boolean = false,
|
||||
agentInfoVisible: Boolean = false,
|
||||
): Boolean = ambientMode ||
|
||||
voiceMode ||
|
||||
): Boolean = voiceMode ||
|
||||
drawerOpenOrMoving ||
|
||||
commandPaletteVisible ||
|
||||
modelSheetVisible ||
|
||||
|
||||
@@ -391,6 +391,32 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
val showGitWorkspaceInChat by
|
||||
connectionViewModel.showGitWorkspaceInChat.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_settings_show_git_workspace),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.chat_settings_show_git_workspace_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = showGitWorkspaceInChat,
|
||||
onCheckedChange = connectionViewModel::setShowGitWorkspaceInChat,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
val recentPromptsEnabled by
|
||||
connectionViewModel.chatRecentPromptsEnabled.collectAsState()
|
||||
Row(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Message
|
||||
import androidx.compose.material.icons.filled.Analytics
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
@@ -190,6 +191,7 @@ fun SettingsScreen(
|
||||
onNavigateToManage: () -> Unit,
|
||||
onNavigateToProviderUsage: () -> Unit,
|
||||
onNavigateToPlugins: () -> Unit,
|
||||
onNavigateToGitWorkspace: () -> Unit,
|
||||
onNavigateToChatSettings: () -> Unit,
|
||||
onNavigateToTerminal: () -> Unit,
|
||||
onNavigateToBridge: () -> Unit,
|
||||
@@ -603,6 +605,14 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.AccountTree,
|
||||
title = stringResource(R.string.settings_git_workspace),
|
||||
subtitle = stringResource(R.string.settings_git_workspace_desc),
|
||||
onClick = onNavigateToGitWorkspace,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.AutoMirrored.Filled.Chat,
|
||||
title = stringResource(R.string.settings_chat),
|
||||
|
||||
@@ -419,10 +419,8 @@ class ChatViewModel : ViewModel() {
|
||||
_sessionDirectoryRefreshRequests.asSharedFlow()
|
||||
|
||||
private fun activityScope(contextKey: String? = activeProfileContextKey): SessionActivityScope? {
|
||||
val raw = contextKey?.trim().orEmpty()
|
||||
val separator = raw.lastIndexOf("::")
|
||||
if (separator <= 0 || separator >= raw.lastIndex) return null
|
||||
return SessionActivityScope.of(raw.substring(0, separator), raw.substring(separator + 2))
|
||||
val identity = AgentDisplay.parseProfileContextKey(contextKey) ?: return null
|
||||
return SessionActivityScope.of(identity.connectionId, identity.profileKey)
|
||||
}
|
||||
|
||||
private fun activityOwner(
|
||||
@@ -534,7 +532,8 @@ class ChatViewModel : ViewModel() {
|
||||
private fun backgroundTurnKey(sessionId: String, profile: String?): TurnCheckpointKey? {
|
||||
val profileKey = AgentDisplay.profileSessionKey(profile)
|
||||
return backgroundTurnCheckpoints.keys.firstOrNull { key ->
|
||||
key.sessionId == sessionId && key.contextKey.substringAfterLast("::") == profileKey
|
||||
key.sessionId == sessionId &&
|
||||
AgentDisplay.parseProfileContextKey(key.contextKey)?.profileKey == profileKey
|
||||
}
|
||||
}
|
||||
private var checkpointWriteJob: Job? = null
|
||||
@@ -547,6 +546,7 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
private data class ActiveTurnCheckpointSeed(
|
||||
var contextKey: String?,
|
||||
val profileKey: String?,
|
||||
var sessionId: String,
|
||||
var liveSessionId: String?,
|
||||
var transport: String,
|
||||
@@ -2078,6 +2078,10 @@ class ChatViewModel : ViewModel() {
|
||||
private var chatVisible = false
|
||||
private var gatewayProcessSource: GatewayProcessSource? = null
|
||||
private val gatewayProcessController = GatewayProcessController(viewModelScope)
|
||||
private val subagentActivityController = SubagentActivityController()
|
||||
private val subagentChildPreviewController = SubagentChildPreviewController(viewModelScope)
|
||||
internal val subagentChildPreview: StateFlow<SubagentChildPreview?> =
|
||||
subagentChildPreviewController.state
|
||||
|
||||
/** Session-scoped upstream shell processes shown beside the composer. */
|
||||
val backgroundProcesses: StateFlow<List<GatewayProcess>> = gatewayProcessController.processes
|
||||
@@ -2089,6 +2093,33 @@ class ChatViewModel : ViewModel() {
|
||||
val backgroundProcessesLoading: StateFlow<Boolean> = gatewayProcessController.loading
|
||||
val stoppingProcessIds: StateFlow<Set<String>> = gatewayProcessController.stoppingProcessIds
|
||||
|
||||
/** Bounded parent-session lifecycle previews for the active chat's delegated work. */
|
||||
internal val subagentActivities: StateFlow<List<SubagentActivity>> =
|
||||
subagentActivityController.activities
|
||||
|
||||
fun openSubagentChildPreview(activityKey: String) {
|
||||
val activity = subagentActivities.value.firstOrNull { it.stableKey == activityKey } ?: return
|
||||
val parentSessionId = chatHandler?.currentSessionId?.value ?: return
|
||||
val parentScopeKey = activeProfileContextKey
|
||||
val client = gatewayClient
|
||||
subagentChildPreviewController.open(
|
||||
activity = activity,
|
||||
client = client,
|
||||
parentSessionId = parentSessionId,
|
||||
parentScopeKey = parentScopeKey,
|
||||
gatewayRouteActive = streamingEndpoint == "gateway",
|
||||
stillOwnsParent = {
|
||||
gatewayClient === client &&
|
||||
chatHandler?.currentSessionId?.value == parentSessionId &&
|
||||
activeProfileContextKey == parentScopeKey
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun closeSubagentChildPreview() {
|
||||
subagentChildPreviewController.close()
|
||||
}
|
||||
|
||||
private val _messageReactionsSupported = MutableStateFlow(true)
|
||||
val messageReactionsSupported: StateFlow<Boolean> = _messageReactionsSupported.asStateFlow()
|
||||
|
||||
@@ -2442,6 +2473,8 @@ class ChatViewModel : ViewModel() {
|
||||
resetApprovalModeState()
|
||||
_messageReactionsSupported.value = true
|
||||
gatewayProcessSource = client?.let(::GatewayChatProcessSource)
|
||||
closeSubagentChildPreview()
|
||||
subagentActivityController.resetConnection()
|
||||
gatewayProcessController.bind(
|
||||
newSource = gatewayProcessSource,
|
||||
sessionId = chatHandler?.currentSessionId?.value,
|
||||
@@ -2604,7 +2637,7 @@ class ChatViewModel : ViewModel() {
|
||||
val matching = backgroundTurnCheckpoints.keys.filter { key ->
|
||||
val checkpoint = backgroundTurnCheckpoints[key]
|
||||
key.sessionId == completion.storedSessionId &&
|
||||
key.contextKey.substringAfterLast("::") == profileKey &&
|
||||
AgentDisplay.parseProfileContextKey(key.contextKey)?.profileKey == profileKey &&
|
||||
checkpoint?.liveSessionId == completion.liveSessionId
|
||||
}
|
||||
if (matching.isEmpty()) return
|
||||
@@ -2653,11 +2686,14 @@ class ChatViewModel : ViewModel() {
|
||||
queuedRecovery: QueuedRecoveryHandoff? = null,
|
||||
): GatewayInboundTurnRegistration? {
|
||||
val handler = chatHandler ?: return null
|
||||
val eventScopeKey = activeProfileContextKey
|
||||
val eventProfile = currentSessionProfileName()
|
||||
fun matchesAdmissionContext(): Boolean =
|
||||
gatewayClient === client &&
|
||||
streamingEndpoint == "gateway" &&
|
||||
chatHandler === handler &&
|
||||
handler.currentSessionId.value == storedSessionId
|
||||
handler.currentSessionId.value == storedSessionId &&
|
||||
activeProfileContextKey == eventScopeKey
|
||||
|
||||
val messageId = "gateway-inbound-${UUID.randomUUID()}"
|
||||
val queuedUserMessageId = "gateway-queued-user-${UUID.randomUUID()}"
|
||||
@@ -2692,6 +2728,11 @@ class ChatViewModel : ViewModel() {
|
||||
onStart = {
|
||||
if (!started && ownsBoundTurn()) {
|
||||
started = true
|
||||
subagentActivityController.beginTurn(
|
||||
storedSessionId,
|
||||
eventScopeKey,
|
||||
messageId,
|
||||
)
|
||||
cancelAnswerRecovery()
|
||||
intentionallyCancelled = false
|
||||
firstTokenNotified = false
|
||||
@@ -2750,6 +2791,7 @@ class ChatViewModel : ViewModel() {
|
||||
?.content
|
||||
?.takeIf { it.isNotBlank() }
|
||||
if (canWriteTranscript) {
|
||||
subagentActivityController.endTurn(messageId)
|
||||
val failed = handler.messages.value
|
||||
.lastOrNull { it.id == messageId }
|
||||
?.badges
|
||||
@@ -2785,6 +2827,7 @@ class ChatViewModel : ViewModel() {
|
||||
onError = { message ->
|
||||
val canWriteTranscript = acceptsEvent()
|
||||
if (canWriteTranscript) {
|
||||
subagentActivityController.endTurn(messageId)
|
||||
AppAnalytics.onStreamError()
|
||||
handler.onStreamError(message)
|
||||
emitError(Exception(message), context = "send_message")
|
||||
@@ -2802,7 +2845,16 @@ class ChatViewModel : ViewModel() {
|
||||
if (acceptsEvent()) handler.onToolGenerating(messageId, name)
|
||||
},
|
||||
onSubagentEvent = { event ->
|
||||
if (acceptsEvent()) handler.onSubagentEvent(messageId, event)
|
||||
if (acceptsEvent()) {
|
||||
subagentActivityController.onEvent(
|
||||
sessionId = storedSessionId,
|
||||
eventScopeKey = eventScopeKey,
|
||||
turnId = messageId,
|
||||
event = event,
|
||||
profile = eventProfile,
|
||||
)
|
||||
handler.onSubagentEvent(messageId, event)
|
||||
}
|
||||
},
|
||||
onMoaReference = { event ->
|
||||
if (acceptsEvent()) handler.onMoaReference(messageId, event)
|
||||
@@ -3372,7 +3424,13 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId: String?,
|
||||
scopeKey: String? = activeProfileContextKey,
|
||||
) {
|
||||
subagentChildPreview.value?.let { preview ->
|
||||
if (preview.parentSessionId != sessionId || preview.parentScopeKey != scopeKey) {
|
||||
closeSubagentChildPreview()
|
||||
}
|
||||
}
|
||||
gatewayProcessController.selectSession(sessionId, scopeKey)
|
||||
subagentActivityController.selectSession(sessionId, scopeKey)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3639,6 +3697,14 @@ class ChatViewModel : ViewModel() {
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.connectionState.collect { state ->
|
||||
if (gatewayClient !== client) return@collect
|
||||
subagentActivityController.onConnectionReady(
|
||||
state == com.hermesandroid.relay.network.upstream.GatewayConnectionState.Ready,
|
||||
)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.serverPersonality.collect { value ->
|
||||
if (gatewayClient !== client || value == null) return@collect
|
||||
@@ -6263,6 +6329,16 @@ class ChatViewModel : ViewModel() {
|
||||
checkpointWriteJob?.cancel()
|
||||
activeTurnCheckpointSeed = ActiveTurnCheckpointSeed(
|
||||
contextKey = activeProfileContextKey,
|
||||
// Persist the explicit UI selection, not the effective sticky
|
||||
// server profile used to bind the current live session. Server
|
||||
// Default must survive restart as the sentinel even when Hermes
|
||||
// currently resolves it to a named profile such as `victor`.
|
||||
profileKey = AgentDisplay.profileSessionKey(
|
||||
conversationBinding.value.let { binding ->
|
||||
if (binding.hasExplicitOwner) binding.profileName
|
||||
else selectedProfileProvider()?.name
|
||||
},
|
||||
),
|
||||
sessionId = sessionId,
|
||||
liveSessionId = null,
|
||||
transport = transport,
|
||||
@@ -6303,6 +6379,7 @@ class ChatViewModel : ViewModel() {
|
||||
checkpointWriteJob?.cancel()
|
||||
activeTurnCheckpointSeed = ActiveTurnCheckpointSeed(
|
||||
contextKey = checkpoint.contextKey,
|
||||
profileKey = checkpoint.profileKey,
|
||||
sessionId = checkpoint.sessionId,
|
||||
liveSessionId = checkpoint.liveSessionId,
|
||||
transport = checkpoint.transport,
|
||||
@@ -6363,6 +6440,7 @@ class ChatViewModel : ViewModel() {
|
||||
val now = System.currentTimeMillis()
|
||||
return ChatTurnCheckpoint(
|
||||
contextKey = contextKey,
|
||||
profileKey = seed.profileKey,
|
||||
sessionId = sessionId,
|
||||
liveSessionId = gatewayClient?.currentLiveSessionId(sessionId) ?: seed.liveSessionId,
|
||||
transport = seed.transport,
|
||||
@@ -6810,6 +6888,11 @@ class ChatViewModel : ViewModel() {
|
||||
queuedSuccessorPending: AtomicBoolean,
|
||||
): GatewayTurnCallbacks {
|
||||
val messageId = checkpoint.assistant.id
|
||||
subagentActivityController.beginTurn(
|
||||
checkpoint.sessionId,
|
||||
checkpoint.contextKey,
|
||||
messageId,
|
||||
)
|
||||
fun owns(): Boolean = ownsTurnCheckpoint(checkpoint, handler)
|
||||
return GatewayTurnCallbacks(
|
||||
onSessionId = { },
|
||||
@@ -6860,6 +6943,7 @@ class ChatViewModel : ViewModel() {
|
||||
onReconcileRequired = { },
|
||||
onComplete = {
|
||||
if (owns()) {
|
||||
subagentActivityController.endTurn(messageId)
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
val failed = handler.messages.value
|
||||
.lastOrNull { it.id == messageId }
|
||||
@@ -6897,6 +6981,7 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
onError = { error ->
|
||||
if (owns()) {
|
||||
subagentActivityController.endTurn(messageId)
|
||||
if (queuedSuccessorPending.get()) {
|
||||
AppAnalytics.onStreamError()
|
||||
handler.onStreamError(error)
|
||||
@@ -6921,6 +7006,18 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
onSubagentEvent = { event ->
|
||||
if (owns()) {
|
||||
subagentActivityController.onEvent(
|
||||
sessionId = checkpoint.sessionId,
|
||||
eventScopeKey = checkpoint.contextKey,
|
||||
turnId = messageId,
|
||||
event = event,
|
||||
profile = if (checkpoint.profileKey != null) {
|
||||
AgentDisplay.profileRequestName(checkpoint.profileKey)
|
||||
} else {
|
||||
AgentDisplay.parseProfileContextKey(checkpoint.contextKey)
|
||||
?.requestProfileName
|
||||
},
|
||||
)
|
||||
handler.onSubagentEvent(messageId, event)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
@@ -8693,6 +8790,7 @@ class ChatViewModel : ViewModel() {
|
||||
// wins — stop the poller before finalizing so the turn can't
|
||||
// finish twice.
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
subagentActivityController.endTurn(currentMessageId)
|
||||
val completedTransport = dispatchedSseEndpoint
|
||||
?: if (activeStreamIsGateway) "gateway" else streamingEndpoint
|
||||
val turnErrored = handler.messages.value
|
||||
@@ -8820,6 +8918,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
val onErrorCb = { errorMsg: String ->
|
||||
markTransportFailed(errorMsg)
|
||||
subagentActivityController.endTurn(currentMessageId)
|
||||
stopImageActivityBridge()
|
||||
flushAndReleaseStreamDeltas()
|
||||
val errorSessionId = handler.currentSessionId.value
|
||||
@@ -9220,6 +9319,13 @@ class ChatViewModel : ViewModel() {
|
||||
// context rides the SSE systemMessage (invisible) + the on-demand
|
||||
// android_phone_status tool instead. See PhoneStatusPromptBuilder.
|
||||
_steerableTurn.value = true
|
||||
handler.currentSessionId.value?.let { existingSessionId ->
|
||||
subagentActivityController.beginTurn(
|
||||
existingSessionId,
|
||||
activeProfileContextKey,
|
||||
currentMessageId,
|
||||
)
|
||||
}
|
||||
gateway.sendTurn(
|
||||
sessionId = handler.currentSessionId.value,
|
||||
text = message,
|
||||
@@ -9241,6 +9347,11 @@ class ChatViewModel : ViewModel() {
|
||||
markSessionActivityStarting(sid)
|
||||
updateTurnCheckpointSession(sid)
|
||||
selectBackgroundProcessSession(sid)
|
||||
subagentActivityController.beginTurn(
|
||||
sid,
|
||||
activeProfileContextKey,
|
||||
currentMessageId,
|
||||
)
|
||||
gatewayProcessController.sessionReady(sid)
|
||||
onSessionChanged?.invoke(sid)
|
||||
// The brand-new chat now has a session — apply any
|
||||
@@ -9285,6 +9396,13 @@ class ChatViewModel : ViewModel() {
|
||||
onSubagentEvent = { event ->
|
||||
ensurePostInterimMessage()
|
||||
streamDeltas.flushNow()
|
||||
subagentActivityController.onEvent(
|
||||
sessionId = handler.currentSessionId.value,
|
||||
eventScopeKey = activeProfileContextKey,
|
||||
turnId = currentMessageId,
|
||||
event = event,
|
||||
profile = currentSessionProfileName(),
|
||||
)
|
||||
handler.onSubagentEvent(currentMessageId, event)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
},
|
||||
|
||||
@@ -2412,6 +2412,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
val showGitWorkspaceInChat: StateFlow<Boolean> =
|
||||
chatInputPreferencesRepository.showGitWorkspaceInChat
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setShowGitWorkspaceInChat(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
chatInputPreferencesRepository.setShowGitWorkspaceInChat(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Turn-complete notification (default ON). RelayApp mirrors this into
|
||||
// ChatViewModel.notifyOnTurnComplete; ChatSettingsScreen owns the toggle
|
||||
// + the POST_NOTIFICATIONS runtime request on first enable.
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface GitStateUiState {
|
||||
data object Loading : GitStateUiState
|
||||
data class Unavailable(val message: String) : GitStateUiState
|
||||
data class Error(val message: String) : GitStateUiState
|
||||
data class Ready(val repos: List<GitRepo>, val notice: String?) : GitStateUiState
|
||||
}
|
||||
@@ -106,6 +107,9 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
private val _writeGrant = MutableStateFlow(false)
|
||||
val writeGrant: StateFlow<Boolean> = _writeGrant.asStateFlow()
|
||||
|
||||
private val _selectedRepoId = MutableStateFlow<String?>(null)
|
||||
val selectedRepoId: StateFlow<String?> = _selectedRepoId.asStateFlow()
|
||||
|
||||
private var api: GitStateApiClient? = null
|
||||
private var reposJob: Job? = null
|
||||
private var detailJob: Job? = null
|
||||
@@ -114,13 +118,11 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
private var messageJob: Job? = null
|
||||
private var scopeKey: String? = null
|
||||
private var targetGeneration: Long = 0
|
||||
private var selectedRepoId: String? = null
|
||||
|
||||
fun selectedRepoIdForDisplay(): String? = selectedRepoId
|
||||
fun selectedRepoIdForDisplay(): String? = _selectedRepoId.value
|
||||
|
||||
fun currentTarget(): GitTarget? {
|
||||
val owner = scopeKey ?: return null
|
||||
val repo = selectedRepoId ?: return null
|
||||
val repo = _selectedRepoId.value ?: return null
|
||||
return GitTarget(owner, repo, targetGeneration)
|
||||
}
|
||||
|
||||
@@ -132,7 +134,7 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
messageJob?.cancel()
|
||||
targetGeneration += 1
|
||||
scopeKey = ownerKey
|
||||
selectedRepoId = null
|
||||
_selectedRepoId.value = null
|
||||
_writeGrant.value = false
|
||||
_detail.value = GitRepoDetailState.Idle
|
||||
_content.value = GitContentViewState.Idle
|
||||
@@ -168,7 +170,17 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Error(error.message ?: "Failed to load repositories")
|
||||
val message = error.message.orEmpty()
|
||||
_repos.value = if (
|
||||
message.contains("HTTP 404", ignoreCase = true) ||
|
||||
message.contains("No such API endpoint", ignoreCase = true)
|
||||
) {
|
||||
GitStateUiState.Unavailable(
|
||||
"Git isn't available on this Hermes host yet.",
|
||||
)
|
||||
} else {
|
||||
GitStateUiState.Error(message.ifBlank { "Failed to load repositories" })
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -178,7 +190,7 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
fun selectRepo(repoId: String) {
|
||||
val client = api ?: return
|
||||
targetGeneration += 1
|
||||
selectedRepoId = repoId
|
||||
_selectedRepoId.value = repoId
|
||||
val target = currentTarget() ?: return
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
internal enum class SubagentActivityPhase {
|
||||
STARTED,
|
||||
THINKING,
|
||||
TOOL,
|
||||
PROGRESS,
|
||||
COMPLETED,
|
||||
FAILED,
|
||||
INTERRUPTED,
|
||||
ENDED_WITH_PARENT,
|
||||
}
|
||||
|
||||
internal enum class SubagentActivityEventKind { STARTED, UPDATE, TOOL, COMPLETED }
|
||||
|
||||
internal data class SubagentActivityEvent(
|
||||
val sequence: Long,
|
||||
val kind: SubagentActivityEventKind,
|
||||
val text: String? = null,
|
||||
val toolName: String? = null,
|
||||
val phase: SubagentActivityPhase,
|
||||
val observedAtMillis: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* A bounded, ephemeral projection of parent-session `subagent.*` events.
|
||||
*
|
||||
* This is intentionally not a child transcript. Upstream currently exposes no
|
||||
* durable child-session key or child-history route, so the projection is owned
|
||||
* by the exact profile-scoped parent session and parent turn that emitted it.
|
||||
*/
|
||||
internal data class SubagentActivity(
|
||||
val laneId: Long,
|
||||
val turnId: String,
|
||||
val taskIndex: Int,
|
||||
val taskCount: Int,
|
||||
val goal: String,
|
||||
val subagentId: String? = null,
|
||||
val childSessionId: String? = null,
|
||||
val parentId: String? = null,
|
||||
val depth: Int? = null,
|
||||
val model: String? = null,
|
||||
val profile: String? = null,
|
||||
val phase: SubagentActivityPhase,
|
||||
val summary: String? = null,
|
||||
val durationSeconds: Double? = null,
|
||||
val events: List<SubagentActivityEvent> = emptyList(),
|
||||
val truncated: Boolean = false,
|
||||
val partialAfterGap: Boolean = false,
|
||||
val revision: Long = 0L,
|
||||
) {
|
||||
val stableKey: String
|
||||
get() = "$turnId:$laneId"
|
||||
|
||||
val isTerminal: Boolean
|
||||
get() = phase in setOf(
|
||||
SubagentActivityPhase.COMPLETED,
|
||||
SubagentActivityPhase.FAILED,
|
||||
SubagentActivityPhase.INTERRUPTED,
|
||||
SubagentActivityPhase.ENDED_WITH_PARENT,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps live child activity isolated from the unrelated `process.list`
|
||||
* registry. All text is control-sanitized and bounded before entering UI state.
|
||||
*/
|
||||
internal class SubagentActivityController(
|
||||
private val clock: () -> Long = System::currentTimeMillis,
|
||||
) {
|
||||
companion object {
|
||||
internal const val MAX_EVENTS_PER_CHILD = 50
|
||||
internal const val MAX_CHARS_PER_CHILD = 32_000
|
||||
internal const val MAX_GOAL_CHARS = 500
|
||||
internal const val MAX_EVENT_TEXT_CHARS = 2_000
|
||||
internal const val MAX_TOOL_NAME_CHARS = 160
|
||||
}
|
||||
|
||||
private val _activities = MutableStateFlow<List<SubagentActivity>>(emptyList())
|
||||
val activities: StateFlow<List<SubagentActivity>> = _activities.asStateFlow()
|
||||
|
||||
private var storedSessionId: String? = null
|
||||
private var scopeKey: String? = null
|
||||
private var activeTurnId: String? = null
|
||||
private var sequence = 0L
|
||||
private var laneSequence = 0L
|
||||
private var connectionWasReady = false
|
||||
private var pendingGap = false
|
||||
|
||||
fun selectSession(sessionId: String?, newScopeKey: String?) {
|
||||
if (storedSessionId == sessionId && scopeKey == newScopeKey) return
|
||||
storedSessionId = sessionId
|
||||
scopeKey = newScopeKey
|
||||
activeTurnId = null
|
||||
sequence = 0L
|
||||
laneSequence = 0L
|
||||
connectionWasReady = false
|
||||
pendingGap = false
|
||||
_activities.value = emptyList()
|
||||
}
|
||||
|
||||
fun resetConnection() {
|
||||
activeTurnId = null
|
||||
sequence = 0L
|
||||
laneSequence = 0L
|
||||
connectionWasReady = false
|
||||
pendingGap = false
|
||||
_activities.value = emptyList()
|
||||
}
|
||||
|
||||
fun onConnectionReady(ready: Boolean) {
|
||||
if (connectionWasReady && !ready && _activities.value.any { !it.isTerminal }) {
|
||||
pendingGap = true
|
||||
}
|
||||
if (ready && pendingGap) {
|
||||
_activities.value = _activities.value.map { activity ->
|
||||
if (activity.isTerminal) activity else activity.copy(
|
||||
partialAfterGap = true,
|
||||
revision = activity.revision + 1,
|
||||
)
|
||||
}
|
||||
pendingGap = false
|
||||
}
|
||||
connectionWasReady = ready
|
||||
}
|
||||
|
||||
fun beginTurn(sessionId: String?, eventScopeKey: String?, turnId: String) {
|
||||
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
|
||||
if (activeTurnId == turnId) return
|
||||
activeTurnId = turnId
|
||||
sequence = 0L
|
||||
laneSequence = 0L
|
||||
_activities.value = emptyList()
|
||||
}
|
||||
|
||||
fun onEvent(
|
||||
sessionId: String?,
|
||||
eventScopeKey: String?,
|
||||
turnId: String,
|
||||
event: GatewaySubagentEvent,
|
||||
profile: String? = null,
|
||||
) {
|
||||
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
|
||||
if (activeTurnId != turnId) return
|
||||
|
||||
val taskIndex = event.taskIndex.coerceAtLeast(0)
|
||||
val eventIdentity = event.subagentId?.takeIf(String::isNotBlank)
|
||||
?: event.childSessionId?.takeIf(String::isNotBlank)
|
||||
val identityMatch = eventIdentity?.let { identity ->
|
||||
_activities.value.firstOrNull {
|
||||
it.subagentId == identity || it.childSessionId == identity
|
||||
}
|
||||
}
|
||||
val compatibleIndexMatches = _activities.value.filter { activity ->
|
||||
activity.taskIndex == taskIndex &&
|
||||
(event.subagentId.isNullOrBlank() || activity.subagentId.isNullOrBlank() ||
|
||||
event.subagentId == activity.subagentId) &&
|
||||
(event.childSessionId.isNullOrBlank() || activity.childSessionId.isNullOrBlank() ||
|
||||
event.childSessionId == activity.childSessionId) &&
|
||||
(event.parentId.isNullOrBlank() || activity.parentId.isNullOrBlank() ||
|
||||
event.parentId == activity.parentId) &&
|
||||
(event.depth == null || activity.depth == null || event.depth == activity.depth)
|
||||
}
|
||||
val current = identityMatch ?: compatibleIndexMatches.singleOrNull()
|
||||
if (
|
||||
current?.isTerminal == true &&
|
||||
event.phase != GatewaySubagentEvent.Phase.SPAWN_REQUESTED &&
|
||||
event.phase != GatewaySubagentEvent.Phase.START
|
||||
) return
|
||||
|
||||
val base = if (current?.isTerminal == true) null else current
|
||||
val phase = event.toActivityPhase()
|
||||
val goal = sanitize(event.goal, MAX_GOAL_CHARS)
|
||||
val preview = sanitize(event.preview, MAX_EVENT_TEXT_CHARS).ifBlank { null }
|
||||
val summary = sanitize(event.summary, MAX_EVENT_TEXT_CHARS).ifBlank { null }
|
||||
val toolName = sanitize(event.toolName, MAX_TOOL_NAME_CHARS).ifBlank { null }
|
||||
val eventRow = SubagentActivityEvent(
|
||||
sequence = sequence++,
|
||||
kind = when (event.phase) {
|
||||
GatewaySubagentEvent.Phase.SPAWN_REQUESTED,
|
||||
GatewaySubagentEvent.Phase.START,
|
||||
-> SubagentActivityEventKind.STARTED
|
||||
GatewaySubagentEvent.Phase.THINKING,
|
||||
GatewaySubagentEvent.Phase.PROGRESS,
|
||||
-> SubagentActivityEventKind.UPDATE
|
||||
GatewaySubagentEvent.Phase.TOOL -> SubagentActivityEventKind.TOOL
|
||||
GatewaySubagentEvent.Phase.COMPLETE -> SubagentActivityEventKind.COMPLETED
|
||||
},
|
||||
text = if (event.phase == GatewaySubagentEvent.Phase.COMPLETE) summary else preview,
|
||||
toolName = toolName,
|
||||
phase = phase,
|
||||
observedAtMillis = clock(),
|
||||
)
|
||||
|
||||
val priorEvents = base?.events.orEmpty()
|
||||
val coalesced = eventRow.kind == SubagentActivityEventKind.UPDATE &&
|
||||
priorEvents.lastOrNull()?.let { previous ->
|
||||
previous.kind == eventRow.kind && previous.text == eventRow.text
|
||||
} == true
|
||||
val appended = if (coalesced) priorEvents else priorEvents + eventRow
|
||||
val (boundedEvents, truncated) = boundEvents(appended)
|
||||
val next = SubagentActivity(
|
||||
laneId = base?.laneId ?: laneSequence++,
|
||||
turnId = turnId,
|
||||
taskIndex = taskIndex,
|
||||
taskCount = maxOf(1, event.taskCount, base?.taskCount ?: 1),
|
||||
goal = goal.ifBlank { base?.goal.orEmpty() },
|
||||
subagentId = event.subagentId?.takeIf(String::isNotBlank) ?: base?.subagentId,
|
||||
childSessionId = event.childSessionId?.takeIf(String::isNotBlank) ?: base?.childSessionId,
|
||||
parentId = event.parentId?.takeIf(String::isNotBlank) ?: base?.parentId,
|
||||
depth = event.depth ?: base?.depth,
|
||||
model = event.model?.takeIf(String::isNotBlank) ?: base?.model,
|
||||
profile = profile?.takeIf(String::isNotBlank) ?: base?.profile,
|
||||
phase = phase,
|
||||
summary = summary ?: base?.summary,
|
||||
durationSeconds = event.durationSeconds ?: base?.durationSeconds,
|
||||
events = boundedEvents,
|
||||
truncated = base?.truncated == true || truncated,
|
||||
partialAfterGap = base?.partialAfterGap == true,
|
||||
revision = (base?.revision ?: 0L) + 1,
|
||||
)
|
||||
_activities.value = (_activities.value.filterNot { it.stableKey == next.stableKey } + next)
|
||||
.sortedWith(compareBy<SubagentActivity> { it.isTerminal }.thenBy { it.taskIndex })
|
||||
}
|
||||
|
||||
fun endTurn(turnId: String) {
|
||||
if (activeTurnId != turnId) return
|
||||
_activities.value = _activities.value.map { activity ->
|
||||
if (activity.isTerminal) activity else activity.copy(
|
||||
phase = SubagentActivityPhase.ENDED_WITH_PARENT,
|
||||
partialAfterGap = true,
|
||||
revision = activity.revision + 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun boundEvents(
|
||||
events: List<SubagentActivityEvent>,
|
||||
): Pair<List<SubagentActivityEvent>, Boolean> {
|
||||
val bounded = events.toMutableList()
|
||||
var truncated = false
|
||||
fun charCount(): Int = bounded.sumOf { (it.text?.length ?: 0) + (it.toolName?.length ?: 0) }
|
||||
while (bounded.size > MAX_EVENTS_PER_CHILD || charCount() > MAX_CHARS_PER_CHILD) {
|
||||
if (bounded.size <= 1) break
|
||||
bounded.removeAt(if (bounded.first().kind == SubagentActivityEventKind.STARTED) 1 else 0)
|
||||
truncated = true
|
||||
}
|
||||
return bounded to truncated
|
||||
}
|
||||
}
|
||||
|
||||
private fun GatewaySubagentEvent.toActivityPhase(): SubagentActivityPhase = when (phase) {
|
||||
GatewaySubagentEvent.Phase.SPAWN_REQUESTED,
|
||||
GatewaySubagentEvent.Phase.START -> SubagentActivityPhase.STARTED
|
||||
GatewaySubagentEvent.Phase.THINKING -> SubagentActivityPhase.THINKING
|
||||
GatewaySubagentEvent.Phase.TOOL -> SubagentActivityPhase.TOOL
|
||||
GatewaySubagentEvent.Phase.PROGRESS -> SubagentActivityPhase.PROGRESS
|
||||
GatewaySubagentEvent.Phase.COMPLETE -> when (status?.trim()?.lowercase()) {
|
||||
"failed", "error" -> SubagentActivityPhase.FAILED
|
||||
"interrupted", "cancelled", "canceled" -> SubagentActivityPhase.INTERRUPTED
|
||||
else -> SubagentActivityPhase.COMPLETED
|
||||
}
|
||||
}
|
||||
|
||||
private val ANSI_ESCAPE = Regex("\\u001B(?:\\[[0-?]*[ -/]*[@-~]|\\][^\\u0007]*(?:\\u0007|\\u001B\\\\))")
|
||||
|
||||
private fun sanitize(value: String?, maxChars: Int): String = value.orEmpty()
|
||||
.replace(ANSI_ESCAPE, "")
|
||||
.filter { it == '\n' || it == '\t' || it >= ' ' }
|
||||
.trim()
|
||||
.take(maxChars)
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChildWatch
|
||||
import com.hermesandroid.relay.network.upstream.GatewayTurnCallbacks
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
internal data class SubagentChildPreview(
|
||||
val activityKey: String,
|
||||
val parentSessionId: String,
|
||||
val parentScopeKey: String?,
|
||||
/** null while opening, false for a truthful parent-event fallback. */
|
||||
val childWatchAvailable: Boolean? = null,
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
val running: Boolean = false,
|
||||
val status: String? = null,
|
||||
val historyTruncated: Boolean = false,
|
||||
val partialAfterGap: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
internal class SubagentChildPreviewController(
|
||||
private val scope: CoroutineScope,
|
||||
private val openWatch: suspend (
|
||||
GatewayChatClient,
|
||||
String,
|
||||
String?,
|
||||
GatewayTurnCallbacks,
|
||||
) -> Result<GatewayChildWatch> = { client, sessionId, profile, callbacks ->
|
||||
client.openChildWatch(sessionId, profile, callbacks)
|
||||
},
|
||||
private val closeWatch: suspend (GatewayChatClient, GatewayChildWatch) -> Result<Unit> =
|
||||
{ client, watch -> client.closeChildWatch(watch) },
|
||||
) {
|
||||
private class WatchContext(
|
||||
val activity: SubagentActivity,
|
||||
val client: GatewayChatClient,
|
||||
val parentSessionId: String,
|
||||
val parentScopeKey: String?,
|
||||
val generation: Long,
|
||||
val stillOwnsParent: () -> Boolean,
|
||||
) {
|
||||
val handler = ChatHandler()
|
||||
var messageOrdinal = 0
|
||||
var messageId = "child-watch-$generation-0"
|
||||
var contentTruncated = false
|
||||
var initialized = false
|
||||
var pendingOverflow = false
|
||||
val pendingCallbacks = mutableListOf<() -> Unit>()
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<SubagentChildPreview?>(null)
|
||||
val state: StateFlow<SubagentChildPreview?> = _state.asStateFlow()
|
||||
private val generation = AtomicLong(0)
|
||||
private var watch: GatewayChildWatch? = null
|
||||
private var watchClient: GatewayChatClient? = null
|
||||
|
||||
fun open(
|
||||
activity: SubagentActivity,
|
||||
client: GatewayChatClient?,
|
||||
parentSessionId: String,
|
||||
parentScopeKey: String?,
|
||||
gatewayRouteActive: Boolean,
|
||||
stillOwnsParent: () -> Boolean,
|
||||
) {
|
||||
if (isAlreadyOpen(activity.stableKey, parentSessionId, parentScopeKey)) return
|
||||
close(clearState = false)
|
||||
if (activity.childSessionId.isNullOrBlank() || client == null || !gatewayRouteActive) {
|
||||
_state.value = fallbackState(activity, parentSessionId, parentScopeKey)
|
||||
return
|
||||
}
|
||||
|
||||
val context = WatchContext(
|
||||
activity = activity,
|
||||
client = client,
|
||||
parentSessionId = parentSessionId,
|
||||
parentScopeKey = parentScopeKey,
|
||||
generation = generation.incrementAndGet(),
|
||||
stillOwnsParent = stillOwnsParent,
|
||||
)
|
||||
_state.value = baseState(context)
|
||||
// Once upstream creates a lazy watcher, only the resume acknowledgement
|
||||
// reveals the live id needed to close it. Let a dismissed open finish;
|
||||
// generation invalidation makes [acceptOpenedWatch] close the late handle.
|
||||
scope.launch { openWatch(context) }
|
||||
}
|
||||
|
||||
fun close() = close(clearState = true)
|
||||
|
||||
private suspend fun openWatch(context: WatchContext) {
|
||||
openWatch(
|
||||
context.client,
|
||||
context.activity.childSessionId.orEmpty(),
|
||||
context.activity.profile,
|
||||
callbacks(context),
|
||||
).fold(
|
||||
onSuccess = { opened -> acceptOpenedWatch(context, opened) },
|
||||
onFailure = { error -> publishOpenFailure(context, error.message) },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun acceptOpenedWatch(context: WatchContext, opened: GatewayChildWatch) {
|
||||
if (!owns(context)) {
|
||||
closeWatch(context.client, opened)
|
||||
return
|
||||
}
|
||||
watch = opened
|
||||
watchClient = context.client
|
||||
context.handler.setSessionId(opened.storedSessionId)
|
||||
context.handler.loadMessageHistory(opened.messages)
|
||||
context.contentTruncated = context.handler.boundReadOnlyPreview()
|
||||
val pending = synchronized(context.pendingCallbacks) {
|
||||
context.initialized = true
|
||||
context.pendingCallbacks.toList().also { context.pendingCallbacks.clear() }
|
||||
}
|
||||
publish(
|
||||
context = context,
|
||||
running = opened.running,
|
||||
status = opened.status,
|
||||
historyTruncated = opened.historyTruncated || context.contentTruncated,
|
||||
partial = context.activity.partialAfterGap || context.pendingOverflow,
|
||||
)
|
||||
pending.forEach { callback -> if (owns(context)) callback() }
|
||||
}
|
||||
|
||||
private fun callbacks(context: WatchContext) = GatewayTurnCallbacks(
|
||||
onSessionId = { },
|
||||
onStart = { runOrQueue(context) { startMessage(context) } },
|
||||
onTextDelta = { delta ->
|
||||
runOrQueue(context) { mutate(context) { onTextDelta(context.messageId, delta) } }
|
||||
},
|
||||
onThinkingDelta = { delta ->
|
||||
runOrQueue(context) { mutate(context) { onThinkingDelta(context.messageId, delta) } }
|
||||
},
|
||||
onToolCallStart = { id, name, preview ->
|
||||
runOrQueue(context) {
|
||||
mutate(context) { onToolCallStart(context.messageId, id, name, preview) }
|
||||
}
|
||||
},
|
||||
onToolCallDone = { id, preview ->
|
||||
runOrQueue(context) {
|
||||
mutate(context, ensureMessage = false) {
|
||||
onToolCallComplete(context.messageId, id, preview)
|
||||
}
|
||||
}
|
||||
},
|
||||
onToolCallFailed = { id, error ->
|
||||
runOrQueue(context) {
|
||||
mutate(context, ensureMessage = false) {
|
||||
onToolCallFailed(context.messageId, id, error)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTurnComplete = {
|
||||
runOrQueue(context) {
|
||||
if (owns(context)) context.handler.onTurnComplete(context.messageId)
|
||||
}
|
||||
},
|
||||
onReconcileRequired = { runOrQueue(context) { publish(context, partial = true) } },
|
||||
onComplete = { runOrQueue(context) { complete(context) } },
|
||||
onUsage = { },
|
||||
onError = { message ->
|
||||
runOrQueue(context) {
|
||||
publish(context, running = false, partial = true, error = message)
|
||||
}
|
||||
},
|
||||
onToolGenerating = { },
|
||||
onSubagentEvent = { event ->
|
||||
runOrQueue(context) { mutate(context) { onSubagentEvent(context.messageId, event) } }
|
||||
},
|
||||
onMoaReference = { },
|
||||
onInteractionRequest = { },
|
||||
onInteractionExpired = { },
|
||||
onResumeFailure = { message ->
|
||||
runOrQueue(context) {
|
||||
publish(context, running = false, partial = true, error = message)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private fun runOrQueue(context: WatchContext, callback: () -> Unit) {
|
||||
if (!owns(context)) return
|
||||
val runNow = synchronized(context.pendingCallbacks) {
|
||||
if (context.initialized) {
|
||||
true
|
||||
} else {
|
||||
if (context.pendingCallbacks.size >= 256) {
|
||||
context.pendingCallbacks.removeAt(0)
|
||||
context.pendingOverflow = true
|
||||
}
|
||||
context.pendingCallbacks += callback
|
||||
false
|
||||
}
|
||||
}
|
||||
if (runNow) callback()
|
||||
}
|
||||
|
||||
private fun startMessage(context: WatchContext) {
|
||||
if (!owns(context)) return
|
||||
context.messageId = "child-watch-${context.generation}-${context.messageOrdinal++}"
|
||||
ensureLiveMessage(context)
|
||||
publish(context)
|
||||
}
|
||||
|
||||
private inline fun mutate(
|
||||
context: WatchContext,
|
||||
ensureMessage: Boolean = true,
|
||||
mutation: ChatHandler.() -> Unit,
|
||||
) {
|
||||
if (!owns(context)) return
|
||||
if (ensureMessage) ensureLiveMessage(context)
|
||||
context.handler.mutation()
|
||||
context.contentTruncated = context.handler.boundReadOnlyPreview() || context.contentTruncated
|
||||
publish(context)
|
||||
}
|
||||
|
||||
private fun complete(context: WatchContext) {
|
||||
if (!owns(context)) return
|
||||
context.handler.onStreamComplete(context.messageId)
|
||||
// The child mirror's message.complete omits failed/interrupted status.
|
||||
// Keep this neutral; the parent activity lane is authoritative.
|
||||
publish(context, running = false)
|
||||
}
|
||||
|
||||
private fun ensureLiveMessage(context: WatchContext) {
|
||||
if (context.handler.messages.value.any { it.id == context.messageId }) return
|
||||
context.handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = context.messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun publish(
|
||||
context: WatchContext,
|
||||
running: Boolean = true,
|
||||
status: String? = _state.value?.status,
|
||||
historyTruncated: Boolean =
|
||||
_state.value?.historyTruncated == true || context.contentTruncated,
|
||||
partial: Boolean = _state.value?.partialAfterGap == true,
|
||||
error: String? = null,
|
||||
) {
|
||||
if (!owns(context)) return
|
||||
_state.value = baseState(context).copy(
|
||||
childWatchAvailable = true,
|
||||
messages = context.handler.messages.value.takeLast(200),
|
||||
running = running,
|
||||
status = status,
|
||||
historyTruncated = historyTruncated,
|
||||
partialAfterGap = partial,
|
||||
error = error,
|
||||
)
|
||||
}
|
||||
|
||||
private fun publishOpenFailure(context: WatchContext, message: String?) {
|
||||
if (!owns(context)) return
|
||||
_state.value = fallbackState(
|
||||
context.activity,
|
||||
context.parentSessionId,
|
||||
context.parentScopeKey,
|
||||
).copy(error = message)
|
||||
}
|
||||
|
||||
private fun owns(context: WatchContext): Boolean =
|
||||
generation.get() == context.generation && context.stillOwnsParent()
|
||||
|
||||
private fun isAlreadyOpen(key: String, sessionId: String, scopeKey: String?): Boolean =
|
||||
_state.value?.let {
|
||||
it.activityKey == key &&
|
||||
it.parentSessionId == sessionId &&
|
||||
it.parentScopeKey == scopeKey &&
|
||||
it.error.isNullOrBlank() &&
|
||||
it.childWatchAvailable != false
|
||||
} == true
|
||||
|
||||
private fun baseState(context: WatchContext) = SubagentChildPreview(
|
||||
activityKey = context.activity.stableKey,
|
||||
parentSessionId = context.parentSessionId,
|
||||
parentScopeKey = context.parentScopeKey,
|
||||
)
|
||||
|
||||
private fun fallbackState(
|
||||
activity: SubagentActivity,
|
||||
parentSessionId: String,
|
||||
parentScopeKey: String?,
|
||||
) = SubagentChildPreview(
|
||||
activityKey = activity.stableKey,
|
||||
parentSessionId = parentSessionId,
|
||||
parentScopeKey = parentScopeKey,
|
||||
childWatchAvailable = false,
|
||||
partialAfterGap = activity.partialAfterGap,
|
||||
)
|
||||
|
||||
private fun close(clearState: Boolean) {
|
||||
generation.incrementAndGet()
|
||||
val closingWatch = watch
|
||||
val closingClient = watchClient
|
||||
watch = null
|
||||
watchClient = null
|
||||
if (closingWatch != null && closingClient != null) {
|
||||
scope.launch { closeWatch(closingClient, closingWatch) }
|
||||
}
|
||||
if (clearState) _state.value = null
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -4265,4 +4271,47 @@
|
||||
<string name="custom_theme_saved_to">Alterações salvas em %1$s</string>
|
||||
<string name="custom_theme_modified">Alterações não salvas</string>
|
||||
<string name="custom_theme_saved_count">%1$d temas salvos</string>
|
||||
<string name="chat_settings_show_git_workspace">Mostrar o espaço de trabalho Git no chat</string>
|
||||
<string name="chat_settings_show_git_workspace_desc">Mostra a branch e as alterações acima do campo de mensagem</string>
|
||||
<string name="chat_git_open_workspace">Abrir o espaço de trabalho Git</string>
|
||||
<string name="chat_git_branch">Branch %1$s</string>
|
||||
<string name="chat_git_additions">%1$d adições</string>
|
||||
<string name="chat_git_deletions">%1$d exclusões</string>
|
||||
<plurals name="chat_git_change_count"><item quantity="one">%1$d alteração</item><item quantity="other">%1$d alterações</item></plurals>
|
||||
<string name="settings_git_workspace">Espaço de trabalho Git</string>
|
||||
<string name="settings_git_workspace_desc">Revise alterações, branches, commits e remotos</string>
|
||||
<string name="current_chat_activity_title">Atividade atual do chat</string>
|
||||
<string name="current_chat_activity_subtitle">Detalhes ao vivo e somente leitura deste chat</string>
|
||||
<string name="current_chat_activity_open">Visualizar atividade atual do chat</string>
|
||||
<string name="current_chat_activity_summary">%1$d agentes · %2$d processos</string>
|
||||
<string name="current_chat_activity_close">Fechar prévia da atividade</string>
|
||||
<string name="current_chat_activity_empty">Nenhuma atividade atual neste chat</string>
|
||||
<string name="current_chat_activity_latest">Mais recente</string>
|
||||
<string name="agent_activity_section">Atividade ao vivo dos agentes</string>
|
||||
<string name="agent_activity_disclosure">Atualizações recebidas por este chat. O histórico completo do agente filho pode não estar disponível.</string>
|
||||
<string name="agent_activity_fallback">Agente %d</string>
|
||||
<string name="agent_activity_task_position">%1$d de %2$d · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s, %2$s, agente %3$d de %4$d</string>
|
||||
<string name="agent_activity_duration">%1$.1fs</string>
|
||||
<string name="agent_activity_older_omitted">Atividade anterior omitida</string>
|
||||
<string name="agent_activity_partial">Atualizações ao vivo retomadas. Pode faltar atividade do período offline.</string>
|
||||
<string name="agent_activity_event_started">Iniciado</string>
|
||||
<string name="agent_activity_event_update">Atualização</string>
|
||||
<string name="agent_activity_event_tool">Prévia da ferramenta</string>
|
||||
<string name="agent_activity_status_started">Iniciando</string>
|
||||
<string name="agent_activity_status_thinking">Pensando</string>
|
||||
<string name="agent_activity_status_tool">Usando uma ferramenta</string>
|
||||
<string name="agent_activity_status_progress">Trabalhando</string>
|
||||
<string name="agent_activity_status_completed">Concluído</string>
|
||||
<string name="agent_activity_status_failed">Falhou</string>
|
||||
<string name="agent_activity_status_interrupted">Interrompido</string>
|
||||
<string name="agent_activity_status_unavailable">Estado final indisponível</string>
|
||||
<string name="agent_activity_child_loading">Abrindo histórico filho somente leitura…</string>
|
||||
<string name="agent_activity_child_unavailable">O histórico filho não está disponível nesta versão ou rota do Hermes. A atividade da sessão principal é mostrada acima.</string>
|
||||
<string name="agent_activity_child_live">Histórico filho · atualizações ao vivo</string>
|
||||
<string name="agent_activity_child_history">Histórico filho · somente leitura</string>
|
||||
<string name="agent_activity_child_truncated">Atividade filha recente exibida · detalhes antigos ou muito grandes foram omitidos</string>
|
||||
<string name="agent_activity_child_role_task">Tarefa</string>
|
||||
<string name="agent_activity_child_role_agent">Agente</string>
|
||||
<string name="agent_activity_child_role_system">Sistema</string>
|
||||
</resources>
|
||||
|
||||
@@ -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">更新可用 — v%1$s</string>
|
||||
@@ -4350,4 +4353,47 @@
|
||||
<string name="custom_theme_saved_to">更改已保存到 %1$s</string>
|
||||
<string name="custom_theme_modified">未保存的更改</string>
|
||||
<string name="custom_theme_saved_count">已保存 %1$d 个预设</string>
|
||||
<string name="chat_settings_show_git_workspace">在聊天中显示 Git 工作区</string>
|
||||
<string name="chat_settings_show_git_workspace_desc">在输入框上方显示分支和更改</string>
|
||||
<string name="chat_git_open_workspace">打开 Git 工作区</string>
|
||||
<string name="chat_git_branch">分支 %1$s</string>
|
||||
<string name="chat_git_additions">新增 %1$d 行</string>
|
||||
<string name="chat_git_deletions">删除 %1$d 行</string>
|
||||
<plurals name="chat_git_change_count"><item quantity="other">%1$d 个更改</item></plurals>
|
||||
<string name="settings_git_workspace">Git 工作区</string>
|
||||
<string name="settings_git_workspace_desc">查看更改、分支、提交和远程仓库</string>
|
||||
<string name="current_chat_activity_title">当前聊天活动</string>
|
||||
<string name="current_chat_activity_subtitle">此聊天中的只读实时详情</string>
|
||||
<string name="current_chat_activity_open">预览当前聊天活动</string>
|
||||
<string name="current_chat_activity_summary">%1$d 个代理 · %2$d 个进程</string>
|
||||
<string name="current_chat_activity_close">关闭活动预览</string>
|
||||
<string name="current_chat_activity_empty">此聊天当前没有活动</string>
|
||||
<string name="current_chat_activity_latest">最新</string>
|
||||
<string name="agent_activity_section">实时代理活动</string>
|
||||
<string name="agent_activity_disclosure">此聊天接收到的更新。可能无法获取子代理的完整历史记录。</string>
|
||||
<string name="agent_activity_fallback">代理 %d</string>
|
||||
<string name="agent_activity_task_position">第 %1$d 个,共 %2$d 个 · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s,%2$s,第 %3$d 个代理,共 %4$d 个</string>
|
||||
<string name="agent_activity_duration">%1$.1f 秒</string>
|
||||
<string name="agent_activity_older_omitted">已省略较早的活动</string>
|
||||
<string name="agent_activity_partial">实时更新已恢复。离线期间的活动可能缺失。</string>
|
||||
<string name="agent_activity_event_started">已开始</string>
|
||||
<string name="agent_activity_event_update">更新</string>
|
||||
<string name="agent_activity_event_tool">工具预览</string>
|
||||
<string name="agent_activity_status_started">正在启动</string>
|
||||
<string name="agent_activity_status_thinking">正在思考</string>
|
||||
<string name="agent_activity_status_tool">正在使用工具</string>
|
||||
<string name="agent_activity_status_progress">正在工作</string>
|
||||
<string name="agent_activity_status_completed">已完成</string>
|
||||
<string name="agent_activity_status_failed">失败</string>
|
||||
<string name="agent_activity_status_interrupted">已中断</string>
|
||||
<string name="agent_activity_status_unavailable">最终状态不可用</string>
|
||||
<string name="agent_activity_child_loading">正在打开只读子历史记录…</string>
|
||||
<string name="agent_activity_child_unavailable">此 Hermes 版本或路由不提供子历史记录。上方显示父会话活动。</string>
|
||||
<string name="agent_activity_child_live">子历史记录 · 实时更新</string>
|
||||
<string name="agent_activity_child_history">子历史记录 · 只读</string>
|
||||
<string name="agent_activity_child_truncated">显示近期子活动 · 已省略较早或过大的详情</string>
|
||||
<string name="agent_activity_child_role_task">任务</string>
|
||||
<string name="agent_activity_child_role_agent">代理</string>
|
||||
<string name="agent_activity_child_role_system">系统</string>
|
||||
</resources>
|
||||
|
||||
@@ -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 — v%1$s</string>
|
||||
@@ -4425,4 +4428,47 @@
|
||||
<string name="custom_theme_saved_to">Änderungen in %1$s gespeichert</string>
|
||||
<string name="custom_theme_modified">Nicht gespeicherte Änderungen</string>
|
||||
<string name="custom_theme_saved_count">%1$d gespeicherte Presets</string>
|
||||
<string name="chat_settings_show_git_workspace">Git-Arbeitsbereich im Chat anzeigen</string>
|
||||
<string name="chat_settings_show_git_workspace_desc">Zeigt Branch und Änderungen über dem Eingabefeld an</string>
|
||||
<string name="chat_git_open_workspace">Git-Arbeitsbereich öffnen</string>
|
||||
<string name="chat_git_branch">Branch %1$s</string>
|
||||
<string name="chat_git_additions">%1$d Hinzufügungen</string>
|
||||
<string name="chat_git_deletions">%1$d Löschungen</string>
|
||||
<plurals name="chat_git_change_count"><item quantity="one">%1$d Änderung</item><item quantity="other">%1$d Änderungen</item></plurals>
|
||||
<string name="settings_git_workspace">Git-Arbeitsbereich</string>
|
||||
<string name="settings_git_workspace_desc">Änderungen, Branches, Commits und Remotes prüfen</string>
|
||||
<string name="current_chat_activity_title">Aktuelle Chat-Aktivität</string>
|
||||
<string name="current_chat_activity_subtitle">Schreibgeschützte Live-Details aus diesem Chat</string>
|
||||
<string name="current_chat_activity_open">Aktuelle Chat-Aktivität ansehen</string>
|
||||
<string name="current_chat_activity_summary">%1$d Agenten · %2$d Prozesse</string>
|
||||
<string name="current_chat_activity_close">Aktivitätsvorschau schließen</string>
|
||||
<string name="current_chat_activity_empty">Keine aktuelle Aktivität in diesem Chat</string>
|
||||
<string name="current_chat_activity_latest">Neueste</string>
|
||||
<string name="agent_activity_section">Live-Agentenaktivität</string>
|
||||
<string name="agent_activity_disclosure">Von diesem Chat empfangene Updates. Der vollständige Verlauf des untergeordneten Agenten ist möglicherweise nicht verfügbar.</string>
|
||||
<string name="agent_activity_fallback">Agent %d</string>
|
||||
<string name="agent_activity_task_position">%1$d von %2$d · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s, %2$s, Agent %3$d von %4$d</string>
|
||||
<string name="agent_activity_duration">%1$.1fs</string>
|
||||
<string name="agent_activity_older_omitted">Ältere Aktivität ausgelassen</string>
|
||||
<string name="agent_activity_partial">Live-Updates fortgesetzt. Aktivität während der Offlinezeit kann fehlen.</string>
|
||||
<string name="agent_activity_event_started">Gestartet</string>
|
||||
<string name="agent_activity_event_update">Update</string>
|
||||
<string name="agent_activity_event_tool">Werkzeugvorschau</string>
|
||||
<string name="agent_activity_status_started">Wird gestartet</string>
|
||||
<string name="agent_activity_status_thinking">Denkt nach</string>
|
||||
<string name="agent_activity_status_tool">Verwendet ein Werkzeug</string>
|
||||
<string name="agent_activity_status_progress">Arbeitet</string>
|
||||
<string name="agent_activity_status_completed">Abgeschlossen</string>
|
||||
<string name="agent_activity_status_failed">Fehlgeschlagen</string>
|
||||
<string name="agent_activity_status_interrupted">Unterbrochen</string>
|
||||
<string name="agent_activity_status_unavailable">Endstatus nicht verfügbar</string>
|
||||
<string name="agent_activity_child_loading">Schreibgeschützter untergeordneter Verlauf wird geöffnet…</string>
|
||||
<string name="agent_activity_child_unavailable">Der untergeordnete Verlauf ist in dieser Hermes-Version oder Route nicht verfügbar. Die Aktivität der übergeordneten Sitzung wird oben angezeigt.</string>
|
||||
<string name="agent_activity_child_live">Untergeordneter Verlauf · Live-Updates</string>
|
||||
<string name="agent_activity_child_history">Untergeordneter Verlauf · schreibgeschützt</string>
|
||||
<string name="agent_activity_child_truncated">Neueste untergeordnete Aktivität angezeigt · ältere oder zu große Details ausgelassen</string>
|
||||
<string name="agent_activity_child_role_task">Aufgabe</string>
|
||||
<string name="agent_activity_child_role_agent">Agent</string>
|
||||
<string name="agent_activity_child_role_system">System</string>
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
@@ -4110,4 +4119,47 @@
|
||||
<string name="custom_theme_saved_to">Cambios guardados en %1$s</string>
|
||||
<string name="custom_theme_modified">Cambios sin guardar</string>
|
||||
<string name="custom_theme_saved_count">%1$d preajustes guardados</string>
|
||||
<string name="chat_settings_show_git_workspace">Mostrar el espacio de Git en el chat</string>
|
||||
<string name="chat_settings_show_git_workspace_desc">Muestra la rama y los cambios encima del cuadro de texto</string>
|
||||
<string name="chat_git_open_workspace">Abrir el espacio de Git</string>
|
||||
<string name="chat_git_branch">Rama %1$s</string>
|
||||
<string name="chat_git_additions">%1$d adiciones</string>
|
||||
<string name="chat_git_deletions">%1$d eliminaciones</string>
|
||||
<plurals name="chat_git_change_count"><item quantity="one">%1$d cambio</item><item quantity="other">%1$d cambios</item></plurals>
|
||||
<string name="settings_git_workspace">Espacio de Git</string>
|
||||
<string name="settings_git_workspace_desc">Revisa cambios, ramas, commits y remotos</string>
|
||||
<string name="current_chat_activity_title">Actividad actual del chat</string>
|
||||
<string name="current_chat_activity_subtitle">Detalles en vivo de solo lectura de este chat</string>
|
||||
<string name="current_chat_activity_open">Ver la actividad actual del chat</string>
|
||||
<string name="current_chat_activity_summary">%1$d agentes · %2$d procesos</string>
|
||||
<string name="current_chat_activity_close">Cerrar vista previa de actividad</string>
|
||||
<string name="current_chat_activity_empty">No hay actividad actual en este chat</string>
|
||||
<string name="current_chat_activity_latest">Más reciente</string>
|
||||
<string name="agent_activity_section">Actividad de agentes en vivo</string>
|
||||
<string name="agent_activity_disclosure">Actualizaciones recibidas por este chat. Es posible que el historial completo del agente secundario no esté disponible.</string>
|
||||
<string name="agent_activity_fallback">Agente %d</string>
|
||||
<string name="agent_activity_task_position">%1$d de %2$d · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s, %2$s, agente %3$d de %4$d</string>
|
||||
<string name="agent_activity_duration">%1$.1fs</string>
|
||||
<string name="agent_activity_older_omitted">Se omitió la actividad anterior</string>
|
||||
<string name="agent_activity_partial">Se reanudaron las actualizaciones en vivo. Puede faltar actividad mientras estaba sin conexión.</string>
|
||||
<string name="agent_activity_event_started">Iniciado</string>
|
||||
<string name="agent_activity_event_update">Actualización</string>
|
||||
<string name="agent_activity_event_tool">Vista previa de herramienta</string>
|
||||
<string name="agent_activity_status_started">Iniciando</string>
|
||||
<string name="agent_activity_status_thinking">Pensando</string>
|
||||
<string name="agent_activity_status_tool">Usando una herramienta</string>
|
||||
<string name="agent_activity_status_progress">Trabajando</string>
|
||||
<string name="agent_activity_status_completed">Completado</string>
|
||||
<string name="agent_activity_status_failed">Falló</string>
|
||||
<string name="agent_activity_status_interrupted">Interrumpido</string>
|
||||
<string name="agent_activity_status_unavailable">Estado final no disponible</string>
|
||||
<string name="agent_activity_child_loading">Abriendo el historial secundario de solo lectura…</string>
|
||||
<string name="agent_activity_child_unavailable">El historial secundario no está disponible en esta versión o ruta de Hermes. La actividad de la sesión principal se muestra arriba.</string>
|
||||
<string name="agent_activity_child_live">Historial secundario · actualizaciones en vivo</string>
|
||||
<string name="agent_activity_child_history">Historial secundario · solo lectura</string>
|
||||
<string name="agent_activity_child_truncated">Se muestra la actividad secundaria reciente · se omitieron detalles anteriores o demasiado grandes</string>
|
||||
<string name="agent_activity_child_role_task">Tarea</string>
|
||||
<string name="agent_activity_child_role_agent">Agente</string>
|
||||
<string name="agent_activity_child_role_system">Sistema</string>
|
||||
</resources>
|
||||
|
||||
@@ -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">利用可能なアップデート — v%1$s</string>
|
||||
@@ -4423,4 +4424,47 @@
|
||||
<string name="custom_theme_saved_to">変更を %1$s に保存しました</string>
|
||||
<string name="custom_theme_modified">未保存の変更</string>
|
||||
<string name="custom_theme_saved_count">保存済み %1$d 件</string>
|
||||
<string name="chat_settings_show_git_workspace">チャットに Git ワークスペースを表示</string>
|
||||
<string name="chat_settings_show_git_workspace_desc">入力欄の上にブランチと変更内容を表示します</string>
|
||||
<string name="chat_git_open_workspace">Git ワークスペースを開く</string>
|
||||
<string name="chat_git_branch">ブランチ %1$s</string>
|
||||
<string name="chat_git_additions">%1$d 件の追加</string>
|
||||
<string name="chat_git_deletions">%1$d 件の削除</string>
|
||||
<plurals name="chat_git_change_count"><item quantity="other">%1$d 件の変更</item></plurals>
|
||||
<string name="settings_git_workspace">Git ワークスペース</string>
|
||||
<string name="settings_git_workspace_desc">変更、ブランチ、コミット、リモートを確認</string>
|
||||
<string name="current_chat_activity_title">現在のチャットのアクティビティ</string>
|
||||
<string name="current_chat_activity_subtitle">このチャットからの読み取り専用ライブ詳細</string>
|
||||
<string name="current_chat_activity_open">現在のチャットのアクティビティを表示</string>
|
||||
<string name="current_chat_activity_summary">エージェント %1$d · プロセス %2$d</string>
|
||||
<string name="current_chat_activity_close">アクティビティのプレビューを閉じる</string>
|
||||
<string name="current_chat_activity_empty">このチャットに現在のアクティビティはありません</string>
|
||||
<string name="current_chat_activity_latest">最新</string>
|
||||
<string name="agent_activity_section">エージェントのライブアクティビティ</string>
|
||||
<string name="agent_activity_disclosure">このチャットが受信した更新です。子エージェントの完全な履歴は利用できない場合があります。</string>
|
||||
<string name="agent_activity_fallback">エージェント %d</string>
|
||||
<string name="agent_activity_task_position">%2$d 件中 %1$d 件目 · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s、%2$s、%4$d 件中 %3$d 件目のエージェント</string>
|
||||
<string name="agent_activity_duration">%1$.1f秒</string>
|
||||
<string name="agent_activity_older_omitted">古いアクティビティは省略されました</string>
|
||||
<string name="agent_activity_partial">ライブ更新を再開しました。オフライン中のアクティビティが欠けている場合があります。</string>
|
||||
<string name="agent_activity_event_started">開始</string>
|
||||
<string name="agent_activity_event_update">更新</string>
|
||||
<string name="agent_activity_event_tool">ツールのプレビュー</string>
|
||||
<string name="agent_activity_status_started">開始中</string>
|
||||
<string name="agent_activity_status_thinking">思考中</string>
|
||||
<string name="agent_activity_status_tool">ツールを使用中</string>
|
||||
<string name="agent_activity_status_progress">作業中</string>
|
||||
<string name="agent_activity_status_completed">完了</string>
|
||||
<string name="agent_activity_status_failed">失敗</string>
|
||||
<string name="agent_activity_status_interrupted">中断</string>
|
||||
<string name="agent_activity_status_unavailable">最終状態を確認できません</string>
|
||||
<string name="agent_activity_child_loading">読み取り専用の子履歴を開いています…</string>
|
||||
<string name="agent_activity_child_unavailable">この Hermes のバージョンまたはルートでは子履歴を利用できません。親セッションのアクティビティは上に表示されます。</string>
|
||||
<string name="agent_activity_child_live">子履歴 · ライブ更新</string>
|
||||
<string name="agent_activity_child_history">子履歴 · 読み取り専用</string>
|
||||
<string name="agent_activity_child_truncated">最近の子アクティビティを表示 · 古い詳細または大きすぎる詳細は省略されました</string>
|
||||
<string name="agent_activity_child_role_task">タスク</string>
|
||||
<string name="agent_activity_child_role_agent">エージェント</string>
|
||||
<string name="agent_activity_child_role_system">システム</string>
|
||||
</resources>
|
||||
|
||||
@@ -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">Доступно обновление \&#8212; v%1$s</string>
|
||||
<string name="update_banner_body_fmt">Вы используете %1$s. Нажмите Обновить, чтобы скачать.</string>
|
||||
<string name="attach_viewer_cd_close">Закрыть</string>
|
||||
@@ -4152,4 +4165,47 @@
|
||||
<string name="custom_theme_saved_to">Изменения сохранены в %1$s</string>
|
||||
<string name="custom_theme_modified">Несохраненные изменения</string>
|
||||
<string name="custom_theme_saved_count">Сохранено тем: %1$d</string>
|
||||
<string name="chat_settings_show_git_workspace">Показывать рабочую область Git в чате</string>
|
||||
<string name="chat_settings_show_git_workspace_desc">Показывает ветку и изменения над полем ввода</string>
|
||||
<string name="chat_git_open_workspace">Открыть рабочую область Git</string>
|
||||
<string name="chat_git_branch">Ветка %1$s</string>
|
||||
<string name="chat_git_additions">Добавлено строк: %1$d</string>
|
||||
<string name="chat_git_deletions">Удалено строк: %1$d</string>
|
||||
<plurals name="chat_git_change_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="settings_git_workspace">Рабочая область Git</string>
|
||||
<string name="settings_git_workspace_desc">Изменения, ветки, коммиты и удалённые репозитории</string>
|
||||
<string name="current_chat_activity_title">Текущая активность чата</string>
|
||||
<string name="current_chat_activity_subtitle">Доступные только для чтения сведения в реальном времени из этого чата</string>
|
||||
<string name="current_chat_activity_open">Просмотреть текущую активность чата</string>
|
||||
<string name="current_chat_activity_summary">Агенты: %1$d · процессы: %2$d</string>
|
||||
<string name="current_chat_activity_close">Закрыть просмотр активности</string>
|
||||
<string name="current_chat_activity_empty">В этом чате сейчас нет активности</string>
|
||||
<string name="current_chat_activity_latest">Последнее</string>
|
||||
<string name="agent_activity_section">Активность агентов в реальном времени</string>
|
||||
<string name="agent_activity_disclosure">Обновления, полученные этим чатом. Полная история дочернего агента может быть недоступна.</string>
|
||||
<string name="agent_activity_fallback">Агент %d</string>
|
||||
<string name="agent_activity_task_position">%1$d из %2$d · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s, %2$s, агент %3$d из %4$d</string>
|
||||
<string name="agent_activity_duration">%1$.1f с</string>
|
||||
<string name="agent_activity_older_omitted">Более ранняя активность опущена</string>
|
||||
<string name="agent_activity_partial">Обновления возобновлены. Активность во время отсутствия подключения может быть пропущена.</string>
|
||||
<string name="agent_activity_event_started">Запущено</string>
|
||||
<string name="agent_activity_event_update">Обновление</string>
|
||||
<string name="agent_activity_event_tool">Предпросмотр инструмента</string>
|
||||
<string name="agent_activity_status_started">Запуск</string>
|
||||
<string name="agent_activity_status_thinking">Размышляет</string>
|
||||
<string name="agent_activity_status_tool">Использует инструмент</string>
|
||||
<string name="agent_activity_status_progress">Работает</string>
|
||||
<string name="agent_activity_status_completed">Завершено</string>
|
||||
<string name="agent_activity_status_failed">Ошибка</string>
|
||||
<string name="agent_activity_status_interrupted">Прервано</string>
|
||||
<string name="agent_activity_status_unavailable">Итоговое состояние недоступно</string>
|
||||
<string name="agent_activity_child_loading">Открывается дочерняя история только для чтения…</string>
|
||||
<string name="agent_activity_child_unavailable">Дочерняя история недоступна в этой версии или маршруте Hermes. Активность родительского сеанса показана выше.</string>
|
||||
<string name="agent_activity_child_live">Дочерняя история · обновления в реальном времени</string>
|
||||
<string name="agent_activity_child_history">Дочерняя история · только чтение</string>
|
||||
<string name="agent_activity_child_truncated">Показана недавняя дочерняя активность · более ранние или слишком большие сведения опущены</string>
|
||||
<string name="agent_activity_child_role_task">Задача</string>
|
||||
<string name="agent_activity_child_role_agent">Агент</string>
|
||||
<string name="agent_activity_child_role_system">Система</string>
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
@@ -791,6 +804,16 @@
|
||||
<string name="chat_settings_keep_keyboard_open_desc">Stay in the composer after sending. Turn off to dismiss the keyboard after each sent message.</string>
|
||||
<string name="chat_settings_large_pastes">Convert large pastes to attachments</string>
|
||||
<string name="chat_settings_large_pastes_desc">Turn pastes of 5,000 or more characters into reviewable text attachments. On by default.</string>
|
||||
<string name="chat_settings_show_git_workspace">Show Git workspace in Chat</string>
|
||||
<string name="chat_settings_show_git_workspace_desc">Show the Git status entry and changes rail in Chat. The full Git workspace remains available when this is off.</string>
|
||||
<string name="chat_git_open_workspace">Open Git workspace</string>
|
||||
<string name="chat_git_branch">Git branch %1$s</string>
|
||||
<string name="chat_git_additions">%1$d additions</string>
|
||||
<string name="chat_git_deletions">%1$d deletions</string>
|
||||
<plurals name="chat_git_change_count">
|
||||
<item quantity="one">%1$d change</item>
|
||||
<item quantity="other">%1$d changes</item>
|
||||
</plurals>
|
||||
<string name="chat_large_paste_attached">Large paste added as a text attachment</string>
|
||||
<string name="chat_large_paste_too_large">Pasted text exceeds the %1$d MB attachment limit</string>
|
||||
<string name="chat_settings_physical_keyboard_enter">Physical keyboard Enter key</string>
|
||||
@@ -2918,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>
|
||||
@@ -3343,8 +3363,6 @@
|
||||
|
||||
<!-- PowerFeatureGate -->
|
||||
|
||||
<!-- AgentTextFlow / CleanModeComposer -->
|
||||
|
||||
<!-- ExtraKeysToolbar -->
|
||||
|
||||
<!-- ToolProgressCard -->
|
||||
@@ -3427,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 — v%1$s</string>
|
||||
@@ -3722,6 +3735,40 @@
|
||||
<string name="tool_failed_a11y">Failed</string>
|
||||
<string name="background_process_count">Background · %1$d</string>
|
||||
<string name="background_processes_title">Background processes</string>
|
||||
<string name="current_chat_activity_title">Current chat activity</string>
|
||||
<string name="current_chat_activity_subtitle">Read-only live details from this chat</string>
|
||||
<string name="current_chat_activity_open">Preview current chat activity</string>
|
||||
<string name="current_chat_activity_summary">%1$d agents · %2$d processes</string>
|
||||
<string name="current_chat_activity_close">Close activity preview</string>
|
||||
<string name="current_chat_activity_empty">No current activity in this chat</string>
|
||||
<string name="current_chat_activity_latest">Latest</string>
|
||||
<string name="agent_activity_section">Live agent activity</string>
|
||||
<string name="agent_activity_disclosure">Updates received by this chat. Full child history may be unavailable.</string>
|
||||
<string name="agent_activity_fallback">Agent %d</string>
|
||||
<string name="agent_activity_task_position">%1$d of %2$d · %3$s</string>
|
||||
<string name="agent_activity_lane_a11y">%1$s, %2$s, agent %3$d of %4$d</string>
|
||||
<string name="agent_activity_duration">%1$.1fs</string>
|
||||
<string name="agent_activity_older_omitted">Older activity omitted</string>
|
||||
<string name="agent_activity_partial">Live updates resumed. Activity while offline may be missing.</string>
|
||||
<string name="agent_activity_event_started">Started</string>
|
||||
<string name="agent_activity_event_update">Update</string>
|
||||
<string name="agent_activity_event_tool">Tool preview</string>
|
||||
<string name="agent_activity_status_started">Starting</string>
|
||||
<string name="agent_activity_status_thinking">Thinking</string>
|
||||
<string name="agent_activity_status_tool">Using a tool</string>
|
||||
<string name="agent_activity_status_progress">Working</string>
|
||||
<string name="agent_activity_status_completed">Completed</string>
|
||||
<string name="agent_activity_status_failed">Failed</string>
|
||||
<string name="agent_activity_status_interrupted">Interrupted</string>
|
||||
<string name="agent_activity_status_unavailable">Final state unavailable</string>
|
||||
<string name="agent_activity_child_loading">Opening read-only child history…</string>
|
||||
<string name="agent_activity_child_unavailable">Child transcript unavailable on this Hermes version or route. Parent-session activity is shown above.</string>
|
||||
<string name="agent_activity_child_live">Child history · live updates</string>
|
||||
<string name="agent_activity_child_history">Child history · read only</string>
|
||||
<string name="agent_activity_child_truncated">Recent child activity shown · older or oversized details omitted</string>
|
||||
<string name="agent_activity_child_role_task">Task</string>
|
||||
<string name="agent_activity_child_role_agent">Agent</string>
|
||||
<string name="agent_activity_child_role_system">System</string>
|
||||
<string name="background_processes_refresh_a11y">Refresh processes</string>
|
||||
<string name="background_processes_subtitle">Current chat · live output and recent results</string>
|
||||
<string name="background_processes_stop">Stop</string>
|
||||
@@ -4435,4 +4482,6 @@
|
||||
<string name="provider_usage_capability_relay_body">Credential pools, structured Nous balances, and OpenCode Go are provided by the Relay plugin.</string>
|
||||
<string name="provider_usage_capability_basic_title">Basic usage from Hermes</string>
|
||||
<string name="provider_usage_capability_basic_body">Install or update the Relay plugin for credential pools, structured Nous balances, and OpenCode Go.</string>
|
||||
<string name="settings_git_workspace">Git workspace</string>
|
||||
<string name="settings_git_workspace_desc">Review changes, branches, commits, and remotes</string>
|
||||
</resources>
|
||||
|
||||
@@ -277,4 +277,40 @@ class AgentDisplayTest {
|
||||
assertEquals("conn::__server_default__", AgentDisplay.profileContextKey("conn", null))
|
||||
assertEquals("conn::mizu", AgentDisplay.profileContextKey("conn", "mizu"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseProfileContextKey_preservesRequestIdentityAndConnectionScope() {
|
||||
val serverDefault = AgentDisplay.parseProfileContextKey(
|
||||
AgentDisplay.profileContextKey("connection-a", null),
|
||||
)
|
||||
assertEquals("connection-a", serverDefault?.connectionId)
|
||||
assertEquals(AgentDisplay.SERVER_DEFAULT_PROFILE_KEY, serverDefault?.profileKey)
|
||||
assertNull(serverDefault?.requestProfileName)
|
||||
|
||||
val literalDefault = AgentDisplay.parseProfileContextKey(
|
||||
AgentDisplay.profileContextKey("connection-a", "default"),
|
||||
)
|
||||
assertEquals("default", literalDefault?.profileKey)
|
||||
assertEquals("default", literalDefault?.requestProfileName)
|
||||
|
||||
val named = AgentDisplay.parseProfileContextKey(
|
||||
AgentDisplay.profileContextKey("connection-b", "mizu"),
|
||||
)
|
||||
assertEquals("connection-b", named?.connectionId)
|
||||
assertEquals("mizu", named?.requestProfileName)
|
||||
|
||||
val delimitedProfile = AgentDisplay.parseProfileContextKey(
|
||||
AgentDisplay.profileContextKey("connection-c", "team::writer"),
|
||||
)
|
||||
assertEquals("connection-c", delimitedProfile?.connectionId)
|
||||
assertEquals("team::writer", delimitedProfile?.requestProfileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseProfileContextKey_failsClosedForLegacyOrMalformedKeys() {
|
||||
assertNull(AgentDisplay.parseProfileContextKey("connection/profile-default"))
|
||||
assertNull(AgentDisplay.parseProfileContextKey("connection-a::"))
|
||||
assertNull(AgentDisplay.parseProfileContextKey("::default"))
|
||||
assertNull(AgentDisplay.parseProfileContextKey(null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,25 @@ class ChatInputPreferencesTest {
|
||||
assertEquals(true, repository.convertLargePastesToAttachments.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Git workspace in Chat defaults on and round trips without replacing other settings`() = runTest {
|
||||
val unrelatedKey = stringPreferencesKey("unrelated_git_chat_test")
|
||||
val store = InMemoryChatInputDataStore(
|
||||
mutablePreferencesOf(unrelatedKey to "keep-me"),
|
||||
)
|
||||
val repository = ChatInputPreferencesRepository(store)
|
||||
|
||||
assertEquals(true, repository.showGitWorkspaceInChat.first())
|
||||
|
||||
repository.setShowGitWorkspaceInChat(false)
|
||||
assertEquals(false, repository.showGitWorkspaceInChat.first())
|
||||
assertEquals("keep-me", store.data.first()[unrelatedKey])
|
||||
|
||||
repository.setShowGitWorkspaceInChat(true)
|
||||
assertEquals(true, repository.showGitWorkspaceInChat.first())
|
||||
assertEquals("keep-me", store.data.first()[unrelatedKey])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enter defaults to send and unknown values fall back safely`() = runTest {
|
||||
assertEquals(
|
||||
|
||||
@@ -87,6 +87,20 @@ class ChatTurnCheckpointStoreTest {
|
||||
assertEquals(checkpoint, store.read())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkpointWithoutProfileKey_remainsReadableAsLegacyIdentity() = runTest {
|
||||
val current = sampleCheckpoint().copy(profileKey = "default")
|
||||
val legacyJson = Json.encodeToString(current)
|
||||
.replace("\"profileKey\":\"default\",", "")
|
||||
dataStore.edit { preferences ->
|
||||
preferences[stringPreferencesKey("chat_inflight_turn_checkpoint_v1")] = legacyJson
|
||||
}
|
||||
|
||||
val restored = store.read()
|
||||
assertEquals(current.contextKey, restored?.contextKey)
|
||||
assertNull(restored?.profileKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleRunningSessions_mergeAndRemoveIndependently() = runTest {
|
||||
val first = sampleCheckpoint()
|
||||
@@ -106,7 +120,8 @@ class ChatTurnCheckpointStoreTest {
|
||||
}
|
||||
|
||||
private fun sampleCheckpoint() = ChatTurnCheckpoint(
|
||||
contextKey = "connection-a/profile-default",
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", null),
|
||||
profileKey = AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
sessionId = "stored-42",
|
||||
liveSessionId = "live-42",
|
||||
transport = "gateway",
|
||||
|
||||
+209
-1
@@ -74,6 +74,12 @@ class GatewayClientHarness(
|
||||
@Volatile
|
||||
var recoveryAssistant = ""
|
||||
|
||||
@Volatile
|
||||
var recoveryMessages: JsonArray = JsonArray(emptyList())
|
||||
|
||||
@Volatile
|
||||
var resumeEventsBeforeAck: List<Pair<String, JsonObject?>> = emptyList()
|
||||
|
||||
@Volatile
|
||||
var recoveryInflightStreaming: Boolean? = null
|
||||
var recoveryInflightError: String? = null
|
||||
@@ -539,6 +545,16 @@ class GatewayClientHarness(
|
||||
put("error", buildJsonObject { put("message", "$method refused") })
|
||||
}
|
||||
}
|
||||
if (method == "session.resume" && result != null) {
|
||||
val liveId = (result["session_id"] as? JsonPrimitive)?.contentOrNull
|
||||
val events = resumeEventsBeforeAck
|
||||
resumeEventsBeforeAck = emptyList()
|
||||
if (!liveId.isNullOrBlank()) {
|
||||
events.forEach { (type, payload) ->
|
||||
webSocket.send(eventFrame(type, payload, liveId))
|
||||
}
|
||||
}
|
||||
}
|
||||
webSocket.send(reply.toString())
|
||||
}
|
||||
}
|
||||
@@ -549,6 +565,7 @@ class GatewayClientHarness(
|
||||
put("session_id", sessionId)
|
||||
put("running", recoveryRunning)
|
||||
put("status", if (recoveryRunning) "streaming" else "idle")
|
||||
put("messages", recoveryMessages)
|
||||
if (!omitSessionProfileMetadata || recoveryProject != null) {
|
||||
put("info", buildJsonObject {
|
||||
if (!omitSessionProfileMetadata) {
|
||||
@@ -760,13 +777,14 @@ class GatewayChatClientTest {
|
||||
rpcTimeoutMs: Long = 15_000L,
|
||||
promptSubmitTimeoutMs: Long = 1_800_000L,
|
||||
turnIdleTimeoutMs: Long = 180_000L,
|
||||
callbackDispatcher: (block: () -> Unit) -> Unit = { it() },
|
||||
) = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = harness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
callbackDispatcher = callbackDispatcher,
|
||||
onGatewayUnsupported = { unsupportedMarked = true },
|
||||
scope = scope,
|
||||
// Keep the mid-turn reconnect window short so `failed rejoin`
|
||||
@@ -2182,6 +2200,196 @@ class GatewayChatClientTest {
|
||||
assertEquals("focus on Android", (params["text"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `child watch is profile pinned bounded and isolated from main session`() = runBlocking {
|
||||
harness.sessionProfileOverride = "operator"
|
||||
harness.resumeLiveSessionIds["parent-session"] = "live-parent"
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
client.sessionProfileProvider = { "operator" }
|
||||
|
||||
assertTrue(client.prewarmAwait("parent-session"))
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
assertEquals("live-parent", client.currentLiveSessionId("parent-session"))
|
||||
|
||||
harness.recoveryRunning = true
|
||||
harness.recoveryMessages = JsonArray(listOf(
|
||||
buildJsonObject { put("role", "user"); put("text", "old") },
|
||||
buildJsonObject { put("role", "assistant"); put("text", "recent") },
|
||||
buildJsonObject { put("role", "assistant"); put("text", "newest") },
|
||||
))
|
||||
val childRecorder = Recorder()
|
||||
val watch = client.openChildWatch(
|
||||
childSessionId = "child-session",
|
||||
profile = "operator",
|
||||
callbacks = childRecorder.callbacks,
|
||||
historyLimit = 2,
|
||||
).getOrThrow()
|
||||
|
||||
val resume = harness.awaitRpcCount("session.resume", 2).last()
|
||||
assertEquals("child-session", (resume["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("operator", (resume["profile"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(true, (resume["lazy"] as? JsonPrimitive)?.booleanOrNull)
|
||||
assertEquals(true, (resume["close_on_disconnect"] as? JsonPrimitive)?.booleanOrNull)
|
||||
assertEquals("live-child", watch.liveSessionId)
|
||||
assertTrue(watch.running)
|
||||
assertTrue(watch.historyTruncated)
|
||||
assertEquals(listOf("recent", "newest"), watch.messages.map { it.contentText })
|
||||
assertEquals("live-parent", client.currentLiveSessionId("parent-session"))
|
||||
|
||||
serverWs.send(harness.eventFrame("message.start", null, "live-child"))
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"reasoning.delta",
|
||||
buildJsonObject { put("text", "checking") },
|
||||
"live-child",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "working") },
|
||||
"live-child",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "done") },
|
||||
"live-child",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(childRecorder.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("checking"), childRecorder.thinkingDeltas.toList())
|
||||
assertTrue(childRecorder.textDeltas.contains("working"))
|
||||
assertTrue(childRecorder.errors.isEmpty())
|
||||
|
||||
client.closeChildWatch(watch).getOrThrow()
|
||||
val close = harness.awaitRpc("session.close")
|
||||
assertEquals("live-child", (close["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("live-parent", client.currentLiveSessionId("parent-session"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent child opens keep newest generation and stale close is harmless`() = runBlocking {
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
val recorders = listOf(Recorder(), Recorder())
|
||||
val opens = recorders.map { recorder ->
|
||||
async(Dispatchers.IO) {
|
||||
client.openChildWatch(
|
||||
"child-session",
|
||||
callbacks = recorder.callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
}
|
||||
val watches = opens.map { it.await() }
|
||||
harness.awaitServerSocket()
|
||||
val stale = watches.minBy { it.generation }
|
||||
val newest = watches.maxBy { it.generation }
|
||||
|
||||
client.closeChildWatch(stale).getOrThrow()
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.close" })
|
||||
|
||||
client.closeChildWatch(newest).getOrThrow()
|
||||
val close = harness.awaitRpc("session.close")
|
||||
assertEquals("live-child", (close["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(1, recorders.sumOf { it.resumeFailures.size })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `child watch replays terminal event that arrives before resume ack`() = runBlocking {
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
harness.recoveryRunning = true
|
||||
harness.resumeEventsBeforeAck = listOf(
|
||||
"message.start" to null,
|
||||
"message.delta" to buildJsonObject { put("text", "pre-ack") },
|
||||
"message.complete" to buildJsonObject { put("text", "done") },
|
||||
)
|
||||
val recorder = Recorder()
|
||||
|
||||
val watch = client.openChildWatch(
|
||||
"child-session",
|
||||
callbacks = recorder.callbacks,
|
||||
).getOrThrow()
|
||||
harness.awaitServerSocket()
|
||||
|
||||
assertEquals("live-child", watch.liveSessionId)
|
||||
assertFalse(watch.running)
|
||||
assertTrue(recorder.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(recorder.textDeltas.contains("pre-ack"))
|
||||
assertTrue(recorder.errors.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed child watch close can be retried`() = runBlocking {
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
val watch = client.openChildWatch(
|
||||
"child-session",
|
||||
callbacks = Recorder().callbacks,
|
||||
).getOrThrow()
|
||||
harness.awaitServerSocket()
|
||||
harness.rpcErrors["session.close"] = 5000 to "busy"
|
||||
|
||||
assertTrue(client.closeChildWatch(watch).isFailure)
|
||||
harness.rpcErrors.remove("session.close")
|
||||
client.closeChildWatch(watch).getOrThrow()
|
||||
|
||||
val closes = harness.awaitRpcCount("session.close", 2)
|
||||
assertEquals(2, closes.size)
|
||||
assertTrue(closes.all {
|
||||
(it["session_id"] as? JsonPrimitive)?.contentOrNull == "live-child"
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queued child callback is dropped after exact watch closes`() = runBlocking {
|
||||
client.shutdown()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val queuedCallbacks = ConcurrentLinkedQueue<() -> Unit>()
|
||||
client = buildClient(callbackDispatcher = { queuedCallbacks += it })
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
val recorder = Recorder()
|
||||
val watch = client.openChildWatch(
|
||||
"child-session",
|
||||
callbacks = recorder.callbacks,
|
||||
).getOrThrow()
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "stale") },
|
||||
"live-child",
|
||||
),
|
||||
)
|
||||
awaitCondition { queuedCallbacks.isNotEmpty() }
|
||||
client.closeChildWatch(watch).getOrThrow()
|
||||
while (true) queuedCallbacks.poll()?.invoke() ?: break
|
||||
|
||||
assertTrue(recorder.textDeltas.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `child watch history enforces total character bound`() = runBlocking {
|
||||
harness.resumeLiveSessionIds["child-session"] = "live-child"
|
||||
harness.recoveryMessages = JsonArray(listOf(
|
||||
buildJsonObject { put("role", "assistant"); put("text", "kept") },
|
||||
buildJsonObject {
|
||||
put("role", "assistant")
|
||||
put("text", "x".repeat(GatewayChatClient.MAX_CHILD_WATCH_HISTORY_CHARS + 1))
|
||||
},
|
||||
))
|
||||
|
||||
val watch = client.openChildWatch(
|
||||
"child-session",
|
||||
callbacks = Recorder().callbacks,
|
||||
).getOrThrow()
|
||||
harness.awaitServerSocket()
|
||||
|
||||
assertTrue(watch.historyTruncated)
|
||||
assertEquals(listOf("kept"), watch.messages.map { it.contentText })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compress session uses dedicated rpc and parses authoritative messages`() {
|
||||
val r = Recorder()
|
||||
|
||||
+16
-6
@@ -595,6 +595,10 @@ class GatewayEventMapperTest {
|
||||
fun `subagent lifecycle maps phases and fields`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r)
|
||||
mapper.onEvent(
|
||||
"subagent.spawn_requested",
|
||||
obj("""{"goal":"research topic","task_index":1,"task_count":3,"subagent_id":"child-17","child_session_id":"session-17","parent_id":"parent-child","depth":2,"model":"hermes-4"}"""),
|
||||
)
|
||||
mapper.onEvent("subagent.start", obj("""{"goal":"research topic","task_index":1,"task_count":3,"subagent_id":"child-17"}"""))
|
||||
mapper.onEvent("subagent.thinking", obj("""{"goal":"research topic","task_index":1,"task_count":3,"text":"hmm"}"""))
|
||||
mapper.onEvent(
|
||||
@@ -609,6 +613,7 @@ class GatewayEventMapperTest {
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewaySubagentEvent.Phase.SPAWN_REQUESTED,
|
||||
GatewaySubagentEvent.Phase.START,
|
||||
GatewaySubagentEvent.Phase.THINKING,
|
||||
GatewaySubagentEvent.Phase.TOOL,
|
||||
@@ -617,17 +622,22 @@ class GatewayEventMapperTest {
|
||||
),
|
||||
r.subagentEvents.map { it.phase },
|
||||
)
|
||||
val start = r.subagentEvents[0]
|
||||
val spawn = r.subagentEvents[0]
|
||||
assertEquals("session-17", spawn.childSessionId)
|
||||
assertEquals("parent-child", spawn.parentId)
|
||||
assertEquals(2, spawn.depth)
|
||||
assertEquals("hermes-4", spawn.model)
|
||||
val start = r.subagentEvents[1]
|
||||
assertEquals(1, start.taskIndex)
|
||||
assertEquals(3, start.taskCount)
|
||||
assertEquals("research topic", start.goal)
|
||||
assertEquals("child-17", start.subagentId)
|
||||
assertEquals("hmm", r.subagentEvents[1].preview)
|
||||
val tool = r.subagentEvents[2]
|
||||
assertEquals("hmm", r.subagentEvents[2].preview)
|
||||
val tool = r.subagentEvents[3]
|
||||
assertEquals("web_search", tool.toolName)
|
||||
assertEquals("searching docs", tool.preview)
|
||||
assertEquals("halfway", r.subagentEvents[3].preview)
|
||||
val complete = r.subagentEvents[4]
|
||||
assertEquals("halfway", r.subagentEvents[4].preview)
|
||||
val complete = r.subagentEvents[5]
|
||||
assertEquals("completed", complete.status)
|
||||
assertEquals("found it", complete.summary)
|
||||
assertEquals(12.5, complete.durationSeconds!!, 0.001)
|
||||
@@ -1057,7 +1067,7 @@ class GatewayEventMapperTest {
|
||||
"message.complete", "error", "clarify.request", "approval.request",
|
||||
"sudo.request", "secret.request", "reasoning.available",
|
||||
"clarify.expire", "sudo.expire", "secret.expire", "approval.expire",
|
||||
"tool.generating", "subagent.start", "subagent.thinking",
|
||||
"tool.generating", "subagent.spawn_requested", "subagent.start", "subagent.thinking",
|
||||
"subagent.tool", "subagent.progress", "subagent.complete",
|
||||
"tool.output_risk", "moa.reference", "moa.progress", "moa.phase", "moa.aggregating",
|
||||
).forEach { type ->
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ReadOnlyPreviewBoundsTest {
|
||||
@Test
|
||||
fun `child preview drops system rows results and oversized live content`() {
|
||||
val handler = ChatHandler()
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage("system", MessageRole.SYSTEM, "private system context", 1L),
|
||||
)
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "child",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "x".repeat(20_000),
|
||||
timestamp = 2L,
|
||||
thinkingContent = "y".repeat(20_000),
|
||||
toolCalls = listOf(
|
||||
ToolCall(
|
||||
name = "read_file",
|
||||
args = "a".repeat(5_000),
|
||||
result = "secret result",
|
||||
success = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(handler.boundReadOnlyPreview(maxTotalChars = 4_000, maxFieldChars = 2_000))
|
||||
|
||||
val messages = handler.messages.value
|
||||
assertEquals(listOf("child"), messages.map(ChatMessage::id))
|
||||
assertTrue(messages.sumOf { it.content.length + it.thinkingContent.length } <= 4_000)
|
||||
assertTrue(messages.single().toolCalls.single().args.orEmpty().length <= 1_000)
|
||||
assertEquals(null, messages.single().toolCalls.single().result)
|
||||
assertFalse(messages.any { it.role == MessageRole.SYSTEM })
|
||||
}
|
||||
}
|
||||
+32
@@ -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")
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.ui.components.ChatGitContextButton
|
||||
import com.hermesandroid.relay.ui.components.ChatGitWorkspaceRail
|
||||
import com.hermesandroid.relay.ui.components.ChatGitWorkspaceSummary
|
||||
import com.hermesandroid.relay.ui.screens.GitStateScreen
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
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 = "w400dp-h800dp-432dpi")
|
||||
class GitWorkspaceScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detailWorkspaceMatchesApprovedInformationHierarchy() {
|
||||
enqueue("""{"repos":[{"id":"hermes-relay","name":"hermes-relay","root":"/srv/projects/hermes-relay","current_branch":"main","dirty":true}]}""")
|
||||
enqueue(
|
||||
"""{"counts":{"staged":1,"modified":1,"untracked":1,"changes":3,"additions":24,"deletions":7},"staged":[{"path":"app/src/main/kotlin/RelayApp.kt","additions":12,"deletions":3}],"modified":[{"path":"plugin/git_state.py","additions":8,"deletions":4}],"untracked":[{"path":"docs/git-workspace.md","additions":null,"deletions":null}],"truncated":false}""",
|
||||
)
|
||||
enqueue("""{"branches":[{"name":"main","upstream":"origin/main","ahead":1,"behind":0,"is_current":true},{"name":"dev","upstream":"origin/dev","ahead":0,"behind":0,"is_current":false}]}""")
|
||||
enqueue(
|
||||
"""{"path":"plugin/git_state.py","kind":"unstaged","diff":"@@ repository containment @@\n+ def is_within_repo(path):\n- return false\n+ return path.startswith(repo_root)","truncated":false}""",
|
||||
)
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val viewModel = GitStateViewModel(app)
|
||||
val owner = "visual-owner"
|
||||
viewModel.configure(DashboardApiClient(server.url("/").toString()), owner)
|
||||
viewModel.setWriteGrant(owner, true)
|
||||
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
GitStateScreen(viewModel = viewModel, onBack = {})
|
||||
}
|
||||
}
|
||||
|
||||
compose.waitUntil(5_000) {
|
||||
runCatching { compose.onNodeWithText("3 changes").assertExists() }.isSuccess
|
||||
}
|
||||
compose.onNodeWithContentDescription("Select plugin/git_state.py").performClick()
|
||||
compose.onNodeWithText("git_state.py").performClick()
|
||||
compose.waitUntil(5_000) {
|
||||
runCatching { compose.onNodeWithText("@@ repository containment @@", substring = true).assertExists() }.isSuccess
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/store-shots/15_git_workspace.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatRailMatchesApprovedCompactTreatment() {
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(androidx.compose.material3.MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
ChatGitContextButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(16.dp),
|
||||
)
|
||||
Column(
|
||||
Modifier.fillMaxWidth().align(Alignment.BottomCenter).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
ChatGitWorkspaceRail(
|
||||
summary = ChatGitWorkspaceSummary("main", 3, 24, 7),
|
||||
onClick = {},
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxWidth().background(
|
||||
androidx.compose.material3.MaterialTheme.colorScheme.surfaceContainer,
|
||||
androidx.compose.foundation.shape.RoundedCornerShape(18.dp),
|
||||
).padding(vertical = 28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/ui-evidence/chat-git-workspace-rail.png")
|
||||
}
|
||||
|
||||
private fun enqueue(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body),
|
||||
)
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
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.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
|
||||
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivity
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEvent
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEventKind
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
|
||||
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
|
||||
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-h720dp-xhdpi")
|
||||
class SubagentActivitySheetScreenshotTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun concurrentLiveAgentsRenderAsReadOnlyActivity() {
|
||||
val activities = listOf(
|
||||
activity(0, 0, "Inspect Android event handling", SubagentActivityPhase.PROGRESS),
|
||||
activity(1, 1, "Review privacy boundaries", SubagentActivityPhase.INTERRUPTED),
|
||||
)
|
||||
val preview = SubagentChildPreview(
|
||||
activityKey = activities.first().stableKey,
|
||||
parentSessionId = "parent",
|
||||
parentScopeKey = "scope",
|
||||
childWatchAvailable = true,
|
||||
messages = listOf(
|
||||
ChatMessage("task", MessageRole.USER, "Trace the upstream child watch contract.", 1L),
|
||||
ChatMessage("answer", MessageRole.ASSISTANT, "The child-only history is available read-only.", 2L),
|
||||
),
|
||||
running = true,
|
||||
status = "streaming",
|
||||
)
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
GatewayBackgroundProcessSheet(
|
||||
processes = emptyList(),
|
||||
subagentActivities = activities,
|
||||
subagentChildPreview = preview,
|
||||
subagentPreviewVisibility = SubagentPreviewVisibility(),
|
||||
loading = false,
|
||||
stoppingProcessIds = emptySet(),
|
||||
onRefresh = {},
|
||||
onStop = {},
|
||||
onDismissProcess = {},
|
||||
onOpenSubagentChild = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithContentDescription(
|
||||
"Inspect Android event handling, Working, agent 1 of 2",
|
||||
).performClick()
|
||||
compose.onNodeWithText("Child history · live updates").assertExists()
|
||||
compose.onNodeWithText("The child-only history is available read-only.").assertExists()
|
||||
compose.onNodeWithText("Stop").assertDoesNotExist()
|
||||
compose.onRoot().captureRoboImage("build/ui-regression/subagent-activity-sheet.png")
|
||||
}
|
||||
|
||||
private fun activity(
|
||||
laneId: Long,
|
||||
taskIndex: Int,
|
||||
goal: String,
|
||||
phase: SubagentActivityPhase,
|
||||
) = SubagentActivity(
|
||||
laneId = laneId,
|
||||
turnId = "turn",
|
||||
taskIndex = taskIndex,
|
||||
taskCount = 2,
|
||||
goal = goal,
|
||||
phase = phase,
|
||||
childSessionId = "child-$taskIndex",
|
||||
profile = "default",
|
||||
events = listOf(
|
||||
SubagentActivityEvent(
|
||||
sequence = laneId,
|
||||
kind = SubagentActivityEventKind.UPDATE,
|
||||
text = if (phase == SubagentActivityPhase.INTERRUPTED) {
|
||||
"Stopped safely"
|
||||
} else {
|
||||
"Mapping Gateway events"
|
||||
},
|
||||
phase = phase,
|
||||
observedAtMillis = 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+114
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.GitBranch
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.data.GitStatus
|
||||
import com.hermesandroid.relay.data.GitStatusCounts
|
||||
import com.hermesandroid.relay.data.GitStatusEntry
|
||||
import com.hermesandroid.relay.viewmodel.GitRepoDetailState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class GitWorkspaceProjectionTest {
|
||||
private val parent = GitRepo("parent", "projects", "/srv/projects")
|
||||
private val nested = GitRepo("nested", "relay", "/srv/projects/hermes-relay")
|
||||
|
||||
@Test
|
||||
fun exactSessionRootWinsAndCwdUsesLongestSegmentMatch() {
|
||||
assertEquals(
|
||||
nested,
|
||||
selectGitRepoForWorkspace(listOf(parent, nested), null, nested.root, null),
|
||||
)
|
||||
assertEquals(
|
||||
nested,
|
||||
selectGitRepoForWorkspace(
|
||||
listOf(parent, nested),
|
||||
null,
|
||||
null,
|
||||
"/srv/projects/hermes-relay/app/src",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ambiguousCatalogDoesNotInventASelection() {
|
||||
assertNull(selectGitRepoForWorkspace(listOf(parent, nested), null, null, null))
|
||||
assertNull(selectGitRepoForWorkspace(listOf(parent, nested), nested.id, null, null))
|
||||
assertEquals(parent, selectGitRepoForWorkspace(listOf(parent), null, null, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun summaryCountsEachChangedPathOnce() {
|
||||
val summary = buildChatGitWorkspaceSummary(
|
||||
nested,
|
||||
GitRepoDetailState.Ready(
|
||||
status = GitStatus(
|
||||
counts = GitStatusCounts(additions = 24, deletions = 7),
|
||||
staged = listOf(GitStatusEntry("shared.kt")),
|
||||
modified = listOf(GitStatusEntry("shared.kt"), GitStatusEntry("other.kt")),
|
||||
untracked = listOf(GitStatusEntry("new.kt")),
|
||||
),
|
||||
branches = listOf(GitBranch("dev", isCurrent = true)),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("dev", summary?.branch)
|
||||
assertEquals(3, summary?.changeCount)
|
||||
assertEquals(24, summary?.additions)
|
||||
assertEquals(7, summary?.deletions)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
+5
-19
@@ -5,12 +5,10 @@ import org.junit.Test
|
||||
|
||||
class MorphingSphereMotionPolicyTest {
|
||||
@Test
|
||||
fun `visible idle sphere uses lightweight ambient motion`() {
|
||||
fun `every visible sphere uses smooth procedural motion`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.AmbientLayer,
|
||||
SphereMotionMode.Procedural,
|
||||
sphereMotionMode(
|
||||
state = SphereState.Idle,
|
||||
voiceMode = false,
|
||||
motionVisible = true,
|
||||
fixedTime = null,
|
||||
fixedColorPhase = null,
|
||||
@@ -19,26 +17,14 @@ class MorphingSphereMotionPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hidden or paused idle sphere is still`() {
|
||||
fun `hidden or paused sphere is still`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.Still,
|
||||
sphereMotionMode(SphereState.Idle, false, false, null, null),
|
||||
sphereMotionMode(false, null, null),
|
||||
)
|
||||
assertEquals(
|
||||
SphereMotionMode.Still,
|
||||
sphereMotionMode(SphereState.Idle, false, true, 0f, 0f),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible active and voice states keep procedural motion`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.Procedural,
|
||||
sphereMotionMode(SphereState.Thinking, false, true, null, null),
|
||||
)
|
||||
assertEquals(
|
||||
SphereMotionMode.Procedural,
|
||||
sphereMotionMode(SphereState.Idle, true, true, null, null),
|
||||
sphereMotionMode(true, 0f, 0f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivity
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEvent
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityEventKind
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
|
||||
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SubagentActivityPreviewTest {
|
||||
@Test
|
||||
fun `supervised visibility excludes child history rows`() {
|
||||
val activity = activity(laneId = 0, taskIndex = 0)
|
||||
val preview = preview(activity, messageCount = 2)
|
||||
val expanded = setOf(activity.stableKey)
|
||||
|
||||
val full = subagentActivityItemCount(
|
||||
listOf(activity),
|
||||
expanded,
|
||||
SubagentPreviewVisibility(showChildHistory = true),
|
||||
preview,
|
||||
)
|
||||
val supervised = subagentActivityItemCount(
|
||||
listOf(activity),
|
||||
expanded,
|
||||
SubagentPreviewVisibility(showChildHistory = false),
|
||||
preview,
|
||||
)
|
||||
|
||||
assertTrue(full > supervised)
|
||||
assertEquals(4, full - supervised) // heading, two child messages, and tail anchor
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `follow target stays with selected child instead of final concurrent lane`() {
|
||||
val first = activity(laneId = 0, taskIndex = 0)
|
||||
val second = activity(laneId = 1, taskIndex = 1)
|
||||
val preview = preview(first, messageCount = 1)
|
||||
val target = subagentActivityFollowTarget(
|
||||
activities = listOf(first, second),
|
||||
expandedKeys = setOf(first.stableKey, second.stableKey),
|
||||
visibility = SubagentPreviewVisibility(),
|
||||
childPreview = preview,
|
||||
)
|
||||
val total = subagentActivityItemCount(
|
||||
listOf(first, second),
|
||||
setOf(first.stableKey, second.stableKey),
|
||||
SubagentPreviewVisibility(),
|
||||
preview,
|
||||
)
|
||||
|
||||
assertTrue(target < total - 1)
|
||||
}
|
||||
|
||||
private fun activity(laneId: Long, taskIndex: Int) = SubagentActivity(
|
||||
laneId = laneId,
|
||||
turnId = "turn",
|
||||
taskIndex = taskIndex,
|
||||
taskCount = 2,
|
||||
goal = "Task $taskIndex",
|
||||
phase = SubagentActivityPhase.PROGRESS,
|
||||
events = listOf(
|
||||
SubagentActivityEvent(
|
||||
sequence = 0,
|
||||
kind = SubagentActivityEventKind.UPDATE,
|
||||
text = "Working",
|
||||
phase = SubagentActivityPhase.PROGRESS,
|
||||
observedAtMillis = 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun preview(activity: SubagentActivity, messageCount: Int) = SubagentChildPreview(
|
||||
activityKey = activity.stableKey,
|
||||
parentSessionId = "parent",
|
||||
parentScopeKey = "scope",
|
||||
childWatchAvailable = true,
|
||||
messages = List(messageCount) { index ->
|
||||
ChatMessage("message-$index", MessageRole.ASSISTANT, "Text", index.toLong())
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+196
@@ -1297,6 +1297,120 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredServerDefaultSubagentWatchOmitsProfileOverride() {
|
||||
assertRecoveredSubagentWatchProfile(
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", null),
|
||||
persistedProfileKey = AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
expectedProfile = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredLiteralDefaultSubagentWatchKeepsExplicitProfile() {
|
||||
assertRecoveredSubagentWatchProfile(
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", "default"),
|
||||
persistedProfileKey = "default",
|
||||
expectedProfile = "default",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredNamedSubagentWatchKeepsOwningProfileAcrossConnectionScope() {
|
||||
assertRecoveredSubagentWatchProfile(
|
||||
contextKey = AgentDisplay.profileContextKey("connection-b", "team::writer"),
|
||||
persistedProfileKey = "team::writer",
|
||||
expectedProfile = "team::writer",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredLegacyCheckpointFailsClosedWithoutInventingProfile() {
|
||||
assertRecoveredSubagentWatchProfile(
|
||||
contextKey = "connection-a/profile-default",
|
||||
persistedProfileKey = null,
|
||||
expectedProfile = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentServerDefaultCheckpointPersistsExplicitSentinel() {
|
||||
assertCurrentCheckpointProfileKey(
|
||||
profileName = null,
|
||||
effectiveSessionProfileName = "victor",
|
||||
expectedProfileKey = AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentLiteralDefaultCheckpointPersistsNamedProfile() {
|
||||
assertCurrentCheckpointProfileKey(
|
||||
profileName = "default",
|
||||
effectiveSessionProfileName = "default",
|
||||
expectedProfileKey = "default",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitConversationOwnerWinsAmbientSelectorInCurrentCheckpoint() {
|
||||
val checkpointStore = MemoryCheckpointStore()
|
||||
val global = Profile(name = "global", model = "global-model")
|
||||
val writer = Profile(name = "writer", model = "writer-model")
|
||||
viewModel.setSelectedProfileProvider { global }
|
||||
viewModel.setSessionProfileNameProvider { global.name }
|
||||
viewModel.setProfileMessageLoader { Result.success(emptyList()) }
|
||||
viewModel.setChatTurnCheckpointStore(checkpointStore)
|
||||
|
||||
assertTrue(
|
||||
viewModel.openProfileSession(
|
||||
profileName = writer.name,
|
||||
profile = writer,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", writer.name),
|
||||
sessionId = STORED_SESSION_ID,
|
||||
),
|
||||
)
|
||||
awaitCondition { viewModel.conversationBinding.value.hasExplicitOwner }
|
||||
|
||||
viewModel.sendMessage("Persist explicit owner")
|
||||
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
awaitCondition { checkpointStore.checkpoint?.profileKey == writer.name }
|
||||
assertEquals(writer.name, checkpointStore.checkpoint?.profileKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveNonRecoveredSubagentWatchKeepsLiteralDefaultProfile() {
|
||||
val profile = Profile(name = "default", model = "model")
|
||||
viewModel.setSelectedProfileProvider { profile }
|
||||
viewModel.setSessionProfileNameProvider { profile.name }
|
||||
viewModel.switchProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", profile.name),
|
||||
STORED_SESSION_ID,
|
||||
)
|
||||
gatewayHarness.resumeLiveSessionIds["live-child-stored"] = "live-child"
|
||||
|
||||
viewModel.sendMessage("Delegate live work")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"subagent.start",
|
||||
buildJsonObject {
|
||||
put("goal", "Inspect live path")
|
||||
put("task_index", 0)
|
||||
put("task_count", 1)
|
||||
put("subagent_id", "live-child-agent")
|
||||
put("child_session_id", "live-child-stored")
|
||||
},
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { viewModel.subagentActivities.value.size == 1 }
|
||||
viewModel.openSubagentChildPreview(viewModel.subagentActivities.value.single().stableKey)
|
||||
|
||||
val resume = gatewayHarness.awaitRpcCount("session.resume", 2).last()
|
||||
assertEquals(JsonPrimitive("default"), resume["profile"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitApprovalActionAloneEmitsResponseAndCollapsesCard() {
|
||||
viewModel.sendMessage("Run the guarded command")
|
||||
@@ -2465,6 +2579,88 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertRecoveredSubagentWatchProfile(
|
||||
contextKey: String,
|
||||
persistedProfileKey: String?,
|
||||
expectedProfile: String?,
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
val checkpointStore = MemoryCheckpointStore(
|
||||
ChatTurnCheckpoint(
|
||||
contextKey = contextKey,
|
||||
profileKey = persistedProfileKey,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
liveSessionId = "live-resumed",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("pending-user", "Delegate this", now - 2_000L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = "pending-assistant",
|
||||
content = "Partial",
|
||||
timestamp = now - 1_900L,
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = now - 1_900L,
|
||||
updatedAt = now,
|
||||
),
|
||||
)
|
||||
gatewayHarness.recoveryRunning = true
|
||||
gatewayHarness.recoveryAssistant = "Partial"
|
||||
gatewayHarness.resumeLiveSessionIds["child-stored"] = "child-live"
|
||||
viewModel.setChatTurnCheckpointStore(checkpointStore)
|
||||
handler.setSessionId(null)
|
||||
viewModel.switchProfileContext(contextKey, STORED_SESSION_ID)
|
||||
|
||||
viewModel.prewarmGateway()
|
||||
|
||||
gatewayHarness.awaitRpc("session.activate")
|
||||
awaitCondition { handler.isStreaming.value }
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"subagent.start",
|
||||
buildJsonObject {
|
||||
put("goal", "Inspect recovery")
|
||||
put("task_index", 0)
|
||||
put("task_count", 1)
|
||||
put("subagent_id", "child-agent")
|
||||
put("child_session_id", "child-stored")
|
||||
},
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { viewModel.subagentActivities.value.size == 1 }
|
||||
viewModel.openSubagentChildPreview(viewModel.subagentActivities.value.single().stableKey)
|
||||
|
||||
val resume = gatewayHarness.awaitRpcCount("session.resume", 2).last()
|
||||
if (expectedProfile == null) {
|
||||
assertFalse(resume.containsKey("profile"))
|
||||
} else {
|
||||
assertEquals(JsonPrimitive(expectedProfile), resume["profile"])
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertCurrentCheckpointProfileKey(
|
||||
profileName: String?,
|
||||
effectiveSessionProfileName: String?,
|
||||
expectedProfileKey: String,
|
||||
) {
|
||||
val checkpointStore = MemoryCheckpointStore()
|
||||
val profile = profileName?.let { Profile(name = it, model = "model") }
|
||||
viewModel.setSelectedProfileProvider { profile }
|
||||
viewModel.setSessionProfileNameProvider { effectiveSessionProfileName }
|
||||
viewModel.setChatTurnCheckpointStore(checkpointStore)
|
||||
viewModel.switchProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", profileName),
|
||||
STORED_SESSION_ID,
|
||||
)
|
||||
|
||||
viewModel.sendMessage("Persist profile identity")
|
||||
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
awaitCondition { checkpointStore.checkpoint?.profileKey == expectedProfileKey }
|
||||
assertEquals(expectedProfileKey, checkpointStore.checkpoint?.profileKey)
|
||||
}
|
||||
|
||||
private fun activeSessionPayload(status: String) = buildJsonObject {
|
||||
put("sessions", buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -85,6 +86,20 @@ class GitStateViewModelTest {
|
||||
assertTrue(state.message.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRepos maps missing plugin route to friendly unavailable state`() = runBlocking {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(404)
|
||||
.setBody("""{"detail":"No such API endpoint"}"""),
|
||||
)
|
||||
val vm = viewModel()
|
||||
val state = withTimeout(5_000) {
|
||||
vm.repos.filterIsInstance<GitStateUiState.Unavailable>().first()
|
||||
}
|
||||
assertEquals("Git isn't available on this Hermes host yet.", state.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectRepo loads status and branches and preserves truncation flag`() = runBlocking {
|
||||
enqueueJson(
|
||||
@@ -107,6 +122,10 @@ class GitStateViewModelTest {
|
||||
assertEquals(1, ready.status.counts.staged)
|
||||
assertEquals(2, ready.status.counts.modified)
|
||||
assertEquals(3, ready.status.counts.untracked)
|
||||
assertEquals(-1, ready.status.counts.changes)
|
||||
assertEquals(0, ready.status.counts.additions)
|
||||
assertEquals(0, ready.status.counts.deletions)
|
||||
assertNull(ready.status.staged.single().additions)
|
||||
assertTrue(ready.status.truncated)
|
||||
assertEquals("main", ready.branches.single().name)
|
||||
assertTrue(ready.branches.single().isCurrent)
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SubagentActivityControllerTest {
|
||||
private var now = 1_000L
|
||||
private val controller = SubagentActivityController { now++ }
|
||||
|
||||
@Test
|
||||
fun `interleaved children retain independent lifecycle previews`() {
|
||||
controller.selectSession("parent", "connection::default")
|
||||
controller.beginTurn("parent", "connection::default", "turn-1")
|
||||
controller.onEvent("parent", "connection::default", "turn-1", event(0, GatewaySubagentEvent.Phase.START, goal = "Research"))
|
||||
controller.onEvent("parent", "connection::default", "turn-1", event(1, GatewaySubagentEvent.Phase.START, goal = "Review"))
|
||||
controller.onEvent("parent", "connection::default", "turn-1", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "Halfway"))
|
||||
controller.onEvent("parent", "connection::default", "turn-1", event(1, GatewaySubagentEvent.Phase.TOOL, preview = "file.kt", tool = "read_file"))
|
||||
controller.onEvent("parent", "connection::default", "turn-1", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "complete", summary = "Done"))
|
||||
|
||||
val activities = controller.activities.value.sortedBy { it.taskIndex }
|
||||
assertEquals(listOf("Research", "Review"), activities.map { it.goal })
|
||||
assertEquals(SubagentActivityPhase.COMPLETED, activities[0].phase)
|
||||
assertEquals("Done", activities[0].summary)
|
||||
assertEquals(SubagentActivityPhase.TOOL, activities[1].phase)
|
||||
assertEquals("read_file", activities[1].events.last().toolName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile session and newer turn fence stale events`() {
|
||||
controller.selectSession("shared", "connection::alpha")
|
||||
controller.beginTurn("shared", "connection::alpha", "turn-old")
|
||||
controller.onEvent("shared", "connection::alpha", "turn-old", event(0, GatewaySubagentEvent.Phase.START, goal = "Old"))
|
||||
controller.selectSession("shared", "connection::beta")
|
||||
controller.onEvent("shared", "connection::alpha", "turn-old", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "stale"))
|
||||
assertTrue(controller.activities.value.isEmpty())
|
||||
|
||||
controller.beginTurn("shared", "connection::beta", "turn-new")
|
||||
controller.onEvent("shared", "connection::beta", "turn-new", event(0, GatewaySubagentEvent.Phase.START, goal = "New"))
|
||||
controller.beginTurn("shared", "connection::beta", "turn-newer")
|
||||
controller.onEvent("shared", "connection::beta", "turn-newer", event(0, GatewaySubagentEvent.Phase.START, goal = "Newest"))
|
||||
controller.onEvent("shared", "connection::beta", "turn-new", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "late"))
|
||||
assertEquals(listOf("Newest"), controller.activities.value.map { it.goal })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal truth distinguishes failure interruption and missing terminal`() {
|
||||
controller.selectSession("parent", "scope")
|
||||
controller.beginTurn("parent", "scope", "turn-1")
|
||||
controller.onEvent("parent", "scope", "turn-1", event(0, GatewaySubagentEvent.Phase.START))
|
||||
controller.onEvent("parent", "scope", "turn-1", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "interrupted"))
|
||||
assertEquals(SubagentActivityPhase.INTERRUPTED, controller.activities.value.single().phase)
|
||||
|
||||
controller.beginTurn("parent", "scope", "turn-2")
|
||||
controller.onEvent("parent", "scope", "turn-2", event(0, GatewaySubagentEvent.Phase.START))
|
||||
controller.onEvent("parent", "scope", "turn-2", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "failed"))
|
||||
assertEquals(SubagentActivityPhase.FAILED, controller.activities.value.single().phase)
|
||||
|
||||
controller.beginTurn("parent", "scope", "turn-3")
|
||||
controller.onEvent("parent", "scope", "turn-3", event(0, GatewaySubagentEvent.Phase.START))
|
||||
controller.endTurn("turn-3")
|
||||
assertEquals(SubagentActivityPhase.ENDED_WITH_PARENT, controller.activities.value.single().phase)
|
||||
assertTrue(controller.activities.value.single().partialAfterGap)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconnect marks only live activity partial and late events do not reopen terminal child`() {
|
||||
controller.selectSession("parent", "scope")
|
||||
controller.beginTurn("parent", "scope", "turn")
|
||||
controller.onConnectionReady(true)
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.START))
|
||||
controller.onConnectionReady(false)
|
||||
controller.onConnectionReady(true)
|
||||
assertTrue(controller.activities.value.single().partialAfterGap)
|
||||
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "complete"))
|
||||
val revision = controller.activities.value.single().revision
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "late"))
|
||||
assertEquals(revision, controller.activities.value.single().revision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event history is sanitized coalesced and bounded`() {
|
||||
controller.selectSession("parent", "scope")
|
||||
controller.beginTurn("parent", "scope", "turn")
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.START, goal = "\u001B[31mSecret\u0000"))
|
||||
repeat(SubagentActivityController.MAX_EVENTS_PER_CHILD + 10) { index ->
|
||||
controller.onEvent(
|
||||
"parent",
|
||||
"scope",
|
||||
"turn",
|
||||
event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "update-$index"),
|
||||
)
|
||||
}
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "same"))
|
||||
controller.onEvent("parent", "scope", "turn", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "same"))
|
||||
|
||||
val activity = controller.activities.value.single()
|
||||
assertEquals("Secret", activity.goal)
|
||||
assertTrue(activity.truncated)
|
||||
assertTrue(activity.events.size <= SubagentActivityController.MAX_EVENTS_PER_CHILD)
|
||||
assertEquals(1, activity.events.count { it.text == "same" })
|
||||
assertFalse(activity.events.any { it.text?.contains('\u0000') == true })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identity enrichment keeps one lane while conflicting child stays separate`() {
|
||||
controller.selectSession("parent", "scope")
|
||||
controller.beginTurn("parent", "scope", "turn")
|
||||
controller.onEvent(
|
||||
"parent", "scope", "turn",
|
||||
event(0, GatewaySubagentEvent.Phase.SPAWN_REQUESTED, childId = "session-1"),
|
||||
)
|
||||
controller.onEvent(
|
||||
"parent", "scope", "turn",
|
||||
event(0, GatewaySubagentEvent.Phase.START, subagentId = "agent-1"),
|
||||
)
|
||||
assertEquals(1, controller.activities.value.size)
|
||||
assertEquals("session-1", controller.activities.value.single().childSessionId)
|
||||
assertEquals("agent-1", controller.activities.value.single().subagentId)
|
||||
|
||||
controller.onEvent(
|
||||
"parent", "scope", "turn",
|
||||
event(
|
||||
0,
|
||||
GatewaySubagentEvent.Phase.START,
|
||||
subagentId = "agent-2",
|
||||
childId = "session-2",
|
||||
),
|
||||
)
|
||||
assertEquals(2, controller.activities.value.size)
|
||||
assertEquals(2, controller.activities.value.map { it.stableKey }.distinct().size)
|
||||
}
|
||||
|
||||
private fun event(
|
||||
index: Int,
|
||||
phase: GatewaySubagentEvent.Phase,
|
||||
goal: String = "",
|
||||
preview: String? = null,
|
||||
tool: String? = null,
|
||||
status: String? = null,
|
||||
summary: String? = null,
|
||||
subagentId: String? = null,
|
||||
childId: String? = null,
|
||||
) = GatewaySubagentEvent(
|
||||
phase = phase,
|
||||
taskIndex = index,
|
||||
taskCount = 2,
|
||||
goal = goal,
|
||||
preview = preview,
|
||||
toolName = tool,
|
||||
status = status,
|
||||
summary = summary,
|
||||
subagentId = subagentId,
|
||||
childSessionId = childId,
|
||||
)
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChildWatch
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SubagentChildPreviewControllerTest {
|
||||
@Test
|
||||
fun `pre-ack live callbacks append after hydrated history`() = runTest {
|
||||
val client = mockk<GatewayChatClient>()
|
||||
val controller = SubagentChildPreviewController(
|
||||
scope = this,
|
||||
openWatch = { _, _, _, callbacks ->
|
||||
callbacks.onStart()
|
||||
callbacks.onTextDelta("live")
|
||||
callbacks.onComplete()
|
||||
Result.success(watch(messages = listOf(message("history")), running = true))
|
||||
},
|
||||
)
|
||||
|
||||
controller.open(activity(), client, "parent", "scope", true) { true }
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = controller.state.value!!
|
||||
assertTrue(state.messages.any { it.content == "history" })
|
||||
assertTrue(state.messages.any { it.content == "live" })
|
||||
assertFalse(state.running)
|
||||
assertFalse(state.status == "completed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissed in-flight open closes the late exact watch without publishing`() = runTest {
|
||||
val client = mockk<GatewayChatClient>()
|
||||
val acknowledgement = CompletableDeferred<GatewayChildWatch>()
|
||||
var closed: GatewayChildWatch? = null
|
||||
val controller = SubagentChildPreviewController(
|
||||
scope = this,
|
||||
openWatch = { _, _, _, _ -> Result.success(acknowledgement.await()) },
|
||||
closeWatch = { _, watch ->
|
||||
closed = watch
|
||||
Result.success(Unit)
|
||||
},
|
||||
)
|
||||
|
||||
controller.open(activity(), client, "parent", "scope", true) { true }
|
||||
runCurrent()
|
||||
controller.close()
|
||||
val late = watch()
|
||||
acknowledgement.complete(late)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(late, closed)
|
||||
assertNull(controller.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale open generation closes late watch and cannot replace newer profile`() = runTest {
|
||||
val client = mockk<GatewayChatClient>()
|
||||
val oldAcknowledgement = CompletableDeferred<GatewayChildWatch>()
|
||||
val closed = mutableListOf<GatewayChildWatch>()
|
||||
val controller = SubagentChildPreviewController(
|
||||
scope = this,
|
||||
openWatch = { _, sessionId, profile, _ ->
|
||||
if (sessionId == "child-old") {
|
||||
Result.success(oldAcknowledgement.await())
|
||||
} else {
|
||||
Result.success(watch(sessionId, "live-new", profile))
|
||||
}
|
||||
},
|
||||
closeWatch = { _, watch ->
|
||||
closed += watch
|
||||
Result.success(Unit)
|
||||
},
|
||||
)
|
||||
|
||||
controller.open(activity("child-old", "old", laneId = 0), client, "parent", "scope", true) { true }
|
||||
runCurrent()
|
||||
controller.open(activity("child-new", "default", laneId = 1), client, "parent", "scope", true) { true }
|
||||
advanceUntilIdle()
|
||||
assertEquals("default", controller.state.value?.status)
|
||||
|
||||
val late = watch("child-old", "live-old", "old")
|
||||
oldAcknowledgement.complete(late)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(late in closed)
|
||||
assertEquals("default", controller.state.value?.status)
|
||||
assertEquals("turn:1", controller.state.value?.activityKey)
|
||||
}
|
||||
|
||||
private fun activity(
|
||||
childSessionId: String = "child-stored",
|
||||
profile: String = "default",
|
||||
laneId: Long = 0,
|
||||
) = SubagentActivity(
|
||||
laneId = laneId,
|
||||
turnId = "turn",
|
||||
taskIndex = 0,
|
||||
taskCount = 1,
|
||||
goal = "Inspect",
|
||||
phase = SubagentActivityPhase.PROGRESS,
|
||||
childSessionId = childSessionId,
|
||||
profile = profile,
|
||||
)
|
||||
|
||||
private fun message(text: String) = MessageItem(
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(text),
|
||||
)
|
||||
|
||||
private fun watch(
|
||||
messages: List<MessageItem> = emptyList(),
|
||||
running: Boolean = false,
|
||||
) = GatewayChildWatch(
|
||||
storedSessionId = "child-stored",
|
||||
liveSessionId = "child-live",
|
||||
profile = "default",
|
||||
generation = 1,
|
||||
messages = messages,
|
||||
historyTruncated = false,
|
||||
running = running,
|
||||
status = if (running) "streaming" else "idle",
|
||||
)
|
||||
|
||||
private fun watch(
|
||||
storedSessionId: String,
|
||||
liveSessionId: String,
|
||||
status: String?,
|
||||
) = GatewayChildWatch(
|
||||
storedSessionId = storedSessionId,
|
||||
liveSessionId = liveSessionId,
|
||||
profile = status,
|
||||
generation = if (storedSessionId == "child-old") 1 else 2,
|
||||
messages = emptyList(),
|
||||
historyTruncated = false,
|
||||
running = true,
|
||||
status = status,
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -94,6 +94,17 @@ therefore cannot reach Relay management APIs or acquire executable backend behav
|
||||
The contribution ID `git` is reserved for the Relay plugin's native Git workspace;
|
||||
generated drafts cannot shadow or duplicate that route.
|
||||
|
||||
Android presents that reserved contribution as a first-class native surface rather
|
||||
than a generic declarative page. When the live plugin API confirms Git is available,
|
||||
Chat can show a compact branch/change rail and a dot-only context action; **Chat →
|
||||
Show Git workspace in Chat** hides those two Chat affordances without disabling the
|
||||
workspace. The full native workspace remains reachable from Settings and Plugins and
|
||||
owns repository/branch selection, diffs, staging, confirmed destructive actions,
|
||||
commits, and remotes. Session `git_repo_root`/`cwd` metadata selects an exact matching
|
||||
repository when possible; ambiguous catalogs require an explicit user choice. Mobile
|
||||
discovery alone is not treated as runtime readiness: a missing Git API route renders a
|
||||
retryable unavailable state instead of exposing the raw Dashboard error.
|
||||
|
||||
The Relay mobile manifest exposes drafts as preview pages under the authenticated
|
||||
`hermes-relay` plugin namespace. Android polls the catalog every five seconds while
|
||||
the Plugins hub is visible and polls a visible generated page every five seconds.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
|
||||
},
|
||||
"en": {
|
||||
"native_name": "English",
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -65,14 +65,14 @@
|
||||
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
|
||||
},
|
||||
"ja": {
|
||||
"native_name": "\u65e5\u672c\u8a9e",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -89,14 +89,14 @@
|
||||
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
|
||||
},
|
||||
"pt-BR": {
|
||||
"native_name": "Portugu\u00eas (Brasil)",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -113,14 +113,14 @@
|
||||
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
|
||||
},
|
||||
"ru": {
|
||||
"native_name": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "e98464fc5fd7283a6cfd0053155c20ca2306f360f7f3dac52c0e56343850e61a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -152,7 +152,7 @@
|
||||
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,12 @@
|
||||
"title": "Start a conversation",
|
||||
"source": "assets/screenshots/supplemental/14_startup.png",
|
||||
"captureNote": "Current empty-chat chrome, idle visualization, model and effort controls, and public-safe suggestions."
|
||||
},
|
||||
{
|
||||
"id": "15_git_workspace",
|
||||
"title": "Native Git workspace",
|
||||
"source": "assets/screenshots/supplemental/15_git_workspace.png",
|
||||
"captureNote": "Real native Git workspace with public-safe repository status, line totals, filters, selection, inline diff, branch controls, commit action, and no live host data."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.13.1 - Accurate session activity
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
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
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ Play 2:1 clipping), and only mock public-safe data.
|
||||
### Run
|
||||
|
||||
```bash
|
||||
./gradlew :app:testGooglePlayDebugUnitTest --tests "*StoreScreenshotTest*"
|
||||
./gradlew :app:testGooglePlayDebugUnitTest --tests "*StoreScreenshotTest*" --tests "*GitWorkspaceScreenshotTest*"
|
||||
# Output: app/build/store-shots/<scene>.png (1080x2160, exactly 2:1)
|
||||
```
|
||||
|
||||
@@ -149,6 +149,9 @@ The canonical marketing lineup intentionally exercises current product seams:
|
||||
coverage beyond the two Play-listing frames.
|
||||
- Supplemental `14_startup` preserves the lower-information empty-chat frame
|
||||
outside the eight-slot Play lineup.
|
||||
- Supplemental `15_git_workspace` renders the real native Git screen from a
|
||||
deterministic plugin API fixture. It is used by README, user docs, and the
|
||||
website but intentionally remains outside the eight-slot Play lineup.
|
||||
|
||||
### Render any view going forward
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.13.1"
|
||||
appVersionCode = "50"
|
||||
appVersionName = "1.13.2"
|
||||
appVersionCode = "51"
|
||||
agp = "9.3.2"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
|
||||
+57
-10
@@ -281,11 +281,46 @@ def resolve_repo_path(repo: Path, path: str) -> str:
|
||||
def repo_status(repo: Path) -> dict[str, Any]:
|
||||
"""Return grouped working-tree status with counts and truncation flag."""
|
||||
porcelain = _git(repo, "status", "--porcelain=v1", "-z")
|
||||
staged: list[dict[str, str]] = []
|
||||
modified: list[dict[str, str]] = []
|
||||
untracked: list[dict[str, str]] = []
|
||||
staged: list[dict[str, Any]] = []
|
||||
modified: list[dict[str, Any]] = []
|
||||
untracked: list[dict[str, Any]] = []
|
||||
truncated = False
|
||||
|
||||
def _numstat(*args: str) -> dict[str, tuple[int, int]]:
|
||||
"""Return bounded text-line deltas keyed by the destination path.
|
||||
|
||||
``--numstat -z`` keeps tabs/newlines in filenames unambiguous. Binary
|
||||
files report ``-`` for each count and intentionally remain without a
|
||||
line delta in the UI.
|
||||
"""
|
||||
raw = _git(repo, "diff", "--numstat", "-z", *args)
|
||||
values = raw.split("\0")
|
||||
result: dict[str, tuple[int, int]] = {}
|
||||
index = 0
|
||||
while index < len(values):
|
||||
record = values[index]
|
||||
index += 1
|
||||
if not record:
|
||||
continue
|
||||
fields = record.split("\t", 2)
|
||||
if len(fields) != 3:
|
||||
continue
|
||||
added, deleted, path = fields
|
||||
if not path:
|
||||
# Rename/copy records place old and new paths in the next two
|
||||
# NUL-delimited fields. Status exposes the destination path.
|
||||
index += 1
|
||||
if index >= len(values):
|
||||
break
|
||||
path = values[index]
|
||||
index += 1
|
||||
if added.isdigit() and deleted.isdigit():
|
||||
result[path] = (int(added), int(deleted))
|
||||
return result
|
||||
|
||||
staged_numstat = _numstat("--cached", "--")
|
||||
modified_numstat = _numstat("--")
|
||||
|
||||
# -z separates records with NUL; each record is "<XY> <path>\0". A rename
|
||||
# or copy emits TWO records ("R new\0old\0"); the bare source-path record
|
||||
# must be skipped, not misparsed as an XY record.
|
||||
@@ -301,33 +336,45 @@ def repo_status(repo: Path) -> dict[str, Any]:
|
||||
if not path:
|
||||
continue
|
||||
if xy == "??":
|
||||
untracked.append({"path": path})
|
||||
untracked.append({"path": path, "additions": None, "deletions": None})
|
||||
# Independent checks: a file staged AND modified lands in both groups.
|
||||
if xy[0] in ("M", "A", "D", "R", "C"):
|
||||
staged.append({"path": path})
|
||||
additions, deletions = staged_numstat.get(path, (None, None))
|
||||
staged.append({"path": path, "additions": additions, "deletions": deletions})
|
||||
if xy[1] in ("M", "D"):
|
||||
modified.append({"path": path})
|
||||
additions, deletions = modified_numstat.get(path, (None, None))
|
||||
modified.append({"path": path, "additions": additions, "deletions": deletions})
|
||||
if xy[0] in ("R", "C"):
|
||||
# The immediately following record is the rename/copy source path;
|
||||
# skip it so it is not misparsed as an XY record.
|
||||
i += 1
|
||||
|
||||
def _bounded(items: list[dict[str, str]]) -> list[dict[str, str]]:
|
||||
def _bounded(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
nonlocal truncated
|
||||
if len(items) > MAX_STATUS_ENTRIES:
|
||||
truncated = True
|
||||
return items[:MAX_STATUS_ENTRIES]
|
||||
return items
|
||||
|
||||
staged_count = len(staged)
|
||||
modified_count = len(modified)
|
||||
untracked_count = len(untracked)
|
||||
changed_count = len({entry["path"] for entry in (*staged, *modified, *untracked)})
|
||||
additions = sum(added for added, _ in (*staged_numstat.values(), *modified_numstat.values()))
|
||||
deletions = sum(deleted for _, deleted in (*staged_numstat.values(), *modified_numstat.values()))
|
||||
|
||||
staged = _bounded(staged)
|
||||
modified = _bounded(modified)
|
||||
untracked = _bounded(untracked)
|
||||
|
||||
return {
|
||||
"counts": {
|
||||
"staged": len(staged),
|
||||
"modified": len(modified),
|
||||
"untracked": len(untracked),
|
||||
"staged": staged_count,
|
||||
"modified": modified_count,
|
||||
"untracked": untracked_count,
|
||||
"changes": changed_count,
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
},
|
||||
"staged": staged,
|
||||
"modified": modified,
|
||||
|
||||
@@ -150,9 +150,18 @@ class GitStateStatusTests(unittest.TestCase):
|
||||
self.assertEqual(1, status["counts"]["staged"])
|
||||
self.assertEqual(1, status["counts"]["modified"])
|
||||
self.assertEqual(1, status["counts"]["untracked"])
|
||||
self.assertEqual(3, status["counts"]["changes"])
|
||||
self.assertEqual(2, status["counts"]["additions"])
|
||||
self.assertEqual(2, status["counts"]["deletions"])
|
||||
self.assertEqual("tracked.txt", status["staged"][0]["path"])
|
||||
self.assertEqual(1, status["staged"][0]["additions"])
|
||||
self.assertEqual(1, status["staged"][0]["deletions"])
|
||||
self.assertEqual("README.md", status["modified"][0]["path"])
|
||||
self.assertEqual(1, status["modified"][0]["additions"])
|
||||
self.assertEqual(1, status["modified"][0]["deletions"])
|
||||
self.assertEqual("untracked.txt", status["untracked"][0]["path"])
|
||||
self.assertIsNone(status["untracked"][0]["additions"])
|
||||
self.assertIsNone(status["untracked"][0]["deletions"])
|
||||
self.assertFalse(status["truncated"])
|
||||
|
||||
def test_status_truncates_when_over_cap(self) -> None:
|
||||
@@ -160,6 +169,7 @@ class GitStateStatusTests(unittest.TestCase):
|
||||
(self.repo / f"file-{i}.txt").write_text("x", encoding="utf-8")
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertTrue(status["truncated"])
|
||||
self.assertEqual(git_state.MAX_STATUS_ENTRIES + 5, status["counts"]["changes"])
|
||||
self.assertLessEqual(
|
||||
len(status["untracked"]),
|
||||
git_state.MAX_STATUS_ENTRIES,
|
||||
@@ -193,6 +203,8 @@ class GitStateStatusTests(unittest.TestCase):
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual(["new.txt"], [e["path"] for e in status["staged"]])
|
||||
self.assertEqual(0, status["staged"][0]["additions"])
|
||||
self.assertEqual(0, status["staged"][0]["deletions"])
|
||||
self.assertEqual([], status["modified"])
|
||||
self.assertEqual([], status["untracked"])
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -30,6 +30,7 @@ GATEWAY_SETTLED_INFO = "gateway.settled_session_info"
|
||||
SESSION_ACTIVATE = "gateway.session_activate_live"
|
||||
SESSION_RESUME = "gateway.session_resume_durable"
|
||||
SESSION_ACTIVE_LIST = "gateway.session_active_list"
|
||||
SUBAGENT_CHILD_WATCH = "gateway.subagent_child_watch"
|
||||
API_BOUNDARY = "api.fallback_boundary"
|
||||
ALL_CONTRACTS = (
|
||||
GATEWAY_TERMINAL,
|
||||
@@ -37,6 +38,7 @@ ALL_CONTRACTS = (
|
||||
SESSION_ACTIVATE,
|
||||
SESSION_RESUME,
|
||||
SESSION_ACTIVE_LIST,
|
||||
SUBAGENT_CHILD_WATCH,
|
||||
API_BOUNDARY,
|
||||
)
|
||||
|
||||
@@ -375,6 +377,38 @@ def _check_api_boundary(api: SourceFile) -> CheckResult:
|
||||
return CheckResult(contract, False, (), str(exc))
|
||||
|
||||
|
||||
def _check_subagent_child_watch(server: SourceFile, methods: SourceFile) -> CheckResult:
|
||||
contract = SUBAGENT_CHILD_WATCH
|
||||
try:
|
||||
resume = methods.method_handler("session.resume")
|
||||
resume_segment = methods.segment(resume)
|
||||
resume_strings = _string_constants(resume)
|
||||
server_strings = _string_constants(server.tree)
|
||||
missing_resume = sorted(
|
||||
{"lazy", "close_on_disconnect"} - resume_strings
|
||||
)
|
||||
if missing_resume:
|
||||
raise ValueError("lazy child resume field(s) missing: " + ", ".join(missing_resume))
|
||||
if "include_ancestors" not in resume_segment:
|
||||
raise ValueError("lazy child resume does not declare child-only history")
|
||||
missing_events = sorted(
|
||||
{"child_session_id", "subagent.text", "reasoning.delta", "message.delta"}
|
||||
- server_strings
|
||||
)
|
||||
if missing_events:
|
||||
raise ValueError("child watch mirror event(s) missing: " + ", ".join(missing_events))
|
||||
return CheckResult(
|
||||
contract,
|
||||
True,
|
||||
(
|
||||
methods.evidence(resume, "session.resume supports a lazy child-only watch"),
|
||||
"tui_gateway/server.py: child_session_id routes child mirror events",
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return CheckResult(contract, False, (), str(exc))
|
||||
|
||||
|
||||
def load_requirements(manifest: Path | None) -> tuple[str, ...]:
|
||||
if manifest is None:
|
||||
return ALL_CONTRACTS
|
||||
@@ -416,6 +450,7 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
|
||||
SESSION_ACTIVATE: lambda: _check_activate(server, methods),
|
||||
SESSION_RESUME: lambda: _check_resume(methods),
|
||||
SESSION_ACTIVE_LIST: lambda: _check_active_list(server, methods),
|
||||
SUBAGENT_CHILD_WATCH: lambda: _check_subagent_child_watch(server, methods),
|
||||
API_BOUNDARY: lambda: _check_api_boundary(api),
|
||||
}
|
||||
return [checks[requirement]() for requirement in requirements]
|
||||
|
||||
@@ -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()
|
||||
@@ -56,6 +56,12 @@ def _session_live_item(sid, session, current_sid=""):
|
||||
"session_key": session.get("session_key", sid),
|
||||
"status": _session_live_status(sid, session),
|
||||
}
|
||||
|
||||
def _mirror_subagent_child(event):
|
||||
child = event.get("child_session_id")
|
||||
if event.get("type") == "subagent.text":
|
||||
return (child, "reasoning.delta", "message.delta")
|
||||
return child
|
||||
'''
|
||||
|
||||
METHODS_SOURCE = '''
|
||||
@@ -65,6 +71,9 @@ def method(name):
|
||||
@method("session.resume")
|
||||
def _(rid, params):
|
||||
target = params.get("session_id", "")
|
||||
lazy = bool(params.get("lazy"))
|
||||
close_on_disconnect = bool(params.get("close_on_disconnect"))
|
||||
include_ancestors = not lazy
|
||||
found = db.get_session(target)
|
||||
if not found:
|
||||
return _err(rid, 4007, "session not found")
|
||||
|
||||
@@ -88,6 +88,11 @@ The initial catalog covers ordinary streaming, rapid chunks/reasoning/tool
|
||||
events, queued turns, scoped and foreign/unscoped inputs, persisted history,
|
||||
and both issue #365 terminal-gap forms:
|
||||
|
||||
- `subagent_child_preview`: interleaved concurrent child lifecycle events carry
|
||||
stable child/session identity, thinking/progress/tool previews, and distinct
|
||||
completed/interrupted terminal states. Its upstream requirement also proves
|
||||
the vanilla lazy child-session watch contract used by read-only clients.
|
||||
|
||||
- `active_status_lifecycle`: one successful live snapshot contains starting,
|
||||
working, waiting, and idle rows; the next successful snapshot is empty so a
|
||||
client can prove a complete, unambiguously resolved snapshot clears prior
|
||||
|
||||
@@ -267,6 +267,7 @@ class ScenarioTestCase(unittest.TestCase):
|
||||
"active_status_unsupported",
|
||||
"ordinary_turn",
|
||||
"rapid_tools_interims",
|
||||
"subagent_child_preview",
|
||||
"terminal_gap_activate",
|
||||
"terminal_gap_session_info",
|
||||
"queued_follow_up",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "subagent_child_preview",
|
||||
"live_session_id": "fixture-live-1",
|
||||
"stored_session_id": "20260821_120000_fixture",
|
||||
"profile": "default",
|
||||
"contract_requirements": [
|
||||
"gateway.message_complete",
|
||||
"gateway.subagent_child_watch"
|
||||
],
|
||||
"turns": [
|
||||
{
|
||||
"steps": [
|
||||
{"op": "event", "type": "message.start"},
|
||||
{"op": "event", "type": "subagent.spawn_requested", "payload": {"goal": "Inspect Android", "task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "depth": 0}},
|
||||
{"op": "event", "type": "subagent.start", "payload": {"goal": "Inspect Android", "task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "depth": 0}},
|
||||
{"op": "event", "type": "subagent.start", "payload": {"goal": "Review privacy", "task_index": 1, "task_count": 2, "subagent_id": "child-b", "child_session_id": "child-session-b", "depth": 0}},
|
||||
{"op": "event", "type": "subagent.thinking", "payload": {"task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "text": "Mapping events"}},
|
||||
{"op": "event", "type": "subagent.tool", "payload": {"task_index": 1, "task_count": 2, "subagent_id": "child-b", "child_session_id": "child-session-b", "tool_name": "read_file", "tool_preview": "policy.md"}},
|
||||
{"op": "event", "type": "subagent.progress", "payload": {"task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "text": "One tool complete"}},
|
||||
{"op": "event", "type": "subagent.complete", "payload": {"task_index": 1, "task_count": 2, "subagent_id": "child-b", "child_session_id": "child-session-b", "status": "interrupted", "summary": "Stopped safely"}},
|
||||
{"op": "event", "type": "subagent.complete", "payload": {"task_index": 0, "task_count": 2, "subagent_id": "child-a", "child_session_id": "child-session-a", "status": "completed", "summary": "Mapped Android events", "duration_seconds": 2.5}},
|
||||
{"op": "persist", "messages": [{"id": 1, "role": "user", "content": "Exercise child previews.", "timestamp": 1.0}, {"id": 2, "role": "assistant", "content": "Delegation complete.", "timestamp": 2.0}]},
|
||||
{"op": "set_running", "value": false},
|
||||
{"op": "event", "type": "message.complete", "payload": {"text": "Delegation complete.", "status": "complete"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -250,6 +250,7 @@ export default defineConfig({
|
||||
{ text: 'Token Tracking', link: '/features/tokens' },
|
||||
{ text: 'Tool Progress', link: '/features/tools' },
|
||||
{ text: 'Plugins', link: '/features/plugins' },
|
||||
{ text: 'Git Workspace', link: '/features/git-workspace' },
|
||||
{ text: 'Phone Control Tools', link: '/features/phone-control-tools' },
|
||||
{ text: 'Dashboard Plugin', link: '/features/dashboard' },
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Git workspace
|
||||
|
||||
Hermes-Relay Android can review and operate a host repository through a native
|
||||
Git workspace. The Android interface is first-party Compose UI; the optional
|
||||
Hermes-Relay plugin supplies the authenticated, repository-scoped Git API.
|
||||
|
||||

|
||||
|
||||
## Requirements
|
||||
|
||||
- The Hermes-Relay plugin must be installed and enabled on the selected Hermes
|
||||
connection and profile.
|
||||
- The Dashboard must expose the plugin's `git/*` API routes.
|
||||
- Repositories must be below the host's configured Git discovery root.
|
||||
- Write actions require the plugin's **Allow changes** grant.
|
||||
|
||||
If Android discovers the Git contribution but the live route is unavailable,
|
||||
the workspace shows a retryable availability message instead of a raw server
|
||||
response.
|
||||
|
||||
## Open the workspace
|
||||
|
||||
Use any of these entry points:
|
||||
|
||||
- Tap the branch indicator or change rail above the Chat composer.
|
||||
- Open **Settings → Git workspace**.
|
||||
- Open **Settings → Plugins → Hermes-Relay → Git**.
|
||||
|
||||
When the active chat session reports an exact repository root or working
|
||||
directory, Android selects the matching repository. If several repositories
|
||||
are possible, choose one from the repository picker.
|
||||
|
||||
## Chat controls
|
||||
|
||||
The compact Chat rail shows the current branch, unique changed-file count, and
|
||||
tracked line additions/deletions. Open **Settings → Chat** and turn off **Show
|
||||
Git workspace in Chat** to hide the Chat indicator and rail. This setting does
|
||||
not disable the full Git workspace.
|
||||
|
||||
## Review and change files
|
||||
|
||||
The workspace groups staged, modified, and untracked files and provides filters,
|
||||
tracked-file content, and staged or unstaged diffs. Binary and untracked files
|
||||
do not expose arbitrary working-tree content through the preview route.
|
||||
|
||||
With **Allow changes** enabled, you can:
|
||||
|
||||
- stage modified or untracked files and unstage staged files;
|
||||
- discard modified content or delete selected untracked files after confirmation;
|
||||
- create and switch branches, including a recoverable stash-and-switch path;
|
||||
- generate or enter a commit message and optionally push after committing;
|
||||
- fetch, fast-forward pull, and push.
|
||||
|
||||
Discard, push, and dirty checkout each require a fresh confirmation. Requests
|
||||
remain bound to the selected connection, profile, repository, and repository
|
||||
generation so switching context cannot redirect an already-reviewed action.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user