Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa27d6084 | ||
|
|
17446f13d0 | ||
|
|
89f2f772e2 | ||
|
|
3ae19758e4 | ||
|
|
10f62d798c | ||
|
|
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 | ||
|
|
d5cce4e390 | ||
|
|
ae9b22a9e6 | ||
|
|
96a9e8077e | ||
|
|
a97e6a2b14 | ||
|
|
4317da85fd | ||
|
|
45fde0ad9a | ||
|
|
94565e9d6d | ||
|
|
56c2e6fa07 | ||
|
|
28629f3d93 | ||
|
|
0d1faf47a0 | ||
|
|
00288a2b3b | ||
|
|
8f52feffba | ||
|
|
ee29e49361 | ||
|
|
e2073b7692 | ||
|
|
70b6d8ee5a | ||
|
|
1f5e50ccd7 | ||
|
|
5580c9d9bb | ||
|
|
2ebdf55501 | ||
|
|
ad107ea205 | ||
|
|
f5aeb27e5a | ||
|
|
fdaeb121d5 | ||
|
|
06c0df6304 | ||
|
|
71a2b3a7fb | ||
|
|
65e48084cb | ||
|
|
1074ecc24f | ||
|
|
f2a23e32aa | ||
|
|
630cc6d316 | ||
|
|
e16205d82a | ||
|
|
676c37e5ca | ||
|
|
9b6fed9bdd | ||
|
|
4d90eef3d8 | ||
|
|
478323893a | ||
|
|
fcddeeb810 | ||
|
|
49002b7141 | ||
|
|
326eb47df3 |
@@ -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
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ jobs:
|
||||
working-directory: plugin/dashboard
|
||||
run: npm run build
|
||||
|
||||
- name: Test dashboard source
|
||||
working-directory: plugin/dashboard
|
||||
run: npm test
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
@@ -54,7 +58,11 @@ jobs:
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx requests
|
||||
|
||||
- name: Run dashboard API tests
|
||||
run: python -m unittest plugin.dashboard.test_plugin_api
|
||||
run: >-
|
||||
python -m unittest
|
||||
plugin.dashboard.test_plugin_api
|
||||
plugin.dashboard.test_git_api
|
||||
plugin.dashboard.test_mobile_plugin_api
|
||||
|
||||
- name: Verify dashboard bundle outputs
|
||||
run: |
|
||||
|
||||
@@ -106,4 +106,8 @@ jobs:
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_native_layout_imports.py \
|
||||
plugin/tests/test_profile_discovery.py \
|
||||
plugin/tests/test_profiles_updated_broadcast.py
|
||||
plugin/tests/test_profiles_updated_broadcast.py \
|
||||
plugin/tests/test_git_state.py \
|
||||
plugin/tests/test_git_state_write.py \
|
||||
plugin/tests/test_git_state_extras.py \
|
||||
plugin/tests/test_mobile_plugin_store.py
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
|
||||
- **Android Continuous voice waits for barge-in microphone teardown before listening again.** Multi-turn hands-free conversations no longer lose the microphone after a response finishes with barge-in enabled. (#464)
|
||||
- **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.
|
||||
- **Android Voice Focus keeps Stop and immediate spoken steering available across every interaction mode.** Hold-to-talk now interrupts Thinking and Transcribing turns before capturing the replacement direction, remains operable through TalkBack, Switch Access, and keyboard controls, preserves pointer press-and-release behavior across floating controls, and Google Play no longer offers the sideload-only system overlay action.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
Supervised Mode adds a parent-configured, profile-pinned chat view with device-authenticated settings. Parents can limit attachments, Standard voice, generated media, history, actions, and technical details. Session rows stay neutral while live activity is unavailable, and returning from parent settings no longer blanks Supervised Chat.
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
|
||||
Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.13.2",
|
||||
"title": "Supervised Mode and clearer activity",
|
||||
"date": "2026-08-25",
|
||||
"highlight": {
|
||||
"title": "Supervised Mode",
|
||||
"summary": "Create a simpler, profile-focused chat and choose which features remain available.",
|
||||
"bullets": [
|
||||
"Protect parent settings with your phone's device authentication.",
|
||||
"Choose access to attachments, Standard voice, generated media, history, actions, and technical details.",
|
||||
"Keep Supervised Chat open when returning from parent settings."
|
||||
]
|
||||
},
|
||||
"improvements": [
|
||||
"Activity indicators now appear only while Hermes is genuinely working."
|
||||
],
|
||||
"toastDigest": {
|
||||
"additionalFeatureCount": 0,
|
||||
"fixCount": 2,
|
||||
"preview": [
|
||||
"Accurate activity",
|
||||
"safer return"
|
||||
]
|
||||
},
|
||||
"playNotes": "Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Use a supervised chat",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
|
||||
* Configure a profile-pinned Supervised Mode with device-authenticated parent settings.
|
||||
* Keep Supervised Chat visible when parent access relocks after full settings.
|
||||
* Keep uncertain session activity neutral until live work is confirmed.
|
||||
Supervised Mode
|
||||
* Protect parent settings with your phone's device authentication.
|
||||
* Choose access to attachments, Standard voice, generated media, history, actions, and technical details.
|
||||
* Keep Supervised Chat open when returning from parent settings.
|
||||
|
||||
Also improved
|
||||
* Activity indicators now appear only while Hermes is genuinely working.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.plugins.runtime.ScopedPluginApiClient
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
// Read + write client for the Hermes-Relay Git State endpoints.
|
||||
// All requests are confined to the ``hermes-relay`` plugin namespace and the
|
||||
// ``git/*`` sub-path via ScopedPluginApiClient, which rejects traversal and
|
||||
// encodes query values.
|
||||
|
||||
private fun pathsArray(paths: List<String>) = buildJsonArray { paths.forEach { add(JsonPrimitive(it)) } }
|
||||
|
||||
class GitStateApiClient(
|
||||
dashboard: DashboardApiClient,
|
||||
) {
|
||||
private val scoped = ScopedPluginApiClient("hermes-relay", dashboard)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun repos(): Result<List<GitRepo>> = scoped
|
||||
.get("git/repos")
|
||||
.mapCatching { element ->
|
||||
json.decodeFromJsonElement<ReposResponse>(element).repos
|
||||
}
|
||||
|
||||
suspend fun status(repo: String): Result<GitStatus> = scoped
|
||||
.get("git/status", mapOf("repo" to repo))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitStatus>(element) }
|
||||
|
||||
suspend fun branches(repo: String): Result<List<GitBranch>> = scoped
|
||||
.get("git/branches", mapOf("repo" to repo))
|
||||
.mapCatching { element ->
|
||||
json.decodeFromJsonElement<BranchesResponse>(element).branches
|
||||
}
|
||||
|
||||
suspend fun diff(repo: String, path: String, kind: String): Result<GitDiff> = scoped
|
||||
.get("git/diff", mapOf("repo" to repo, "path" to path, "kind" to kind))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitDiff>(element) }
|
||||
|
||||
suspend fun file(repo: String, path: String): Result<GitFile> = scoped
|
||||
.get("git/file", mapOf("repo" to repo, "path" to path))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitFile>(element) }
|
||||
|
||||
// ── Write operations ───────────────────────────────────────────────────
|
||||
// Every write requires the plugin.api.write grant, which the app enforces
|
||||
// (see GitStateViewModel: a POST is never sent without the grant). The
|
||||
// server additionally enforces per-use confirmation strings for destructive
|
||||
// ops (discard/push/dirty-checkout) — the caller passes the echoed token.
|
||||
|
||||
suspend fun stage(repo: String, paths: List<String>): Result<GitMutationResult> =
|
||||
scoped.post("git/stage", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun unstage(repo: String, paths: List<String>): Result<GitMutationResult> =
|
||||
scoped.post("git/unstage", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun discard(
|
||||
repo: String,
|
||||
paths: List<String>,
|
||||
confirmation: String,
|
||||
deleteUntracked: Boolean = false,
|
||||
): Result<GitMutationResult> = scoped.post("git/discard", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
put("confirmation", confirmation)
|
||||
put("delete_untracked", deleteUntracked)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun commit(repo: String, message: String): Result<GitMutationResult> =
|
||||
scoped.post("git/commit", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("message", message)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun commitSelected(
|
||||
repo: String,
|
||||
message: String,
|
||||
paths: List<String>,
|
||||
): Result<GitMutationResult> = scoped.post("git/commit_selected", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("message", message)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun fetch(repo: String, remote: String = "origin"): Result<GitMutationResult> =
|
||||
scoped.post("git/fetch", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun pull(repo: String, remote: String = "origin", branch: String = ""): Result<GitMutationResult> =
|
||||
scoped.post("git/pull", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
put("branch", branch)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun push(
|
||||
repo: String,
|
||||
confirmation: String,
|
||||
remote: String = "origin",
|
||||
branch: String = "",
|
||||
): Result<GitMutationResult> = scoped.post("git/push", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
put("branch", branch)
|
||||
put("confirmation", confirmation)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun checkout(
|
||||
repo: String,
|
||||
ref: String,
|
||||
confirmation: String? = null,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
): Result<GitMutationResult> = scoped.post("git/checkout", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("ref", ref)
|
||||
if (confirmation != null) put("confirmation", confirmation)
|
||||
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
|
||||
put("track", track)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────
|
||||
|
||||
/** Generate a commit-message suggestion from the staged diff. */
|
||||
suspend fun commitMessage(repo: String): Result<GitCommitMessage> =
|
||||
scoped.post("git/commit_message", buildJsonObject {
|
||||
put("repo", repo)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
|
||||
|
||||
/** Generate a commit-message suggestion from the given paths' staged diff. */
|
||||
suspend fun commitMessageSelected(
|
||||
repo: String,
|
||||
paths: List<String>,
|
||||
): Result<GitCommitMessage> = scoped.post("git/commit_message_selected", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
|
||||
|
||||
/** Checkout that auto-stashes a dirty tree first. */
|
||||
suspend fun stashCheckout(
|
||||
repo: String,
|
||||
ref: String,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
): Result<GitStashCheckoutResult> = scoped.post("git/stash_checkout", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("ref", ref)
|
||||
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
|
||||
put("track", track)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitStashCheckoutResult>(it) }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** A repository discovered by the plugin's /git/repos endpoint. */
|
||||
@Serializable
|
||||
data class GitRepo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val root: String,
|
||||
@SerialName("current_branch") val currentBranch: String? = null,
|
||||
val dirty: Boolean = false,
|
||||
)
|
||||
|
||||
/** Working-tree status from /git/status. */
|
||||
@Serializable
|
||||
data class GitStatus(
|
||||
val counts: GitStatusCounts = GitStatusCounts(),
|
||||
val staged: List<GitStatusEntry> = emptyList(),
|
||||
val modified: List<GitStatusEntry> = emptyList(),
|
||||
val untracked: List<GitStatusEntry> = emptyList(),
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
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. */
|
||||
@Serializable
|
||||
data class GitBranch(
|
||||
val name: String,
|
||||
val upstream: String? = null,
|
||||
val ahead: Int = 0,
|
||||
val behind: Int = 0,
|
||||
@SerialName("is_current") val isCurrent: Boolean = false,
|
||||
)
|
||||
|
||||
/** A per-file diff from /git/diff. */
|
||||
@Serializable
|
||||
data class GitDiff(
|
||||
val path: String,
|
||||
val kind: String,
|
||||
val diff: String,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** A tracked-file read from /git/file. */
|
||||
@Serializable
|
||||
data class GitFile(
|
||||
val path: String,
|
||||
val content: String,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** Wrapper for /git/repos response. */
|
||||
@Serializable
|
||||
internal data class ReposResponse(
|
||||
val repos: List<GitRepo> = emptyList(),
|
||||
val notice: String? = null,
|
||||
)
|
||||
|
||||
/** Wrapper for /git/branches response. */
|
||||
@Serializable
|
||||
internal data class BranchesResponse(
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
)
|
||||
|
||||
/** A mutation response: fresh HEAD oid + working-tree status (+ branches). */
|
||||
@Serializable
|
||||
data class GitMutationResult(
|
||||
val head: String = "",
|
||||
val status: GitStatus = GitStatus(),
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
)
|
||||
|
||||
/** A /git/commit_message suggestion: generated message + optional notice. */
|
||||
@Serializable
|
||||
data class GitCommitMessage(
|
||||
val message: String = "",
|
||||
val notice: String = "",
|
||||
)
|
||||
|
||||
/** A /git/stash_checkout result: standard mutation shape + stash flag/message. */
|
||||
@Serializable
|
||||
data class GitStashCheckoutResult(
|
||||
val head: String = "",
|
||||
val status: GitStatus = GitStatus(),
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
val stashed: Boolean = false,
|
||||
@SerialName("stash_message") val stashMessage: String = "",
|
||||
)
|
||||
@@ -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
|
||||
@@ -179,6 +184,9 @@ import com.hermesandroid.relay.ui.screens.SupervisedAppearanceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
|
||||
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
|
||||
@@ -198,7 +206,9 @@ import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.plugins.runtime.PLUGIN_API_WRITE_CAPABILITY
|
||||
import com.hermesandroid.relay.viewmodel.PluginsViewModel
|
||||
import com.hermesandroid.relay.viewmodel.PluginsHubState
|
||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
@@ -446,6 +456,7 @@ sealed class Screen(
|
||||
}
|
||||
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||
data object Plugins : Screen("plugins", "Plugins", Icons.Filled.Extension)
|
||||
data object GitState : Screen("git_state", "Git", Icons.Filled.Code)
|
||||
data object PluginPage : Screen(
|
||||
"plugins/{pluginId}/pages/{pageId}",
|
||||
"Plugin",
|
||||
@@ -632,6 +643,7 @@ fun RelayApp() {
|
||||
val chatViewModel: ChatViewModel = processRuntime.chatViewModel
|
||||
val terminalViewModel: TerminalViewModel = viewModel()
|
||||
val pluginsViewModel: PluginsViewModel = viewModel()
|
||||
val gitStateViewModel: GitStateViewModel = viewModel()
|
||||
val voiceViewModel: VoiceViewModel = processRuntime.voiceViewModel
|
||||
val runtimeInitializationState by processRuntime.initializationState.collectAsState()
|
||||
|
||||
@@ -850,6 +862,11 @@ fun RelayApp() {
|
||||
val serverCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val gitOwnerKey = activeConnectionId?.takeIf { it.isNotBlank() }?.let { connectionId ->
|
||||
effectiveDashboardUrl.takeIf { it.isNotBlank() }?.let { dashboardUrl ->
|
||||
"$connectionId\u0000${effectiveSessionProfileName.orEmpty()}\u0000$dashboardUrl"
|
||||
}
|
||||
}
|
||||
LaunchedEffect(
|
||||
activeConnectionId,
|
||||
effectiveDashboardUrl,
|
||||
@@ -864,12 +881,98 @@ fun RelayApp() {
|
||||
sessionId = currentChatSessionId,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(gitOwnerKey) {
|
||||
val dashboard = effectiveDashboardUrl
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { connectionViewModel.dashboardClientForActive(it) }
|
||||
gitStateViewModel.configure(dashboard, gitOwnerKey)
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
// Mirror the plugin.api.write grant into the Git view model so write
|
||||
// mutations are refused client-side until the user grants write access
|
||||
// (matches the plug-in's grant gating in PluginsViewModel).
|
||||
val pluginsHubState by pluginsViewModel.hubState.collectAsState()
|
||||
LaunchedEffect(pluginsHubState, gitOwnerKey) {
|
||||
val ready = pluginsHubState as? PluginsHubState.Ready
|
||||
val granted = ready
|
||||
?.takeIf { it.ownerKey == gitOwnerKey }
|
||||
?.plugins
|
||||
?.firstOrNull { it.catalog.id == "hermes-relay" }
|
||||
?.preferences
|
||||
?.grants
|
||||
?.contains(PLUGIN_API_WRITE_CAPABILITY) == true
|
||||
gitStateViewModel.setWriteGrant(gitOwnerKey, granted)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
// 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) {
|
||||
WhatsNewDialog(onDismiss = { connectionViewModel.dismissWhatsNew() })
|
||||
if (showWhatsNew && !showWhatsNewExpanded) {
|
||||
WhatsNewToast(
|
||||
onDismiss = { connectionViewModel.dismissWhatsNew() },
|
||||
onExpand = {
|
||||
connectionViewModel.dismissWhatsNew()
|
||||
showWhatsNewExpanded = true
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showWhatsNewExpanded) {
|
||||
WhatsNewDialog(
|
||||
onDismiss = { showWhatsNewExpanded = false },
|
||||
onViewHistory = {
|
||||
showWhatsNewExpanded = false
|
||||
showWhatsNewHistory = true
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showWhatsNewHistory) {
|
||||
Dialog(
|
||||
onDismissRequest = { showWhatsNewHistory = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
ChangelogScreen(onClose = { showWhatsNewHistory = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark version as seen on first launch (when there's no previous version)
|
||||
@@ -2262,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) {
|
||||
@@ -2617,6 +2725,9 @@ fun RelayApp() {
|
||||
onNavigateToPlugins = {
|
||||
navController.navigate(Screen.Plugins.route)
|
||||
},
|
||||
onNavigateToGitWorkspace = {
|
||||
navController.navigate(Screen.GitState.route)
|
||||
},
|
||||
onNavigateToChatSettings = {
|
||||
navController.navigate(Screen.ChatSettings.route)
|
||||
},
|
||||
@@ -2740,10 +2851,20 @@ fun RelayApp() {
|
||||
viewModel = pluginsViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenPage = { pluginId, pageId ->
|
||||
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
|
||||
if (pluginId == "hermes-relay" && pageId == "git") {
|
||||
navController.navigate(Screen.GitState.route)
|
||||
} else {
|
||||
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.GitState.route) {
|
||||
GitStateScreen(
|
||||
viewModel = gitStateViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.PluginPage.route,
|
||||
arguments = listOf(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -63,13 +64,20 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -103,6 +111,8 @@ import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
|
||||
internal const val VOICE_MODE_MIC_TEST_TAG = "voiceModeMic"
|
||||
|
||||
/**
|
||||
* Full-screen voice-mode overlay. Renders the MorphingSphere in its voiceMode
|
||||
* expanded state plus a mic button that drives the [VoiceViewModel] turn
|
||||
@@ -152,6 +162,7 @@ fun VoiceModeOverlay(
|
||||
voiceOutputEnabled: Boolean? = null,
|
||||
voiceOutputFallbackEnabled: Boolean? = null,
|
||||
onOverlayRequest: () -> Unit = {},
|
||||
systemOverlayAvailable: Boolean = true,
|
||||
presentationMode: VoicePresentationMode = VoicePresentationMode.Focus,
|
||||
onPresentationModeChange: (VoicePresentationMode) -> Unit = {},
|
||||
// === END PHASE3-voice-mode-transcript ===
|
||||
@@ -238,6 +249,7 @@ fun VoiceModeOverlay(
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
onOverlayRequest = onOverlayRequest,
|
||||
systemOverlayAvailable = systemOverlayAvailable,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onExit = onDismiss,
|
||||
modifier = Modifier
|
||||
@@ -622,6 +634,22 @@ private fun VoiceMicButton(
|
||||
) {
|
||||
if (!visible) return
|
||||
|
||||
val micActionDescription = when (uiState.state) {
|
||||
VoiceState.Idle, VoiceState.Error -> stringResource(R.string.voice_overlay_tap_action_idle)
|
||||
VoiceState.Listening ->
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
stringResource(R.string.voice_overlay_tap_action_pause)
|
||||
} else {
|
||||
stringResource(R.string.voice_overlay_tap_action_listening)
|
||||
}
|
||||
VoiceState.Transcribing, VoiceState.Thinking, VoiceState.Speaking ->
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
stringResource(R.string.voice_overlay_tap_action_pause)
|
||||
} else {
|
||||
stringResource(R.string.voice_overlay_tap_action_interrupt)
|
||||
}
|
||||
}
|
||||
|
||||
val pulseScale by animateFloatAsState(
|
||||
targetValue = when (uiState.state) {
|
||||
VoiceState.Listening -> 1f + uiState.amplitude * 0.15f
|
||||
@@ -656,34 +684,22 @@ private fun VoiceMicButton(
|
||||
else -> Icons.Filled.Mic
|
||||
}
|
||||
|
||||
val currentOnTap by rememberUpdatedState(onTap)
|
||||
val currentOnHoldPress by rememberUpdatedState(onHoldPress)
|
||||
val currentOnHoldRelease by rememberUpdatedState(onHoldRelease)
|
||||
|
||||
val gestureModifier = when (uiState.interactionMode) {
|
||||
InteractionMode.HoldToTalk -> Modifier.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
currentOnHoldPress()
|
||||
// Hold until the finger genuinely lifts. Don't use
|
||||
// waitForUpOrCancellation(): it ends the hold on ANY cancel — a
|
||||
// consumed move event or the finger drifting just off the small
|
||||
// circle — which made the button feel like it released by
|
||||
// accident. Loop until no pointer is still pressed so drift and
|
||||
// minor consumption don't cut the recording short.
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
} while (event.changes.any { it.pressed })
|
||||
currentOnHoldRelease()
|
||||
}
|
||||
}
|
||||
else -> Modifier.clickable { currentOnTap() }
|
||||
InteractionMode.HoldToTalk -> Modifier.voiceHoldGesture(
|
||||
state = uiState.state,
|
||||
inactiveActionLabel = micActionDescription,
|
||||
activeActionLabel = stringResource(R.string.voice_overlay_tap_action_listening),
|
||||
onPress = onHoldPress,
|
||||
onRelease = onHoldRelease,
|
||||
)
|
||||
else -> Modifier.clickable(onClick = onTap)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.size((baseSize * pulseScale).dp)
|
||||
.clip(CircleShape)
|
||||
.testTag(VOICE_MODE_MIC_TEST_TAG)
|
||||
.then(gestureModifier),
|
||||
shape = CircleShape,
|
||||
color = containerColor,
|
||||
@@ -692,7 +708,11 @@ private fun VoiceMicButton(
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = stringResource(R.string.voice_overlay_mic_cd),
|
||||
contentDescription = if (uiState.interactionMode == InteractionMode.HoldToTalk) {
|
||||
null
|
||||
} else {
|
||||
micActionDescription
|
||||
},
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(iconSize.dp),
|
||||
)
|
||||
@@ -700,6 +720,96 @@ private fun VoiceMicButton(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Hold-to-talk gesture for in-app and system-overlay voice controls.
|
||||
* The callback refs update across recomposition without restarting the active
|
||||
* gesture when its press changes VoiceUiState to Listening.
|
||||
*/
|
||||
internal fun Modifier.voiceHoldGesture(
|
||||
state: VoiceState,
|
||||
inactiveActionLabel: String,
|
||||
activeActionLabel: String,
|
||||
onPress: () -> Unit,
|
||||
onRelease: () -> Unit,
|
||||
inactiveContentDescription: String = inactiveActionLabel,
|
||||
activeContentDescription: String = activeActionLabel,
|
||||
): Modifier = composed {
|
||||
val currentOnPress by rememberUpdatedState(onPress)
|
||||
val currentOnRelease by rememberUpdatedState(onRelease)
|
||||
var semanticHoldActive by remember { mutableStateOf(state == VoiceState.Listening) }
|
||||
var semanticCaptureStarted by remember { mutableStateOf(state == VoiceState.Listening) }
|
||||
|
||||
LaunchedEffect(state) {
|
||||
when {
|
||||
state == VoiceState.Listening -> {
|
||||
semanticHoldActive = true
|
||||
semanticCaptureStarted = true
|
||||
}
|
||||
semanticCaptureStarted -> {
|
||||
semanticHoldActive = false
|
||||
semanticCaptureStarted = false
|
||||
}
|
||||
state == VoiceState.Error -> semanticHoldActive = false
|
||||
}
|
||||
}
|
||||
|
||||
val semanticToggle = {
|
||||
if (semanticHoldActive) {
|
||||
semanticHoldActive = false
|
||||
semanticCaptureStarted = false
|
||||
currentOnRelease()
|
||||
} else {
|
||||
semanticHoldActive = true
|
||||
currentOnPress()
|
||||
}
|
||||
}
|
||||
Modifier
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = Role.Button
|
||||
contentDescription = if (semanticHoldActive) {
|
||||
activeContentDescription
|
||||
} else {
|
||||
inactiveContentDescription
|
||||
}
|
||||
onClick(
|
||||
label = if (semanticHoldActive) activeActionLabel else inactiveActionLabel,
|
||||
) {
|
||||
semanticToggle()
|
||||
true
|
||||
}
|
||||
}
|
||||
.onPreviewKeyEvent { event ->
|
||||
val native = event.nativeKeyEvent
|
||||
val activationKey = native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER ||
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_SPACE ||
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER
|
||||
if (native.action == android.view.KeyEvent.ACTION_UP && activationKey) {
|
||||
semanticToggle()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
.focusable()
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
currentOnPress()
|
||||
// Hold until the finger genuinely lifts. Don't use
|
||||
// waitForUpOrCancellation(): it ends the hold on ANY cancel — a
|
||||
// consumed move event or the finger drifting just off the small
|
||||
// circle — which made the button feel like it released by
|
||||
// accident. Loop until no pointer is still pressed so drift and
|
||||
// minor consumption don't cut the recording short.
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
} while (event.changes.any { it.pressed })
|
||||
currentOnRelease()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun voiceStateToSphereState(state: VoiceState): SphereState = when (state) {
|
||||
VoiceState.Listening -> SphereState.Listening
|
||||
VoiceState.Speaking -> SphereState.Speaking
|
||||
@@ -773,6 +883,7 @@ fun ConversationVoiceDock(
|
||||
onOverlayRequest: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
systemOverlayAvailable: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
@@ -816,17 +927,19 @@ fun ConversationVoiceDock(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onOverlayRequest,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(40.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.voice_overlay_overlay),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (systemOverlayAvailable) {
|
||||
TextButton(
|
||||
onClick = onOverlayRequest,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(40.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.voice_overlay_overlay),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = onExit,
|
||||
@@ -1006,6 +1119,7 @@ private fun VoiceSessionPill(
|
||||
onInterrupt: () -> Unit,
|
||||
onPauseAutoMode: () -> Unit,
|
||||
onOverlayRequest: () -> Unit,
|
||||
systemOverlayAvailable: Boolean,
|
||||
onOpenSettings: () -> Unit,
|
||||
onExit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -1158,21 +1272,23 @@ private fun VoiceSessionPill(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
onFocusModeChange(false)
|
||||
onOverlayRequest()
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(0.95f)
|
||||
.height(40.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.voice_overlay_overlay),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (systemOverlayAvailable) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onFocusModeChange(false)
|
||||
onOverlayRequest()
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(0.95f)
|
||||
.height(40.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.voice_overlay_overlay),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = onExit,
|
||||
@@ -1289,8 +1405,9 @@ internal fun dispatchVoiceMicHoldPress(
|
||||
) {
|
||||
when (uiState.state) {
|
||||
VoiceState.Idle, VoiceState.Error -> onStartListening()
|
||||
VoiceState.Speaking -> onInterruptAndStart()
|
||||
VoiceState.Listening, VoiceState.Transcribing, VoiceState.Thinking -> Unit
|
||||
VoiceState.Speaking, VoiceState.Transcribing, VoiceState.Thinking ->
|
||||
onInterruptAndStart()
|
||||
VoiceState.Listening -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -171,6 +169,7 @@ import com.hermesandroid.relay.util.AttachmentTooLargeException
|
||||
import com.hermesandroid.relay.util.readBase64Bounded
|
||||
import com.hermesandroid.relay.data.ChatComposerDraftKey
|
||||
import com.hermesandroid.relay.data.ChatQuoteReference
|
||||
import com.hermesandroid.relay.data.BuildFlavor
|
||||
import com.hermesandroid.relay.data.LARGE_PASTE_THRESHOLD_CHARS
|
||||
import com.hermesandroid.relay.data.buildChatQuotedPrompt
|
||||
import com.hermesandroid.relay.data.largePasteAttachment
|
||||
@@ -193,13 +192,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
|
||||
@@ -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()
|
||||
@@ -1067,6 +1071,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 +1185,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 {
|
||||
@@ -1415,7 +1420,6 @@ fun ChatScreen(
|
||||
PetInteractionLayer(
|
||||
owner = "chat-interaction-layer",
|
||||
active = shouldHideChatPet(
|
||||
ambientMode = ambientMode,
|
||||
voiceMode = voiceUiState.voiceMode,
|
||||
drawerOpenOrMoving =
|
||||
drawerState.currentValue != DrawerValue.Closed ||
|
||||
@@ -1556,8 +1560,11 @@ fun ChatScreen(
|
||||
voiceOutputConfig?.enabled
|
||||
}
|
||||
|
||||
val voiceSystemOverlayAvailable = BuildFlavor.isSideload
|
||||
val showVoiceSystemOverlay: () -> Unit = {
|
||||
if (assistantSessionActive) {
|
||||
if (!voiceSystemOverlayAvailable) {
|
||||
pendingVoiceOverlayPermission = false
|
||||
} else if (assistantSessionActive) {
|
||||
voiceOverlayHost.hide()
|
||||
} else if (!voiceOverlayHost.hasOverlayPermission()) {
|
||||
pendingVoiceOverlayPermission = true
|
||||
@@ -2996,6 +3003,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 +3114,6 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean text-flow mode has no top-bar toggle — it's a quiet
|
||||
// gesture: long-press the conversation background to enter.
|
||||
// Exit is the in-mode dismiss control or system back (not
|
||||
// any-tap, since the mode carries its own composer). A
|
||||
// first-run hint teaches the entry.
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = RelayRefresh.Background.copy(alpha = 0.96f)
|
||||
@@ -3184,11 +3192,6 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Clean text-flow mode is rendered as a full-screen overlay (a
|
||||
// sibling of this Column, below) so it can own its own minimal
|
||||
// composer and explicit-dismiss control. The Column always renders
|
||||
// the normal chat underneath; the overlay covers it when active.
|
||||
|
||||
// Message list or empty state
|
||||
if (messages.isEmpty() && !isStreaming && (!isLoadingHistory || isChatConnecting)) {
|
||||
AnimatedContent(
|
||||
@@ -3425,28 +3428,12 @@ fun ChatScreen(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
// Quiet entry to clean mode: long-press the
|
||||
// conversation background. Bubbles keep their own
|
||||
// long-press (copy) — they consume the gesture first,
|
||||
// so only presses on empty space land here. Enters
|
||||
// regardless of animationEnabled — clean mode degrades
|
||||
// to static text when motion is suppressed, so the
|
||||
// conversation is never gated on animation.
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
ambientMode = true
|
||||
},
|
||||
)
|
||||
}
|
||||
) {
|
||||
// Ambient avatar behind messages
|
||||
if (
|
||||
LocalBackgroundVisualizationEnabled.current &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity) &&
|
||||
animationBehindChat &&
|
||||
!ambientMode
|
||||
animationBehindChat
|
||||
) {
|
||||
LocalAgentAvatar.current.Render(
|
||||
state = AvatarRenderState(
|
||||
@@ -4457,6 +4444,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 },
|
||||
@@ -4665,6 +4660,7 @@ fun ChatScreen(
|
||||
setVoicePresentationMode(VoicePresentationMode.Focus)
|
||||
},
|
||||
onOverlayRequest = showVoiceSystemOverlay,
|
||||
systemOverlayAvailable = voiceSystemOverlayAvailable,
|
||||
onOpenSettings = onNavigateToVoiceSettings,
|
||||
onExit = { voiceViewModel.exitVoiceMode() },
|
||||
)
|
||||
@@ -4783,62 +4779,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,
|
||||
@@ -4877,6 +4817,7 @@ fun ChatScreen(
|
||||
presentationMode = effectiveVoicePresentationMode,
|
||||
onPresentationModeChange = setVoicePresentationMode,
|
||||
onOverlayRequest = showVoiceSystemOverlay,
|
||||
systemOverlayAvailable = voiceSystemOverlayAvailable,
|
||||
// Gear button in the overlay's expanded controls. The overlay
|
||||
// exits voice mode before invoking this, so navigation lands
|
||||
// on Voice Settings with no overlay left on top.
|
||||
@@ -5547,15 +5488,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 +5498,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(
|
||||
|
||||
@@ -0,0 +1,846 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.GitBranch
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.data.GitStatus
|
||||
import com.hermesandroid.relay.viewmodel.GitConfirmationStrings
|
||||
import com.hermesandroid.relay.viewmodel.GitContentViewState
|
||||
import com.hermesandroid.relay.viewmodel.GitMessageGenerationState
|
||||
import com.hermesandroid.relay.viewmodel.GitMutationState
|
||||
import com.hermesandroid.relay.viewmodel.GitRepoDetailState
|
||||
import com.hermesandroid.relay.viewmodel.GitStateUiState
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import com.hermesandroid.relay.viewmodel.GitTarget
|
||||
|
||||
private enum class FileFilter { All, Staged, Modified, Untracked }
|
||||
private enum class ContentMode { Diff, File }
|
||||
private data class DisplayFile(
|
||||
val path: String,
|
||||
val filter: FileFilter,
|
||||
val additions: Int?,
|
||||
val deletions: Int?,
|
||||
)
|
||||
|
||||
/** First-class native Git workspace backed by the optional Relay contribution. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GitStateScreen(viewModel: GitStateViewModel, onBack: () -> Unit) {
|
||||
val reposState by viewModel.repos.collectAsState()
|
||||
val detailState by viewModel.detail.collectAsState()
|
||||
val contentState by viewModel.content.collectAsState()
|
||||
val mutation by viewModel.mutation.collectAsState()
|
||||
val messageGeneration by viewModel.messageGeneration.collectAsState()
|
||||
val stashNotice by viewModel.stashNotice.collectAsState()
|
||||
val hasGrant by viewModel.writeGrant.collectAsState()
|
||||
val selectedRepoId by viewModel.selectedRepoId.collectAsState()
|
||||
|
||||
// A path can be staged and modified at the same time. Keep the category in
|
||||
// the selection identity so selecting one row never silently selects the
|
||||
// other layer or disables its correct action.
|
||||
var selection by remember { mutableStateOf(setOf<DisplayFile>()) }
|
||||
var filter by rememberSaveable { mutableStateOf(FileFilter.All) }
|
||||
var expandedPath by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var contentMode by rememberSaveable { mutableStateOf(ContentMode.Diff) }
|
||||
var showRepos by rememberSaveable { mutableStateOf(false) }
|
||||
var showBranches by rememberSaveable { mutableStateOf(false) }
|
||||
var showCommit by rememberSaveable { mutableStateOf(false) }
|
||||
var showOverflow by rememberSaveable { mutableStateOf(false) }
|
||||
var pushAfter by rememberSaveable { mutableStateOf(false) }
|
||||
var pendingConfirm by remember { mutableStateOf<ConfirmationRequest?>(null) }
|
||||
|
||||
val repos = (reposState as? GitStateUiState.Ready)?.repos.orEmpty()
|
||||
val selectedRepo = repos.firstOrNull { it.id == selectedRepoId }
|
||||
val detail = detailState as? GitRepoDetailState.Ready
|
||||
val currentBranch = detail?.branches?.firstOrNull { it.isCurrent }
|
||||
val changeCount = detail?.status?.uniqueChangeCount() ?: 0
|
||||
val stageSelection: () -> Unit = {
|
||||
val unstaged = selection.filter { it.filter != FileFilter.Staged }.map { it.path }
|
||||
if (unstaged.isEmpty()) {
|
||||
viewModel.unstage(selection.map { it.path }.distinct())
|
||||
} else {
|
||||
viewModel.stage(unstaged.distinct())
|
||||
}
|
||||
selection = emptySet()
|
||||
}
|
||||
val discardSelection: () -> Unit = {
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
val discardable = selection.filter { it.filter != FileFilter.Staged }
|
||||
if (discardable.isNotEmpty()) {
|
||||
pendingConfirm = ConfirmationRequest.Discard(
|
||||
paths = discardable.map { it.path }.distinct(),
|
||||
deleteUntracked = discardable.any { it.filter == FileFilter.Untracked },
|
||||
target = target,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(repos, selectedRepoId) {
|
||||
if (repos.size == 1 && selectedRepoId == null) viewModel.selectRepo(repos.single().id)
|
||||
}
|
||||
LaunchedEffect(selectedRepoId) {
|
||||
selection = emptySet()
|
||||
expandedPath = null
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.git_state_back))
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Surface(
|
||||
onClick = { showRepos = true },
|
||||
enabled = repos.isNotEmpty(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 10.dp, vertical = 5.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
selectedRepo?.name ?: stringResource(R.string.git_state_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (repos.isNotEmpty()) Icon(Icons.Filled.ExpandMore, "Choose repository", Modifier.size(20.dp))
|
||||
}
|
||||
selectedRepo?.let {
|
||||
Text(branchSummary(it, currentBranch), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { selectedRepo?.let { viewModel.selectRepo(it.id) } ?: viewModel.loadRepos() }) {
|
||||
Icon(Icons.Filled.Refresh, "Refresh Git workspace")
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showOverflow = true }) { Icon(Icons.Filled.MoreVert, "More Git actions") }
|
||||
DropdownMenu(expanded = showOverflow, onDismissRequest = { showOverflow = false }) {
|
||||
DropdownMenuItem(text = { Text("Choose repository") }, onClick = { showOverflow = false; showRepos = true })
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.git_state_branches)) }, enabled = detail != null, onClick = { showOverflow = false; showBranches = true })
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (detail != null) {
|
||||
Surface(shadowElevation = 8.dp, tonalElevation = 2.dp) {
|
||||
Column {
|
||||
if (selection.isNotEmpty()) {
|
||||
SelectionRail(
|
||||
count = selection.size,
|
||||
canWrite = hasGrant,
|
||||
allStaged = selection.all { it.filter == FileFilter.Staged },
|
||||
onStage = stageSelection,
|
||||
onDiscard = discardSelection,
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedButton(onClick = { showBranches = true }, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.AccountTree, null, Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.git_state_branches))
|
||||
}
|
||||
Button(onClick = { showCommit = true }, enabled = hasGrant && detail.status.counts.staged > 0, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.AutoAwesome, null, Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.git_state_commit))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
when (val state = reposState) {
|
||||
GitStateUiState.Loading -> FullState(Modifier.padding(padding), true, "Finding repositories")
|
||||
is GitStateUiState.Unavailable -> UnavailableState(Modifier.padding(padding), state.message, viewModel::loadRepos)
|
||||
is GitStateUiState.Error -> UnavailableState(Modifier.padding(padding), state.message, viewModel::loadRepos)
|
||||
is GitStateUiState.Ready -> when {
|
||||
state.repos.isEmpty() -> FullState(Modifier.padding(padding), false, "No Git repositories found", "Add a repository to the host's configured Git roots, then refresh.")
|
||||
selectedRepo == null -> RepositoryPrompt(Modifier.padding(padding), state.repos, viewModel::selectRepo)
|
||||
else -> WorkspaceBody(
|
||||
modifier = Modifier.padding(padding),
|
||||
repo = selectedRepo,
|
||||
reposNotice = state.notice,
|
||||
detailState = detailState,
|
||||
mutation = mutation,
|
||||
stashNotice = stashNotice,
|
||||
hasGrant = hasGrant,
|
||||
filter = filter,
|
||||
onFilter = { filter = it },
|
||||
selection = selection,
|
||||
onToggleSelected = { file ->
|
||||
selection = if (file in selection) selection - file else selection + file
|
||||
},
|
||||
expandedPath = expandedPath,
|
||||
contentMode = contentMode,
|
||||
contentState = contentState,
|
||||
onOpen = { file, mode ->
|
||||
if (file.filter != FileFilter.Untracked) {
|
||||
val opening = expandedPath != file.path || contentMode != mode
|
||||
expandedPath = if (opening) file.path else null
|
||||
contentMode = mode
|
||||
if (opening) {
|
||||
if (mode == ContentMode.File) viewModel.loadFile(file.path)
|
||||
else viewModel.loadDiff(file.path, if (file.filter == FileFilter.Staged) "staged" else "unstaged")
|
||||
}
|
||||
}
|
||||
},
|
||||
onFetch = viewModel::fetch,
|
||||
onPull = viewModel::pull,
|
||||
onPush = { viewModel.currentTarget()?.let { pendingConfirm = ConfirmationRequest.Push(it) } },
|
||||
onClearMutation = viewModel::clearMutationError,
|
||||
onRetry = { viewModel.selectRepo(selectedRepo.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showRepos) RepositorySheet(repos, selectedRepo?.id, { showRepos = false }) { showRepos = false; viewModel.selectRepo(it) }
|
||||
if (showBranches && detail != null) {
|
||||
BranchSheet(
|
||||
branches = detail.branches,
|
||||
hasGrant = hasGrant,
|
||||
dirty = changeCount > 0,
|
||||
onDismiss = { showBranches = false },
|
||||
onSwitch = { ref ->
|
||||
showBranches = false
|
||||
if (changeCount > 0) viewModel.currentTarget()?.let { pendingConfirm = ConfirmationRequest.DirtyCheckout(ref, it) } else viewModel.checkout(ref)
|
||||
},
|
||||
onStashSwitch = { showBranches = false; viewModel.stashCheckout(it) },
|
||||
onCreate = { name, track -> showBranches = false; viewModel.checkout("", newBranch = name, track = track) },
|
||||
)
|
||||
}
|
||||
if (showCommit && detail != null) {
|
||||
val stagedPaths = detail.status.staged.map { it.path }
|
||||
CommitDialog(
|
||||
hasStaged = stagedPaths.isNotEmpty(),
|
||||
generating = messageGeneration is GitMessageGenerationState.Loading,
|
||||
generatedMessage = (messageGeneration as? GitMessageGenerationState.Ready)?.message.orEmpty(),
|
||||
generationNotice = (messageGeneration as? GitMessageGenerationState.Ready)?.notice,
|
||||
pushAfter = pushAfter,
|
||||
onPushAfterChange = { pushAfter = it },
|
||||
onGenerate = { viewModel.generateCommitMessage(stagedPaths) },
|
||||
onDismiss = { showCommit = false },
|
||||
onCommit = { message ->
|
||||
showCommit = false
|
||||
viewModel.commit(message) { target -> if (pushAfter) pendingConfirm = ConfirmationRequest.Push(target) }
|
||||
},
|
||||
)
|
||||
}
|
||||
pendingConfirm?.let { request ->
|
||||
ConfirmationDialog(
|
||||
request,
|
||||
onDismiss = { pendingConfirm = null },
|
||||
onDiscard = { paths, deleteUntracked, target -> pendingConfirm = null; viewModel.discard(paths, GitConfirmationStrings.DISCARD, deleteUntracked, target) },
|
||||
onPush = { target -> pendingConfirm = null; viewModel.push(GitConfirmationStrings.PUSH, expectedTarget = target) },
|
||||
onCheckout = { ref, target -> pendingConfirm = null; viewModel.checkout(ref, GitConfirmationStrings.DIRTY_CHECKOUT, expectedTarget = target) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkspaceBody(
|
||||
modifier: Modifier,
|
||||
repo: GitRepo,
|
||||
reposNotice: String?,
|
||||
detailState: GitRepoDetailState,
|
||||
mutation: GitMutationState,
|
||||
stashNotice: String?,
|
||||
hasGrant: Boolean,
|
||||
filter: FileFilter,
|
||||
onFilter: (FileFilter) -> Unit,
|
||||
selection: Set<DisplayFile>,
|
||||
onToggleSelected: (DisplayFile) -> Unit,
|
||||
expandedPath: String?,
|
||||
contentMode: ContentMode,
|
||||
contentState: GitContentViewState,
|
||||
onOpen: (DisplayFile, ContentMode) -> Unit,
|
||||
onFetch: () -> Unit,
|
||||
onPull: () -> Unit,
|
||||
onPush: () -> Unit,
|
||||
onClearMutation: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (detailState) {
|
||||
GitRepoDetailState.Idle, GitRepoDetailState.Loading -> FullState(modifier, true, "Loading workspace")
|
||||
is GitRepoDetailState.Error -> UnavailableState(modifier, detailState.message, onRetry)
|
||||
is GitRepoDetailState.Ready -> {
|
||||
val status = detailState.status
|
||||
val allFiles = buildList {
|
||||
status.staged.forEach { add(DisplayFile(it.path, FileFilter.Staged, it.additions, it.deletions)) }
|
||||
status.modified.forEach { add(DisplayFile(it.path, FileFilter.Modified, it.additions, it.deletions)) }
|
||||
status.untracked.forEach { add(DisplayFile(it.path, FileFilter.Untracked, it.additions, it.deletions)) }
|
||||
}
|
||||
val visible = allFiles.filter { filter == FileFilter.All || it.filter == filter }
|
||||
LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 18.dp)) {
|
||||
item {
|
||||
SummaryRail(repo, detailState)
|
||||
RemoteActions(hasGrant, status, onFetch, onPull, onPush)
|
||||
reposNotice?.let { NoticeCard(it) }
|
||||
if (!hasGrant) WriteGrantNotice()
|
||||
MutationBanner(mutation, onClearMutation)
|
||||
stashNotice?.let { NoticeCard(it) }
|
||||
if (status.truncated) NoticeCard(stringResource(R.string.git_state_truncated), error = true)
|
||||
FilterRow(filter, status, onFilter)
|
||||
}
|
||||
if (visible.isEmpty()) item { EmptyFilterState(filter) }
|
||||
FileFilter.entries.filter { it != FileFilter.All }.forEach { group ->
|
||||
val files = visible.filter { it.filter == group }
|
||||
if (files.isNotEmpty()) {
|
||||
item { GroupHeader(group.name, files.size) }
|
||||
items(files, key = { "${group.name}:${it.path}" }) { file ->
|
||||
FileRow(
|
||||
file = file,
|
||||
selected = file in selection,
|
||||
expanded = expandedPath == file.path,
|
||||
mode = contentMode,
|
||||
contentState = contentState,
|
||||
onToggleSelected = { onToggleSelected(file) },
|
||||
onOpen = { onOpen(file, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRail(repo: GitRepo, detail: GitRepoDetailState.Ready) {
|
||||
val count = detail.status.uniqueChangeCount()
|
||||
val current = detail.branches.firstOrNull { it.isCurrent }
|
||||
Surface(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Row(Modifier.padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("$count changes", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
VerticalDivider()
|
||||
Text("+${detail.status.counts.additions}", color = Color(0xFF2E7D32), style = MaterialTheme.typography.labelLarge)
|
||||
VerticalDivider()
|
||||
Text("−${detail.status.counts.deletions}", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelLarge)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
color = Color.Transparent,
|
||||
) {
|
||||
Row(Modifier.padding(horizontal = 9.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.AccountTree, null, Modifier.size(16.dp)); Spacer(Modifier.width(5.dp))
|
||||
Text(current?.name ?: repo.currentBranch ?: "Detached", style = MaterialTheme.typography.labelLarge, maxLines = 1)
|
||||
if (repo.dirty || count > 0) { Spacer(Modifier.width(7.dp)); Box(Modifier.size(7.dp).background(MaterialTheme.colorScheme.tertiary, CircleShape)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerticalDivider() {
|
||||
Box(
|
||||
Modifier.padding(horizontal = 10.dp).width(1.dp).height(22.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteActions(hasGrant: Boolean, status: GitStatus, onFetch: () -> Unit, onPull: () -> Unit, onPush: () -> Unit) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = onFetch, enabled = hasGrant && status.counts.untracked == 0, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.Refresh, null, Modifier.size(18.dp)); Spacer(Modifier.width(6.dp)); Text(stringResource(R.string.git_state_fetch))
|
||||
}
|
||||
VerticalDivider()
|
||||
TextButton(onClick = onPull, enabled = hasGrant, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.Download, null, Modifier.size(18.dp)); Spacer(Modifier.width(6.dp)); Text(stringResource(R.string.git_state_pull))
|
||||
}
|
||||
VerticalDivider()
|
||||
TextButton(onClick = onPush, enabled = hasGrant && status.counts.staged == 0, modifier = Modifier.weight(1f)) {
|
||||
Icon(Icons.Filled.FileUpload, null, Modifier.size(18.dp)); Spacer(Modifier.width(6.dp)); Text(stringResource(R.string.git_state_push))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterRow(selected: FileFilter, status: GitStatus, onSelect: (FileFilter) -> Unit) {
|
||||
val counts = mapOf(
|
||||
FileFilter.All to status.uniqueChangeCount(),
|
||||
FileFilter.Staged to status.counts.staged,
|
||||
FileFilter.Modified to status.counts.modified,
|
||||
FileFilter.Untracked to status.counts.untracked,
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
FileFilter.entries.forEach { item ->
|
||||
val isSelected = selected == item
|
||||
Surface(
|
||||
onClick = { onSelect(item) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent,
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Box(Modifier.padding(horizontal = 3.dp, vertical = 9.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"${item.name} ${counts.getValue(item)}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun GitStatus.uniqueChangeCount(): Int = counts.changes.takeIf { it >= 0 } ?: buildSet {
|
||||
staged.forEach { add(it.path) }
|
||||
modified.forEach { add(it.path) }
|
||||
untracked.forEach { add(it.path) }
|
||||
}.size
|
||||
|
||||
@Composable
|
||||
private fun FileRow(
|
||||
file: DisplayFile,
|
||||
selected: Boolean,
|
||||
expanded: Boolean,
|
||||
mode: ContentMode,
|
||||
contentState: GitContentViewState,
|
||||
onToggleSelected: () -> Unit,
|
||||
onOpen: (ContentMode) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
.then(
|
||||
if (file.filter == FileFilter.Untracked) Modifier
|
||||
else Modifier.clickable { onOpen(ContentMode.Diff) },
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onToggleSelected() },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = "Select ${file.path}"
|
||||
},
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(file.path.substringAfterLast('/'), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
file.path.substringBeforeLast('/', "").takeIf { it.isNotEmpty() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (file.filter == FileFilter.Untracked) {
|
||||
Text("Preview unavailable until staged", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
file.additions?.let { additions ->
|
||||
Text("+$additions", color = Color(0xFF2E7D32), style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
file.deletions?.let { deletions ->
|
||||
Text("−$deletions", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
StatusBadge(file.filter)
|
||||
if (file.filter != FileFilter.Untracked) {
|
||||
Icon(if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, if (expanded) "Collapse" else "Expand")
|
||||
}
|
||||
}
|
||||
if (expanded && file.filter != FileFilter.Untracked) {
|
||||
Row(Modifier.padding(start = 56.dp, end = 16.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (file.filter != FileFilter.Untracked) FilterChip(mode == ContentMode.Diff, { onOpen(ContentMode.Diff) }, label = { Text("Diff") })
|
||||
FilterChip(mode == ContentMode.File, { onOpen(ContentMode.File) }, label = { Text("File") })
|
||||
}
|
||||
InlineContent(contentState, Modifier.padding(start = 16.dp, end = 16.dp, bottom = 10.dp))
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(start = 56.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusBadge(filter: FileFilter) {
|
||||
val label = when (filter) { FileFilter.Staged -> "S"; FileFilter.Modified -> "M"; FileFilter.Untracked -> "U"; else -> "" }
|
||||
val color = when (filter) { FileFilter.Staged -> MaterialTheme.colorScheme.primary; FileFilter.Modified -> MaterialTheme.colorScheme.tertiary; else -> MaterialTheme.colorScheme.secondary }
|
||||
Surface(shape = CircleShape, color = color.copy(alpha = 0.14f), modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
Text(label, color = color, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineContent(state: GitContentViewState, modifier: Modifier) {
|
||||
Surface(modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), color = MaterialTheme.colorScheme.surfaceContainerLow) {
|
||||
when (state) {
|
||||
GitContentViewState.Idle, GitContentViewState.Loading -> Box(Modifier.fillMaxWidth().height(100.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) }
|
||||
is GitContentViewState.Error -> Text(state.message, Modifier.padding(12.dp), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
is GitContentViewState.Diff -> MonospaceContent(state.diff.diff.ifEmpty { stringResource(R.string.git_state_no_changes) }, state.diff.truncated)
|
||||
is GitContentViewState.File -> MonospaceContent(state.file.content, state.file.truncated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonospaceContent(text: String, truncated: Boolean) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
if (truncated) Text(stringResource(R.string.git_state_truncated), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall)
|
||||
Text(text, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, modifier = Modifier.fillMaxWidth().height(220.dp).verticalScroll(rememberScrollState()))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectionRail(count: Int, canWrite: Boolean, allStaged: Boolean, onStage: () -> Unit, onDiscard: () -> Unit) {
|
||||
val canDiscard = canWrite && !allStaged
|
||||
Surface(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
shadowElevation = 6.dp,
|
||||
) {
|
||||
Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("$count selected", Modifier.weight(1f), style = MaterialTheme.typography.labelLarge)
|
||||
TextButton(onClick = onStage, enabled = canWrite) {
|
||||
Text(
|
||||
if (allStaged) "Unstage" else "Stage",
|
||||
color = if (canWrite) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onDiscard, enabled = canDiscard) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_discard),
|
||||
color = if (canDiscard) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepositoryPrompt(modifier: Modifier, repos: List<GitRepo>, onSelect: (String) -> Unit) {
|
||||
Column(modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Choose a repository", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Git actions stay scoped to the repository you select.", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
repos.forEach { repo -> RepositoryRow(repo, false) { onSelect(repo.id) } }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RepositorySheet(repos: List<GitRepo>, selectedId: String?, onDismiss: () -> Unit, onSelect: (String) -> Unit) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(bottom = 28.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Repositories", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(8.dp))
|
||||
repos.forEach { repo -> RepositoryRow(repo, repo.id == selectedId) { onSelect(repo.id) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepositoryRow(repo: GitRepo, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(Modifier.fillMaxWidth().clickable(onClick = onClick), color = if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, shape = RoundedCornerShape(12.dp)) {
|
||||
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.AccountTree, null); Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(repo.name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(repo.currentBranch ?: repo.root, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (repo.dirty) Box(Modifier.size(8.dp).background(MaterialTheme.colorScheme.tertiary, CircleShape))
|
||||
if (selected) { Spacer(Modifier.width(8.dp)); Icon(Icons.Filled.Check, "Selected") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun BranchSheet(
|
||||
branches: List<GitBranch>,
|
||||
hasGrant: Boolean,
|
||||
dirty: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onSwitch: (String) -> Unit,
|
||||
onStashSwitch: (String) -> Unit,
|
||||
onCreate: (String, Boolean) -> Unit,
|
||||
) {
|
||||
var newBranch by rememberSaveable { mutableStateOf("") }
|
||||
var track by rememberSaveable { mutableStateOf(false) }
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp).padding(bottom = 32.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stringResource(R.string.git_state_branches), style = MaterialTheme.typography.titleLarge)
|
||||
branches.forEach { branch ->
|
||||
Surface(shape = RoundedCornerShape(12.dp), color = if (branch.isCurrent) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(branch.name, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodyMedium)
|
||||
branch.upstream?.let { Text("$it · ahead ${branch.ahead} · behind ${branch.behind}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
if (branch.isCurrent) Text(stringResource(R.string.git_state_current), color = MaterialTheme.colorScheme.primary)
|
||||
else {
|
||||
TextButton(onClick = { onSwitch(branch.name) }, enabled = hasGrant) { Text(stringResource(R.string.git_state_switch)) }
|
||||
if (dirty) TextButton(onClick = { onStashSwitch(branch.name) }, enabled = hasGrant) { Text(stringResource(R.string.git_state_switch_stash)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(); Text("Create branch", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(newBranch, { newBranch = it }, Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.git_state_new_branch_hint)) }, singleLine = true)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(track, { track = it }, enabled = hasGrant); Text(stringResource(R.string.git_state_track_remote)) }
|
||||
Button(onClick = { onCreate(newBranch.trim(), track) }, enabled = hasGrant && newBranch.isNotBlank(), modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.git_state_create_branch)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommitDialog(
|
||||
hasStaged: Boolean,
|
||||
generating: Boolean,
|
||||
generatedMessage: String,
|
||||
generationNotice: String?,
|
||||
pushAfter: Boolean,
|
||||
onPushAfterChange: (Boolean) -> Unit,
|
||||
onGenerate: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onCommit: (String) -> Unit,
|
||||
) {
|
||||
var message by rememberSaveable { mutableStateOf("") }
|
||||
LaunchedEffect(generatedMessage) { if (generatedMessage.isNotBlank()) message = generatedMessage }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_commit_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
message, { message = it }, Modifier.fillMaxWidth(),
|
||||
label = { Text(stringResource(R.string.git_state_commit_message_hint)) }, minLines = 3,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onGenerate, enabled = hasStaged && !generating) {
|
||||
if (generating) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) else Icon(Icons.Filled.AutoAwesome, stringResource(R.string.git_state_generate_message))
|
||||
}
|
||||
},
|
||||
)
|
||||
generationNotice?.takeIf { it.isNotBlank() }?.let { Text(it, color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.bodySmall) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(pushAfter, onPushAfterChange); Text(stringResource(R.string.git_state_push_after_commit)) }
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onCommit(message.trim()) }, enabled = hasStaged && message.isNotBlank()) { Text(stringResource(R.string.git_state_commit_confirm)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.git_state_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
private sealed interface ConfirmationRequest {
|
||||
data class Discard(val paths: List<String>, val deleteUntracked: Boolean, val target: GitTarget) : ConfirmationRequest
|
||||
data class Push(val target: GitTarget) : ConfirmationRequest
|
||||
data class DirtyCheckout(val ref: String, val target: GitTarget) : ConfirmationRequest
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfirmationDialog(
|
||||
request: ConfirmationRequest,
|
||||
onDismiss: () -> Unit,
|
||||
onDiscard: (List<String>, Boolean, GitTarget) -> Unit,
|
||||
onPush: (GitTarget) -> Unit,
|
||||
onCheckout: (String, GitTarget) -> Unit,
|
||||
) {
|
||||
val title = stringResource(when (request) {
|
||||
is ConfirmationRequest.Discard -> R.string.git_state_confirm_discard_title
|
||||
is ConfirmationRequest.Push -> R.string.git_state_confirm_push_title
|
||||
is ConfirmationRequest.DirtyCheckout -> R.string.git_state_confirm_checkout_dirty_title
|
||||
})
|
||||
val text = stringResource(when (request) {
|
||||
is ConfirmationRequest.Discard -> R.string.git_state_confirm_discard_text
|
||||
is ConfirmationRequest.Push -> R.string.git_state_confirm_push_text
|
||||
is ConfirmationRequest.DirtyCheckout -> R.string.git_state_confirm_checkout_dirty_text
|
||||
})
|
||||
val confirm = stringResource(when (request) {
|
||||
is ConfirmationRequest.Discard -> R.string.git_state_confirm_discard_confirm
|
||||
is ConfirmationRequest.Push -> R.string.git_state_confirm_push_confirm
|
||||
is ConfirmationRequest.DirtyCheckout -> R.string.git_state_confirm_checkout_confirm
|
||||
})
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) }, text = { Text(text) },
|
||||
confirmButton = { TextButton(onClick = {
|
||||
when (request) {
|
||||
is ConfirmationRequest.Discard -> onDiscard(request.paths, request.deleteUntracked, request.target)
|
||||
is ConfirmationRequest.Push -> onPush(request.target)
|
||||
is ConfirmationRequest.DirtyCheckout -> onCheckout(request.ref, request.target)
|
||||
}
|
||||
}) { Text(confirm) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.git_state_cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutationBanner(mutation: GitMutationState, onClear: () -> Unit) {
|
||||
when (mutation) {
|
||||
GitMutationState.Idle -> Unit
|
||||
is GitMutationState.InProgress -> NoticeCard("${mutation.label} in progress…", loading = true)
|
||||
is GitMutationState.Success -> NoticeCard("${displayLabel(mutation.label)} completed · ${mutation.head.take(8)}")
|
||||
is GitMutationState.Error -> Card(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Row(Modifier.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${displayLabel(mutation.label)} failed: ${mutation.message}",
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
IconButton(onClick = onClear) { Icon(Icons.Filled.Close, "Dismiss error") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoticeCard(message: String, error: Boolean = false, loading: Boolean = false) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = if (error) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceContainer),
|
||||
) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (loading) { CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp); Spacer(Modifier.width(10.dp)) }
|
||||
Text(message, color = if (error) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun WriteGrantNotice() = NoticeCard(stringResource(R.string.git_state_write_grant_required))
|
||||
|
||||
@Composable
|
||||
private fun GroupHeader(label: String, count: Int) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold); Spacer(Modifier.width(6.dp))
|
||||
Text(count.toString(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyFilterState(filter: FileFilter) {
|
||||
Box(Modifier.fillMaxWidth().padding(48.dp), contentAlignment = Alignment.Center) {
|
||||
Text(if (filter == FileFilter.All) "Working tree clean" else "No ${filter.name.lowercase()} files", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FullState(modifier: Modifier, loading: Boolean, title: String, detail: String? = null) {
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(Modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (loading) CircularProgressIndicator(); Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
detail?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnavailableState(modifier: Modifier, message: String, onRetry: () -> Unit) {
|
||||
val missingRoute = message.contains("404") || message.contains("No such API endpoint", true)
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(Modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Icon(Icons.Filled.Close, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(32.dp))
|
||||
Text(if (missingRoute) "Git isn't available on this host yet" else "Git workspace unavailable", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
if (missingRoute) "The host advertised Git, but its API route is not currently reachable. Restart or update the Dashboard, then retry." else message.ifBlank { "The Git service did not return a usable response." },
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(onClick = onRetry) { Icon(Icons.Filled.Refresh, null, Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text("Retry") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun branchSummary(repo: GitRepo, branch: GitBranch?): String {
|
||||
val name = branch?.name ?: repo.currentBranch ?: "Detached HEAD"
|
||||
val tracking = buildList {
|
||||
if ((branch?.ahead ?: 0) > 0) add("ahead ${branch?.ahead}")
|
||||
if ((branch?.behind ?: 0) > 0) add("behind ${branch?.behind}")
|
||||
}
|
||||
return if (tracking.isEmpty()) name else "$name · ${tracking.joinToString(" · ")}"
|
||||
}
|
||||
|
||||
private fun displayLabel(label: String): String = label.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
@@ -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),
|
||||
|
||||
@@ -257,7 +257,10 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
|
||||
|
||||
val msg = t.message.orEmpty().lowercase()
|
||||
|
||||
if ("cannot create audiorecord" in msg || "audiorecord failed to initialize" in msg) {
|
||||
if ("cannot create audiorecord" in msg ||
|
||||
"audiorecord failed to initialize" in msg ||
|
||||
"microphone is in use by another voice feature" in msg
|
||||
) {
|
||||
return HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_mic_unavailable) ?: "Microphone unavailable",
|
||||
body = ctx?.getString(R.string.error_classify_mic_unavailable_body)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.relay.data.GitBranch
|
||||
import com.hermesandroid.relay.data.GitDiff
|
||||
import com.hermesandroid.relay.data.GitFile
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.data.GitStateApiClient
|
||||
import com.hermesandroid.relay.data.GitStatus
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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
|
||||
}
|
||||
|
||||
sealed interface GitRepoDetailState {
|
||||
data object Idle : GitRepoDetailState
|
||||
data object Loading : GitRepoDetailState
|
||||
data class Error(val message: String) : GitRepoDetailState
|
||||
data class Ready(
|
||||
val status: GitStatus,
|
||||
val branches: List<GitBranch>,
|
||||
) : GitRepoDetailState
|
||||
}
|
||||
|
||||
sealed interface GitContentViewState {
|
||||
data object Idle : GitContentViewState
|
||||
data object Loading : GitContentViewState
|
||||
data class Error(val message: String) : GitContentViewState
|
||||
data class Diff(val diff: GitDiff) : GitContentViewState
|
||||
data class File(val file: GitFile) : GitContentViewState
|
||||
}
|
||||
|
||||
/** A single in-flight or completed write mutation on the selected repo. */
|
||||
sealed interface GitMutationState {
|
||||
data object Idle : GitMutationState
|
||||
data class InProgress(val label: String) : GitMutationState
|
||||
data class Error(val label: String, val message: String) : GitMutationState
|
||||
data class Success(val label: String, val head: String) : GitMutationState
|
||||
}
|
||||
|
||||
/** A commit-message generation attempt (AI magic-wand). */
|
||||
sealed interface GitMessageGenerationState {
|
||||
data object Idle : GitMessageGenerationState
|
||||
data object Loading : GitMessageGenerationState
|
||||
data class Ready(val message: String, val notice: String) : GitMessageGenerationState
|
||||
}
|
||||
|
||||
/** Fixed per-use confirmation tokens matching the plugin's server constants. */
|
||||
object GitConfirmationStrings {
|
||||
const val DISCARD = "discard"
|
||||
const val PUSH = "push"
|
||||
const val DIRTY_CHECKOUT = "checkout-dirty"
|
||||
}
|
||||
|
||||
data class GitTarget(
|
||||
val scopeKey: String,
|
||||
val repoId: String,
|
||||
val generation: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* View model for the Git State Android surface (read + write).
|
||||
*
|
||||
* Loads the scanned repo list from the Hermes-Relay plugin and, on selection,
|
||||
* fetches working-tree status + branches. Mutations (stage/unstage/discard/
|
||||
* commit/fetch/pull/push/checkout) all require the ``plugin.api.write`` grant:
|
||||
* ``configure`` binds one connection/profile/Dashboard owner and every mutation
|
||||
* refuses (surfacing a readable message, never a POST) when that owner's grant
|
||||
* is absent. Destructive ops
|
||||
* (discard/push/dirty-checkout) additionally require a per-use confirmation
|
||||
* string the caller echoes from GitConfirmationStrings.
|
||||
*/
|
||||
class GitStateViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _repos = MutableStateFlow<GitStateUiState>(GitStateUiState.Loading)
|
||||
val repos: StateFlow<GitStateUiState> = _repos.asStateFlow()
|
||||
|
||||
private val _detail = MutableStateFlow<GitRepoDetailState>(GitRepoDetailState.Idle)
|
||||
val detail: StateFlow<GitRepoDetailState> = _detail.asStateFlow()
|
||||
|
||||
private val _content = MutableStateFlow<GitContentViewState>(GitContentViewState.Idle)
|
||||
val content: StateFlow<GitContentViewState> = _content.asStateFlow()
|
||||
|
||||
private val _mutation = MutableStateFlow<GitMutationState>(GitMutationState.Idle)
|
||||
val mutation: StateFlow<GitMutationState> = _mutation.asStateFlow()
|
||||
|
||||
private val _messageGeneration =
|
||||
MutableStateFlow<GitMessageGenerationState>(GitMessageGenerationState.Idle)
|
||||
val messageGeneration: StateFlow<GitMessageGenerationState> = _messageGeneration.asStateFlow()
|
||||
|
||||
private val _pushAfterCommit = MutableStateFlow(false)
|
||||
val pushAfterCommit: StateFlow<Boolean> = _pushAfterCommit.asStateFlow()
|
||||
|
||||
private val _stashNotice = MutableStateFlow<String?>(null)
|
||||
val stashNotice: StateFlow<String?> = _stashNotice.asStateFlow()
|
||||
|
||||
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
|
||||
private var contentJob: Job? = null
|
||||
private var mutationJob: Job? = null
|
||||
private var messageJob: Job? = null
|
||||
private var scopeKey: String? = null
|
||||
private var targetGeneration: Long = 0
|
||||
fun selectedRepoIdForDisplay(): String? = _selectedRepoId.value
|
||||
|
||||
fun currentTarget(): GitTarget? {
|
||||
val owner = scopeKey ?: return null
|
||||
val repo = _selectedRepoId.value ?: return null
|
||||
return GitTarget(owner, repo, targetGeneration)
|
||||
}
|
||||
|
||||
fun configure(dashboard: DashboardApiClient?, ownerKey: String?) {
|
||||
reposJob?.cancel()
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob?.cancel()
|
||||
messageJob?.cancel()
|
||||
targetGeneration += 1
|
||||
scopeKey = ownerKey
|
||||
_selectedRepoId.value = null
|
||||
_writeGrant.value = false
|
||||
_detail.value = GitRepoDetailState.Idle
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
_messageGeneration.value = GitMessageGenerationState.Idle
|
||||
_stashNotice.value = null
|
||||
api = dashboard?.let(::GitStateApiClient)
|
||||
loadRepos()
|
||||
}
|
||||
|
||||
/** Grants the plugin.api.write capability for this connection/profile. */
|
||||
fun setWriteGrant(ownerKey: String?, granted: Boolean) {
|
||||
if (ownerKey != scopeKey) return
|
||||
_writeGrant.value = granted
|
||||
}
|
||||
|
||||
fun hasWriteGrant(): Boolean = _writeGrant.value
|
||||
|
||||
fun loadRepos() {
|
||||
val client = api ?: run {
|
||||
_repos.value = GitStateUiState.Error("Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val expectedScope = scopeKey
|
||||
reposJob?.cancel()
|
||||
reposJob = viewModelScope.launch {
|
||||
_repos.value = GitStateUiState.Loading
|
||||
client.repos().fold(
|
||||
onSuccess = { list ->
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Ready(list, null)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (scopeKey == expectedScope) {
|
||||
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" })
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectRepo(repoId: String) {
|
||||
val client = api ?: return
|
||||
targetGeneration += 1
|
||||
_selectedRepoId.value = repoId
|
||||
val target = currentTarget() ?: return
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
detailJob = viewModelScope.launch {
|
||||
_detail.value = GitRepoDetailState.Loading
|
||||
val statusResult = client.status(repoId)
|
||||
val branchesResult = client.branches(repoId)
|
||||
if (currentTarget() != target) return@launch
|
||||
if (statusResult.isFailure) {
|
||||
_detail.value = GitRepoDetailState.Error(
|
||||
statusResult.exceptionOrNull()?.message ?: "Failed to load status",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val status: GitStatus = statusResult.getOrThrow()
|
||||
val branches: List<GitBranch> = branchesResult.getOrDefault(emptyList())
|
||||
_detail.value = GitRepoDetailState.Ready(status, branches)
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one owner/repository-bound mutation without cancelling another mutation. */
|
||||
private fun runMutation(
|
||||
label: String,
|
||||
expectedTarget: GitTarget? = null,
|
||||
onSuccess: (GitTarget) -> Unit = {},
|
||||
block: suspend (GitStateApiClient, String) -> Result<GitMutationState>,
|
||||
) {
|
||||
val client = api ?: run {
|
||||
_mutation.value = GitMutationState.Error(label, "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error(label, "No repository selected")
|
||||
return
|
||||
}
|
||||
if (expectedTarget != null && expectedTarget != target) {
|
||||
_mutation.value = GitMutationState.Error(label, "Repository context changed; review the action again.")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
if (mutationJob?.isActive == true) {
|
||||
_mutation.value = GitMutationState.Error(label, "Another Git action is still in progress.")
|
||||
return
|
||||
}
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob = viewModelScope.launch {
|
||||
_mutation.value = GitMutationState.InProgress(label)
|
||||
block(client, target.repoId).fold(
|
||||
onSuccess = {
|
||||
if (currentTarget() != target) return@fold
|
||||
_mutation.value = it
|
||||
_content.value = GitContentViewState.Idle
|
||||
refreshDetail(client, target)
|
||||
onSuccess(target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDetail(client: GitStateApiClient, target: GitTarget) {
|
||||
val statusResult = client.status(target.repoId)
|
||||
val branchesResult = client.branches(target.repoId)
|
||||
if (currentTarget() == target && statusResult.isSuccess) {
|
||||
_detail.value = GitRepoDetailState.Ready(
|
||||
statusResult.getOrDefault(GitStatus()),
|
||||
branchesResult.getOrDefault(emptyList()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read operations ────────────────────────────────────────────────────
|
||||
|
||||
fun loadDiff(path: String, kind: String) {
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.diff(target.repoId, path, kind).fold(
|
||||
onSuccess = { diff ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Diff(diff)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load diff",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFile(path: String) {
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.file(target.repoId, path).fold(
|
||||
onSuccess = { file ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.File(file)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load file",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write operations ───────────────────────────────────────────────────
|
||||
|
||||
fun stage(paths: List<String>) = runMutation("Stage") { c, r ->
|
||||
c.stage(r, paths).map { GitMutationState.Success("stage", it.head) }
|
||||
}
|
||||
|
||||
fun unstage(paths: List<String>) = runMutation("Unstage") { c, r ->
|
||||
c.unstage(r, paths).map { GitMutationState.Success("unstage", it.head) }
|
||||
}
|
||||
|
||||
fun discard(
|
||||
paths: List<String>,
|
||||
confirmation: String,
|
||||
deleteUntracked: Boolean = false,
|
||||
expectedTarget: GitTarget? = null,
|
||||
) =
|
||||
runMutation("Discard", expectedTarget = expectedTarget) { c, r ->
|
||||
c.discard(r, paths, confirmation, deleteUntracked)
|
||||
.map { GitMutationState.Success("discard", it.head) }
|
||||
}
|
||||
|
||||
fun commit(message: String, onSuccess: (GitTarget) -> Unit = {}) =
|
||||
runMutation("Commit", onSuccess = onSuccess) { c, r ->
|
||||
c.commit(r, message).map {
|
||||
GitMutationState.Success("commit", it.head)
|
||||
}
|
||||
}
|
||||
|
||||
fun commitSelected(message: String, paths: List<String>) = runMutation("Commit") { c, r ->
|
||||
c.commitSelected(r, message, paths).map {
|
||||
GitMutationState.Success("commit", it.head)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetch(remote: String = "origin") = runMutation("Fetch") { c, r ->
|
||||
c.fetch(r, remote).map { GitMutationState.Success("fetch", it.head) }
|
||||
}
|
||||
|
||||
fun pull(remote: String = "origin", branch: String = "") = runMutation("Pull") { c, r ->
|
||||
c.pull(r, remote, branch).map { GitMutationState.Success("pull", it.head) }
|
||||
}
|
||||
|
||||
fun push(
|
||||
confirmation: String,
|
||||
remote: String = "origin",
|
||||
branch: String = "",
|
||||
expectedTarget: GitTarget? = null,
|
||||
) =
|
||||
runMutation("Push", expectedTarget = expectedTarget) { c, r ->
|
||||
c.push(r, confirmation, remote, branch).map { GitMutationState.Success("push", it.head) }
|
||||
}
|
||||
|
||||
fun checkout(
|
||||
ref: String,
|
||||
confirmation: String? = null,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
expectedTarget: GitTarget? = null,
|
||||
) = runMutation("Checkout", expectedTarget = expectedTarget) { c, r ->
|
||||
c.checkout(r, ref, confirmation, newBranch, track)
|
||||
.map { GitMutationState.Success("checkout", it.head) }
|
||||
}
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────
|
||||
|
||||
/** Toggle the push-after-commit flow (default OFF; never bypasses confirm). */
|
||||
fun setPushAfterCommit(enabled: Boolean) {
|
||||
_pushAfterCommit.value = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a commit-message suggestion from the staged diff (AI magic-wand).
|
||||
* Empty staged diff / model-unavailable degrade to a notice, never an error.
|
||||
* Uses the shared write grant gate (a POST is never sent without the grant).
|
||||
*/
|
||||
fun generateCommitMessage(paths: List<String>? = null) {
|
||||
val client = api ?: run {
|
||||
_messageGeneration.value =
|
||||
GitMessageGenerationState.Ready("", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready("", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(
|
||||
"",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
messageJob?.cancel()
|
||||
messageJob = viewModelScope.launch {
|
||||
_messageGeneration.value = GitMessageGenerationState.Loading
|
||||
val result = if (paths != null) {
|
||||
client.commitMessageSelected(target.repoId, paths)
|
||||
} else {
|
||||
client.commitMessage(target.repoId)
|
||||
}
|
||||
if (currentTarget() != target) return@launch
|
||||
result.fold(
|
||||
onSuccess = { msg ->
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(msg.message, msg.notice)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(
|
||||
"",
|
||||
error.message ?: "Could not generate a commit message.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout that auto-stashes a dirty tree first. No confirmation is needed
|
||||
* because a stash is recoverable. Surfaces the stash message via [stashNotice].
|
||||
*/
|
||||
fun stashCheckout(ref: String, newBranch: String = "", track: Boolean = false) {
|
||||
val client = api ?: run {
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
_stashNotice.value = null
|
||||
if (mutationJob?.isActive == true) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
"Another Git action is still in progress.",
|
||||
)
|
||||
return
|
||||
}
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob = viewModelScope.launch {
|
||||
_mutation.value = GitMutationState.InProgress("Stash Checkout")
|
||||
client.stashCheckout(target.repoId, ref, newBranch, track).fold(
|
||||
onSuccess = { result ->
|
||||
if (currentTarget() != target) return@fold
|
||||
if (result.stashed) {
|
||||
_stashNotice.value =
|
||||
"Stashed changes on $ref as \"${result.stashMessage}\". Use \"git stash pop\" to restore them."
|
||||
}
|
||||
_mutation.value = GitMutationState.Success("stash-checkout", result.head)
|
||||
_content.value = GitContentViewState.Idle
|
||||
refreshDetail(client, target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearStashNotice() {
|
||||
_stashNotice.value = null
|
||||
}
|
||||
|
||||
/** True when the push-after-commit toggle is currently enabled. */
|
||||
fun isPushAfterCommitEnabled(): Boolean = _pushAfterCommit.value
|
||||
|
||||
/** True when the named destructive op needs a confirmation echo. */
|
||||
fun requiresConfirmation(op: String): Boolean = op in setOf("discard", "push", "dirty-checkout")
|
||||
|
||||
/** Fixed confirmation token for a destructive op (matches the server). */
|
||||
fun confirmationFor(op: String): String? = when (op) {
|
||||
"discard" -> GitConfirmationStrings.DISCARD
|
||||
"push" -> GitConfirmationStrings.PUSH
|
||||
"dirty-checkout" -> GitConfirmationStrings.DIRTY_CHECKOUT
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun clearMutationError() {
|
||||
if (_mutation.value is GitMutationState.Error) {
|
||||
_mutation.value = GitMutationState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ sealed interface PluginsHubState {
|
||||
data object Disconnected : PluginsHubState
|
||||
data object Loading : PluginsHubState
|
||||
data class Ready(
|
||||
val ownerKey: String,
|
||||
val plugins: List<PluginHubItem>,
|
||||
val preview: PluginCatalogPreview,
|
||||
val refreshing: Boolean = false,
|
||||
@@ -220,6 +221,7 @@ class PluginsViewModel(application: Application) : AndroidViewModel(application)
|
||||
_hubState.value = result.fold(
|
||||
onSuccess = { items ->
|
||||
PluginsHubState.Ready(
|
||||
ownerKey = expectedKey,
|
||||
plugins = items,
|
||||
preview = catalogPreview(items),
|
||||
)
|
||||
|
||||
@@ -90,6 +90,7 @@ import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
|
||||
@@ -1037,6 +1038,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var bargeInListener: BargeInListener? = null
|
||||
private var bargeInListenerJob: Job? = null
|
||||
private var bargeInVadEngine: VadEngine? = null
|
||||
/**
|
||||
* The most recent asynchronous AudioRecord shutdown still releasing the
|
||||
* process-wide BargeIn microphone lease. Teardown is intentionally
|
||||
* idempotent, so completion paths may call [stopBargeInListener] after the
|
||||
* listener reference has already been cleared. Retaining this fence makes
|
||||
* every subsequent VoiceCapture start join the same ownership handoff.
|
||||
*/
|
||||
private val pendingBargeInReaderRelease = AtomicReference<Job?>(null)
|
||||
private val bargeInTurnEpoch = AtomicLong(0L)
|
||||
@Volatile private var activeBargeInTurnEpoch: Long = 0L
|
||||
|
||||
@@ -1373,6 +1382,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
|
||||
// A mode change supersedes any capture that is still waiting for the
|
||||
// previous microphone owner to release. The selected mode below may
|
||||
// start a fresh Continuous capture with its own generation.
|
||||
cancelPendingListeningStart()
|
||||
|
||||
if (mode != InteractionMode.Continuous) {
|
||||
continuousLoopArmed = false
|
||||
continuousListeningPaused = false
|
||||
@@ -1844,6 +1858,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
fun exitVoiceMode() {
|
||||
cancelPendingListeningStart()
|
||||
// Idempotence guard — added 2026-04-21 after logcat showed the voice-
|
||||
// exit chime playing on every Add-connection tap.
|
||||
//
|
||||
@@ -1995,6 +2010,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fun startListening() {
|
||||
startListening(requireContinuousLoop = false)
|
||||
}
|
||||
|
||||
private fun startListening(requireContinuousLoop: Boolean) {
|
||||
// A direct mic tap starts a normal capture. Only the recorder opened by
|
||||
// onBargeInDetected may carry response-interruption command context.
|
||||
responseInterruptedForVoiceCommand = false
|
||||
@@ -2003,7 +2022,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
setError("Recorder not initialized")
|
||||
return
|
||||
}
|
||||
if (pendingListeningStartJob?.isActive == true) return
|
||||
if (requireContinuousLoop && !canStartContinuousCapture()) return
|
||||
// A direct/new capture request supersedes a stale handoff waiter. It
|
||||
// will join the same retained microphone-release fence under a fresh
|
||||
// epoch below instead of being silently dropped.
|
||||
cancelPendingListeningStart()
|
||||
if (rec.isRecording()) return
|
||||
if (_uiState.value.state == VoiceState.Listening) {
|
||||
// Listening is reserved for a live AudioRecord. Reconcile a stale
|
||||
@@ -2037,7 +2060,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
try { realtimePcmPlayer?.stop() } catch (_: Exception) { /* ignore */ }
|
||||
|
||||
if (microphoneRelease == null || microphoneRelease.isCompleted) {
|
||||
startVoiceCapture(rec)
|
||||
if (!requireContinuousLoop || canStartContinuousCapture()) {
|
||||
startVoiceCapture(rec)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2045,7 +2070,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val pendingStart = viewModelScope.launch(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
microphoneRelease.join()
|
||||
if (listeningStartEpoch == startEpoch) {
|
||||
if (listeningStartEpoch == startEpoch &&
|
||||
(!requireContinuousLoop || canStartContinuousCapture())
|
||||
) {
|
||||
startVoiceCapture(rec)
|
||||
}
|
||||
} finally {
|
||||
@@ -2058,8 +2085,20 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
pendingStart.start()
|
||||
}
|
||||
|
||||
private fun startVoiceCapture(rec: VoiceRecorder) {
|
||||
try {
|
||||
private fun canStartContinuousCapture(): Boolean {
|
||||
val state = _uiState.value
|
||||
return state.voiceMode &&
|
||||
state.interactionMode == InteractionMode.Continuous &&
|
||||
state.state == VoiceState.Idle &&
|
||||
continuousLoopArmed &&
|
||||
!continuousListeningPaused
|
||||
}
|
||||
|
||||
private fun startVoiceCapture(
|
||||
rec: VoiceRecorder,
|
||||
bargeInCapture: Boolean = false,
|
||||
): Boolean {
|
||||
return try {
|
||||
rec.startRecording()
|
||||
listeningStartedAtMs = System.currentTimeMillis()
|
||||
_uiState.update {
|
||||
@@ -2074,16 +2113,20 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
handoffStatus = null,
|
||||
)
|
||||
}
|
||||
// 2026-04-18: arm silence-based auto-stop. HoldToTalk skips
|
||||
// this — the physical release is the authoritative stop.
|
||||
if (_uiState.value.interactionMode != InteractionMode.HoldToTalk) {
|
||||
// A normal Hold-to-talk capture ends on physical release. A
|
||||
// barge-in capture is VAD-owned instead, so it always needs the
|
||||
// silence watchdog even when Hold-to-talk is the saved mode;
|
||||
// otherwise the steering utterance remains open indefinitely.
|
||||
if (shouldArmVoiceSilenceWatchdog(_uiState.value.interactionMode, bargeInCapture)) {
|
||||
startSilenceWatchdog()
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
listeningStartedAtMs = 0L
|
||||
Log.e(TAG, "startListening failed: ${e.message}")
|
||||
// SecurityException path becomes "Permission needed" via the classifier.
|
||||
surfaceError(e, context = "record")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2218,6 +2261,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* listening turn; until then, idle queue-drain callbacks are ignored.
|
||||
*/
|
||||
fun pauseContinuousMode() {
|
||||
cancelPendingListeningStart()
|
||||
continuousLoopArmed = false
|
||||
continuousListeningPaused = _uiState.value.interactionMode == InteractionMode.Continuous
|
||||
continuousResumeJob?.cancel()
|
||||
@@ -5760,7 +5804,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
continuousLoopArmed &&
|
||||
_uiState.value.state == VoiceState.Idle
|
||||
) {
|
||||
startListening()
|
||||
startListening(requireContinuousLoop = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5825,6 +5869,29 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!activeResponseOwnsBargeIn()) {
|
||||
Log.i(TAG, "Barge-in listener skipped; no active voice response owns the microphone")
|
||||
return
|
||||
}
|
||||
|
||||
val pendingReaderRelease = pendingBargeInReaderRelease.get()?.takeUnless { it.isCompleted }
|
||||
if (pendingReaderRelease != null) {
|
||||
// A late playback/realtime callback may request the next turn's
|
||||
// listener while the previous AudioRecord is still unwinding.
|
||||
// Join the same ownership fence as VoiceCapture, then re-check the
|
||||
// turn epoch so stale generations cannot reopen the microphone.
|
||||
activeBargeInTurnEpoch = epoch
|
||||
viewModelScope.launch {
|
||||
pendingReaderRelease.join()
|
||||
if (activeBargeInTurnEpoch == epoch &&
|
||||
bargeInListener == null &&
|
||||
activeResponseOwnsBargeIn()
|
||||
) {
|
||||
startBargeInListenerIfEnabled(epoch = epoch)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val vad = try {
|
||||
vadFactory().also { it.setSensitivity(prefs.sensitivity) }
|
||||
@@ -5873,6 +5940,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun activeResponseOwnsBargeIn(): Boolean {
|
||||
val state = _uiState.value
|
||||
return state.voiceMode &&
|
||||
(state.state == VoiceState.Thinking || state.state == VoiceState.Speaking)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the active [BargeInListener], cancel its event subscribers,
|
||||
* unduck the player (in case a ducking watchdog hadn't yet restored
|
||||
@@ -5906,7 +5979,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
try { realtimePcmPlayer?.unduck() } catch (_: Throwable) { /* ignore */ }
|
||||
isDucked = false
|
||||
}
|
||||
return stoppedReaderJob
|
||||
if (stoppedReaderJob != null) {
|
||||
pendingBargeInReaderRelease.set(stoppedReaderJob)
|
||||
stoppedReaderJob.invokeOnCompletion {
|
||||
pendingBargeInReaderRelease.compareAndSet(stoppedReaderJob, null)
|
||||
}
|
||||
}
|
||||
return pendingBargeInReaderRelease.get()?.takeUnless { it.isCompleted }
|
||||
}
|
||||
|
||||
private fun markBargeInPlaybackStarted(graceMs: Long) {
|
||||
@@ -5958,6 +6037,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
internal fun onBargeInDetected() {
|
||||
if (isBargeInStartupGuardActive()) return
|
||||
val interruptedMode = _uiState.value.interactionMode
|
||||
val interruptedEngine = voiceEngineMode
|
||||
val interruptedSpokenReply = _uiState.value.outputAudioActive
|
||||
if (interruptedSpokenReply) spokenInterruptionLatch.mark()
|
||||
duckingWatchdog?.cancel(); duckingWatchdog = null
|
||||
@@ -5991,18 +6072,68 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
responseText = "",
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val captureEpoch = ++listeningStartEpoch
|
||||
val pendingStart = viewModelScope.launch(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
microphoneRelease?.join()
|
||||
val rec = recorder
|
||||
if (rec != null && !rec.isRecording()) {
|
||||
rec.startRecording()
|
||||
if (!canStartBargeInCapture(captureEpoch, interruptedMode, interruptedEngine)) {
|
||||
abandonBargeInCaptureIfCurrent(captureEpoch)
|
||||
return@launch
|
||||
}
|
||||
scheduleResumeWatchdog()
|
||||
val rec = recorder ?: error("Recorder not initialized")
|
||||
val captureStarted = rec.isRecording() ||
|
||||
startVoiceCapture(rec, bargeInCapture = true)
|
||||
if (!captureStarted) {
|
||||
abandonBargeInCaptureIfCurrent(captureEpoch)
|
||||
return@launch
|
||||
}
|
||||
if (canStartBargeInCapture(captureEpoch, interruptedMode, interruptedEngine)) {
|
||||
scheduleResumeWatchdog()
|
||||
} else {
|
||||
silenceWatchdogJob?.cancel()
|
||||
silenceWatchdogJob = null
|
||||
try { rec.cancel() } catch (_: Throwable) { /* ignore */ }
|
||||
abandonBargeInCaptureIfCurrent(captureEpoch)
|
||||
}
|
||||
} catch (t: CancellationException) {
|
||||
abandonBargeInCaptureIfCurrent(captureEpoch)
|
||||
throw t
|
||||
} catch (t: Throwable) {
|
||||
responseInterruptedForVoiceCommand = false
|
||||
Log.w(TAG, "barge-in microphone handoff failed: ${t.message}")
|
||||
surfaceError(t, context = "record")
|
||||
if (listeningStartEpoch == captureEpoch) {
|
||||
responseInterruptedForVoiceCommand = false
|
||||
Log.w(TAG, "barge-in microphone handoff failed: ${t.message}")
|
||||
surfaceError(t, context = "record")
|
||||
}
|
||||
} finally {
|
||||
if (listeningStartEpoch == captureEpoch) {
|
||||
pendingListeningStartJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingListeningStartJob = pendingStart
|
||||
pendingStart.start()
|
||||
}
|
||||
|
||||
private fun canStartBargeInCapture(
|
||||
captureEpoch: Long,
|
||||
interruptedMode: InteractionMode,
|
||||
interruptedEngine: VoiceEngineMode,
|
||||
): Boolean {
|
||||
val state = _uiState.value
|
||||
return listeningStartEpoch == captureEpoch &&
|
||||
state.voiceMode &&
|
||||
state.state == VoiceState.Listening &&
|
||||
state.interactionMode == interruptedMode &&
|
||||
voiceEngineMode == interruptedEngine &&
|
||||
responseInterruptedForVoiceCommand
|
||||
}
|
||||
|
||||
private fun abandonBargeInCaptureIfCurrent(captureEpoch: Long) {
|
||||
if (listeningStartEpoch != captureEpoch) return
|
||||
responseInterruptedForVoiceCommand = false
|
||||
if (_uiState.value.state == VoiceState.Listening && recorder?.isRecording() != true) {
|
||||
_uiState.update {
|
||||
it.copy(state = VoiceState.Idle, amplitude = 0f, outputAudioActive = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6314,9 +6445,22 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
startBargeInListenerIfEnabled()
|
||||
}
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun stopBargeInListenerForTest(): Job? = stopBargeInListener()
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun finishAgentAudioOutputForTest() {
|
||||
finishAgentAudioOutput()
|
||||
}
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun setVoiceEngineModeForTest(mode: VoiceEngineMode) {
|
||||
voiceEngineMode = mode
|
||||
}
|
||||
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal fun beginBargeInTurnForTest() {
|
||||
_uiState.update { it.copy(state = VoiceState.Thinking) }
|
||||
_uiState.update { it.copy(voiceMode = true, state = VoiceState.Thinking) }
|
||||
beginBargeInTurnIfEnabled()
|
||||
}
|
||||
|
||||
@@ -6850,6 +6994,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
}
|
||||
|
||||
internal fun shouldArmVoiceSilenceWatchdog(
|
||||
interactionMode: InteractionMode,
|
||||
bargeInCapture: Boolean,
|
||||
): Boolean = bargeInCapture || interactionMode != InteractionMode.HoldToTalk
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Voice capture guards and sentence-boundary detection (top-level so they're
|
||||
// unit-testable without instantiating an AndroidViewModel + Application).
|
||||
|
||||
@@ -73,6 +73,7 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -91,6 +92,9 @@ import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.ui.components.distinctVoiceRouteParts
|
||||
import com.hermesandroid.relay.ui.components.dispatchVoiceMicHoldPress
|
||||
import com.hermesandroid.relay.ui.components.dispatchVoiceMicTap
|
||||
import com.hermesandroid.relay.ui.components.voiceHoldGesture
|
||||
import com.hermesandroid.relay.ui.components.voiceRouteDisplayLabel
|
||||
import com.hermesandroid.relay.ui.theme.PersistedHermesRelayTheme
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
@@ -106,6 +110,8 @@ import kotlin.math.sin
|
||||
|
||||
private const val TWO_PI = 6.2831855f
|
||||
private const val HALF_PI = 1.5707964f
|
||||
internal const val VOICE_FLOATING_OVERLAY_MIC_TEST_TAG = "voiceFloatingOverlayMic"
|
||||
internal const val VOICE_OVERLAY_MIC_CONTROL_TEST_TAG = "voiceOverlayMicControl"
|
||||
|
||||
// Matches the in-app VoiceWaveform palette so minimized overlay mode reads as
|
||||
// the same voice surface, just wrapped around the mic control.
|
||||
@@ -638,7 +644,7 @@ private fun overlayPrimaryText(uiState: VoiceUiState, fallback: String): String
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun VoiceFloatingOverlayBubble(
|
||||
internal fun VoiceFloatingOverlayBubble(
|
||||
uiState: VoiceUiState,
|
||||
stateText: String,
|
||||
onExpand: () -> Unit,
|
||||
@@ -658,6 +664,9 @@ private fun VoiceFloatingOverlayBubble(
|
||||
VoiceState.Transcribing, VoiceState.Thinking ->
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) stringResource(R.string.voice_overlay_tap_action_pause) else stringResource(R.string.voice_overlay_tap_action_interrupt)
|
||||
}
|
||||
val holdStopAction = stringResource(R.string.voice_overlay_tap_action_listening)
|
||||
val inactiveHoldDescription = stringResource(R.string.voice_overlay_a11y, stateText, tapAction)
|
||||
val activeHoldDescription = stringResource(R.string.voice_overlay_a11y, stateText, holdStopAction)
|
||||
val containerColor = when (uiState.state) {
|
||||
VoiceState.Listening, VoiceState.Speaking -> Color(0xFFE53935)
|
||||
VoiceState.Transcribing, VoiceState.Thinking -> Color(0xFFE53935)
|
||||
@@ -669,6 +678,40 @@ private fun VoiceFloatingOverlayBubble(
|
||||
VoiceState.Transcribing, VoiceState.Thinking -> Icons.Filled.Stop
|
||||
VoiceState.Idle, VoiceState.Error -> Icons.Filled.Mic
|
||||
}
|
||||
val bubbleGesture = if (uiState.interactionMode == InteractionMode.HoldToTalk) {
|
||||
Modifier.voiceHoldGesture(
|
||||
state = uiState.state,
|
||||
inactiveActionLabel = tapAction,
|
||||
activeActionLabel = holdStopAction,
|
||||
onPress = {
|
||||
dispatchVoiceMicHoldPress(
|
||||
uiState = uiState,
|
||||
onStartListening = onStartListening,
|
||||
onInterruptAndStart = {
|
||||
onInterrupt()
|
||||
onStartListening()
|
||||
},
|
||||
)
|
||||
},
|
||||
onRelease = onStopListening,
|
||||
inactiveContentDescription = inactiveHoldDescription,
|
||||
activeContentDescription = activeHoldDescription,
|
||||
)
|
||||
} else {
|
||||
Modifier.combinedClickable(
|
||||
onClick = {
|
||||
dispatchVoiceMicTap(
|
||||
uiState = uiState,
|
||||
onStartListening = onStartListening,
|
||||
onStopListening = onStopListening,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
},
|
||||
onLongClick = onExpand,
|
||||
onDoubleClick = onExpand,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -691,19 +734,8 @@ private fun VoiceFloatingOverlayBubble(
|
||||
modifier = Modifier
|
||||
.size(70.dp)
|
||||
.clip(CircleShape)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
dispatchMicAction(
|
||||
uiState = uiState,
|
||||
onStartListening = onStartListening,
|
||||
onStopListening = onStopListening,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
},
|
||||
onLongClick = onExpand,
|
||||
onDoubleClick = onExpand,
|
||||
),
|
||||
.testTag(VOICE_FLOATING_OVERLAY_MIC_TEST_TAG)
|
||||
.then(bubbleGesture),
|
||||
shape = CircleShape,
|
||||
color = containerColor.copy(alpha = if (isHot) 0.96f else 0.92f),
|
||||
shadowElevation = 8.dp,
|
||||
@@ -716,7 +748,11 @@ private fun VoiceFloatingOverlayBubble(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = stringResource(R.string.voice_overlay_a11y, stateText, tapAction),
|
||||
contentDescription = if (uiState.interactionMode == InteractionMode.HoldToTalk) {
|
||||
null
|
||||
} else {
|
||||
stringResource(R.string.voice_overlay_a11y, stateText, tapAction)
|
||||
},
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(23.dp),
|
||||
)
|
||||
@@ -730,6 +766,22 @@ private fun VoiceFloatingOverlayBubble(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (uiState.interactionMode == InteractionMode.HoldToTalk) {
|
||||
IconButton(
|
||||
onClick = onExpand,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.size(30.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ExpandMore,
|
||||
contentDescription = stringResource(R.string.voice_overlay_expand_cd),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,7 +904,7 @@ private fun overlayBubbleStateLabel(state: VoiceState): String = when (state) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MicControlButton(
|
||||
internal fun MicControlButton(
|
||||
uiState: VoiceUiState,
|
||||
onStartListening: () -> Unit,
|
||||
onStopListening: () -> Unit,
|
||||
@@ -864,19 +916,56 @@ private fun MicControlButton(
|
||||
uiState.state == VoiceState.Speaking ||
|
||||
uiState.state == VoiceState.Transcribing ||
|
||||
uiState.state == VoiceState.Thinking
|
||||
val actionDescription = when (uiState.state) {
|
||||
VoiceState.Idle, VoiceState.Error -> stringResource(R.string.voice_overlay_tap_action_idle)
|
||||
VoiceState.Listening ->
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
stringResource(R.string.voice_overlay_tap_action_pause)
|
||||
} else {
|
||||
stringResource(R.string.voice_overlay_tap_action_listening)
|
||||
}
|
||||
VoiceState.Transcribing, VoiceState.Thinking, VoiceState.Speaking ->
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
stringResource(R.string.voice_overlay_tap_action_pause)
|
||||
} else {
|
||||
stringResource(R.string.voice_overlay_tap_action_interrupt)
|
||||
}
|
||||
}
|
||||
val holdStopDescription = stringResource(R.string.voice_overlay_tap_action_listening)
|
||||
val gestureModifier = if (uiState.interactionMode == InteractionMode.HoldToTalk) {
|
||||
Modifier.voiceHoldGesture(
|
||||
state = uiState.state,
|
||||
inactiveActionLabel = actionDescription,
|
||||
activeActionLabel = holdStopDescription,
|
||||
onPress = {
|
||||
dispatchVoiceMicHoldPress(
|
||||
uiState = uiState,
|
||||
onStartListening = onStartListening,
|
||||
onInterruptAndStart = {
|
||||
onInterrupt()
|
||||
onStartListening()
|
||||
},
|
||||
)
|
||||
},
|
||||
onRelease = onStopListening,
|
||||
)
|
||||
} else {
|
||||
Modifier.clickable {
|
||||
dispatchVoiceMicTap(
|
||||
uiState = uiState,
|
||||
onStartListening = onStartListening,
|
||||
onStopListening = onStopListening,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.clickable {
|
||||
dispatchMicAction(
|
||||
uiState = uiState,
|
||||
onStartListening = onStartListening,
|
||||
onStopListening = onStopListening,
|
||||
onInterrupt = onInterrupt,
|
||||
onPauseAutoMode = onPauseAutoMode,
|
||||
)
|
||||
},
|
||||
.testTag(VOICE_OVERLAY_MIC_CONTROL_TEST_TAG)
|
||||
.then(gestureModifier),
|
||||
shape = CircleShape,
|
||||
color = when {
|
||||
isStopAction -> Color(0xFFE53935)
|
||||
@@ -895,7 +984,11 @@ private fun MicControlButton(
|
||||
Icons.Filled.Stop
|
||||
else -> Icons.Filled.Mic
|
||||
},
|
||||
contentDescription = stringResource(R.string.voice_overlay_mic_a11y),
|
||||
contentDescription = if (uiState.interactionMode == InteractionMode.HoldToTalk) {
|
||||
null
|
||||
} else {
|
||||
actionDescription
|
||||
},
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
@@ -903,39 +996,6 @@ private fun MicControlButton(
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchMicAction(
|
||||
uiState: VoiceUiState,
|
||||
onStartListening: () -> Unit,
|
||||
onStopListening: () -> Unit,
|
||||
onInterrupt: () -> Unit,
|
||||
onPauseAutoMode: () -> Unit,
|
||||
) {
|
||||
when (uiState.state) {
|
||||
VoiceState.Listening -> {
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
onPauseAutoMode()
|
||||
} else {
|
||||
onStopListening()
|
||||
}
|
||||
}
|
||||
VoiceState.Speaking -> {
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
onPauseAutoMode()
|
||||
} else {
|
||||
onInterrupt()
|
||||
}
|
||||
}
|
||||
VoiceState.Transcribing, VoiceState.Thinking -> {
|
||||
if (uiState.interactionMode == InteractionMode.Continuous) {
|
||||
onPauseAutoMode()
|
||||
} else {
|
||||
onInterrupt()
|
||||
}
|
||||
}
|
||||
VoiceState.Idle, VoiceState.Error -> onStartListening()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusChip(
|
||||
text: String,
|
||||
|
||||
@@ -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>
|
||||
@@ -3858,6 +3864,48 @@
|
||||
<string name="plugins_keep">Manter</string>
|
||||
<string name="plugins_remove">Remover</string>
|
||||
<string name="plugins_remove_confirm">Remover “%1$s”? Esta página do plugin não aparecerá mais nos dispositivos Android conectados.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Voltar</string>
|
||||
<string name="git_state_staged">Preparados</string>
|
||||
<string name="git_state_modified">Modificados</string>
|
||||
<string name="git_state_untracked">Não rastreados</string>
|
||||
<string name="git_state_branches">Ramos</string>
|
||||
<string name="git_state_truncated">Resultados truncados — apenas as primeiras entradas são exibidas.</string>
|
||||
<string name="git_state_no_changes">(sem alterações)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Alterações de gravação exigem a permissão de alterações de plugin. Ative "Permitir alterações de plugins (plugin.api.write)" nas configurações de permissão de Plugins.</string>
|
||||
<string name="git_state_stage">Preparar</string>
|
||||
<string name="git_state_unstage">Despreparar</string>
|
||||
<string name="git_state_discard">Descartar</string>
|
||||
<string name="git_state_commit">Confirmar</string>
|
||||
<string name="git_state_commit_title">Confirmar alterações preparadas</string>
|
||||
<string name="git_state_commit_message_hint">Mensagem do commit</string>
|
||||
<string name="git_state_commit_confirm">Confirmar</string>
|
||||
<string name="git_state_cancel">Cancelar</string>
|
||||
<string name="git_state_fetch">Buscar</string>
|
||||
<string name="git_state_pull">Puxar</string>
|
||||
<string name="git_state_push">Enviar</string>
|
||||
<string name="git_state_new_branch_hint">Nome da nova ramificação</string>
|
||||
<string name="git_state_create_branch">Criar ramificação</string>
|
||||
<string name="git_state_track_remote">Rastrear ramificação remota</string>
|
||||
<string name="git_state_switch">Alternar</string>
|
||||
<string name="git_state_current">Atual</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s em andamento…</string>
|
||||
<string name="git_state_mutation_success">%1$s concluído. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s falhou: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Descartar alterações?</string>
|
||||
<string name="git_state_confirm_discard_text">As alterações locais dos arquivos selecionados serão descartadas e não poderão ser recuperadas.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Descartar</string>
|
||||
<string name="git_state_confirm_push_title">Enviar alterações?</string>
|
||||
<string name="git_state_confirm_push_text">Envia a ramificação atual para o repositório remoto. Isso publica os commits locais no repositório remoto.</string>
|
||||
<string name="git_state_confirm_push_confirm">Enviar</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Alternar ramificação com alterações locais?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">A árvore de trabalho tem alterações não confirmadas. A alternância pode carregá-las para a ramificação de destino.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Alternar</string>
|
||||
<string name="git_state_generate_message">Gerar mensagem de commit</string>
|
||||
<string name="git_state_generating_message">Gerando mensagem de commit…</string>
|
||||
<string name="git_state_push_after_commit">Enviar após o commit</string>
|
||||
<string name="git_state_switch_stash">Alternar (guardar se houver alterações)</string>
|
||||
<string name="support_bundle_review">Revisar informações de suporte</string>
|
||||
<string name="support_bundle_title">Informações de suporte</string>
|
||||
<string name="support_bundle_privacy">Nada é enviado automaticamente. Revise a exportação local com até %1$d relatórios.</string>
|
||||
@@ -4223,4 +4271,13 @@
|
||||
<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>
|
||||
</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>
|
||||
@@ -3946,6 +3949,48 @@
|
||||
<string name="plugins_keep">保留</string>
|
||||
<string name="plugins_remove">移除</string>
|
||||
<string name="plugins_remove_confirm">要移除“%1$s”吗?此插件页面将不再显示在已连接的 Android 设备上。</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">返回</string>
|
||||
<string name="git_state_staged">已暂存</string>
|
||||
<string name="git_state_modified">已修改</string>
|
||||
<string name="git_state_untracked">未跟踪</string>
|
||||
<string name="git_state_branches">分支</string>
|
||||
<string name="git_state_truncated">结果已截断 — 仅显示前几条。</string>
|
||||
<string name="git_state_no_changes">(无更改)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">写入更改需要插件更改权限。请在“插件”的权限设置中启用“允许插件更改 (plugin.api.write)”。</string>
|
||||
<string name="git_state_stage">暂存</string>
|
||||
<string name="git_state_unstage">取消暂存</string>
|
||||
<string name="git_state_discard">丢弃</string>
|
||||
<string name="git_state_commit">提交</string>
|
||||
<string name="git_state_commit_title">提交已暂存的更改</string>
|
||||
<string name="git_state_commit_message_hint">提交消息</string>
|
||||
<string name="git_state_commit_confirm">提交</string>
|
||||
<string name="git_state_cancel">取消</string>
|
||||
<string name="git_state_fetch">获取</string>
|
||||
<string name="git_state_pull">拉取</string>
|
||||
<string name="git_state_push">推送</string>
|
||||
<string name="git_state_new_branch_hint">新分支名称</string>
|
||||
<string name="git_state_create_branch">创建分支</string>
|
||||
<string name="git_state_track_remote">跟踪远程分支</string>
|
||||
<string name="git_state_switch">切换</string>
|
||||
<string name="git_state_current">当前</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s 正在进行…</string>
|
||||
<string name="git_state_mutation_success">%1$s 已完成。HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s 失败:%2$s</string>
|
||||
<string name="git_state_confirm_discard_title">丢弃更改?</string>
|
||||
<string name="git_state_confirm_discard_text">所选文件的本地更改将被丢弃,且无法恢复。</string>
|
||||
<string name="git_state_confirm_discard_confirm">丢弃</string>
|
||||
<string name="git_state_confirm_push_title">推送更改?</string>
|
||||
<string name="git_state_confirm_push_text">将当前分支推送到其远程仓库。这会把本地提交发送到远程仓库。</string>
|
||||
<string name="git_state_confirm_push_confirm">推送</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">切换包含本地更改的分支?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">工作区有未提交的更改。切换后,这些更改可能会被带到目标分支。</string>
|
||||
<string name="git_state_confirm_checkout_confirm">切换</string>
|
||||
<string name="git_state_generate_message">生成提交信息</string>
|
||||
<string name="git_state_generating_message">正在生成提交信息…</string>
|
||||
<string name="git_state_push_after_commit">提交后推送</string>
|
||||
<string name="git_state_switch_stash">切换(有更改时暂存)</string>
|
||||
<string name="support_bundle_review">查看支持信息</string>
|
||||
<string name="support_bundle_title">支持信息</string>
|
||||
<string name="support_bundle_privacy">不会自动上传任何内容。请查看最多包含 %1$d 个报告的本地导出。</string>
|
||||
@@ -4308,4 +4353,13 @@
|
||||
<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>
|
||||
</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>
|
||||
@@ -4018,6 +4021,48 @@
|
||||
<string name="plugins_keep">Behalten</string>
|
||||
<string name="plugins_remove">Entfernen</string>
|
||||
<string name="plugins_remove_confirm">„%1$s“ entfernen? Diese Plugin-Seite wird auf verbundenen Android-Geräten nicht mehr angezeigt.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Zurück</string>
|
||||
<string name="git_state_staged">Gestaged</string>
|
||||
<string name="git_state_modified">Geändert</string>
|
||||
<string name="git_state_untracked">Unverfolgt</string>
|
||||
<string name="git_state_branches">Branches</string>
|
||||
<string name="git_state_truncated">Ergebnisse abgeschnitten – nur die ersten Einträge werden angezeigt.</string>
|
||||
<string name="git_state_no_changes">(keine Änderungen)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Für Schreibvorgänge ist die Plugin-Änderungsberechtigung erforderlich. Aktivieren Sie „Änderungen durch Plugins zulassen (plugin.api.write)“ in den Plugin-Berechtigungseinstellungen.</string>
|
||||
<string name="git_state_stage">Bereitstellen</string>
|
||||
<string name="git_state_unstage">Zurücksetzen</string>
|
||||
<string name="git_state_discard">Verwerfen</string>
|
||||
<string name="git_state_commit">Committen</string>
|
||||
<string name="git_state_commit_title">Bereitgestellte Änderungen committen</string>
|
||||
<string name="git_state_commit_message_hint">Commit-Nachricht</string>
|
||||
<string name="git_state_commit_confirm">Committen</string>
|
||||
<string name="git_state_cancel">Abbrechen</string>
|
||||
<string name="git_state_fetch">Abrufen</string>
|
||||
<string name="git_state_pull">Pullen</string>
|
||||
<string name="git_state_push">Pushen</string>
|
||||
<string name="git_state_new_branch_hint">Name des neuen Branches</string>
|
||||
<string name="git_state_create_branch">Branch erstellen</string>
|
||||
<string name="git_state_track_remote">Remote-Branch verfolgen</string>
|
||||
<string name="git_state_switch">Wechseln</string>
|
||||
<string name="git_state_current">Aktuell</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s läuft…</string>
|
||||
<string name="git_state_mutation_success">%1$s abgeschlossen. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s fehlgeschlagen: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Änderungen verwerfen?</string>
|
||||
<string name="git_state_confirm_discard_text">Lokale Änderungen an den ausgewählten Dateien werden verworfen und können nicht wiederhergestellt werden.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Verwerfen</string>
|
||||
<string name="git_state_confirm_push_title">Änderungen pushen?</string>
|
||||
<string name="git_state_confirm_push_text">Pushen Sie den aktuellen Branch zu seinem Remote. Dabei werden lokale Commits an das Remote-Repository gesendet.</string>
|
||||
<string name="git_state_confirm_push_confirm">Pushen</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Branch mit lokalen Änderungen wechseln?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">Der Arbeitsbaum enthält nicht committete Änderungen. Beim Wechseln werden sie möglicherweise auf den Zielbranch übertragen.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Wechseln</string>
|
||||
<string name="git_state_generate_message">Commit-Nachricht generieren</string>
|
||||
<string name="git_state_generating_message">Commit-Nachricht wird generiert…</string>
|
||||
<string name="git_state_push_after_commit">Nach Commit pushen</string>
|
||||
<string name="git_state_switch_stash">Wechseln (bei Änderungen stashen)</string>
|
||||
<string name="support_bundle_review">Supportinformationen prüfen</string>
|
||||
<string name="support_bundle_title">Supportinformationen</string>
|
||||
<string name="support_bundle_privacy">Nichts wird automatisch hochgeladen. Prüfen Sie den lokalen Export mit bis zu %1$d Berichten.</string>
|
||||
@@ -4383,4 +4428,13 @@
|
||||
<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>
|
||||
</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>
|
||||
@@ -3703,6 +3712,48 @@
|
||||
<string name="plugins_keep">Conservar</string>
|
||||
<string name="plugins_remove">Eliminar</string>
|
||||
<string name="plugins_remove_confirm">¿Eliminar «%1$s»? Esta página del plugin dejará de aparecer en los dispositivos Android conectados.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Atrás</string>
|
||||
<string name="git_state_staged">Preparados</string>
|
||||
<string name="git_state_modified">Modificados</string>
|
||||
<string name="git_state_untracked">Sin seguimiento</string>
|
||||
<string name="git_state_branches">Ramas</string>
|
||||
<string name="git_state_truncated">Resultados truncados: solo se muestran las primeras entradas.</string>
|
||||
<string name="git_state_no_changes">(sin cambios)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Los cambios de escritura requieren el permiso de cambios de plugin. Activa «Permitir cambios de plugins (plugin.api.write)» en la configuración de permisos de Plugins.</string>
|
||||
<string name="git_state_stage">Preparar</string>
|
||||
<string name="git_state_unstage">Quitar de preparados</string>
|
||||
<string name="git_state_discard">Descartar</string>
|
||||
<string name="git_state_commit">Confirmar</string>
|
||||
<string name="git_state_commit_title">Confirmar cambios preparados</string>
|
||||
<string name="git_state_commit_message_hint">Mensaje de confirmación</string>
|
||||
<string name="git_state_commit_confirm">Confirmar</string>
|
||||
<string name="git_state_cancel">Cancelar</string>
|
||||
<string name="git_state_fetch">Obtener</string>
|
||||
<string name="git_state_pull">Traer</string>
|
||||
<string name="git_state_push">Enviar</string>
|
||||
<string name="git_state_new_branch_hint">Nombre de la nueva rama</string>
|
||||
<string name="git_state_create_branch">Crear rama</string>
|
||||
<string name="git_state_track_remote">Seguir rama remota</string>
|
||||
<string name="git_state_switch">Cambiar</string>
|
||||
<string name="git_state_current">Actual</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s en curso…</string>
|
||||
<string name="git_state_mutation_success">%1$s completado. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s falló: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">¿Descartar cambios?</string>
|
||||
<string name="git_state_confirm_discard_text">Los cambios locales de los archivos seleccionados se descartarán y no podrán recuperarse.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Descartar</string>
|
||||
<string name="git_state_confirm_push_title">¿Enviar cambios?</string>
|
||||
<string name="git_state_confirm_push_text">Envía la rama actual a su remoto. Esto sube los commits locales al repositorio remoto.</string>
|
||||
<string name="git_state_confirm_push_confirm">Enviar</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">¿Cambiar de rama con cambios locales?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">El árbol de trabajo tiene cambios sin confirmar. Cambiar puede trasladarlos a la rama de destino.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Cambiar</string>
|
||||
<string name="git_state_generate_message">Generar mensaje de confirmación</string>
|
||||
<string name="git_state_generating_message">Generando mensaje de confirmación…</string>
|
||||
<string name="git_state_push_after_commit">Enviar después de confirmar</string>
|
||||
<string name="git_state_switch_stash">Cambiar (guardar en stash si hay cambios)</string>
|
||||
<string name="support_bundle_review">Revisar información de soporte</string>
|
||||
<string name="support_bundle_title">Información de soporte</string>
|
||||
<string name="support_bundle_privacy">Nada se sube automáticamente. Revisa la exportación local con hasta %1$d informes.</string>
|
||||
@@ -4068,4 +4119,13 @@
|
||||
<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>
|
||||
</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>
|
||||
@@ -4017,6 +4018,48 @@
|
||||
<string name="plugins_keep">保持</string>
|
||||
<string name="plugins_remove">削除</string>
|
||||
<string name="plugins_remove_confirm">「%1$s」を削除しますか?接続された Android 端末にこのプラグインページは表示されなくなります。</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">戻る</string>
|
||||
<string name="git_state_staged">ステージ済み</string>
|
||||
<string name="git_state_modified">変更</string>
|
||||
<string name="git_state_untracked">未追跡</string>
|
||||
<string name="git_state_branches">ブランチ</string>
|
||||
<string name="git_state_truncated">結果は切り詰められています。最初の項目のみ表示されます。</string>
|
||||
<string name="git_state_no_changes">(変更なし)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">書き込み変更にはプラグイン変更権限が必要です。Plugins の権限設定で「プラグインの変更を許可する(plugin.api.write)」を有効にしてください。</string>
|
||||
<string name="git_state_stage">ステージ</string>
|
||||
<string name="git_state_unstage">ステージ解除</string>
|
||||
<string name="git_state_discard">破棄</string>
|
||||
<string name="git_state_commit">コミット</string>
|
||||
<string name="git_state_commit_title">ステージ済みの変更をコミット</string>
|
||||
<string name="git_state_commit_message_hint">コミットメッセージ</string>
|
||||
<string name="git_state_commit_confirm">コミット</string>
|
||||
<string name="git_state_cancel">キャンセル</string>
|
||||
<string name="git_state_fetch">フェッチ</string>
|
||||
<string name="git_state_pull">プル</string>
|
||||
<string name="git_state_push">プッシュ</string>
|
||||
<string name="git_state_new_branch_hint">新しいブランチ名</string>
|
||||
<string name="git_state_create_branch">ブランチを作成</string>
|
||||
<string name="git_state_track_remote">リモートブランチを追跡</string>
|
||||
<string name="git_state_switch">切り替え</string>
|
||||
<string name="git_state_current">現在</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s を実行中…</string>
|
||||
<string name="git_state_mutation_success">%1$s が完了しました。HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s に失敗しました: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">変更を破棄しますか?</string>
|
||||
<string name="git_state_confirm_discard_text">選択したファイルへのローカル変更は破棄され、元に戻せません。</string>
|
||||
<string name="git_state_confirm_discard_confirm">破棄</string>
|
||||
<string name="git_state_confirm_push_title">変更をプッシュしますか?</string>
|
||||
<string name="git_state_confirm_push_text">現在のブランチをリモートにプッシュします。ローカルのコミットがリモートリポジトリに送信されます。</string>
|
||||
<string name="git_state_confirm_push_confirm">プッシュ</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">ローカル変更のあるブランチに切り替えますか?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">作業ツリーに未コミットの変更があります。切り替えると、それらが対象ブランチへ引き継がれる可能性があります。</string>
|
||||
<string name="git_state_confirm_checkout_confirm">切り替え</string>
|
||||
<string name="git_state_generate_message">コミットメッセージを生成</string>
|
||||
<string name="git_state_generating_message">コミットメッセージを生成中…</string>
|
||||
<string name="git_state_push_after_commit">コミット後にプッシュ</string>
|
||||
<string name="git_state_switch_stash">切り替え(変更があればスタッシュ)</string>
|
||||
<string name="support_bundle_review">サポート情報を確認</string>
|
||||
<string name="support_bundle_title">サポート情報</string>
|
||||
<string name="support_bundle_privacy">自動アップロードはありません。最大 %1$d 件のレポートを含む端末内エクスポートを確認してください。</string>
|
||||
@@ -4381,4 +4424,13 @@
|
||||
<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>
|
||||
</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>
|
||||
@@ -3739,6 +3752,48 @@
|
||||
<string name="plugins_keep">Оставить</string>
|
||||
<string name="plugins_remove">Удалить</string>
|
||||
<string name="plugins_remove_confirm">Удалить «%1$s»? Эта страница плагина больше не будет отображаться на подключённых устройствах Android.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Назад</string>
|
||||
<string name="git_state_staged">В индексе</string>
|
||||
<string name="git_state_modified">Изменённые</string>
|
||||
<string name="git_state_untracked">Неотслеживаемые</string>
|
||||
<string name="git_state_branches">Ветки</string>
|
||||
<string name="git_state_truncated">Результаты усечены — показаны только первые записи.</string>
|
||||
<string name="git_state_no_changes">(без изменений)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Для записи изменений требуется разрешение на изменение плагина. Включите «Разрешить изменения плагинов (plugin.api.write)» в настройках разрешений Plugins.</string>
|
||||
<string name="git_state_stage">Добавить в индекс</string>
|
||||
<string name="git_state_unstage">Убрать из индекса</string>
|
||||
<string name="git_state_discard">Отменить</string>
|
||||
<string name="git_state_commit">Коммит</string>
|
||||
<string name="git_state_commit_title">Зафиксировать индексированные изменения</string>
|
||||
<string name="git_state_commit_message_hint">Сообщение коммита</string>
|
||||
<string name="git_state_commit_confirm">Коммит</string>
|
||||
<string name="git_state_cancel">Отмена</string>
|
||||
<string name="git_state_fetch">Обновить</string>
|
||||
<string name="git_state_pull">Вытянуть</string>
|
||||
<string name="git_state_push">Отправить</string>
|
||||
<string name="git_state_new_branch_hint">Имя новой ветки</string>
|
||||
<string name="git_state_create_branch">Создать ветку</string>
|
||||
<string name="git_state_track_remote">Отслеживать удалённую ветку</string>
|
||||
<string name="git_state_switch">Переключить</string>
|
||||
<string name="git_state_current">Текущая</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s выполняется…</string>
|
||||
<string name="git_state_mutation_success">%1$s завершено. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s не удалось: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Отменить изменения?</string>
|
||||
<string name="git_state_confirm_discard_text">Локальные изменения выбранных файлов будут отменены и их нельзя будет восстановить.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Отменить</string>
|
||||
<string name="git_state_confirm_push_title">Отправить изменения?</string>
|
||||
<string name="git_state_confirm_push_text">Отправить текущую ветку в её удалённый репозиторий. Это передаст локальные коммиты в удалённый репозиторий.</string>
|
||||
<string name="git_state_confirm_push_confirm">Отправить</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Переключить ветку с локальными изменениями?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">В рабочем дереве есть незакоммиченные изменения. Переключение может перенести их в целевую ветку.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Переключить</string>
|
||||
<string name="git_state_generate_message">Создать сообщение коммита</string>
|
||||
<string name="git_state_generating_message">Создание сообщения коммита…</string>
|
||||
<string name="git_state_push_after_commit">Отправить после коммита</string>
|
||||
<string name="git_state_switch_stash">Переключить (сохранить изменения, если есть)</string>
|
||||
<string name="support_bundle_review">Проверить сведения для поддержки</string>
|
||||
<string name="support_bundle_title">Сведения для поддержки</string>
|
||||
<string name="support_bundle_privacy">Ничего не загружается автоматически. Проверьте локальный экспорт с не более чем %1$d отчётами.</string>
|
||||
@@ -4110,4 +4165,13 @@
|
||||
<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>
|
||||
</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>
|
||||
@@ -4214,6 +4227,51 @@
|
||||
<string name="plugins_keep">Keep</string>
|
||||
<string name="plugins_remove">Remove</string>
|
||||
<string name="plugins_remove_confirm">Remove “%1$s”? This plugin page will no longer appear on connected Android devices.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Back</string>
|
||||
<string name="git_state_staged">Staged</string>
|
||||
<string name="git_state_modified">Modified</string>
|
||||
<string name="git_state_untracked">Untracked</string>
|
||||
<string name="git_state_branches">Branches</string>
|
||||
<string name="git_state_truncated">Results truncated — showing the first entries only.</string>
|
||||
<string name="git_state_no_changes">(no changes)</string>
|
||||
|
||||
<!-- Git write surface (Phase 2). All mutations require the plugin.api.write grant. -->
|
||||
<string name="git_state_write_grant_required">Write changes require the plugin change permission. Enable “Allow plugin changes (plugin.api.write)” in the Plugins grant settings.</string>
|
||||
<string name="git_state_stage">Stage</string>
|
||||
<string name="git_state_unstage">Unstage</string>
|
||||
<string name="git_state_discard">Discard</string>
|
||||
<string name="git_state_commit">Commit</string>
|
||||
<string name="git_state_commit_title">Commit staged changes</string>
|
||||
<string name="git_state_commit_message_hint">Commit message</string>
|
||||
<string name="git_state_commit_confirm">Commit</string>
|
||||
<string name="git_state_cancel">Cancel</string>
|
||||
<string name="git_state_fetch">Fetch</string>
|
||||
<string name="git_state_pull">Pull</string>
|
||||
<string name="git_state_push">Push</string>
|
||||
<string name="git_state_new_branch_hint">New branch name</string>
|
||||
<string name="git_state_create_branch">Create branch</string>
|
||||
<string name="git_state_track_remote">Track remote branch</string>
|
||||
<string name="git_state_switch">Switch</string>
|
||||
<string name="git_state_current">Current</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s in progress…</string>
|
||||
<string name="git_state_mutation_success">%1$s completed. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s failed: %2$s</string>
|
||||
<!-- Destructive-op confirmations (token is sent only on explicit user confirmation). -->
|
||||
<string name="git_state_confirm_discard_title">Discard changes?</string>
|
||||
<string name="git_state_confirm_discard_text">Local changes to the selected file(s) will be discarded and cannot be recovered.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Discard</string>
|
||||
<string name="git_state_confirm_push_title">Push changes?</string>
|
||||
<string name="git_state_confirm_push_text">Push the current branch to its remote. This sends local commits to the remote repository.</string>
|
||||
<string name="git_state_confirm_push_confirm">Push</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Switch branch with local changes?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">The working tree has uncommitted changes. Switching may carry them onto the target branch.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Switch</string>
|
||||
<string name="git_state_generate_message">Generate commit message</string>
|
||||
<string name="git_state_generating_message">Generating commit message…</string>
|
||||
<string name="git_state_push_after_commit">Push after commit</string>
|
||||
<string name="git_state_switch_stash">Switch (stash if dirty)</string>
|
||||
|
||||
<string name="support_bundle_review">Review support information</string>
|
||||
<string name="support_bundle_title">Support information</string>
|
||||
<string name="support_bundle_privacy">Nothing is uploaded automatically. Review the exact local export below. It contains up to %1$d recent reports.</string>
|
||||
@@ -4390,4 +4448,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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
+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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+190
-1
@@ -4,14 +4,28 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertContentDescriptionEquals
|
||||
import androidx.compose.ui.test.assertHasClickAction
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.click
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
@@ -53,7 +67,7 @@ class VoiceModeOverlayInteractionTest {
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithContentDescription("Voice mic")
|
||||
compose.onNodeWithContentDescription("start listening")
|
||||
.performTouchInput { click() }
|
||||
compose.onNodeWithContentDescription("Expand voice controls")
|
||||
.performTouchInput { click() }
|
||||
@@ -103,6 +117,181 @@ class VoiceModeOverlayInteractionTest {
|
||||
compose.runOnIdle { assertEquals(0, backgroundTaps) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unavailableSystemOverlayActionIsNotOffered() {
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
VoiceModeOverlay(
|
||||
uiState = idleVoiceState(),
|
||||
onMicTap = {},
|
||||
onMicRelease = {},
|
||||
onInterrupt = {},
|
||||
onDismiss = {},
|
||||
onModeChange = {},
|
||||
onClearError = {},
|
||||
systemOverlayAvailable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithContentDescription("Expand voice controls")
|
||||
.performTouchInput { click() }
|
||||
compose.mainClock.advanceTimeBy(500)
|
||||
|
||||
compose.onNodeWithText("Overlay").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun focusMode_holdSemanticActionTogglesStartAndStopWithoutTouch() {
|
||||
var uiState by mutableStateOf(
|
||||
VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Idle,
|
||||
interactionMode = InteractionMode.HoldToTalk,
|
||||
),
|
||||
)
|
||||
var starts = 0
|
||||
var stops = 0
|
||||
compose.mainClock.autoAdvance = false
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
VoiceModeOverlay(
|
||||
uiState = uiState,
|
||||
onMicTap = {
|
||||
starts += 1
|
||||
uiState = uiState.copy(state = VoiceState.Listening)
|
||||
},
|
||||
onMicRelease = {
|
||||
stops += 1
|
||||
uiState = uiState.copy(state = VoiceState.Idle)
|
||||
},
|
||||
onInterrupt = {},
|
||||
onDismiss = {},
|
||||
onModeChange = {},
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(VOICE_MODE_MIC_TEST_TAG)
|
||||
.assertHasClickAction()
|
||||
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.mainClock.advanceTimeByFrame()
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, starts)
|
||||
assertEquals(0, stops)
|
||||
assertEquals(VoiceState.Listening, uiState.state)
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(VOICE_MODE_MIC_TEST_TAG)
|
||||
.assertHasClickAction()
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.mainClock.advanceTimeByFrame()
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, starts)
|
||||
assertEquals(1, stops)
|
||||
assertEquals(VoiceState.Idle, uiState.state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun focusMode_holdSemanticActionStopsCaptureStillWaitingForMicrophone() {
|
||||
val uiState = VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Idle,
|
||||
interactionMode = InteractionMode.HoldToTalk,
|
||||
)
|
||||
var starts = 0
|
||||
var stops = 0
|
||||
compose.mainClock.autoAdvance = false
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
VoiceModeOverlay(
|
||||
uiState = uiState,
|
||||
onMicTap = { starts += 1 },
|
||||
onMicRelease = { stops += 1 },
|
||||
onInterrupt = {},
|
||||
onDismiss = {},
|
||||
onModeChange = {},
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val mic = compose.onNodeWithTag(VOICE_MODE_MIC_TEST_TAG)
|
||||
.assertHasClickAction()
|
||||
.assertContentDescriptionEquals("start listening")
|
||||
mic.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, starts)
|
||||
assertEquals(0, stops)
|
||||
}
|
||||
mic.assertContentDescriptionEquals("stop listening")
|
||||
|
||||
mic.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, starts)
|
||||
assertEquals(1, stops)
|
||||
}
|
||||
mic.assertContentDescriptionEquals("start listening")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun focusMode_holdKeyboardEnterAndSpaceToggleStartAndStop() {
|
||||
var uiState by mutableStateOf(
|
||||
VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Idle,
|
||||
interactionMode = InteractionMode.HoldToTalk,
|
||||
),
|
||||
)
|
||||
var starts = 0
|
||||
var stops = 0
|
||||
compose.mainClock.autoAdvance = false
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
VoiceModeOverlay(
|
||||
uiState = uiState,
|
||||
onMicTap = {
|
||||
starts += 1
|
||||
uiState = uiState.copy(state = VoiceState.Listening)
|
||||
},
|
||||
onMicRelease = {
|
||||
stops += 1
|
||||
uiState = uiState.copy(state = VoiceState.Idle)
|
||||
},
|
||||
onInterrupt = {},
|
||||
onDismiss = {},
|
||||
onModeChange = {},
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val mic = compose.onNodeWithTag(VOICE_MODE_MIC_TEST_TAG)
|
||||
mic.performSemanticsAction(SemanticsActions.RequestFocus)
|
||||
mic.performKeyInput { pressKey(Key.Enter) }
|
||||
compose.mainClock.advanceTimeByFrame()
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, starts)
|
||||
assertEquals(0, stops)
|
||||
}
|
||||
|
||||
mic.performKeyInput { pressKey(Key.Spacebar) }
|
||||
compose.mainClock.advanceTimeByFrame()
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, starts)
|
||||
assertEquals(1, stops)
|
||||
}
|
||||
}
|
||||
|
||||
private fun idleVoiceState() = VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Idle,
|
||||
|
||||
+40
@@ -15,6 +15,7 @@ import com.hermesandroid.relay.viewmodel.preserveRealtimeTurnOnStop
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTranscriptState
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTurnActiveAfterPromotion
|
||||
import com.hermesandroid.relay.viewmodel.voiceSessionExitState
|
||||
import com.hermesandroid.relay.viewmodel.shouldArmVoiceSilenceWatchdog
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -293,4 +294,43 @@ class VoiceModeOverlayStateTest {
|
||||
|
||||
assertEquals(listOf("interrupt", "start"), calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun holdPress_interruptsAndCapturesSteeringAcrossBusyTurnStates() {
|
||||
listOf(VoiceState.Transcribing, VoiceState.Thinking, VoiceState.Speaking).forEach { state ->
|
||||
val calls = mutableListOf<String>()
|
||||
|
||||
dispatchVoiceMicHoldPress(
|
||||
uiState = VoiceUiState(
|
||||
state = state,
|
||||
interactionMode = InteractionMode.HoldToTalk,
|
||||
),
|
||||
onStartListening = { calls += "start" },
|
||||
onInterruptAndStart = {
|
||||
calls += "interrupt"
|
||||
calls += "start"
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals("Hold-to-talk dispatch for $state", listOf("interrupt", "start"), calls)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bargeInCaptureAlwaysArmsSilenceCompletion() {
|
||||
InteractionMode.values().forEach { mode ->
|
||||
assertEquals(
|
||||
"Barge-in capture for $mode must auto-complete without a physical release",
|
||||
true,
|
||||
shouldArmVoiceSilenceWatchdog(mode, bargeInCapture = true),
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
false,
|
||||
shouldArmVoiceSilenceWatchdog(
|
||||
InteractionMode.HoldToTalk,
|
||||
bargeInCapture = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -152,4 +152,15 @@ class RelayErrorClassifierTest {
|
||||
assertEquals("Microphone unavailable", err.title)
|
||||
assertTrue(err.retryable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun microphoneOwnershipConflictMapsToRetryableMicUnavailableHint() {
|
||||
val err = classifyError(
|
||||
IllegalStateException("Microphone is in use by another voice feature"),
|
||||
context = "record",
|
||||
)
|
||||
|
||||
assertEquals("Microphone unavailable", err.title)
|
||||
assertTrue(err.retryable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateExtrasViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(grant: Boolean = true): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
vm.setWriteGrant(ownerKey, grant)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
private fun selectAlpha(vm: GitStateViewModel) {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
|
||||
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
|
||||
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
|
||||
vm.selectRepo("alpha")
|
||||
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
|
||||
repeat(3) { server.takeRequest() }
|
||||
}
|
||||
|
||||
// ── AI commit message (magic-wand) ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `generate commit message pre-fills the suggestion via selected paths`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson("""{"message":"feat: add feature","notice":""}""")
|
||||
vm.generateCommitMessage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertEquals("feat: add feature", state.message)
|
||||
assertEquals("", state.notice)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/commit_message_selected"))
|
||||
assertTrue(req.body.readUtf8().contains("a.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate message without grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.generateCommitMessage(null)
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertTrue(state.notice.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty staged diff surfaces notice without error`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson("""{"message":"","notice":"nothing staged"}""")
|
||||
vm.generateCommitMessage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertEquals("", state.message)
|
||||
assertEquals("nothing staged", state.notice)
|
||||
}
|
||||
|
||||
// ── Push-after-commit toggle ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `push after commit defaults off and toggles`() {
|
||||
val vm = viewModel()
|
||||
assertTrue(!vm.isPushAfterCommitEnabled())
|
||||
vm.setPushAfterCommit(true)
|
||||
assertTrue(vm.isPushAfterCommitEnabled())
|
||||
vm.setPushAfterCommit(false)
|
||||
assertTrue(!vm.isPushAfterCommitEnabled())
|
||||
}
|
||||
|
||||
// ── Stash-checkout ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stash checkout surfaces the stash notice on success`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson(
|
||||
"""{"head":"abc","stashed":true,"stash_message":"git-state: feature","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
|
||||
)
|
||||
// refreshDetail fires two reads (status + branches).
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
vm.stashCheckout("feature")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val notice = withTimeout(5_000) { vm.stashNotice.first { it != null } }!!
|
||||
assertTrue(notice.contains("git-state: feature"))
|
||||
assertTrue(notice.contains("git stash pop"))
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/stash_checkout"))
|
||||
assertTrue(req.body.readUtf8().contains("feature"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stash checkout without grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.stashCheckout("feature")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clean stash checkout yields no stash notice`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson(
|
||||
"""{"head":"abc","stashed":false,"stash_message":"","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
|
||||
)
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
vm.stashCheckout("feature")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
// No stash notice for a clean checkout.
|
||||
assertEquals(null, vm.stashNotice.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
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
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRepos maps repo list and notice`() = runBlocking {
|
||||
enqueueJson(
|
||||
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":false}],"notice":null}""",
|
||||
)
|
||||
val vm = viewModel()
|
||||
val state = withTimeout(5_000) {
|
||||
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
|
||||
}
|
||||
assertEquals(1, state.repos.size)
|
||||
assertEquals("alpha", state.repos.single().name)
|
||||
assertNotNull(vm.repos.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRepos surfaces server error`() = runBlocking {
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository"}"""))
|
||||
val vm = viewModel()
|
||||
val state = withTimeout(5_000) {
|
||||
vm.repos.filterIsInstance<GitStateUiState.Error>().first()
|
||||
}
|
||||
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(
|
||||
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""",
|
||||
)
|
||||
// status
|
||||
enqueueJson(
|
||||
"""{"counts":{"staged":1,"modified":2,"untracked":3},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":true}""",
|
||||
)
|
||||
// branches
|
||||
enqueueJson(
|
||||
"""{"branches":[{"name":"main","upstream":"origin/main","ahead":1,"behind":0,"is_current":true}]}""",
|
||||
)
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
val ready = withTimeout(5_000) {
|
||||
vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first()
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadDiff surfaces truncated diff`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":1,"untracked":0},"staged":[],"modified":[{"path":"a.txt"}],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
enqueueJson("""{"path":"a.txt","kind":"unstaged","diff":"+change","truncated":true}""")
|
||||
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
|
||||
vm.loadDiff("a.txt", "unstaged")
|
||||
val content = withTimeout(5_000) {
|
||||
vm.content.filterIsInstance<GitContentViewState.Diff>().first()
|
||||
}
|
||||
assertEquals("a.txt", content.diff.path)
|
||||
assertTrue(content.diff.truncated)
|
||||
assertTrue(content.diff.diff.contains("change"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadFile surfaces content and truncation`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":1},"staged":[],"modified":[],"untracked":[{"path":"new.txt"}],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
enqueueJson("""{"path":"new.txt","content":"hello world","truncated":false}""")
|
||||
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
|
||||
vm.loadFile("new.txt")
|
||||
val content = withTimeout(5_000) {
|
||||
vm.content.filterIsInstance<GitContentViewState.File>().first()
|
||||
}
|
||||
assertEquals("hello world", content.file.content)
|
||||
assertFalse(content.file.truncated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectRepo surfaces status error for unknown repo`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository: bogus"}"""))
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("bogus")
|
||||
val error = withTimeout(5_000) {
|
||||
vm.detail.filterIsInstance<GitRepoDetailState.Error>().first()
|
||||
}
|
||||
assertTrue(error.message.contains("unknown repository"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateWriteViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(grant: Boolean = true): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
vm.setWriteGrant(ownerKey, grant)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads the repo list + selects ``alpha`` so a mutation has a target. */
|
||||
private fun selectAlpha(vm: GitStateViewModel) {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
|
||||
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
|
||||
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
|
||||
vm.selectRepo("alpha")
|
||||
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
|
||||
// Drain the three read requests (repos/status/branches) so the next
|
||||
// takeRequest() returns the mutation POST we actually assert on.
|
||||
repeat(3) { server.takeRequest() }
|
||||
}
|
||||
|
||||
private fun enqueuePostSuccess(head: String) {
|
||||
enqueueJson("""{"head":"$head","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}}""")
|
||||
// refreshDetail fires two more requests (status + branches).
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
}
|
||||
|
||||
// ── Grant gating (security first) ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stage without write grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.stage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
// No write POST was sent (only the 3 read requests for load/select).
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard without write grant is refused`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
// ── Happy paths ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stage sends POST and surfaces success + fresh status`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc123")
|
||||
vm.stage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
|
||||
}
|
||||
assertEquals("stage", state.label)
|
||||
assertEquals("abc123", state.head)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/stage"))
|
||||
assertTrue(req.body.readUtf8().contains("a.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit sends message and returns head`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("deadbeef")
|
||||
vm.commit("add feature")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
|
||||
}
|
||||
assertEquals("commit", state.label)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/commit"))
|
||||
assertTrue(req.body.readUtf8().contains("add feature"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit success callback fires only after successful response`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
var committedTarget: GitTarget? = null
|
||||
|
||||
vm.commit("add feature") { committedTarget = it }
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
|
||||
assertEquals("alpha", committedTarget?.repoId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit failure never invokes success callback`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"failed"}"""))
|
||||
var callbackCalled = false
|
||||
|
||||
vm.commit("add feature") { callbackCalled = true }
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Error>().first() }
|
||||
|
||||
assertFalse(callbackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connection change revokes grant and rejects prior target`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
val priorTarget = vm.currentTarget()!!
|
||||
enqueueJson("""{"repos":[]}""")
|
||||
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), "connection-b")
|
||||
vm.setWriteGrant(ownerKey, true)
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.push(GitConfirmationStrings.PUSH, expectedTarget = priorTarget)
|
||||
|
||||
assertFalse(vm.hasWriteGrant())
|
||||
assertEquals(null, vm.currentTarget())
|
||||
assertTrue(vm.mutation.value is GitMutationState.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard echoes the fixed confirmation token`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/discard"))
|
||||
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.DISCARD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `push echoes the confirmation token`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.push(GitConfirmationStrings.PUSH)
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/push"))
|
||||
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.PUSH))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch and pull send their endpoints`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.fetch()
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
assertTrue(server.takeRequest().path!!.contains("/git/fetch"))
|
||||
// Drain the two refreshDetail reads (status + branches) so the next
|
||||
// takeRequest() sees only the pull POST.
|
||||
repeat(2) { server.takeRequest() }
|
||||
|
||||
enqueuePostSuccess("xyz")
|
||||
vm.pull("origin", "main")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
assertTrue(server.takeRequest().path!!.contains("/git/pull"))
|
||||
}
|
||||
|
||||
// ── Error branches ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `commit surfaces server error as readable message`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(400).setBody("""{"detail":"commit message must not be empty"}"""),
|
||||
)
|
||||
vm.commit(" ")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("must not be empty"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard wrong confirmation surfaces server 403`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(403).setBody("""{"detail":"confirmation did not match"}"""),
|
||||
)
|
||||
vm.discard(listOf("a.txt"), "wrong")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("confirmation") || state.message.contains("403"))
|
||||
}
|
||||
|
||||
// ── Confirmation gating helpers ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `requiresConfirmation and confirmationFor match destructive ops`() {
|
||||
val vm = viewModel()
|
||||
assertTrue(vm.requiresConfirmation("discard"))
|
||||
assertTrue(vm.requiresConfirmation("push"))
|
||||
assertTrue(vm.requiresConfirmation("dirty-checkout"))
|
||||
assertEquals(GitConfirmationStrings.DISCARD, vm.confirmationFor("discard"))
|
||||
assertEquals(GitConfirmationStrings.PUSH, vm.confirmationFor("push"))
|
||||
assertEquals(GitConfirmationStrings.DIRTY_CHECKOUT, vm.confirmationFor("dirty-checkout"))
|
||||
assertEquals(null, vm.confirmationFor("commit"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertHasClickAction
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import org.junit.Assert.assertEquals
|
||||
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-xxhdpi")
|
||||
class VoiceOverlayHostInteractionTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun expandedOverlay_holdSemanticActionTogglesStartAndStopWithoutTouch() {
|
||||
assertHoldSemanticToggle(VOICE_OVERLAY_MIC_CONTROL_TEST_TAG) { state, start, stop ->
|
||||
MicControlButton(
|
||||
uiState = state,
|
||||
onStartListening = start,
|
||||
onStopListening = stop,
|
||||
onInterrupt = {},
|
||||
onPauseAutoMode = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun minimizedOverlay_holdSemanticActionTogglesStartAndStopWithoutTouch() {
|
||||
assertHoldSemanticToggle(VOICE_FLOATING_OVERLAY_MIC_TEST_TAG) { state, start, stop ->
|
||||
VoiceFloatingOverlayBubble(
|
||||
uiState = state,
|
||||
stateText = state.state.name,
|
||||
onExpand = {},
|
||||
onStartListening = start,
|
||||
onStopListening = stop,
|
||||
onInterrupt = {},
|
||||
onPauseAutoMode = {},
|
||||
onDragBy = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertHoldSemanticToggle(
|
||||
tag: String,
|
||||
content: @Composable (VoiceUiState, () -> Unit, () -> Unit) -> Unit,
|
||||
) {
|
||||
var uiState by mutableStateOf(
|
||||
VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Idle,
|
||||
interactionMode = InteractionMode.HoldToTalk,
|
||||
),
|
||||
)
|
||||
var starts = 0
|
||||
var stops = 0
|
||||
compose.mainClock.autoAdvance = false
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
content(
|
||||
uiState,
|
||||
{
|
||||
starts += 1
|
||||
uiState = uiState.copy(state = VoiceState.Listening)
|
||||
},
|
||||
{
|
||||
stops += 1
|
||||
uiState = uiState.copy(state = VoiceState.Idle)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(tag)
|
||||
.assertHasClickAction()
|
||||
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.mainClock.advanceTimeByFrame()
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, starts)
|
||||
assertEquals(0, stops)
|
||||
assertEquals(VoiceState.Listening, uiState.state)
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(tag)
|
||||
.assertHasClickAction()
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.mainClock.advanceTimeByFrame()
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, starts)
|
||||
assertEquals(1, stops)
|
||||
assertEquals(VoiceState.Idle, uiState.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import com.hermesandroid.relay.audio.VoiceSfxPlayer
|
||||
import com.hermesandroid.relay.data.BargeInPreferences
|
||||
import com.hermesandroid.relay.data.BargeInPreferencesRepository
|
||||
import com.hermesandroid.relay.data.BargeInSensitivity
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
import io.mockk.every
|
||||
@@ -273,6 +275,360 @@ class VoiceViewModelBargeInTest {
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous completion waits for queue-drain barge-in release before capture`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
// The play worker tears down barge-in before the shared completion
|
||||
// finalizer runs. Repeated teardown calls must retain that first
|
||||
// asynchronous release instead of attempting VoiceCapture immediately.
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous completion repeats serialized handoff across turns`() = runTest {
|
||||
val firstRelease = Job()
|
||||
val secondRelease = Job()
|
||||
var stopCalls = 0
|
||||
every { bargeInListener.stop() } answers {
|
||||
when (stopCalls++) {
|
||||
0 -> firstRelease
|
||||
1 -> secondRelease
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("First."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
firstRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Second."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
|
||||
secondRelease.complete()
|
||||
runCurrent()
|
||||
verify(exactly = 2) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `barge-in disabled keeps continuous completion immediate`() = runTest {
|
||||
val vm = buildViewModel(
|
||||
BargeInPreferences(
|
||||
enabled = false,
|
||||
sensitivity = BargeInSensitivity.Off,
|
||||
),
|
||||
)
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit cancels continuous capture waiting for microphone release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.exitVoiceMode()
|
||||
vm.startBargeInListenerForTest()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
assertTrue(!vm.uiState.value.voiceMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pause cancels continuous capture waiting for microphone release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.pauseContinuousMode()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rapid mode change starts only the newly armed continuous capture`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null, null, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.setInteractionMode(InteractionMode.TapToTalk)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
runCurrent()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next barge-in generation waits for prior reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returnsMany listOf(readerRelease, null)
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("First."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.stopBargeInListenerForTest()
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
verify(exactly = 1) { bargeInListener.start(any()) }
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
verify(exactly = 2) { bargeInListener.start(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `barge-in capture waits for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
runCurrent()
|
||||
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exit invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.exitVoiceMode()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertTrue(!vm.uiState.value.voiceMode)
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continuous pause invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.pauseContinuousMode()
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interaction mode switch invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.setInteractionMode(InteractionMode.HoldToTalk)
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(InteractionMode.HoldToTalk, vm.uiState.value.interactionMode)
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engine switch invalidates barge-in capture waiting for reader release`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.setVoiceEngineModeForTest(VoiceEngineMode.RealtimeAgent)
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Idle, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer manual capture supersedes barge-in release waiter`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
vm.startListening()
|
||||
runCurrent()
|
||||
verify(exactly = 0) { recorder.startRecording() }
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reader release completion starts valid barge-in capture only once`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
assertTrue(readerRelease.complete())
|
||||
runCurrent()
|
||||
assertTrue(!readerRelease.complete())
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed barge-in recorder acquisition does not arm resume watchdog`() = runTest {
|
||||
val readerRelease = Job()
|
||||
every { bargeInListener.stop() } returns readerRelease
|
||||
every { recorder.startRecording() } throws
|
||||
IllegalStateException("Microphone is in use by another voice feature")
|
||||
val vm = buildViewModel()
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Speaking.", "Tail."), currentIdx = 0)
|
||||
vm.startBargeInListenerForTest()
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
|
||||
readerRelease.complete()
|
||||
runCurrent()
|
||||
advanceTimeBy(700)
|
||||
runCurrent()
|
||||
|
||||
verify(exactly = 1) { recorder.startRecording() }
|
||||
assertEquals(VoiceState.Error, vm.uiState.value.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient recorder ownership failures remain explicitly retryable`() = runTest {
|
||||
var attempts = 0
|
||||
every { recorder.startRecording() } answers {
|
||||
attempts++
|
||||
if (attempts <= 2) {
|
||||
throw IllegalStateException("Microphone is in use by another voice feature")
|
||||
}
|
||||
java.io.File("voice-retry-test.wav")
|
||||
}
|
||||
val vm = buildViewModel(
|
||||
BargeInPreferences(
|
||||
enabled = false,
|
||||
sensitivity = BargeInSensitivity.Off,
|
||||
),
|
||||
)
|
||||
vm.seedSpeakingStateForTest(chunks = listOf("Finished."), currentIdx = 0)
|
||||
vm.setInteractionMode(InteractionMode.Continuous)
|
||||
|
||||
vm.finishAgentAudioOutputForTest()
|
||||
runCurrent()
|
||||
assertEquals(VoiceState.Error, vm.uiState.value.state)
|
||||
|
||||
vm.startListening()
|
||||
runCurrent()
|
||||
assertEquals(VoiceState.Error, vm.uiState.value.state)
|
||||
|
||||
vm.startListening()
|
||||
runCurrent()
|
||||
assertEquals(VoiceState.Listening, vm.uiState.value.state)
|
||||
verify(exactly = 3) { recorder.startRecording() }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Test 2 — resume with resumeAfterInterruption=true + silence
|
||||
// -------------------------------------------------------------------
|
||||
@@ -365,6 +721,32 @@ class VoiceViewModelBargeInTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed barge-in capture stays failed instead of resuming stale audio`() = runTest {
|
||||
every { recorder.startRecording() } throws
|
||||
IllegalStateException("Microphone is in use by another voice feature")
|
||||
val vm = buildViewModel(
|
||||
BargeInPreferences(
|
||||
enabled = true,
|
||||
sensitivity = BargeInSensitivity.Default,
|
||||
resumeAfterInterruption = true,
|
||||
),
|
||||
)
|
||||
vm.seedSpeakingStateForTest(
|
||||
chunks = listOf("current.", "stale tail."),
|
||||
currentIdx = 0,
|
||||
)
|
||||
vm.drainTtsQueueForTest()
|
||||
|
||||
vm.onBargeInDetected()
|
||||
runCurrent()
|
||||
advanceTimeBy(650)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(VoiceState.Error, vm.uiState.value.state)
|
||||
assertTrue(vm.drainTtsQueueForTest().isEmpty())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Test 4 — resumeAfterInterruption=false → silence still no re-enqueue
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -91,6 +91,19 @@ the `relay_plugin_draft` tool to create or replace a generated declarative page.
|
||||
tool accepts the same bounded schema as Android, stores JSON atomically below
|
||||
`HERMES_HOME/mobile-plugins`, and rejects every `action.request`. Generated previews
|
||||
therefore cannot reach Relay management APIs or acquire executable backend behavior.
|
||||
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
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"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",
|
||||
@@ -44,11 +44,11 @@
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"native_name": "Español",
|
||||
"native_name": "Espa\u00f1ol",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"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": "日本語",
|
||||
"native_name": "\u65e5\u672c\u8a9e",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"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ês (Brasil)",
|
||||
"native_name": "Portugu\u00eas (Brasil)",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"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": "Русский",
|
||||
"native_name": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -131,11 +131,11 @@
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"native_name": "简体中文",
|
||||
"native_name": "\u7b80\u4f53\u4e2d\u6587",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "f34c3772ee5a6b73514f4ce891cc2b5cbedcfc941798dcdf563682c725ef7bf0",
|
||||
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
|
||||
"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
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ Paste into Play Console → **What's new** (≤500 characters):
|
||||
```
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
|
||||
Supervised Mode adds a parent-configured, profile-pinned chat view with device-authenticated settings. Parents can limit attachments, Standard voice, generated media, history, actions, and technical details. Session rows stay neutral while live activity is unavailable, and returning from parent settings no longer blanks Supervised Chat.
|
||||
Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.
|
||||
```
|
||||
## Category
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+8
-8
File diff suppressed because one or more lines are too long
@@ -0,0 +1,337 @@
|
||||
"""Read and write Git state endpoints for the Hermes-Relay dashboard plugin.
|
||||
|
||||
Mounted by hermes-agent at ``/api/plugins/hermes-relay/git/*``. These routes
|
||||
power both the dashboard tab and the Android mobile page.
|
||||
|
||||
Route map
|
||||
---------
|
||||
Read (GET, no grant):
|
||||
- ``GET /git/repos`` → scanned repo list under the configured base path
|
||||
- ``GET /git/status`` → grouped working-tree status for one repo
|
||||
- ``GET /git/branches`` → branch list (name, upstream, ahead/behind, current)
|
||||
- ``GET /git/diff`` → per-file diff (kind=staged|unstaged)
|
||||
- ``GET /git/file`` → read a tracked file
|
||||
|
||||
Write (POST, require the plugin's ``plugin.api.write`` grant, which the app
|
||||
enforces client-side before ever sending the request — the same gate used for
|
||||
every mutating plugin action in PluginsViewModel.invokeAction):
|
||||
- ``POST /git/stage`` — stage path list → fresh status
|
||||
- ``POST /git/unstage`` — unstage path list → fresh status
|
||||
- ``POST /git/discard`` — discard paths (confirmation) → fresh status
|
||||
- ``POST /git/commit`` — commit staged index (message) → {head,status}
|
||||
- ``POST /git/commit_selected``— commit selected paths (message+paths)
|
||||
- ``POST /git/fetch`` — fetch a remote → {branches,status}
|
||||
- ``POST /git/pull`` — pull remote/branch → fresh status
|
||||
- ``POST /git/push`` — push (confirmation) → {branches,status}
|
||||
- ``POST /git/checkout`` — switch branch (new_branch/track; dirty→confirmation)
|
||||
|
||||
Destructive writes (discard, push, dirty checkout) enforce a per-use
|
||||
confirmation string server-side: missing/wrong → 403. Dirty/conflict trees →
|
||||
409. The HTTP mapping keeps raw stack traces and JSON dumps out of the UI.
|
||||
|
||||
Security
|
||||
--------
|
||||
- ``repo`` is an opaque id validated against the scanned allowlist; unknown
|
||||
ids → 400.
|
||||
- File paths are validated to reject traversal and absolute escapes.
|
||||
- Remote URLs are scrubbed of embedded userinfo.
|
||||
- No ``shell=True`` anywhere: every git invocation uses argument lists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
|
||||
from .. import git_state
|
||||
|
||||
router = APIRouter(prefix="/git")
|
||||
|
||||
|
||||
def _bad_request(exc: Exception) -> HTTPException:
|
||||
return HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
# Structured error taxonomy → HTTP status. The UI renders ``detail`` (a
|
||||
# human-readable message) plus ``code`` for styling; no raw traces/JSON dumps.
|
||||
_GIT_ERROR_STATUS = {
|
||||
"non-repo": 400,
|
||||
"dirty": 409,
|
||||
"conflict": 409,
|
||||
"auth": 502,
|
||||
"network": 502,
|
||||
"invalid-input": 400,
|
||||
"missing-confirmation": 403,
|
||||
"wrong-confirmation": 403,
|
||||
}
|
||||
|
||||
|
||||
def _write_error(exc: git_state.GitError) -> HTTPException:
|
||||
status = _GIT_ERROR_STATUS.get(exc.code, 400)
|
||||
return HTTPException(status_code=status, detail=str(exc))
|
||||
|
||||
|
||||
def _resolve(repo: str) -> Any:
|
||||
return git_state.resolve_repo(git_state.base_path(), repo)
|
||||
|
||||
|
||||
@router.get("/repos")
|
||||
async def get_repos() -> dict[str, Any]:
|
||||
"""Return the scanned repo list plus a notice when the base path is missing."""
|
||||
base = git_state.base_path()
|
||||
repos = git_state.scan_repos(base)
|
||||
notice = None
|
||||
if not base.is_dir():
|
||||
notice = f"Git base path not found: {base}"
|
||||
return {"repos": repos, "base_path": str(base), "notice": notice}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(repo: str = Query(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.repo_status(_resolve(repo))
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.get("/branches")
|
||||
async def get_branches(repo: str = Query(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return {"branches": git_state.repo_branches(_resolve(repo))}
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.get("/diff")
|
||||
async def get_diff(
|
||||
repo: str = Query(...),
|
||||
path: str = Query(...),
|
||||
kind: str = Query("unstaged"),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.repo_diff(_resolve(repo), path, kind)
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.get("/file")
|
||||
async def get_file(repo: str = Query(...), path: str = Query(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.read_file(_resolve(repo), path)
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
# ── Write endpoints ────────────────────────────────────────────────────────
|
||||
# Every write requires the plugin.api.write grant (enforced client-side). The
|
||||
# router re-validates repo + paths against the allowlist/traversal rules and
|
||||
# classifies git failures into a structured error the UI can render.
|
||||
|
||||
def _require_repo(body: dict[str, Any]) -> Any:
|
||||
repo = body.get("repo")
|
||||
if not isinstance(repo, str) or not repo:
|
||||
raise git_state.GitStateError("repo is required")
|
||||
return git_state.resolve_repo(git_state.base_path(), repo)
|
||||
|
||||
|
||||
@router.post("/stage")
|
||||
async def post_stage(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.stage(_require_repo(body), _paths(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/unstage")
|
||||
async def post_unstage(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.unstage(_require_repo(body), _paths(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/discard")
|
||||
async def post_discard(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.discard(
|
||||
_require_repo(body),
|
||||
_paths(body),
|
||||
confirmation=body.get("confirmation"),
|
||||
delete_untracked=bool(body.get("delete_untracked", False)),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit")
|
||||
async def post_commit(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.commit(_require_repo(body), _message(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit_selected")
|
||||
async def post_commit_selected(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.commit_selected(_require_repo(body), _message(body), _paths(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/fetch")
|
||||
async def post_fetch(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.fetch(_require_repo(body), _remote(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/pull")
|
||||
async def post_pull(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.pull(_require_repo(body), _remote(body), _branch(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/push")
|
||||
async def post_push(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.push(
|
||||
_require_repo(body),
|
||||
_remote(body),
|
||||
_branch(body),
|
||||
confirmation=body.get("confirmation"),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/checkout")
|
||||
async def post_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
new_branch = _str_opt(body, "new_branch")
|
||||
return git_state.checkout(
|
||||
_require_repo(body),
|
||||
_ref(body, allow_empty=bool(new_branch)),
|
||||
confirmation=body.get("confirmation"),
|
||||
new_branch=new_branch,
|
||||
track=bool(body.get("track", False)),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/stash_checkout")
|
||||
async def post_stash_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Checkout that auto-stashes a dirty tree first.
|
||||
|
||||
Unlike a plain dirty checkout, no confirmation is required: a stash is
|
||||
recoverable (``git stash pop``), so this is not a data-loss path. The
|
||||
response carries ``stashed`` + ``stash_message`` so the UI can surface the
|
||||
stash after a successful switch.
|
||||
"""
|
||||
try:
|
||||
new_branch = _str_opt(body, "new_branch")
|
||||
return git_state.stash_checkout(
|
||||
_require_repo(body),
|
||||
_ref(body, allow_empty=bool(new_branch)),
|
||||
new_branch=new_branch,
|
||||
track=bool(body.get("track", False)),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit_message")
|
||||
async def post_commit_message(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Generate a conventional-style commit message from the staged diff.
|
||||
|
||||
Empty staged diff → ``{message:"", notice:"nothing staged"}`` without calling
|
||||
the model. A missing/failed model degrades to an empty message + a
|
||||
``model unavailable`` notice — never a 500. Only staged content is sent.
|
||||
"""
|
||||
try:
|
||||
return await git_state.commit_message(_require_repo(body))
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit_message_selected")
|
||||
async def post_commit_message_selected(
|
||||
body: dict[str, Any] = Body(...),
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a message from the staged diff of the given ``paths`` only."""
|
||||
try:
|
||||
return await git_state.commit_message_selected(
|
||||
_require_repo(body), _paths(body)
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
def _paths(body: dict[str, Any]) -> list[str]:
|
||||
paths = body.get("paths")
|
||||
if not isinstance(paths, list) or not paths or not all(isinstance(p, str) for p in paths):
|
||||
raise git_state.GitStateError("paths must be a non-empty list of strings")
|
||||
return paths
|
||||
|
||||
|
||||
def _message(body: dict[str, Any]) -> str:
|
||||
message = body.get("message")
|
||||
if not isinstance(message, str):
|
||||
raise git_state.GitStateError("message is required")
|
||||
return message
|
||||
|
||||
|
||||
def _remote(body: dict[str, Any]) -> str:
|
||||
remote = body.get("remote", "origin")
|
||||
if not isinstance(remote, str):
|
||||
raise git_state.GitStateError("remote must be a string")
|
||||
return remote or "origin"
|
||||
|
||||
|
||||
def _branch(body: dict[str, Any]) -> str:
|
||||
branch = body.get("branch", "")
|
||||
if not isinstance(branch, str):
|
||||
raise git_state.GitStateError("branch must be a string")
|
||||
return branch
|
||||
|
||||
|
||||
def _ref(body: dict[str, Any], *, allow_empty: bool = False) -> str:
|
||||
ref = body.get("ref", "" if allow_empty else None)
|
||||
if not isinstance(ref, str) or (not ref and not allow_empty):
|
||||
raise git_state.GitStateError("ref is required")
|
||||
return ref
|
||||
|
||||
|
||||
def _str_opt(body: dict[str, Any], key: str) -> str:
|
||||
value = body.get(key)
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -32,6 +32,15 @@ async def get_mobile_manifest() -> dict[str, Any]:
|
||||
|
||||
@router.get("/pages/{plugin_id}")
|
||||
async def get_mobile_page(plugin_id: str = Path(...)) -> dict[str, Any]:
|
||||
# The static read-only Git page is served directly from the git_state
|
||||
# module (not the generated-page store). It carries no filesystem paths
|
||||
# per the android-plugins.md document contract.
|
||||
if plugin_id == "git":
|
||||
from .. import git_state
|
||||
|
||||
document = git_state.build_git_document(git_state.base_path())
|
||||
document["host_revision"] = 1
|
||||
return document
|
||||
try:
|
||||
entry = _store().get(plugin_id)
|
||||
document = dict(entry["document"])
|
||||
|
||||
@@ -142,6 +142,7 @@ def _validate_public_url(url: str) -> str:
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(_plugin_module("dashboard.mobile_plugin_api").router)
|
||||
router.include_router(_plugin_module("dashboard.git_api").router)
|
||||
|
||||
|
||||
def _relay_unreachable(err: Exception) -> HTTPException:
|
||||
|
||||
@@ -6,6 +6,7 @@ import RelayManagement from "./tabs/RelayManagement.jsx";
|
||||
import BridgeActivity from "./tabs/BridgeActivity.jsx";
|
||||
import MediaInspector from "./tabs/MediaInspector.jsx";
|
||||
import RemoteAccess from "./tabs/RemoteAccess.jsx";
|
||||
import GitState from "./tabs/GitState.jsx";
|
||||
import RelayStatusSlot from "./components/RelayStatusSlot.jsx";
|
||||
import MobileConnectDialog from "./components/MobileConnectDialog.jsx";
|
||||
import { Button, Switch } from "./lib/ui-shims.jsx";
|
||||
@@ -19,6 +20,7 @@ const TABS = [
|
||||
{ key: "activity", label: "Activity" },
|
||||
{ key: "media", label: "Media" },
|
||||
{ key: "remote", label: "Remote Access" },
|
||||
{ key: "git", label: "Git" },
|
||||
];
|
||||
|
||||
function readAutoRefresh() {
|
||||
@@ -113,6 +115,7 @@ function RelayPluginRoot() {
|
||||
{tab === "activity" && <BridgeActivity autoRefresh={autoRefresh} />}
|
||||
{tab === "media" && <MediaInspector autoRefresh={autoRefresh} />}
|
||||
{tab === "remote" && <RemoteAccess autoRefresh={autoRefresh} />}
|
||||
{tab === "git" && <GitState autoRefresh={autoRefresh} />}
|
||||
</div>
|
||||
<MobileConnectDialog
|
||||
open={mobileConnectOpen}
|
||||
|
||||
@@ -139,3 +139,107 @@ export function mintPairingWithMode({ mode, publicUrl, prefer, ...rest } = {}) {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Git State tab ───────────────────────────────────────────────────────────
|
||||
|
||||
export function getGitRepos() {
|
||||
return fetchJSON("/git/repos");
|
||||
}
|
||||
|
||||
export function getGitStatus(repo) {
|
||||
return fetchJSON(`/git/status?repo=${encodeURIComponent(repo)}`);
|
||||
}
|
||||
|
||||
export function getGitBranches(repo) {
|
||||
return fetchJSON(`/git/branches?repo=${encodeURIComponent(repo)}`);
|
||||
}
|
||||
|
||||
export function getGitDiff(repo, path, kind = "unstaged") {
|
||||
return fetchJSON(
|
||||
`/git/diff?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}&kind=${encodeURIComponent(kind)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getGitFile(repo, path) {
|
||||
return fetchJSON(
|
||||
`/git/file?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Git State write operations ────────────────────────────────────────────
|
||||
// Every write POST goes through the authenticated Dashboard plugin namespace.
|
||||
// Android separately enforces its local plugin.api.write preference before it
|
||||
// calls this namespace. Destructive ops pass a per-use confirmation token.
|
||||
|
||||
function postGit(path, body) {
|
||||
return fetchJSON(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function gitStage(repo, paths) {
|
||||
return postGit("/git/stage", { repo, paths });
|
||||
}
|
||||
|
||||
export function gitUnstage(repo, paths) {
|
||||
return postGit("/git/unstage", { repo, paths });
|
||||
}
|
||||
|
||||
export function gitDiscard(repo, paths, confirmation, deleteUntracked = false) {
|
||||
return postGit("/git/discard", {
|
||||
repo,
|
||||
paths,
|
||||
confirmation,
|
||||
delete_untracked: deleteUntracked,
|
||||
});
|
||||
}
|
||||
|
||||
export function gitCommit(repo, message) {
|
||||
return postGit("/git/commit", { repo, message });
|
||||
}
|
||||
|
||||
export function gitFetch(repo, remote = "origin") {
|
||||
return postGit("/git/fetch", { repo, remote });
|
||||
}
|
||||
|
||||
export function gitPull(repo, remote = "origin", branch = "") {
|
||||
return postGit("/git/pull", { repo, remote, branch });
|
||||
}
|
||||
|
||||
export function gitPush(repo, confirmation, remote = "origin", branch = "") {
|
||||
return postGit("/git/push", { repo, remote, branch, confirmation });
|
||||
}
|
||||
|
||||
export function gitCheckout(repo, ref, opts = {}) {
|
||||
const body = {
|
||||
repo,
|
||||
ref,
|
||||
confirmation: opts.confirmation,
|
||||
new_branch: opts.newBranch || "",
|
||||
track: !!opts.track,
|
||||
};
|
||||
return postGit("/git/checkout", body);
|
||||
}
|
||||
|
||||
// ── Git State Phase 3 extras ───────────────────────────────────────────────
|
||||
// AI commit-message suggestions + auto-stashing checkout.
|
||||
|
||||
export function gitCommitMessage(repo) {
|
||||
return postGit("/git/commit_message", { repo });
|
||||
}
|
||||
|
||||
export function gitCommitMessageSelected(repo, paths) {
|
||||
return postGit("/git/commit_message_selected", { repo, paths });
|
||||
}
|
||||
|
||||
export function gitStashCheckout(repo, ref, opts = {}) {
|
||||
const body = {
|
||||
repo,
|
||||
ref,
|
||||
new_branch: opts.newBranch || "",
|
||||
track: !!opts.track,
|
||||
};
|
||||
return postGit("/git/stash_checkout", body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Pure data-mapping helpers for the Git State dashboard tab.
|
||||
// Kept free of React/DOM so they are unit-testable with node:test.
|
||||
|
||||
/**
|
||||
* Normalize a /git/repos response into a stable repo list.
|
||||
* Accepts {repos:[...]} or a bare array.
|
||||
*/
|
||||
export function normalizeRepos(data) {
|
||||
const list = Array.isArray(data) ? data : (data && data.repos) || [];
|
||||
return list.filter((r) => r && typeof r.id === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/status response into {counts, staged, modified, untracked,
|
||||
* truncated}. Missing groups become empty arrays.
|
||||
*/
|
||||
export function normalizeStatus(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
counts: {
|
||||
staged: Number(src.counts && src.counts.staged) || 0,
|
||||
modified: Number(src.counts && src.counts.modified) || 0,
|
||||
untracked: Number(src.counts && src.counts.untracked) || 0,
|
||||
},
|
||||
staged: Array.isArray(src.staged) ? src.staged : [],
|
||||
modified: Array.isArray(src.modified) ? src.modified : [],
|
||||
untracked: Array.isArray(src.untracked) ? src.untracked : [],
|
||||
truncated: !!src.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/branches response into a branch list.
|
||||
* Accepts {branches:[...]} or a bare array.
|
||||
*/
|
||||
export function normalizeBranches(data) {
|
||||
const list = Array.isArray(data) ? data : (data && data.branches) || [];
|
||||
return list.filter((b) => b && typeof b.name === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable branch label, e.g. "main → origin/main (ahead 1)".
|
||||
*/
|
||||
export function branchLabel(branch) {
|
||||
if (!branch) return "";
|
||||
const base = branch.name || "";
|
||||
if (!branch.upstream) return base;
|
||||
const track =
|
||||
branch.ahead > 0 || branch.behind > 0
|
||||
? ` (ahead ${branch.ahead}, behind ${branch.behind})`
|
||||
: "";
|
||||
return `${base} → ${branch.upstream}${track}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a status response is truncated (over the server cap).
|
||||
*/
|
||||
export function isTruncated(status) {
|
||||
return !!(status && status.truncated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed per-use confirmation tokens for destructive git mutations. These
|
||||
* mirror the plugin's server-side constants (plugin/git_state.py) and are
|
||||
* sent in the POST body so the server can enforce the destructive gate.
|
||||
* The dashboard tab shows a human-readable description before echoing these.
|
||||
*/
|
||||
export const CONFIRMATIONS = {
|
||||
discard: "discard",
|
||||
push: "push",
|
||||
dirtyCheckout: "checkout-dirty",
|
||||
};
|
||||
|
||||
/** Ops that require a per-use confirmation string before the POST is sent. */
|
||||
const DESTRUCTIVE_OPS = new Set(["discard", "push", "dirty-checkout"]);
|
||||
|
||||
/**
|
||||
* True when the named mutation requires a per-use confirmation string.
|
||||
* The tab must not send the POST without it (matches the server gate).
|
||||
*/
|
||||
export function requiresConfirmation(op) {
|
||||
return DESTRUCTIVE_OPS.has(op);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the fixed confirmation token for a destructive op, or null when the
|
||||
* op is non-destructive (no confirmation needed).
|
||||
*/
|
||||
export function confirmationFor(op) {
|
||||
if (!requiresConfirmation(op)) return null;
|
||||
if (op === "discard") return CONFIRMATIONS.discard;
|
||||
if (op === "push") return CONFIRMATIONS.push;
|
||||
return CONFIRMATIONS.dirtyCheckout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a mutation response ({head, status, branches}) into a stable
|
||||
* shape, filling missing groups so the tab can render without defensive
|
||||
* branching.
|
||||
*/
|
||||
export function normalizeMutationResult(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
head: typeof src.head === "string" ? src.head : "",
|
||||
status: normalizeStatus(src.status),
|
||||
branches: normalizeBranches(src.branches),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/commit_message response into {message, notice, stashed}.
|
||||
* Missing fields degrade safely so the tab can render without branching.
|
||||
*/
|
||||
export function normalizeCommitMessage(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
message: typeof src.message === "string" ? src.message : "",
|
||||
notice: typeof src.notice === "string" ? src.notice : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a commit-message suggestion is usable (non-empty message).
|
||||
*/
|
||||
export function hasCommitSuggestion(result) {
|
||||
return !!(result && result.message && result.message.trim());
|
||||
}
|
||||
|
||||
export function isCurrentRepoRequest(currentRepo, currentGeneration, repo, generation) {
|
||||
return currentRepo === repo && currentGeneration === generation;
|
||||
}
|
||||
|
||||
export function shouldOfferPushAfterCommit(commitSucceeded, pushAfterCommit) {
|
||||
return commitSucceeded === true && pushAfterCommit === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/stash_checkout response: the standard mutation shape plus
|
||||
* {stashed, stash_message}.
|
||||
*/
|
||||
export function normalizeStashCheckout(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
...normalizeMutationResult(data),
|
||||
stashed: !!src.stashed,
|
||||
stashMessage: typeof src.stash_message === "string" ? src.stash_message : "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
const SDK = window.__HERMES_PLUGIN_SDK__;
|
||||
const { React } = SDK;
|
||||
const { useState, useEffect, useCallback, useRef } = SDK.hooks;
|
||||
|
||||
import {
|
||||
getGitRepos,
|
||||
getGitStatus,
|
||||
getGitBranches,
|
||||
getGitDiff,
|
||||
getGitFile,
|
||||
gitStage,
|
||||
gitUnstage,
|
||||
gitDiscard,
|
||||
gitCommit,
|
||||
gitCommitMessage,
|
||||
gitCommitMessageSelected,
|
||||
gitStashCheckout,
|
||||
gitFetch,
|
||||
gitPull,
|
||||
gitPush,
|
||||
gitCheckout,
|
||||
} from "../lib/api.js";
|
||||
import {
|
||||
normalizeMutationResult,
|
||||
normalizeCommitMessage,
|
||||
normalizeStashCheckout,
|
||||
hasCommitSuggestion,
|
||||
requiresConfirmation,
|
||||
confirmationFor,
|
||||
isCurrentRepoRequest,
|
||||
shouldOfferPushAfterCommit,
|
||||
} from "../lib/git-state.mjs";
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
CardDescription,
|
||||
Button,
|
||||
Badge,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from "../lib/ui-shims.jsx";
|
||||
|
||||
const {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Label,
|
||||
} = SDK.components;
|
||||
|
||||
function TruncationNotice({ truncated }) {
|
||||
if (!truncated) return null;
|
||||
return (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600">
|
||||
Results truncated — showing the first entries only.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({ status }) {
|
||||
if (!status) return null;
|
||||
const counts = status.counts || {};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{counts.staged || 0} staged
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{counts.modified || 0} modified
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{counts.untracked || 0} untracked
|
||||
</Badge>
|
||||
</div>
|
||||
<TruncationNotice truncated={status.truncated} />
|
||||
{["staged", "modified", "untracked"].map((group) => {
|
||||
const items = status[group] || [];
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{items.map((item) => (
|
||||
<li key={`${group}-${item.path}`} className="font-mono text-xs">
|
||||
{item.path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchesRow({ branches }) {
|
||||
if (!branches || branches.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{branches.map((b) => (
|
||||
<div key={b.name} className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono">{b.name}</span>
|
||||
{b.is_current ? <Badge className="text-xs">current</Badge> : null}
|
||||
{b.upstream ? (
|
||||
<span className="text-muted-foreground">
|
||||
→ {b.upstream}
|
||||
{b.ahead > 0 || b.behind > 0
|
||||
? ` (ahead ${b.ahead}, behind ${b.behind})`
|
||||
: ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write controls for the authenticated Dashboard Git tab. Destructive ops
|
||||
* (discard/push/dirty-checkout) are confirmed via the per-use confirmation
|
||||
* mechanics before the POST is sent.
|
||||
*/
|
||||
function WriteControls({
|
||||
status,
|
||||
selected,
|
||||
commitMessage,
|
||||
onCommitMessageChange,
|
||||
generatingMessage,
|
||||
onGenerateMessage,
|
||||
commitNotice,
|
||||
newBranch,
|
||||
onNewBranchChange,
|
||||
branchRef,
|
||||
onBranchRefChange,
|
||||
mutating,
|
||||
pushAfterCommit,
|
||||
onPushAfterCommitChange,
|
||||
onStageAll,
|
||||
onUnstageAll,
|
||||
onDiscardAll,
|
||||
onStage,
|
||||
onUnstage,
|
||||
onDiscard,
|
||||
onCommit,
|
||||
onFetch,
|
||||
onPull,
|
||||
onPush,
|
||||
onCheckout,
|
||||
onStashCheckout,
|
||||
}) {
|
||||
const staged = (status && status.staged || []).map((e) => e.path);
|
||||
const modified = (status && status.modified || []).map((e) => e.path);
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Write controls
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={mutating || modified.length === 0} onClick={onStageAll}>
|
||||
Stage modified
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={onUnstageAll}>
|
||||
Unstage staged
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={onDiscardAll}>
|
||||
Discard staged
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={commitMessage}
|
||||
onChange={(e) => onCommitMessageChange(e.target.value)}
|
||||
placeholder="Commit message"
|
||||
className="w-full rounded-md border px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={mutating || generatingMessage || staged.length === 0}
|
||||
onClick={onGenerateMessage}
|
||||
title="Generate a commit message from the staged diff"
|
||||
>
|
||||
{generatingMessage ? "Generating…" : "Generate"}
|
||||
</Button>
|
||||
</div>
|
||||
{commitNotice ? (
|
||||
<div className="text-xs text-amber-600">{commitNotice}</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pushAfterCommit}
|
||||
onChange={(e) => onPushAfterCommitChange(e.target.checked)}
|
||||
/>
|
||||
Push after commit
|
||||
</label>
|
||||
<Button size="sm" disabled={mutating || !commitMessage.trim()} onClick={onCommit}>
|
||||
Commit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={branchRef}
|
||||
onChange={(e) => onBranchRefChange(e.target.value)}
|
||||
placeholder="Branch to switch to"
|
||||
className="w-40 rounded-md border px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newBranch}
|
||||
onChange={(e) => onNewBranchChange(e.target.value)}
|
||||
placeholder="New branch name"
|
||||
className="w-40 rounded-md border px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={mutating || !branchRef.trim()} onClick={onCheckout}>
|
||||
Checkout
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={mutating || !branchRef.trim()}
|
||||
onClick={onStashCheckout}
|
||||
title="Switch branches, auto-stashing a dirty tree first"
|
||||
>
|
||||
Stash-checkout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={mutating} onClick={onFetch}>
|
||||
Fetch
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={mutating} onClick={onPull}>
|
||||
Pull
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" disabled={mutating} onClick={onPush}>
|
||||
Push
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GitState({ autoRefresh }) {
|
||||
const [repos, setRepos] = useState(null);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [branches, setBranches] = useState(null);
|
||||
const [diff, setDiff] = useState(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [notice, setNotice] = useState(null);
|
||||
const selectedRef = useRef(null);
|
||||
const requestGenerationRef = useRef(0);
|
||||
const mutationActiveRef = useRef(false);
|
||||
|
||||
const loadRepos = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getGitRepos();
|
||||
const list = (data && data.repos) || [];
|
||||
setRepos(list);
|
||||
setNotice((data && data.notice) || null);
|
||||
const currentSelected = selectedRef.current;
|
||||
if (currentSelected && !list.some((r) => r.id === currentSelected)) {
|
||||
selectedRef.current = null;
|
||||
requestGenerationRef.current += 1;
|
||||
setSelected(null);
|
||||
setStatus(null);
|
||||
setBranches(null);
|
||||
setDiff(null);
|
||||
setFile(null);
|
||||
setGeneratingMessage(false);
|
||||
setCommitNotice(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRepos();
|
||||
}, [loadRepos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return undefined;
|
||||
const id = setInterval(loadRepos, 15000);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, loadRepos]);
|
||||
|
||||
const selectRepo = useCallback(async (repoId) => {
|
||||
const generation = requestGenerationRef.current + 1;
|
||||
requestGenerationRef.current = generation;
|
||||
selectedRef.current = repoId;
|
||||
setSelected(repoId);
|
||||
setStatus(null);
|
||||
setBranches(null);
|
||||
setDiff(null);
|
||||
setFile(null);
|
||||
setGeneratingMessage(false);
|
||||
setCommitNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const [st, br] = await Promise.all([
|
||||
getGitStatus(repoId),
|
||||
getGitBranches(repoId),
|
||||
]);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setStatus(st);
|
||||
setBranches(br && br.branches);
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showDiff = useCallback(async (path, kind) => {
|
||||
const repoId = selectedRef.current;
|
||||
if (!repoId) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setFile(null);
|
||||
setError(null);
|
||||
try {
|
||||
const nextDiff = await getGitDiff(repoId, path, kind);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setDiff(nextDiff);
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showFile = useCallback(async (path) => {
|
||||
const repoId = selectedRef.current;
|
||||
if (!repoId) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setDiff(null);
|
||||
setError(null);
|
||||
try {
|
||||
const nextFile = await getGitFile(repoId, path);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setFile(nextFile);
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Authenticated Dashboard write controls + confirmations ──────────────
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
const [branchRef, setBranchRef] = useState("");
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const [mutationError, setMutationError] = useState(null);
|
||||
const [generatingMessage, setGeneratingMessage] = useState(false);
|
||||
const [commitNotice, setCommitNotice] = useState(null);
|
||||
const [pushAfterCommit, setPushAfterCommit] = useState(false);
|
||||
|
||||
const refreshDetail = useCallback(async (repoId, generation) => {
|
||||
const [st, br] = await Promise.all([
|
||||
getGitStatus(repoId),
|
||||
getGitBranches(repoId),
|
||||
]);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setStatus(st);
|
||||
setBranches(br && br.branches);
|
||||
}, []);
|
||||
|
||||
const applyMutation = useCallback(
|
||||
async (op, paths, opts) => {
|
||||
const repoId = selectedRef.current;
|
||||
const generation = requestGenerationRef.current;
|
||||
if (!repoId || mutationActiveRef.current) return false;
|
||||
mutationActiveRef.current = true;
|
||||
setMutationError(null);
|
||||
setMutating(true);
|
||||
try {
|
||||
if (op === "stage") await gitStage(repoId, paths);
|
||||
else if (op === "unstage") await gitUnstage(repoId, paths);
|
||||
else if (op === "fetch") await gitFetch(repoId, opts?.remote || "origin");
|
||||
else if (op === "pull") await gitPull(repoId, opts?.remote || "origin", opts?.branch || "");
|
||||
else if (op === "commit") await gitCommit(repoId, opts?.message);
|
||||
else if (op === "discard") await gitDiscard(repoId, paths, opts?.confirmation, opts?.deleteUntracked);
|
||||
else if (op === "push") await gitPush(repoId, opts?.confirmation, opts?.remote || "origin", opts?.branch || "");
|
||||
else if (op === "checkout" || op === "dirty-checkout") {
|
||||
await gitCheckout(repoId, opts.ref, {
|
||||
confirmation: opts.confirmation,
|
||||
newBranch: opts.newBranch,
|
||||
track: opts.track,
|
||||
});
|
||||
} else throw new Error(`Unknown Git operation: ${op}`);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
return false;
|
||||
}
|
||||
await refreshDetail(repoId, generation);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
setMutationError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
mutationActiveRef.current = false;
|
||||
setMutating(false);
|
||||
}
|
||||
},
|
||||
[refreshDetail],
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate a commit-message suggestion from the staged diff (AI). Empty
|
||||
* staged diff / model-unavailable degrade to a notice, never an error.
|
||||
*/
|
||||
const generateMessage = useCallback(async () => {
|
||||
const repoId = selectedRef.current;
|
||||
if (!repoId) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setGeneratingMessage(true);
|
||||
setCommitNotice(null);
|
||||
try {
|
||||
const stagedPaths = (status && status.staged || []).map((e) => e.path);
|
||||
const data = stagedPaths.length > 0 && !status?.truncated
|
||||
? await gitCommitMessageSelected(repoId, stagedPaths)
|
||||
: await gitCommitMessage(repoId);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
const result = normalizeCommitMessage(data);
|
||||
if (hasCommitSuggestion(result)) {
|
||||
setCommitMessage(result.message);
|
||||
setCommitNotice(result.notice || null);
|
||||
} else {
|
||||
setCommitNotice(result.notice || "Nothing staged to generate a message from.");
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setCommitNotice(err && err.message ? err.message : String(err));
|
||||
} finally {
|
||||
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
setGeneratingMessage(false);
|
||||
}
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
/**
|
||||
* Stash-checkout: switch branches, auto-stashing a dirty tree first. No
|
||||
* confirmation is needed because a stash is recoverable (git stash pop).
|
||||
* On success, surface the stash message so the user can pop it later.
|
||||
*/
|
||||
const doStashCheckout = useCallback(async () => {
|
||||
const ref = branchRef.trim();
|
||||
const repoId = selectedRef.current;
|
||||
const generation = requestGenerationRef.current;
|
||||
if (!ref || !repoId || mutationActiveRef.current) return;
|
||||
mutationActiveRef.current = true;
|
||||
setMutationError(null);
|
||||
setCommitNotice(null);
|
||||
setMutating(true);
|
||||
try {
|
||||
const data = await gitStashCheckout(repoId, ref, {
|
||||
newBranch: newBranch.trim(),
|
||||
track: false,
|
||||
});
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
const result = normalizeStashCheckout(data);
|
||||
if (result.stashed) {
|
||||
setCommitNotice(
|
||||
`Stashed changes on ${ref} as “${result.stashMessage}”. Use “git stash pop” to restore them.`,
|
||||
);
|
||||
}
|
||||
await refreshDetail(repoId, generation);
|
||||
} catch (err) {
|
||||
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
setMutationError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
mutationActiveRef.current = false;
|
||||
setMutating(false);
|
||||
setBranchRef("");
|
||||
setNewBranch("");
|
||||
}
|
||||
}, [branchRef, newBranch, refreshDetail]);
|
||||
|
||||
/**
|
||||
* Destructive ops (discard, push, dirty-checkout) gate on a per-use
|
||||
* confirmation echoed back to the server. Matches the dashboard's existing
|
||||
* confirm-before-destructive pattern (see RelayManagement onRevoke) and the
|
||||
* plugin's confirmation-string mechanics.
|
||||
*/
|
||||
const requestMutation = useCallback((op, opts) => {
|
||||
if (requiresConfirmation(op)) {
|
||||
const description =
|
||||
op === "discard"
|
||||
? "Discard local changes? This cannot be undone."
|
||||
: op === "push"
|
||||
? "Push local commits to the remote repository?"
|
||||
: "Working tree has uncommitted changes. Switch branches anyway?";
|
||||
if (!window.confirm(description)) return;
|
||||
const token = confirmationFor(op);
|
||||
if (op === "discard") applyMutation("discard", opts?.paths, { confirmation: token, deleteUntracked: opts?.deleteUntracked });
|
||||
else if (op === "push") applyMutation("push", [], { confirmation: token, remote: opts?.remote, branch: opts?.branch });
|
||||
else if (op === "dirty-checkout") applyMutation("dirty-checkout", [], { ...opts, confirmation: token });
|
||||
return;
|
||||
}
|
||||
applyMutation(op, opts?.paths, opts);
|
||||
}, [applyMutation]);
|
||||
|
||||
const doCommit = useCallback(async () => {
|
||||
const message = commitMessage.trim();
|
||||
if (!message) {
|
||||
setMutationError("Commit message must not be empty.");
|
||||
return;
|
||||
}
|
||||
const succeeded = await applyMutation("commit", [], { message });
|
||||
if (!succeeded) return;
|
||||
setCommitMessage("");
|
||||
// Push-after-commit: when the toggle is ON, immediately start the existing
|
||||
// push confirmation flow. Confirmation is still required (never bypassed);
|
||||
// the toggle only auto-starts it after a successful commit.
|
||||
if (shouldOfferPushAfterCommit(succeeded, pushAfterCommit)) {
|
||||
requestMutation("push", {});
|
||||
}
|
||||
}, [commitMessage, status, applyMutation, pushAfterCommit, requestMutation]);
|
||||
|
||||
const doCheckout = useCallback(async () => {
|
||||
const ref = branchRef.trim();
|
||||
if (!ref) return;
|
||||
const dirty = status && (status.counts.modified + status.counts.staged + status.counts.untracked) > 0;
|
||||
const opts = { ref, newBranch: newBranch.trim(), track: false };
|
||||
if (dirty && !opts.newBranch) {
|
||||
requestMutation("dirty-checkout", opts);
|
||||
setBranchRef("");
|
||||
setNewBranch("");
|
||||
return;
|
||||
}
|
||||
await applyMutation("checkout", [], opts);
|
||||
setBranchRef("");
|
||||
setNewBranch("");
|
||||
}, [branchRef, newBranch, status, applyMutation, requestMutation]);
|
||||
|
||||
if (loading && repos === null) {
|
||||
return <div className="text-sm text-muted-foreground">Loading repositories…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Git state unavailable</AlertTitle>
|
||||
<AlertDescription>
|
||||
<pre className="whitespace-pre-wrap text-xs">{error}</pre>
|
||||
<Button className="mt-2" size="sm" variant="outline" onClick={loadRepos}>
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const list = repos || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Repositories</CardTitle>
|
||||
<CardDescription>
|
||||
Repositories scanned from the configured Git base path.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{notice ? (
|
||||
<div className="mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
{list.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No repositories found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{list.map((repo) => (
|
||||
<Button
|
||||
key={repo.id}
|
||||
size="sm"
|
||||
variant={selected === repo.id ? "default" : "outline"}
|
||||
onClick={() => selectRepo(repo.id)}
|
||||
>
|
||||
{repo.name}
|
||||
{repo.dirty ? " •" : ""}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selected ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{selected}</CardTitle>
|
||||
<CardDescription>
|
||||
Working tree and branches. Tap a changed file to view its diff or
|
||||
content.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{mutationError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Git mutation failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<pre className="whitespace-pre-wrap text-xs">{mutationError}</pre>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<StatusRow status={status} />
|
||||
<BranchesRow branches={branches} />
|
||||
<WriteControls
|
||||
status={status}
|
||||
branches={branches}
|
||||
selected={selected}
|
||||
commitMessage={commitMessage}
|
||||
onCommitMessageChange={setCommitMessage}
|
||||
generatingMessage={generatingMessage}
|
||||
onGenerateMessage={generateMessage}
|
||||
commitNotice={commitNotice}
|
||||
newBranch={newBranch}
|
||||
onNewBranchChange={setNewBranch}
|
||||
branchRef={branchRef}
|
||||
onBranchRefChange={setBranchRef}
|
||||
mutating={mutating}
|
||||
pushAfterCommit={pushAfterCommit}
|
||||
onPushAfterCommitChange={setPushAfterCommit}
|
||||
onStageAll={() => requestMutation("stage", { paths: (status && status.modified || []).map((e) => e.path) })}
|
||||
onUnstageAll={() => requestMutation("unstage", { paths: (status && status.staged || []).map((e) => e.path) })}
|
||||
onDiscardAll={() => requestMutation("discard", { paths: (status && status.staged || []).map((e) => e.path), deleteUntracked: false })}
|
||||
onStage={(path) => requestMutation("stage", { paths: [path] })}
|
||||
onUnstage={(path) => requestMutation("unstage", { paths: [path] })}
|
||||
onDiscard={(path) => requestMutation("discard", { paths: [path], deleteUntracked: false })}
|
||||
onCommit={doCommit}
|
||||
onFetch={() => requestMutation("fetch", {})}
|
||||
onPull={() => requestMutation("pull", {})}
|
||||
onPush={() => requestMutation("push", {})}
|
||||
onCheckout={doCheckout}
|
||||
onStashCheckout={doStashCheckout}
|
||||
/>
|
||||
|
||||
{status && (status.staged || []).length > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Staged diffs
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{(status.staged || []).map((item) => (
|
||||
<li key={`sd-${item.path}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-xs text-primary underline"
|
||||
onClick={() => showDiff(item.path, "staged")}
|
||||
>
|
||||
{item.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && (status.modified || []).length > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Unstaged diffs
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{(status.modified || []).map((item) => (
|
||||
<li key={`ud-${item.path}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-xs text-primary underline"
|
||||
onClick={() => showDiff(item.path, "unstaged")}
|
||||
>
|
||||
{item.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && (status.untracked || []).length > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Untracked files
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{(status.untracked || []).map((item) => (
|
||||
<li key={`uf-${item.path}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-xs text-primary underline"
|
||||
onClick={() => showFile(item.path)}
|
||||
>
|
||||
{item.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{diff ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Diff — {diff.path} ({diff.kind})
|
||||
</div>
|
||||
<TruncationNotice truncated={diff.truncated} />
|
||||
</div>
|
||||
<pre className="mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs">
|
||||
{diff.diff || "(no changes)"}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{file ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
File — {file.path}
|
||||
</div>
|
||||
<TruncationNotice truncated={file.truncated} />
|
||||
</div>
|
||||
<pre className="mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs">
|
||||
{file.content}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
normalizeMutationResult,
|
||||
confirmationFor,
|
||||
requiresConfirmation,
|
||||
CONFIRMATIONS,
|
||||
isCurrentRepoRequest,
|
||||
shouldOfferPushAfterCommit,
|
||||
} from "../src/lib/git-state.mjs";
|
||||
|
||||
test("normalizeMutationResult maps head/status/branches safely", () => {
|
||||
const r = normalizeMutationResult({
|
||||
head: "abc123",
|
||||
status: { counts: { staged: 1 } },
|
||||
branches: [{ name: "main" }],
|
||||
});
|
||||
assert.equal(r.head, "abc123");
|
||||
assert.equal(r.status.counts.staged, 1);
|
||||
assert.equal(r.branches.length, 1);
|
||||
});
|
||||
|
||||
test("normalizeMutationResult tolerates missing fields", () => {
|
||||
const r = normalizeMutationResult(null);
|
||||
assert.equal(r.head, "");
|
||||
assert.deepEqual(r.status.counts, { staged: 0, modified: 0, untracked: 0 });
|
||||
assert.deepEqual(r.branches, []);
|
||||
});
|
||||
|
||||
test("CONFIRMATIONS holds the fixed confirmation tokens", () => {
|
||||
assert.equal(CONFIRMATIONS.discard, "discard");
|
||||
assert.equal(CONFIRMATIONS.push, "push");
|
||||
assert.equal(CONFIRMATIONS.dirtyCheckout, "checkout-dirty");
|
||||
});
|
||||
|
||||
test("confirmationFor returns the token only for destructive ops", () => {
|
||||
assert.equal(confirmationFor("discard"), CONFIRMATIONS.discard);
|
||||
assert.equal(confirmationFor("push"), CONFIRMATIONS.push);
|
||||
assert.equal(confirmationFor("dirty-checkout"), CONFIRMATIONS.dirtyCheckout);
|
||||
assert.equal(confirmationFor("commit"), null);
|
||||
assert.equal(confirmationFor("stage"), null);
|
||||
});
|
||||
|
||||
test("requiresConfirmation gates only destructive ops", () => {
|
||||
assert.equal(requiresConfirmation("discard"), true);
|
||||
assert.equal(requiresConfirmation("push"), true);
|
||||
assert.equal(requiresConfirmation("dirty-checkout"), true);
|
||||
assert.equal(requiresConfirmation("stage"), false);
|
||||
assert.equal(requiresConfirmation("commit"), false);
|
||||
assert.equal(requiresConfirmation("fetch"), false);
|
||||
});
|
||||
|
||||
test("repository request ownership rejects stale repo or generation", () => {
|
||||
assert.equal(isCurrentRepoRequest("a", 2, "a", 2), true);
|
||||
assert.equal(isCurrentRepoRequest("b", 2, "a", 2), false);
|
||||
assert.equal(isCurrentRepoRequest("a", 3, "a", 2), false);
|
||||
});
|
||||
|
||||
test("push-after-commit requires the exact commit to succeed", () => {
|
||||
assert.equal(shouldOfferPushAfterCommit(true, true), true);
|
||||
assert.equal(shouldOfferPushAfterCommit(false, true), false);
|
||||
assert.equal(shouldOfferPushAfterCommit(true, false), false);
|
||||
});
|
||||
|
||||
test("GitState commits the complete index and dispatches clean checkout", () => {
|
||||
const source = readFileSync(new URL("../src/tabs/GitState.jsx", import.meta.url), "utf8");
|
||||
assert.match(source, /applyMutation\("commit", \[\], \{ message \}\)/);
|
||||
assert.doesNotMatch(source, /applyMutation\("commitSelected"/);
|
||||
assert.match(source, /op === "checkout" \|\| op === "dirty-checkout"/);
|
||||
});
|
||||
|
||||
test("GitState bulk actions dispatch one bounded path array", () => {
|
||||
const source = readFileSync(new URL("../src/tabs/GitState.jsx", import.meta.url), "utf8");
|
||||
assert.match(source, /onClick=\{onStageAll\}/);
|
||||
assert.match(source, /onClick=\{onUnstageAll\}/);
|
||||
assert.match(source, /onClick=\{onDiscardAll\}/);
|
||||
assert.doesNotMatch(source, /forEach\(onStage\)|forEach\(onUnstage\)|forEach\(onDiscard\)/);
|
||||
});
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
normalizeCommitMessage,
|
||||
hasCommitSuggestion,
|
||||
normalizeStashCheckout,
|
||||
} from "../src/lib/git-state.mjs";
|
||||
|
||||
test("normalizeCommitMessage maps message/notice safely", () => {
|
||||
const r = normalizeCommitMessage({ message: "feat: add x", notice: "" });
|
||||
assert.equal(r.message, "feat: add x");
|
||||
assert.equal(r.notice, "");
|
||||
});
|
||||
|
||||
test("normalizeCommitMessage tolerates missing/empty fields", () => {
|
||||
assert.deepEqual(normalizeCommitMessage(null), { message: "", notice: "" });
|
||||
assert.deepEqual(normalizeCommitMessage({}), { message: "", notice: "" });
|
||||
});
|
||||
|
||||
test("hasCommitSuggestion is false for empty/whitespace messages", () => {
|
||||
assert.equal(hasCommitSuggestion({ message: "feat: add x" }), true);
|
||||
assert.equal(hasCommitSuggestion({ message: "" }), false);
|
||||
assert.equal(hasCommitSuggestion({ message: " " }), false);
|
||||
assert.equal(hasCommitSuggestion(null), false);
|
||||
});
|
||||
|
||||
test("normalizeStashCheckout carries stashed + stash_message", () => {
|
||||
const r = normalizeStashCheckout({
|
||||
head: "abc",
|
||||
stashed: true,
|
||||
stash_message: "git-state: feature",
|
||||
status: { counts: { staged: 0 } },
|
||||
branches: [],
|
||||
});
|
||||
assert.equal(r.head, "abc");
|
||||
assert.equal(r.stashed, true);
|
||||
assert.equal(r.stashMessage, "git-state: feature");
|
||||
assert.equal(r.status.counts.staged, 0);
|
||||
});
|
||||
|
||||
test("normalizeStashCheckout defaults stashed false when absent", () => {
|
||||
const r = normalizeStashCheckout({});
|
||||
assert.equal(r.stashed, false);
|
||||
assert.equal(r.stashMessage, "");
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
normalizeRepos,
|
||||
normalizeStatus,
|
||||
normalizeBranches,
|
||||
branchLabel,
|
||||
isTruncated,
|
||||
} from "../src/lib/git-state.mjs";
|
||||
|
||||
test("normalizeRepos accepts object and bare-array shapes", () => {
|
||||
assert.deepEqual(
|
||||
normalizeRepos({ repos: [{ id: "a", name: "A" }] }),
|
||||
[{ id: "a", name: "A" }],
|
||||
);
|
||||
assert.deepEqual(normalizeRepos([{ id: "b", name: "B" }]), [
|
||||
{ id: "b", name: "B" },
|
||||
]);
|
||||
assert.deepEqual(normalizeRepos(null), []);
|
||||
assert.deepEqual(normalizeRepos({ repos: [{ id: 1 }, null] }), []);
|
||||
});
|
||||
|
||||
test("normalizeStatus fills missing groups and preserves truncation", () => {
|
||||
const status = normalizeStatus({
|
||||
counts: { staged: 1, modified: 2, untracked: 3 },
|
||||
staged: [{ path: "a" }],
|
||||
truncated: true,
|
||||
});
|
||||
assert.equal(status.counts.staged, 1);
|
||||
assert.equal(status.counts.modified, 2);
|
||||
assert.equal(status.counts.untracked, 3);
|
||||
assert.deepEqual(status.staged, [{ path: "a" }]);
|
||||
assert.deepEqual(status.modified, []);
|
||||
assert.deepEqual(status.untracked, []);
|
||||
assert.equal(status.truncated, true);
|
||||
assert.equal(isTruncated(status), true);
|
||||
assert.equal(isTruncated(normalizeStatus({})), false);
|
||||
});
|
||||
|
||||
test("normalizeBranches accepts object and bare-array shapes", () => {
|
||||
assert.deepEqual(normalizeBranches({ branches: [{ name: "main" }] }), [
|
||||
{ name: "main" },
|
||||
]);
|
||||
assert.deepEqual(normalizeBranches([{ name: "dev" }]), [{ name: "dev" }]);
|
||||
assert.deepEqual(normalizeBranches(null), []);
|
||||
});
|
||||
|
||||
test("branchLabel renders upstream and ahead/behind", () => {
|
||||
assert.equal(branchLabel({ name: "main" }), "main");
|
||||
assert.equal(
|
||||
branchLabel({ name: "main", upstream: "origin/main" }),
|
||||
"main → origin/main",
|
||||
);
|
||||
assert.equal(
|
||||
branchLabel({ name: "feature", upstream: "origin/feature", ahead: 1, behind: 2 }),
|
||||
"feature → origin/feature (ahead 1, behind 2)",
|
||||
);
|
||||
assert.equal(branchLabel(null), "");
|
||||
});
|
||||
@@ -0,0 +1,559 @@
|
||||
"""Tests for the write (POST) Git State endpoints in plugin/dashboard/git_api.py.
|
||||
|
||||
Write endpoints are POST and rely on the plugin's ``plugin.api.write`` grant
|
||||
which the app enforces client-side (see PluginsViewModel.invokeAction — the
|
||||
precedent gate). Server-side, destructive operations are additionally enforced
|
||||
by a required confirmation string; missing/wrong confirmation maps to 403 and
|
||||
dirty/conflict trees map to 409, so the UI can render a readable message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from plugin.dashboard import git_api
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
return subprocess.run(
|
||||
cmd, cwd=cwd, capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
def _init_bare_remote(root: Path, name: str) -> Path:
|
||||
remote = root / name
|
||||
remote.mkdir(parents=True, exist_ok=True)
|
||||
_run(["git", "init", "-q", "--bare", "-b", "main"], remote)
|
||||
return remote
|
||||
|
||||
|
||||
class GitWriteApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "alpha")
|
||||
self.env = patch.dict(
|
||||
os.environ,
|
||||
{"HERMES_HOME": self.temp.name, "GIT_STATE_BASE_PATH": str(self.base)},
|
||||
)
|
||||
self.env.start()
|
||||
self.addCleanup(self.env.stop)
|
||||
app = FastAPI()
|
||||
app.include_router(git_api.router)
|
||||
self.client = TestClient(app)
|
||||
|
||||
def _stage(self, path: str) -> None:
|
||||
(self.repo / path).write_text("x", encoding="utf-8")
|
||||
self.client.post("/git/stage", json={"repo": "alpha", "paths": [path]})
|
||||
|
||||
def test_stage_returns_fresh_status(self) -> None:
|
||||
(self.repo / "new.txt").write_text("x", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": ["new.txt"]}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual(1, body["status"]["counts"]["staged"])
|
||||
|
||||
def test_unknown_repo_rejected(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "bogus", "paths": ["x"]}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_remote_operations_reject_urls_options_and_wrong_types(self) -> None:
|
||||
for path, payload in (
|
||||
("/git/fetch", {"remote": "https://example.invalid/repo.git"}),
|
||||
("/git/fetch", {"remote": "--all"}),
|
||||
("/git/pull", {"remote": ["origin"], "branch": "main"}),
|
||||
("/git/push", {"remote": "origin", "branch": "--mirror", "confirmation": "push"}),
|
||||
):
|
||||
with self.subTest(path=path, payload=payload):
|
||||
response = self.client.post(path, json={"repo": "alpha", **payload})
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_commit_creates_commit(self) -> None:
|
||||
self._stage("feature.txt")
|
||||
before = _git(self.repo, "rev-parse", "HEAD")
|
||||
response = self.client.post(
|
||||
"/git/commit", json={"repo": "alpha", "message": "add feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
after = _git(self.repo, "rev-parse", "HEAD")
|
||||
self.assertNotEqual(before, after)
|
||||
|
||||
def test_commit_empty_message_rejected(self) -> None:
|
||||
self._stage("feature.txt")
|
||||
response = self.client.post(
|
||||
"/git/commit", json={"repo": "alpha", "message": " "}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_discard_without_confirmation_is_403(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/discard", json={"repo": "alpha", "paths": ["tracked.txt"]}
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_discard_wrong_confirmation_is_403(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/discard",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"paths": ["tracked.txt"],
|
||||
"confirmation": "wrong",
|
||||
},
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_discard_with_confirmation_succeeds(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/discard",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"paths": ["tracked.txt"],
|
||||
"confirmation": git_state.CONFIRM_DISCARD,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual("v1", (self.repo / "tracked.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_push_requires_confirmation(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "origin-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
self._stage("feature.txt")
|
||||
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
response = self.client.post(
|
||||
"/git/push", json={"repo": "alpha", "remote": "origin", "branch": "main"}
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_push_with_confirmation_succeeds(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "remote-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
self._stage("feature.txt")
|
||||
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
response = self.client.post(
|
||||
"/git/push",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"remote": "origin",
|
||||
"branch": "main",
|
||||
"confirmation": git_state.CONFIRM_PUSH,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
|
||||
def test_checkout_dirty_requires_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
# Missing confirmation on a dirty-tree switch is the destructive gate.
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
# Confirming proceeds (git still refuses to overwrite conflicting work).
|
||||
response = self.client.post(
|
||||
"/git/checkout",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"ref": "feature",
|
||||
"confirmation": git_state.CONFIRM_DIRTY_CHECKOUT,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
|
||||
def test_pull_dirty_returns_409_never_clobbers(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "remote-bare2")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
# Advance the remote from a descendant clone.
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "t@e.c")
|
||||
_git(other, "config", "user.name", "T")
|
||||
(other / "tracked.txt").write_text("remote", encoding="utf-8")
|
||||
_git(other, "add", "tracked.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
(self.repo / "tracked.txt").write_text("local-uncommitted", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/pull", json={"repo": "alpha", "remote": "origin", "branch": "main"}
|
||||
)
|
||||
self.assertEqual(409, response.status_code, response.text)
|
||||
self.assertEqual("local-uncommitted", (self.repo / "tracked.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_fetch_returns_branches(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "remote-bare3")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
response = self.client.post(
|
||||
"/git/fetch", json={"repo": "alpha", "remote": "origin"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
|
||||
def test_structured_error_taxonomy_is_readable(self) -> None:
|
||||
# Unknown repo → 400 with a readable detail, never a stack trace.
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "missing", "paths": ["x"]}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertNotIn("Traceback", response.text)
|
||||
self.assertNotIn("subprocess", response.text.lower())
|
||||
|
||||
|
||||
def test_push_wrong_confirmation_is_403(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "push-wrong-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
self._stage("feature.txt")
|
||||
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
response = self.client.post(
|
||||
"/git/push",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"remote": "origin",
|
||||
"branch": "main",
|
||||
"confirmation": "nope",
|
||||
},
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_push_with_confirmation_updates_remote_and_returns_branches(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "push-ok-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
self._stage("feature.txt")
|
||||
r = self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
self.assertEqual(200, r.status_code, r.text)
|
||||
head = r.json()["head"]
|
||||
response = self.client.post(
|
||||
"/git/push",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"remote": "origin",
|
||||
"branch": "main",
|
||||
"confirmation": git_state.CONFIRM_PUSH,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
remote_head = _run(
|
||||
["git", "ls-remote", str(remote), "refs/heads/main"], self.base
|
||||
)
|
||||
self.assertIn(head, remote_head)
|
||||
|
||||
def test_checkout_dirty_tree_wrong_confirmation_is_403(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/checkout",
|
||||
json={"repo": "alpha", "ref": "feature", "confirmation": "wrong"},
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_checkout_clean_tree_works_without_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
response = self.client.post(
|
||||
"/git/checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
self.assertEqual(
|
||||
"feature", _git(self.repo, "symbolic-ref", "--short", "HEAD")
|
||||
)
|
||||
|
||||
def test_checkout_new_branch_with_track_sets_upstream(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "newbranch-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
response = self.client.post(
|
||||
"/git/checkout",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"ref": "main",
|
||||
"new_branch": "exp",
|
||||
"track": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
# Upstream is set — rev-parse resolves to a commit, not an error.
|
||||
self.assertTrue(_git(self.repo, "rev-parse", "exp@{upstream}"))
|
||||
|
||||
def test_fetch_updates_remote_tracking_ref(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "fetch-adv-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "t@e.c")
|
||||
_git(other, "config", "user.name", "T")
|
||||
(other / "remote.txt").write_text("rc fetch", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "rc fetch")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
response = self.client.post(
|
||||
"/git/fetch", json={"repo": "alpha", "remote": "origin"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
remote_main = _git(self.repo, "rev-parse", "origin/main")
|
||||
self.assertNotEqual(remote_main, _git(self.repo, "rev-parse", "HEAD"))
|
||||
|
||||
def test_pull_returns_200_with_remote_commit(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "pull-ok-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "t@e.c")
|
||||
_git(other, "config", "user.name", "T")
|
||||
(other / "remote.txt").write_text("rc pull", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "rc pull")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
response = self.client.post(
|
||||
"/git/pull",
|
||||
json={"repo": "alpha", "remote": "origin", "branch": "main"},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("status", body)
|
||||
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
|
||||
self.assertIn("remote.txt", head_files)
|
||||
|
||||
def test_commit_selected_commits_only_given_paths(self) -> None:
|
||||
(self.repo / "kept.txt").write_text("keep", encoding="utf-8")
|
||||
(self.repo / "skip.txt").write_text("skip", encoding="utf-8")
|
||||
_git(self.repo, "add", "kept.txt", "skip.txt")
|
||||
response = self.client.post(
|
||||
"/git/commit_selected",
|
||||
json={"repo": "alpha", "message": "commit kept", "paths": ["kept.txt"]},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("head", body)
|
||||
self.assertIn("status", body)
|
||||
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
|
||||
self.assertIn("kept.txt", head_files)
|
||||
self.assertNotIn("skip.txt", head_files)
|
||||
|
||||
def test_commit_response_includes_head_and_fresh_status(self) -> None:
|
||||
self._stage("feature.txt")
|
||||
response = self.client.post(
|
||||
"/git/commit", json={"repo": "alpha", "message": "add feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("head", body)
|
||||
self.assertEqual(body["head"], _git(self.repo, "rev-parse", "HEAD"))
|
||||
self.assertIn("status", body)
|
||||
self.assertEqual(0, body["status"]["counts"]["staged"])
|
||||
self.assertEqual(0, body["status"]["counts"]["modified"])
|
||||
|
||||
def test_unstage_returns_fresh_status(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
r = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": ["tracked.txt"]}
|
||||
)
|
||||
self.assertEqual(200, r.status_code, r.text)
|
||||
response = self.client.post(
|
||||
"/git/unstage", json={"repo": "alpha", "paths": ["tracked.txt"]}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("status", body)
|
||||
status = body["status"]
|
||||
self.assertNotIn(
|
||||
"tracked.txt", [e["path"] for e in status["staged"]]
|
||||
)
|
||||
self.assertIn(
|
||||
"tracked.txt", [e["path"] for e in status["modified"]]
|
||||
)
|
||||
|
||||
def test_stage_missing_repo_in_body_is_400(self) -> None:
|
||||
response = self.client.post("/git/stage", json={"paths": ["x"]})
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_stage_traversal_path_is_400(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": ["../escape"]}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_stage_too_many_paths_is_400(self) -> None:
|
||||
paths = [f"f{i}.txt" for i in range(201)]
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": paths}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
|
||||
class GitExtrasApiTests(unittest.TestCase):
|
||||
"""Endpoints for the Phase 3 extras surface (AI messages + stash-checkout)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "alpha")
|
||||
self.env = patch.dict(
|
||||
os.environ,
|
||||
{"HERMES_HOME": self.temp.name, "GIT_STATE_BASE_PATH": str(self.base)},
|
||||
)
|
||||
self.env.start()
|
||||
self.addCleanup(self.env.stop)
|
||||
app = FastAPI()
|
||||
app.include_router(git_api.router)
|
||||
self.client = TestClient(app)
|
||||
|
||||
def _staged(self, path: str, content: str) -> None:
|
||||
(self.repo / path).write_text(content, encoding="utf-8")
|
||||
self.client.post("/git/stage", json={"repo": "alpha", "paths": [path]})
|
||||
|
||||
def test_commit_message_empty_staged_returns_notice_without_model(self) -> None:
|
||||
# Clean tree → nothing staged → no model needed, no 500.
|
||||
response = self.client.post("/git/commit_message", json={"repo": "alpha"})
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual("", body["message"])
|
||||
self.assertEqual("nothing staged", body["notice"])
|
||||
|
||||
def test_commit_message_with_staged_diff_generates_message(self) -> None:
|
||||
self._staged("feature.txt", "new feature\n")
|
||||
with patch.object(git_state, "_llm_call", new=AsyncMock(return_value="resp")), patch.object(
|
||||
git_state, "_llm_extract", return_value="feat: add feature"
|
||||
):
|
||||
response = self.client.post(
|
||||
"/git/commit_message", json={"repo": "alpha"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual("feat: add feature", response.json()["message"])
|
||||
|
||||
def test_commit_message_model_failure_degrades_gracefully(self) -> None:
|
||||
self._staged("a.txt", "x\n")
|
||||
with patch.object(
|
||||
git_state, "_llm_call", new=AsyncMock(side_effect=RuntimeError("no model"))
|
||||
):
|
||||
response = self.client.post(
|
||||
"/git/commit_message", json={"repo": "alpha"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual("", body["message"])
|
||||
self.assertIn("model", body["notice"].lower())
|
||||
|
||||
def test_commit_message_selected_honors_only_given_paths(self) -> None:
|
||||
self._staged("kept.txt", "kept\n")
|
||||
self._staged("skip.txt", "skip\n")
|
||||
with patch.object(git_state, "_llm_call", new=AsyncMock(return_value="ok")), patch.object(
|
||||
git_state, "_llm_extract", return_value="add kept"
|
||||
):
|
||||
response = self.client.post(
|
||||
"/git/commit_message_selected",
|
||||
json={"repo": "alpha", "paths": ["kept.txt"]},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual("add kept", response.json()["message"])
|
||||
|
||||
def test_commit_message_unknown_repo_is_400(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/commit_message", json={"repo": "bogus"}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_stash_checkout_dirty_tree_returns_stash_notice(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "README.md").write_text("dirty", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/stash_checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertTrue(body["stashed"])
|
||||
self.assertEqual("git-state: feature", body["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
self.assertIn("git-state: feature", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_stash_checkout_clean_tree_plain_checkout(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
response = self.client.post(
|
||||
"/git/stash_checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertFalse(body["stashed"])
|
||||
self.assertEqual("", body["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_stash_checkout_bad_ref_is_400(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/stash_checkout", json={"repo": "alpha", "ref": "nope"}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -50,7 +50,8 @@ class MobilePluginApiTests(unittest.TestCase):
|
||||
|
||||
manifest = self.client.get("/mobile/manifest").json()
|
||||
self.assertEqual("hermes-relay", manifest["id"])
|
||||
self.assertEqual("draft", manifest["contributions"][0]["status"])
|
||||
draft = next(c for c in manifest["contributions"] if c["id"] == "system-status")
|
||||
self.assertEqual("draft", draft["status"])
|
||||
loaded_document = self.client.get("/mobile/pages/system-status").json()
|
||||
self.assertEqual(1, loaded_document.pop("host_revision"))
|
||||
self.assertEqual(_document(), loaded_document)
|
||||
@@ -70,7 +71,35 @@ class MobilePluginApiTests(unittest.TestCase):
|
||||
json={"expected_digest": published_digest},
|
||||
)
|
||||
self.assertEqual({"ok": True, "id": "system-status"}, removed.json())
|
||||
self.assertEqual([], self.client.get("/mobile/manifest").json()["contributions"])
|
||||
remaining = self.client.get("/mobile/manifest").json()["contributions"]
|
||||
self.assertEqual(["git"], [c["id"] for c in remaining])
|
||||
|
||||
def test_manifest_exposes_static_git_page(self) -> None:
|
||||
manifest = self.client.get("/mobile/manifest").json()
|
||||
git = next(c for c in manifest["contributions"] if c["id"] == "git")
|
||||
self.assertEqual("page", git["surface"])
|
||||
self.assertEqual("mobile/pages/git", git["document"]["path"])
|
||||
# The Git page document is a GET-only read surface; it must not carry
|
||||
# any action.request (which would require the write grant).
|
||||
page = self.client.get("/mobile/pages/git").json()
|
||||
self.assertNotIn("action", str(page))
|
||||
|
||||
def test_git_page_document_is_served(self) -> None:
|
||||
response = self.client.get("/mobile/pages/git")
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual(1, body["schemaVersion"])
|
||||
self.assertEqual("git", body["pages"][0]["id"])
|
||||
self.assertEqual(1, body["host_revision"])
|
||||
|
||||
def test_reserved_git_plugin_id_is_rejected(self) -> None:
|
||||
response = self.client.put(
|
||||
"/mobile/plugins/git/draft",
|
||||
json={"title": "Shadow", "document": _document()},
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
manifest = self.client.get("/mobile/manifest").json()
|
||||
self.assertEqual(["git"], [item["id"] for item in manifest["contributions"]])
|
||||
|
||||
def test_traversal_and_bad_document_are_rejected(self) -> None:
|
||||
traversal = self.client.put(
|
||||
|
||||
+1110
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ from typing import Any, Optional
|
||||
|
||||
|
||||
PLUGIN_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
||||
RESERVED_PLUGIN_IDS = frozenset({"git"})
|
||||
MAX_DOCUMENT_BYTES = 512 * 1024
|
||||
ALLOWED_LIFECYCLES = frozenset({"session", "persistent"})
|
||||
ALLOWED_ELEMENT_TYPES = frozenset(
|
||||
@@ -132,6 +133,8 @@ class MobilePluginStore:
|
||||
for path in sorted(self.root.glob("*.json")):
|
||||
if not PLUGIN_ID_RE.fullmatch(path.stem):
|
||||
continue
|
||||
if path.stem in RESERVED_PLUGIN_IDS:
|
||||
continue
|
||||
entry = self._read(path.stem, required=False)
|
||||
if entry:
|
||||
entries.append({k: v for k, v in entry.items() if k != "document"})
|
||||
@@ -139,6 +142,26 @@ class MobilePluginStore:
|
||||
|
||||
def manifest(self) -> dict[str, Any]:
|
||||
contributions = []
|
||||
# Static read-only Git page contributed by the relay plugin itself.
|
||||
# It carries no filesystem paths (per the android-plugins.md document
|
||||
# contract); repo data flows through the /git/* API responses and is
|
||||
# rendered as plain text by the host. No plugin.api.write grant is
|
||||
# required for the read-only Git surface.
|
||||
contributions.append(
|
||||
{
|
||||
"id": "git",
|
||||
"surface": "page",
|
||||
"title": "Git",
|
||||
"description": "Browse repositories on this Hermes host",
|
||||
"status": "published",
|
||||
"lifecycle": "persistent",
|
||||
"revision": 1,
|
||||
"document": {
|
||||
"method": "GET",
|
||||
"path": "mobile/pages/git",
|
||||
},
|
||||
}
|
||||
)
|
||||
for summary in self.list():
|
||||
is_draft = summary["status"] == "draft"
|
||||
contributions.append(
|
||||
@@ -180,6 +203,8 @@ class MobilePluginStore:
|
||||
normalized = str(plugin_id).strip().lower()
|
||||
if not PLUGIN_ID_RE.fullmatch(normalized):
|
||||
raise MobilePluginStoreError("invalid plugin id")
|
||||
if normalized in RESERVED_PLUGIN_IDS:
|
||||
raise MobilePluginStoreError("plugin id is reserved")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
@@ -311,6 +336,8 @@ class MobilePluginStore:
|
||||
if required:
|
||||
raise MobilePluginNotFoundError(plugin_id)
|
||||
return {}
|
||||
except MobilePluginStoreError:
|
||||
raise
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
if required:
|
||||
raise MobilePluginNotFoundError(plugin_id)
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
"""Tests for the read-only Git state surface (plugin/git_state.py).
|
||||
|
||||
Fixtures create REAL throwaway git repositories in tmp_path — init, config
|
||||
user, commits, branches, and bare remotes. Git itself is never mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
def _add_remote(repo: Path, remote_url: str, name: str = "origin") -> None:
|
||||
_git(repo, "remote", "add", name, remote_url)
|
||||
|
||||
|
||||
def _link_directory(link: Path, target: Path) -> None:
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
except OSError:
|
||||
if os.name != "nt":
|
||||
raise
|
||||
_run(["cmd", "/c", "mklink", "/J", str(link), str(target)], link.parent)
|
||||
|
||||
|
||||
class GitStateScanTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = Path(self.tempdir())
|
||||
|
||||
def tempdir(self) -> str:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
return self._td.name
|
||||
|
||||
def test_scan_finds_nested_repos_and_ignores_non_repos(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
_init_repo(base, "alpha")
|
||||
_init_repo(base / "nested", "beta")
|
||||
# A plain directory with no .git must be ignored.
|
||||
(base / "plain").mkdir()
|
||||
(base / "plain" / "file.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
repos = git_state.scan_repos(base)
|
||||
names = {r["name"] for r in repos}
|
||||
self.assertEqual({"alpha", "beta"}, names)
|
||||
for repo in repos:
|
||||
self.assertEqual("main", repo["current_branch"])
|
||||
self.assertFalse(repo["dirty"])
|
||||
|
||||
def test_scan_missing_base_path_returns_empty(self) -> None:
|
||||
missing = self.tmp / "does-not-exist"
|
||||
self.assertEqual([], git_state.scan_repos(missing))
|
||||
|
||||
def test_scan_excludes_git_internals(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
_init_repo(base, "alpha")
|
||||
# A .git directory itself must never be reported as a repo.
|
||||
repos = git_state.scan_repos(base)
|
||||
self.assertTrue(all(".git" not in r["name"] for r in repos))
|
||||
|
||||
def test_scan_marks_dirty_repo(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
repo = _init_repo(base, "dirty")
|
||||
(repo / "new.txt").write_text("untracked", encoding="utf-8")
|
||||
repos = git_state.scan_repos(base)
|
||||
dirty = next(r for r in repos if r["name"] == "dirty")
|
||||
self.assertTrue(dirty["dirty"])
|
||||
|
||||
def test_nested_same_name_repos_have_distinct_round_trip_ids(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
first = _init_repo(base / "team-a", "service")
|
||||
second = _init_repo(base / "team-b", "service")
|
||||
|
||||
repos = git_state.scan_repos(base)
|
||||
self.assertEqual({"team-a/service", "team-b/service"}, {repo["id"] for repo in repos})
|
||||
self.assertEqual(first.resolve(), git_state.resolve_repo(base, "team-a/service"))
|
||||
self.assertEqual(second.resolve(), git_state.resolve_repo(base, "team-b/service"))
|
||||
|
||||
def test_scan_rejects_linked_repo_outside_base(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
outside = _init_repo(self.tmp, "outside")
|
||||
link = base / "linked"
|
||||
_link_directory(link, outside)
|
||||
|
||||
self.assertEqual([], git_state.scan_repos(base))
|
||||
|
||||
|
||||
class GitStateStatusTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "status-repo")
|
||||
|
||||
def test_status_groups_modified_untracked_and_staged(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
# staged change
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
# unstaged change
|
||||
(self.repo / "README.md").write_text("# Changed\n", encoding="utf-8")
|
||||
# untracked
|
||||
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
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:
|
||||
for i in range(git_state.MAX_STATUS_ENTRIES + 5):
|
||||
(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,
|
||||
)
|
||||
|
||||
def test_status_lists_staged_and_modified_same_file(self) -> None:
|
||||
# A file staged AND then modified again ("MM" in porcelain) must appear
|
||||
# in BOTH the staged and modified groups (independent checks, not elif).
|
||||
(self.repo / "mm.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "mm.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add mm")
|
||||
(self.repo / "mm.txt").write_text("v2", encoding="utf-8")
|
||||
_git(self.repo, "add", "mm.txt")
|
||||
(self.repo / "mm.txt").write_text("v3", encoding="utf-8")
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
staged_paths = {e["path"] for e in status["staged"]}
|
||||
modified_paths = {e["path"] for e in status["modified"]}
|
||||
self.assertIn("mm.txt", staged_paths)
|
||||
self.assertIn("mm.txt", modified_paths)
|
||||
|
||||
def test_status_rename_emits_single_staged_entry(self) -> None:
|
||||
# `git mv` produces two NUL-separated porcelain records ("R new\0old\0");
|
||||
# the bare source-path record must be skipped, not misparsed as an XY
|
||||
# record. Use a source name starting with "M" so a naive parser would
|
||||
# misclassify the bare source record as staged with a truncated path.
|
||||
(self.repo / "Moved.txt").write_text("content", encoding="utf-8")
|
||||
_git(self.repo, "add", "Moved.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add Moved")
|
||||
_git(self.repo, "mv", "Moved.txt", "new.txt")
|
||||
|
||||
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"])
|
||||
|
||||
def test_status_unstaged_rename_shows_delete_and_untracked(self) -> None:
|
||||
# Rename on disk only (no `git mv`): delete + create → D + ??.
|
||||
(self.repo / "old.txt").write_text("content", encoding="utf-8")
|
||||
_git(self.repo, "add", "old.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add old")
|
||||
(self.repo / "old.txt").unlink()
|
||||
(self.repo / "new.txt").write_text("content", encoding="utf-8")
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual([], status["staged"])
|
||||
self.assertEqual(["old.txt"], [e["path"] for e in status["modified"]])
|
||||
self.assertEqual(["new.txt"], [e["path"] for e in status["untracked"]])
|
||||
|
||||
|
||||
class GitStateBranchesTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "branch-repo")
|
||||
|
||||
def test_branches_reports_current_upstream_ahead_behind(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
_git(self.repo, "add", "feature.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "feature work")
|
||||
|
||||
branches = git_state.repo_branches(self.repo)
|
||||
by_name = {b["name"]: b for b in branches}
|
||||
self.assertIn("main", by_name)
|
||||
self.assertIn("feature", by_name)
|
||||
self.assertTrue(by_name["feature"]["is_current"])
|
||||
self.assertFalse(by_name["main"]["is_current"])
|
||||
|
||||
|
||||
class GitStateDiffTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "diff-repo")
|
||||
|
||||
def test_diff_unstaged_and_staged_kinds(self) -> None:
|
||||
(self.repo / "a.txt").write_text("one\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "a.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add a")
|
||||
# staged change
|
||||
(self.repo / "a.txt").write_text("two\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "a.txt")
|
||||
# unstaged change
|
||||
(self.repo / "a.txt").write_text("three\n", encoding="utf-8")
|
||||
|
||||
staged = git_state.repo_diff(self.repo, "a.txt", kind="staged")
|
||||
self.assertIn("+two", staged["diff"])
|
||||
unstaged = git_state.repo_diff(self.repo, "a.txt", kind="unstaged")
|
||||
self.assertIn("+three", unstaged["diff"])
|
||||
|
||||
def test_diff_invalid_kind_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.repo_diff(self.repo, "a.txt", kind="bogus")
|
||||
|
||||
def test_diff_truncates_large_output(self) -> None:
|
||||
big = "x" * 200_000
|
||||
(self.repo / "big.txt").write_text(big + "\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "big.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add big")
|
||||
(self.repo / "big.txt").write_text(big + "y\n", encoding="utf-8")
|
||||
result = git_state.repo_diff(self.repo, "big.txt", kind="unstaged")
|
||||
self.assertTrue(result["truncated"])
|
||||
self.assertLessEqual(len(result["diff"]), git_state.MAX_DIFF_BYTES)
|
||||
|
||||
|
||||
class GitStateFileTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "file-repo")
|
||||
|
||||
def test_read_tracked_file(self) -> None:
|
||||
content = git_state.read_file(self.repo, "README.md")
|
||||
self.assertIn("Hello", content["content"])
|
||||
|
||||
def test_read_tracked_file_returns_working_tree_not_committed(self) -> None:
|
||||
# A modified-but-uncommitted tracked file must return the on-disk
|
||||
# (working-tree) content, not the last committed version.
|
||||
(self.repo / "README.md").write_text("# Working Tree\n", encoding="utf-8")
|
||||
content = git_state.read_file(self.repo, "README.md")
|
||||
self.assertIn("Working Tree", content["content"])
|
||||
self.assertNotIn("Hello", content["content"])
|
||||
|
||||
def test_read_untracked_file_raises(self) -> None:
|
||||
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.read_file(self.repo, "untracked.txt")
|
||||
|
||||
def test_read_missing_file_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.read_file(self.repo, "nope.txt")
|
||||
|
||||
def test_read_tracked_binary_file_raises_gitstateerror(self) -> None:
|
||||
# A TRACKED binary file (e.g. a committed PNG) must raise GitStateError
|
||||
# ("binary file is not supported" / UTF-8), never an unhandled
|
||||
# UnicodeDecodeError that escapes as a 500.
|
||||
(self.repo / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00binary\xff\xfe")
|
||||
_git(self.repo, "add", "image.png")
|
||||
_git(self.repo, "commit", "-q", "-m", "add binary")
|
||||
with self.assertRaises(git_state.GitStateError) as ctx:
|
||||
git_state.read_file(self.repo, "image.png")
|
||||
message = str(ctx.exception)
|
||||
self.assertTrue("binary" in message or "UTF-8" in message)
|
||||
|
||||
def test_read_tracked_non_utf8_text_raises(self) -> None:
|
||||
# A tracked, NUL-free but non-UTF-8 text file (Latin-1) must raise a
|
||||
# clear GitStateError, not an unhandled UnicodeDecodeError.
|
||||
(self.repo / "latin1.txt").write_bytes(b"caf\xe9 latin1")
|
||||
_git(self.repo, "add", "latin1.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add latin1")
|
||||
with self.assertRaises(git_state.GitStateError) as ctx:
|
||||
git_state.read_file(self.repo, "latin1.txt")
|
||||
self.assertIn("not valid UTF-8 text", str(ctx.exception))
|
||||
|
||||
def test_read_tracked_link_outside_repo_is_rejected(self) -> None:
|
||||
if os.name == "nt":
|
||||
outside = self.base / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("secret", encoding="utf-8")
|
||||
link = self.repo / "leak"
|
||||
_link_directory(link, outside)
|
||||
tracked_path = "leak/secret.txt"
|
||||
else:
|
||||
outside = self.base / "outside.txt"
|
||||
outside.write_text("secret", encoding="utf-8")
|
||||
(self.repo / "leak.txt").symlink_to(outside)
|
||||
tracked_path = "leak.txt"
|
||||
_git(self.repo, "add", tracked_path)
|
||||
_git(self.repo, "commit", "-q", "-m", "track link")
|
||||
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "escapes repository"):
|
||||
git_state.read_file(self.repo, tracked_path)
|
||||
|
||||
def test_read_tracked_file_is_bounded_during_read(self) -> None:
|
||||
(self.repo / "large.txt").write_text("x" * (git_state.MAX_FILE_BYTES + 100), encoding="utf-8")
|
||||
_git(self.repo, "add", "large.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "large")
|
||||
|
||||
result = git_state.read_file(self.repo, "large.txt")
|
||||
|
||||
self.assertTrue(result["truncated"])
|
||||
self.assertEqual(git_state.MAX_FILE_BYTES, len(result["content"]))
|
||||
|
||||
|
||||
class GitStateDocumentTests(unittest.TestCase):
|
||||
def test_document_missing_base_notice_leaks_no_path(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
missing = Path(self._td.name) / "does-not-exist"
|
||||
|
||||
doc = git_state.build_git_document(missing)
|
||||
notice = doc["pages"][0]["content"]["children"][0]
|
||||
self.assertEqual("notice", notice["id"])
|
||||
value = notice["text"]["value"]
|
||||
# The document contract forbids filesystem paths: no "/" and no
|
||||
# path-like substring (e.g. the tmp dir name).
|
||||
self.assertNotIn("/", value)
|
||||
self.assertNotIn(self._td.name, value)
|
||||
|
||||
|
||||
class GitStateSecurityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "sec-repo")
|
||||
|
||||
def test_path_traversal_rejected(self) -> None:
|
||||
for bad in ("../outside", "/etc/passwd", "a/../../b", "..%2Fescape"):
|
||||
with self.subTest(path=bad):
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.resolve_repo_path(self.repo, bad)
|
||||
|
||||
def test_remote_urls_scrubbed_of_userinfo(self) -> None:
|
||||
_add_remote(self.repo, "https://user:secret@example.com/org/repo.git", "https")
|
||||
_add_remote(self.repo, "ssh://git@example.com:2222/org/repo.git", "ssh")
|
||||
_add_remote(self.repo, "git@example.com:org/repo.git", "scp")
|
||||
remotes = git_state.repo_remotes(self.repo)
|
||||
self.assertEqual(3, len(remotes))
|
||||
for remote in remotes:
|
||||
self.assertNotIn("secret", remote["url"])
|
||||
self.assertNotIn("user:", remote["url"])
|
||||
self.assertNotIn("git@", remote["url"])
|
||||
|
||||
def test_git_error_text_scrubs_embedded_remote_credentials(self) -> None:
|
||||
message = git_state._safe_git_error(
|
||||
"fatal: unable to access 'https://user:secret@example.com/repo.git'"
|
||||
)
|
||||
self.assertNotIn("user", message)
|
||||
self.assertNotIn("secret", message)
|
||||
|
||||
def test_allowlist_accepts_only_scanned_repos(self) -> None:
|
||||
scanned = git_state.scan_repos(self.base)
|
||||
ids = {r["id"] for r in scanned}
|
||||
self.assertIn(git_state.repo_id(self.repo), ids)
|
||||
self.assertNotIn("bogus-id", ids)
|
||||
|
||||
def test_git_output_over_cap_fails_closed(self) -> None:
|
||||
for index in range(20):
|
||||
(self.repo / f"long-untracked-name-{index}.txt").write_text("x", encoding="utf-8")
|
||||
with patch.object(git_state, "MAX_GIT_OUTPUT_BYTES", 32):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "output exceeded"):
|
||||
git_state.repo_status(self.repo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Tests for the Phase 3 extras surface of git_state.
|
||||
|
||||
Covers:
|
||||
- ``commit_message`` / ``commit_message_selected``: staged-diff → LLM → a
|
||||
conventional-style message suggestion. The model client is MONKEYPATCHED
|
||||
(never a real API call); empty staged diff must skip the LLM entirely; a
|
||||
missing/failing model degrades to an empty message + notice, never a 500.
|
||||
- ``stash_checkout``: dirty tree → auto-stash + switch (stash list entry
|
||||
present); clean tree → plain checkout; unknown ref → error.
|
||||
|
||||
Fixtures build REAL throwaway git repos in tmp_path. git itself is never
|
||||
mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
return subprocess.run(
|
||||
cmd, cwd=cwd, capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
class _ExtrasBase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "extras-repo")
|
||||
|
||||
def _staged_change(self, path: str, content: str) -> None:
|
||||
(self.repo / path).write_text(content, encoding="utf-8")
|
||||
_git(self.repo, "add", path)
|
||||
|
||||
def _branch(self, name: str) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", name)
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
|
||||
def _patch_llm(self, *, text: str = "", exc: Exception | None = None):
|
||||
"""Inject a fake agent.auxiliary_client chain into git_state.
|
||||
|
||||
``_llm_call`` is the async client; ``_llm_extract`` turns its return
|
||||
into text. The failure path raises ``exc`` from the client.
|
||||
"""
|
||||
client = AsyncMock(side_effect=exc) if exc else AsyncMock(return_value="resp")
|
||||
extractor = MagicMock(return_value=text)
|
||||
return patch.object(git_state, "_llm_call", client), client, extractor, patch.object(
|
||||
git_state, "_llm_extract", extractor
|
||||
)
|
||||
|
||||
|
||||
class CommitMessageTests(_ExtrasBase):
|
||||
def test_commit_message_returns_suggestion_from_staged_diff(self) -> None:
|
||||
self._staged_change("feature.txt", "new feature body\n")
|
||||
llm_patch, client, extractor, extract_patch = self._patch_llm(
|
||||
text="feat: add new feature\n\nBrings a new feature."
|
||||
)
|
||||
with llm_patch, extract_patch:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
# The generated message is the LLM text (subject + optional body) so it
|
||||
# can flow into the commit dialog and be edited.
|
||||
self.assertEqual(
|
||||
{"message": "feat: add new feature\n\nBrings a new feature.", "notice": ""},
|
||||
result,
|
||||
)
|
||||
# The client was called with a messages list containing the staged diff.
|
||||
_, kwargs = client.call_args
|
||||
self.assertIn("messages", kwargs)
|
||||
payload = " ".join(str(m) for m in kwargs["messages"])
|
||||
self.assertIn("new feature", payload)
|
||||
extractor.assert_called_once_with("resp")
|
||||
|
||||
def test_commit_message_empty_staged_diff_skips_llm(self) -> None:
|
||||
# Clean tree: nothing staged → no LLM call.
|
||||
p = patch.object(git_state, "_llm_call", AsyncMock())
|
||||
with p as client:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_commit_message_skips_llm_when_staged_diff_is_empty(self) -> None:
|
||||
# A tracked file modified but NOT staged must never be sent.
|
||||
(self.repo / "README.md").write_text("# Hello v2\n", encoding="utf-8")
|
||||
p = patch.object(git_state, "_llm_call", AsyncMock())
|
||||
with p as client:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_commit_message_degrades_when_model_fails(self) -> None:
|
||||
self._staged_change("feature.txt", "boom\n")
|
||||
p, _, _, _ = self._patch_llm(exc=RuntimeError("no provider configured"))
|
||||
with p:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual("", result["message"])
|
||||
self.assertIn("model", result["notice"].lower())
|
||||
|
||||
def test_commit_message_degrades_when_model_unavailable(self) -> None:
|
||||
self._staged_change("feature.txt", "x\n")
|
||||
# Resolver itself fails (e.g. `agent` not installed).
|
||||
with patch.object(git_state, "_resolve_llm", side_effect=ImportError("no agent")):
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual("", result["message"])
|
||||
self.assertIn("model", result["notice"].lower())
|
||||
|
||||
def test_commit_message_bounds_staged_diff(self) -> None:
|
||||
# A large staged diff is truncated to MAX_DIFF_BYTES and flagged.
|
||||
big = "x" * (git_state.MAX_DIFF_BYTES + 10)
|
||||
self._staged_change("big.txt", big)
|
||||
p, client, extractor, ep = self._patch_llm(text="add big")
|
||||
with p, ep:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual("add big", result["message"])
|
||||
_, kwargs = client.call_args
|
||||
payload = "".join(str(m) for m in kwargs["messages"])
|
||||
self.assertLessEqual(len(payload), git_state.MAX_DIFF_BYTES + 2048)
|
||||
self.assertNotIn("nothing staged", result.get("notice", ""))
|
||||
|
||||
|
||||
class CommitMessageSelectedTests(_ExtrasBase):
|
||||
def test_commit_message_selected_honors_only_given_paths(self) -> None:
|
||||
self._staged_change("kept.txt", "kept content\n")
|
||||
self._staged_change("skip.txt", "skip content\n")
|
||||
p, client, extractor, extract_p = self._patch_llm(text="add kept")
|
||||
with p, extract_p:
|
||||
result = asyncio.run(
|
||||
git_state.commit_message_selected(self.repo, ["kept.txt"])
|
||||
)
|
||||
self.assertEqual("add kept", result["message"])
|
||||
_, kwargs = client.call_args
|
||||
payload = "".join(str(m) for m in kwargs["messages"])
|
||||
self.assertIn("kept content", payload)
|
||||
self.assertNotIn("skip content", payload)
|
||||
|
||||
def test_commit_message_selected_skips_llm_when_none_selected_staged(self) -> None:
|
||||
self._staged_change("kept.txt", "kept\n")
|
||||
# A path with no staged content → nothing staged → no call.
|
||||
(self.repo / "other.txt").write_text("other\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "other.txt")
|
||||
# Stage only kept.txt, ask for other.txt's diff (not staged) → nothing.
|
||||
p = patch.object(git_state, "_llm_call", AsyncMock())
|
||||
with p as client:
|
||||
# Ask for a path that has no STAGED diff: unstage other first.
|
||||
_git(self.repo, "restore", "--staged", "other.txt")
|
||||
result = asyncio.run(
|
||||
git_state.commit_message_selected(self.repo, ["other.txt"])
|
||||
)
|
||||
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_commit_message_selected_rejects_traversal_path(self) -> None:
|
||||
self._staged_change("kept.txt", "kept\n")
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
asyncio.run(
|
||||
git_state.commit_message_selected(self.repo, ["../escape"])
|
||||
)
|
||||
|
||||
|
||||
class StashCheckoutTests(_ExtrasBase):
|
||||
def test_dirty_tree_stashes_then_switches(self) -> None:
|
||||
self._branch("feature")
|
||||
(self.repo / "README.md").write_text("dirty change\n", encoding="utf-8")
|
||||
result = git_state.stash_checkout(self.repo, "feature")
|
||||
self.assertTrue(result["stashed"])
|
||||
self.assertEqual("git-state: feature", result["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
stash_list = _git(self.repo, "stash", "list")
|
||||
self.assertIn("git-state: feature", stash_list)
|
||||
|
||||
def test_clean_tree_plain_checkout_no_stash(self) -> None:
|
||||
self._branch("feature")
|
||||
result = git_state.stash_checkout(self.repo, "feature")
|
||||
self.assertFalse(result["stashed"])
|
||||
self.assertEqual("", result["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
self.assertEqual("", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_clean_tree_new_branch_switch(self) -> None:
|
||||
result = git_state.stash_checkout(self.repo, "main", new_branch="exp")
|
||||
self.assertFalse(result["stashed"])
|
||||
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_stash_returns_fresh_status_and_branches(self) -> None:
|
||||
self._branch("feature")
|
||||
(self.repo / "README.md").write_text("dirty\n", encoding="utf-8")
|
||||
result = git_state.stash_checkout(self.repo, "feature")
|
||||
self.assertIn("status", result)
|
||||
self.assertIn("branches", result)
|
||||
self.assertIn("head", result)
|
||||
|
||||
def test_bad_ref_raises(self) -> None:
|
||||
(self.repo / "README.md").write_text("still here\n", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.stash_checkout(self.repo, "no-such-branch")
|
||||
self.assertEqual("still here\n", (self.repo / "README.md").read_text(encoding="utf-8"))
|
||||
self.assertEqual("", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_existing_new_branch_is_rejected_before_stashing(self) -> None:
|
||||
(self.repo / "README.md").write_text("still here\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(git_state.GitError, "already exists"):
|
||||
git_state.stash_checkout(self.repo, "main", new_branch="main")
|
||||
self.assertEqual("still here\n", (self.repo / "README.md").read_text(encoding="utf-8"))
|
||||
self.assertEqual("", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_checkout_failure_restores_tracked_staged_and_untracked_changes(self) -> None:
|
||||
self._branch("feature")
|
||||
(self.repo / "README.md").write_text("dirty\n", encoding="utf-8")
|
||||
(self.repo / "staged.txt").write_text("staged\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "staged.txt")
|
||||
(self.repo / "untracked.txt").write_text("untracked\n", encoding="utf-8")
|
||||
original_mutate = git_state._mutate
|
||||
|
||||
def fail_checkout(repo: Path, args: list[str]) -> str:
|
||||
if args[0] == "checkout":
|
||||
raise git_state.GitError("forced checkout failure", code="conflict")
|
||||
return original_mutate(repo, args)
|
||||
|
||||
with patch.object(git_state, "_mutate", side_effect=fail_checkout):
|
||||
with self.assertRaisesRegex(git_state.GitError, "working changes were restored"):
|
||||
git_state.stash_checkout(self.repo, "feature")
|
||||
|
||||
self.assertEqual("dirty\n", (self.repo / "README.md").read_text(encoding="utf-8"))
|
||||
self.assertTrue((self.repo / "staged.txt").exists())
|
||||
self.assertTrue((self.repo / "untracked.txt").exists())
|
||||
self.assertIn("staged.txt", _git(self.repo, "diff", "--cached", "--name-only"))
|
||||
self.assertIn("git-state: feature", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_new_branch_uses_requested_start_point(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
(self.repo / "feature-only.txt").write_text("feature", encoding="utf-8")
|
||||
_git(self.repo, "add", "feature-only.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
|
||||
git_state.stash_checkout(self.repo, "feature", new_branch="from-feature")
|
||||
|
||||
self.assertTrue((self.repo / "feature-only.txt").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Tests for the write (mutation) surface of git_state.
|
||||
|
||||
Security denials are tested FIRST: destructive mutations (discard, push, dirty
|
||||
checkout) reject when their confirmation string is missing or wrong. Then happy
|
||||
paths, then error branches. Fixtures build REAL throwaway git repos and bare
|
||||
remotes in tmp_path — git itself is never mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
def _init_bare_remote(root: Path, name: str) -> Path:
|
||||
remote = root / name
|
||||
remote.mkdir(parents=True, exist_ok=True)
|
||||
_run(["git", "init", "-q", "--bare", "-b", "main"], remote)
|
||||
return remote
|
||||
|
||||
|
||||
class _MutationBase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "write-repo")
|
||||
|
||||
def _make_change(self, path: str = "feature.txt", content: str = "hello") -> None:
|
||||
(self.repo / path).write_text(content, encoding="utf-8")
|
||||
_git(self.repo, "add", path)
|
||||
|
||||
def _head(self) -> str:
|
||||
return _git(self.repo, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
class StageUnstageTests(_MutationBase):
|
||||
def test_stage_moves_untracked_to_staged(self) -> None:
|
||||
(self.repo / "new.txt").write_text("x", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["new.txt"])
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertIn("new.txt", [e["path"] for e in status["staged"]])
|
||||
self.assertNotIn("new.txt", [e["path"] for e in status["untracked"]])
|
||||
|
||||
def test_stage_accepts_multiple_paths(self) -> None:
|
||||
(self.repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
(self.repo / "b.txt").write_text("b", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["a.txt", "b.txt"])
|
||||
status = git_state.repo_status(self.repo)
|
||||
staged = {e["path"] for e in status["staged"]}
|
||||
self.assertTrue({"a.txt", "b.txt"} <= staged)
|
||||
|
||||
def test_unstage_returns_to_modified(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
git_state.unstage(self.repo, ["tracked.txt"])
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertNotIn("tracked.txt", [e["path"] for e in status["staged"]])
|
||||
self.assertIn("tracked.txt", [e["path"] for e in status["modified"]])
|
||||
|
||||
def test_stage_rejects_traversal_path(self) -> None:
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.stage(self.repo, ["../outside"])
|
||||
|
||||
|
||||
class CommitTests(_MutationBase):
|
||||
def test_commit_creates_a_real_commit(self) -> None:
|
||||
before = self._head()
|
||||
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "add feature")
|
||||
after = self._head()
|
||||
self.assertNotEqual(before, after)
|
||||
message = _git(self.repo, "log", "-1", "--format=%s")
|
||||
self.assertEqual("add feature", message)
|
||||
|
||||
def test_commit_rejects_empty_message(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.commit(self.repo, " ")
|
||||
|
||||
def test_commit_returns_fresh_status(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
result = git_state.commit(self.repo, "add feature")
|
||||
self.assertEqual(result["head"], self._head())
|
||||
self.assertEqual(result["status"]["counts"]["staged"], 0)
|
||||
self.assertEqual(result["status"]["counts"]["modified"], 0)
|
||||
|
||||
def test_commit_selected_commits_only_given_paths(self) -> None:
|
||||
(self.repo / "kept.txt").write_text("keep", encoding="utf-8")
|
||||
(self.repo / "skip.txt").write_text("skip", encoding="utf-8")
|
||||
_git(self.repo, "add", "kept.txt", "skip.txt")
|
||||
git_state.commit_selected(self.repo, "commit kept only", ["kept.txt"])
|
||||
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
|
||||
self.assertIn("kept.txt", head_files)
|
||||
self.assertNotIn("skip.txt", head_files)
|
||||
|
||||
def test_commit_selected_returns_fresh_status(self) -> None:
|
||||
(self.repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["a.txt"])
|
||||
result = git_state.commit_selected(self.repo, "commit a", ["a.txt"])
|
||||
self.assertEqual(result["head"], self._head())
|
||||
self.assertEqual(result["status"]["counts"]["staged"], 0)
|
||||
|
||||
def test_commit_selected_rejects_empty_message(self) -> None:
|
||||
(self.repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["a.txt"])
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.commit_selected(self.repo, "", ["a.txt"])
|
||||
|
||||
|
||||
class DiscardConfirmationTests(_MutationBase):
|
||||
def test_discard_requires_confirmation_string(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.discard(self.repo, ["tracked.txt"], confirmation="")
|
||||
self.assertIn("confirmation", str(ctx.exception).lower())
|
||||
|
||||
def test_discard_rejects_wrong_confirmation(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.discard(self.repo, ["tracked.txt"], confirmation="wrong")
|
||||
|
||||
def test_discard_with_confirmation_reverts_tracked_changes(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
git_state.discard(self.repo, ["tracked.txt"], confirmation=git_state.CONFIRM_DISCARD)
|
||||
content = (self.repo / "tracked.txt").read_text(encoding="utf-8")
|
||||
self.assertEqual("v1", content)
|
||||
|
||||
def test_discard_delete_untracked_removes_untracked(self) -> None:
|
||||
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["untracked.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
self.assertFalse((self.repo / "untracked.txt").exists())
|
||||
|
||||
def test_discard_returns_fresh_status(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
result = git_state.discard(
|
||||
self.repo,
|
||||
["tracked.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
)
|
||||
self.assertEqual(result["status"]["counts"]["modified"], 0)
|
||||
|
||||
|
||||
class FetchPullPushTests(_MutationBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.remote = _init_bare_remote(self.base, "origin-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(self.remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
|
||||
def test_fetch_rejects_unknown_and_option_like_remote(self) -> None:
|
||||
for remote in ("https://example.invalid/repo.git", "--all", "missing"):
|
||||
with self.subTest(remote=remote):
|
||||
with self.assertRaisesRegex(git_state.GitError, "remote"):
|
||||
git_state.fetch(self.repo, remote)
|
||||
|
||||
def test_pull_and_push_reject_option_like_branch(self) -> None:
|
||||
with self.assertRaisesRegex(git_state.GitError, "branch"):
|
||||
git_state.pull(self.repo, "origin", "--all")
|
||||
with self.assertRaisesRegex(git_state.GitError, "branch"):
|
||||
git_state.push(self.repo, "origin", "--mirror", git_state.CONFIRM_PUSH)
|
||||
|
||||
def test_fetch_updates_remote_refs(self) -> None:
|
||||
# Advance the remote from a descendant clone (not an independent repo:
|
||||
# an independent root has its own "initial commit" SHA, and when it
|
||||
# lands in a different second than the remote's initial commit the push
|
||||
# is rejected as non-fast-forward, flaking the test).
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "test@example.com")
|
||||
_git(other, "config", "user.name", "Test User")
|
||||
(other / "remote.txt").write_text("remote change fetch", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote change fetch")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
|
||||
git_state.fetch(self.repo, "origin")
|
||||
remote_main = _git(self.repo, "rev-parse", "origin/main")
|
||||
self.assertNotEqual(remote_main, self._head())
|
||||
|
||||
def test_pull_brings_remote_commits(self) -> None:
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "test@example.com")
|
||||
_git(other, "config", "user.name", "Test User")
|
||||
(other / "remote.txt").write_text("remote change pull", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote change pull")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
|
||||
git_state.pull(self.repo, "origin", "main")
|
||||
self.assertIn("remote.txt", _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD"))
|
||||
|
||||
def test_push_requires_confirmation(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.push(self.repo, "origin", "main", confirmation="")
|
||||
self.assertIn("confirmation", str(ctx.exception).lower())
|
||||
|
||||
def test_push_rejects_wrong_confirmation(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.push(self.repo, "origin", "main", confirmation="nope")
|
||||
|
||||
def test_push_with_confirmation_updates_remote(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
git_state.push(self.repo, "origin", "main", confirmation=git_state.CONFIRM_PUSH)
|
||||
remote_head = _run(["git", "ls-remote", str(self.remote), "refs/heads/main"], self.remote)
|
||||
self.assertIn(self._head(), remote_head)
|
||||
|
||||
def test_pull_with_local_changes_yields_structured_dirty_error(self) -> None:
|
||||
# Repo tracks a file, then the remote advances it. A local uncommitted
|
||||
# edit to that file must never be clobbered by pull → structured dirty.
|
||||
(self.repo / "tracked.txt").write_text("base", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
|
||||
# Advance the remote from a clean clone whose history descends from it.
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "test@example.com")
|
||||
_git(other, "config", "user.name", "Test User")
|
||||
(other / "tracked.txt").write_text("remote", encoding="utf-8")
|
||||
_git(other, "add", "tracked.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote tracked")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
|
||||
# Local uncommitted change to tracked.txt → git refuses to clobber.
|
||||
(self.repo / "tracked.txt").write_text("uncommitted", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.pull(self.repo, "origin", "main")
|
||||
self.assertEqual("dirty", ctx.exception.code)
|
||||
|
||||
def test_push_auth_failure_maps_to_auth_error(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
# Point origin at a host that will reject credentials.
|
||||
_git(self.repo, "remote", "set-url", "origin", "https://invalid.invalid/x.git")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.push(self.repo, "origin", "main", confirmation=git_state.CONFIRM_PUSH)
|
||||
self.assertIn(ctx.exception.code, ("auth", "network"))
|
||||
|
||||
|
||||
class CheckoutTests(_MutationBase):
|
||||
def test_checkout_rejects_option_like_ref(self) -> None:
|
||||
with self.assertRaisesRegex(git_state.GitError, "git option"):
|
||||
git_state.checkout(self.repo, "--detach")
|
||||
|
||||
def test_checkout_switches_branch(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
git_state.checkout(self.repo, "feature", confirmation="")
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_new_branch_creates_branch(self) -> None:
|
||||
git_state.checkout(
|
||||
self.repo,
|
||||
"main",
|
||||
new_branch="exp",
|
||||
confirmation="",
|
||||
)
|
||||
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_new_branch_uses_requested_start_point(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
(self.repo / "feature-only.txt").write_text("feature", encoding="utf-8")
|
||||
_git(self.repo, "add", "feature-only.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
|
||||
git_state.checkout(self.repo, "feature", new_branch="from-feature")
|
||||
|
||||
self.assertTrue((self.repo / "feature-only.txt").exists())
|
||||
|
||||
def test_checkout_clean_tree_needs_no_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
git_state.checkout(self.repo, "feature", confirmation="")
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_dirty_tree_requires_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.checkout(self.repo, "feature", confirmation="")
|
||||
self.assertIn("confirmation", str(ctx.exception).lower())
|
||||
|
||||
def test_checkout_dirty_tree_wrong_confirmation_rejected(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.checkout(self.repo, "feature", confirmation="wrong")
|
||||
|
||||
def test_checkout_dirty_tree_with_confirmation_switches(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
git_state.checkout(
|
||||
self.repo,
|
||||
"feature",
|
||||
confirmation=git_state.CONFIRM_DIRTY_CHECKOUT,
|
||||
)
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_track_sets_upstream(self) -> None:
|
||||
self.remote = _init_bare_remote(self.base, "remote-bare.git")
|
||||
_git(self.repo, "remote", "add", "origin", str(self.remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
# Create origin/feature remotely via a second clone.
|
||||
other = _init_repo(self.base, "other")
|
||||
_git(other, "remote", "add", "origin", str(self.remote))
|
||||
_git(other, "checkout", "-q", "-b", "feature")
|
||||
(other / "feature.txt").write_text("f", encoding="utf-8")
|
||||
_git(other, "add", "feature.txt")
|
||||
_git(other, "commit", "-q", "-m", "feature")
|
||||
_git(other, "push", "-q", "origin", "feature")
|
||||
# Bring the remote-tracking ref into this repo, then track it.
|
||||
git_state.fetch(self.repo, "origin")
|
||||
git_state.checkout(
|
||||
self.repo,
|
||||
"origin/feature",
|
||||
confirmation="",
|
||||
track=True,
|
||||
)
|
||||
current = _git(self.repo, "symbolic-ref", "--short", "HEAD")
|
||||
self.assertEqual("feature", current)
|
||||
upstream = _git(self.repo, "rev-parse", "--abbrev-ref", "feature@{upstream}")
|
||||
self.assertEqual("origin/feature", upstream)
|
||||
|
||||
|
||||
class AllowlistTests(_MutationBase):
|
||||
def test_mutations_reject_unknown_repo(self) -> None:
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.stage(Path("/nonexistent"), ["x.txt"])
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.discard(
|
||||
Path("/nonexistent"),
|
||||
["x.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -46,14 +46,17 @@ class MobilePluginStoreTests(unittest.TestCase):
|
||||
|
||||
manifest = self.store.manifest()
|
||||
self.assertEqual("hermes-relay", manifest["id"])
|
||||
contribution = manifest["contributions"][0]
|
||||
contribution = next(c for c in manifest["contributions"] if c["id"] == "daily-brief")
|
||||
self.assertEqual("Draft: Daily Brief", contribution["title"])
|
||||
self.assertEqual("mobile/pages/daily-brief", contribution["document"]["path"])
|
||||
self.assertEqual(_document(), self.store.get("daily-brief")["document"])
|
||||
|
||||
published = self.store.publish("daily-brief")
|
||||
self.assertEqual("published", published["status"])
|
||||
self.assertEqual("Daily Brief", self.store.manifest()["contributions"][0]["title"])
|
||||
published_contribution = next(
|
||||
c for c in self.store.manifest()["contributions"] if c["id"] == "daily-brief"
|
||||
)
|
||||
self.assertEqual("Daily Brief", published_contribution["title"])
|
||||
|
||||
self.assertEqual({"ok": True, "id": "daily-brief"}, self.store.remove("daily-brief"))
|
||||
self.assertEqual([], self.store.list())
|
||||
@@ -81,6 +84,11 @@ class MobilePluginStoreTests(unittest.TestCase):
|
||||
document={"schemaVersion": 1, "pages": []},
|
||||
)
|
||||
|
||||
def test_rejects_reserved_git_id(self) -> None:
|
||||
with self.assertRaisesRegex(MobilePluginStoreError, "reserved"):
|
||||
self.store.draft("git", title="Shadow", description="", document=_document())
|
||||
self.assertEqual(["git"], [item["id"] for item in self.store.manifest()["contributions"]])
|
||||
|
||||
def test_listing_omits_document_payload(self) -> None:
|
||||
self.store.draft("compact", title="Compact", description="", document=_document())
|
||||
self.assertNotIn("document", self.store.list()[0])
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user