Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a97b71844 | ||
|
|
ae51ae3ab1 | ||
|
|
1bdcab8fc0 | ||
|
|
0c77d010fa | ||
|
|
dc18209c3c | ||
|
|
69347adb34 | ||
|
|
43a809e41a | ||
|
|
a5cc0104bf | ||
|
|
86a0bebc0d | ||
|
|
04d9421c74 | ||
|
|
14401aa3c3 | ||
|
|
99897274c6 | ||
|
|
20c5b690a8 | ||
|
|
dc86c043bc | ||
|
|
d5cce4e390 | ||
|
|
ae9b22a9e6 | ||
|
|
96a9e8077e | ||
|
|
a97e6a2b14 | ||
|
|
4317da85fd | ||
|
|
45fde0ad9a | ||
|
|
94565e9d6d | ||
|
|
56c2e6fa07 | ||
|
|
28629f3d93 | ||
|
|
c9a5c767c6 | ||
|
|
0d1faf47a0 | ||
|
|
524e319f95 | ||
|
|
647d1f9aea |
@@ -3,8 +3,11 @@
|
||||
function classifyCiPaths(paths) {
|
||||
const forceAll = paths.some((path) => [
|
||||
'.github/workflows/ci-required.yml',
|
||||
'.github/workflows/release-backmerge.yml',
|
||||
'.github/scripts/classify-ci-paths.cjs',
|
||||
'.github/scripts/classify-ci-paths.test.cjs',
|
||||
'scripts/plan_release_backmerge.py',
|
||||
'scripts/tests/plan_release_backmerge_test.py',
|
||||
].includes(path));
|
||||
const exact = (values) => paths.some((path) => values.includes(path));
|
||||
const under = (prefixes) => paths.some((path) => prefixes.some((prefix) => path.startsWith(prefix)));
|
||||
|
||||
@@ -30,5 +30,13 @@ assert.deepEqual(classifyCiPaths(['.github/workflows/ci-required.yml']), {
|
||||
contract: true,
|
||||
docs: true,
|
||||
});
|
||||
assert.deepEqual(classifyCiPaths(['.github/workflows/release-backmerge.yml']), {
|
||||
android: true,
|
||||
desktop: true,
|
||||
plugin: true,
|
||||
dashboard: true,
|
||||
contract: true,
|
||||
docs: true,
|
||||
});
|
||||
|
||||
console.log('CI path classification tests passed.');
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
name: CI — Upstream Contract
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
@@ -40,45 +43,64 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout hermes-relay
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Resolve upstream ref
|
||||
id: ref
|
||||
env:
|
||||
REQUESTED_REF: ${{ github.event.inputs.upstream_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# PR/push runs use a known-good NousResearch/hermes-agent commit so
|
||||
# normal CI is stable. The weekly schedule below intentionally tracks
|
||||
# main as the upstream-drift siren.
|
||||
DEFAULT_REF="ef4b897a1843cd32c4f141f55db60f0f0602cc98"
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
REF="main" # weekly drift siren
|
||||
elif [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
|
||||
REF="${{ github.event.inputs.upstream_ref }}" # manual override
|
||||
elif [ -n "$REQUESTED_REF" ]; then
|
||||
REF="$REQUESTED_REF" # manual override
|
||||
else
|
||||
REF="$DEFAULT_REF"
|
||||
fi
|
||||
|
||||
# The ref is passed to git below, so reject option-like or malformed
|
||||
# values before it reaches that boundary. Full commit IDs and normal
|
||||
# branch/tag names remain supported for manual contract checks.
|
||||
if [[ "$REF" == -* ]] ||
|
||||
! git check-ref-format --allow-onelevel "$REF" >/dev/null; then
|
||||
echo "FAIL: invalid upstream branch or tag name." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
echo "Checking standard-path route contract against upstream ref: $REF"
|
||||
|
||||
- name: Checkout vanilla upstream (no plugin, no bootstrap)
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: NousResearch/hermes-agent
|
||||
ref: ${{ steps.ref.outputs.ref }}
|
||||
path: _upstream
|
||||
fetch-depth: 1
|
||||
- name: Extract trusted upstream contract sources
|
||||
env:
|
||||
UPSTREAM_REF: ${{ steps.ref.outputs.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
UPSTREAM_GIT="$RUNNER_TEMP/hermes-agent-contract.git"
|
||||
git init --bare "$UPSTREAM_GIT"
|
||||
git -C "$UPSTREAM_GIT" remote add origin \
|
||||
"https://github.com/NousResearch/hermes-agent.git"
|
||||
git -C "$UPSTREAM_GIT" fetch --no-tags --depth=1 origin -- "$UPSTREAM_REF"
|
||||
UPSTREAM_COMMIT="$(git -C "$UPSTREAM_GIT" rev-parse 'FETCH_HEAD^{commit}')"
|
||||
|
||||
mkdir -p _upstream/gateway/platforms _upstream/hermes_cli
|
||||
git -C "$UPSTREAM_GIT" show \
|
||||
"$UPSTREAM_COMMIT:gateway/platforms/api_server.py" \
|
||||
> _upstream/gateway/platforms/api_server.py
|
||||
git -C "$UPSTREAM_GIT" show \
|
||||
"$UPSTREAM_COMMIT:hermes_cli/web_server.py" \
|
||||
> _upstream/hermes_cli/web_server.py
|
||||
echo "Extracted contract sources from upstream commit: $UPSTREAM_COMMIT"
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Assert upstream checkout is vanilla (no relay bootstrap/plugin)
|
||||
run: |
|
||||
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
|
||||
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
|
||||
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
|
||||
echo "FAIL: upstream checkout contains a relay bootstrap — not vanilla."; exit 1
|
||||
fi
|
||||
echo "OK: upstream checkout carries no relay plugin/bootstrap."
|
||||
|
||||
- name: Run route-surface contract
|
||||
run: python scripts/check-upstream-route-contract.py "_upstream"
|
||||
|
||||
@@ -10,6 +10,16 @@ on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base_sha:
|
||||
description: "Exact base commit for a trusted release-backmerge candidate"
|
||||
required: true
|
||||
type: string
|
||||
head_sha:
|
||||
description: "Exact candidate commit to check"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -31,22 +41,63 @@ jobs:
|
||||
contract: ${{ steps.filter.outputs.contract }}
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Checkout pull request merge
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Checkout exact dispatched candidate
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.head_sha }}
|
||||
|
||||
- name: Test path classifier
|
||||
run: node .github/scripts/classify-ci-paths.test.cjs
|
||||
|
||||
- name: Classify changed files
|
||||
id: filter
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
DISPATCH_BASE_SHA: ${{ inputs.base_sha }}
|
||||
DISPATCH_HEAD_SHA: ${{ inputs.head_sha }}
|
||||
with:
|
||||
script: |
|
||||
let diffArgs;
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
const base = process.env.DISPATCH_BASE_SHA || '';
|
||||
const head = process.env.DISPATCH_HEAD_SHA || '';
|
||||
const shaPattern = /^[0-9a-f]{40}$/;
|
||||
if (!shaPattern.test(base) || !shaPattern.test(head)) {
|
||||
core.setFailed('Exact-tree dispatch requires full 40-character base/head SHAs.');
|
||||
return;
|
||||
}
|
||||
const { stdout: checkedOut } = await exec.getExecOutput(
|
||||
'git',
|
||||
['rev-parse', 'HEAD'],
|
||||
);
|
||||
if (checkedOut.trim() !== head) {
|
||||
core.setFailed(`Checked out ${checkedOut.trim()}, expected ${head}.`);
|
||||
return;
|
||||
}
|
||||
const ancestry = await exec.exec(
|
||||
'git',
|
||||
['merge-base', '--is-ancestor', base, head],
|
||||
{ ignoreReturnCode: true },
|
||||
);
|
||||
if (ancestry !== 0) {
|
||||
core.setFailed(`Candidate ${head} does not descend from base ${base}.`);
|
||||
return;
|
||||
}
|
||||
diffArgs = ['diff', '--name-only', base, head];
|
||||
} else {
|
||||
diffArgs = ['diff', '--name-only', 'HEAD^1', 'HEAD^2'];
|
||||
}
|
||||
const { stdout } = await exec.getExecOutput(
|
||||
'git',
|
||||
['diff', '--name-only', 'HEAD^1', 'HEAD^2'],
|
||||
diffArgs,
|
||||
);
|
||||
const paths = stdout.split(/\r?\n/).filter(Boolean);
|
||||
const { classifyCiPaths } = require(
|
||||
|
||||
@@ -365,3 +365,22 @@ jobs:
|
||||
find app/build/outputs/apk -name '*.apk' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
find app/build/outputs/bundle -name '*.aab' -exec ls -la {} + >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
request-backmerge:
|
||||
name: Request stable release backmerge
|
||||
needs: [validate, release]
|
||||
if: needs.validate.outputs.prerelease != 'true'
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch fail-closed release reconciliation
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: android-v${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
gh workflow run release-backmerge.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref main \
|
||||
-f release_tag="$RELEASE_TAG"
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# Reconcile a completed stable hotfix into dev without adding a ceremonial PR
|
||||
# merge commit. Normal dev -> main releases are detected and intentionally no-op.
|
||||
# A conflicted merge, failed exact-tree CI, stale dev ref, or denied branch update
|
||||
# stops without mutating dev and falls back to the normal reconciliation PR path.
|
||||
|
||||
name: Release Backmerge
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Published stable tag to reconcile (android-v*, server-v*, or desktop-v*)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-backmerge-dev
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Prepare exact backmerge candidate
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
outcome: ${{ steps.prepare.outputs.outcome }}
|
||||
base_dev_sha: ${{ steps.prepare.outputs.base_dev_sha }}
|
||||
candidate_branch: ${{ steps.prepare.outputs.candidate_branch }}
|
||||
candidate_sha: ${{ steps.prepare.outputs.candidate_sha }}
|
||||
release_commit: ${{ steps.prepare.outputs.release_commit }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Validate release and prepare merge commit
|
||||
id: prepare
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! "$RELEASE_TAG" =~ ^(android|server|desktop)-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Release Backmerge accepts stable SemVer production tags only; got $RELEASE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
"+refs/heads/dev:refs/remotes/origin/dev" \
|
||||
"+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
|
||||
release_commit="$(git rev-parse "${RELEASE_TAG}^{commit}")"
|
||||
base_dev_sha="$(git rev-parse origin/dev)"
|
||||
echo "release_commit=$release_commit" >> "$GITHUB_OUTPUT"
|
||||
echo "base_dev_sha=$base_dev_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if ! git merge-base --is-ancestor "$release_commit" origin/main; then
|
||||
echo "::error::$RELEASE_TAG ($release_commit) is not contained in origin/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -r is_draft is_prerelease < <(
|
||||
gh release view "$RELEASE_TAG" --json isDraft,isPrerelease \
|
||||
--jq '[.isDraft, .isPrerelease] | @tsv'
|
||||
)
|
||||
if [ "$is_draft" != "false" ] || [ "$is_prerelease" != "false" ]; then
|
||||
echo "::error::$RELEASE_TAG is not a published stable GitHub release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plan="$(
|
||||
python3 scripts/plan_release_backmerge.py \
|
||||
--release-commit "$release_commit" \
|
||||
--dev-commit "$base_dev_sha"
|
||||
)"
|
||||
case "$plan" in
|
||||
already-contained)
|
||||
echo "outcome=noop" >> "$GITHUB_OUTPUT"
|
||||
echo "## Release backmerge not needed" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "\`$RELEASE_TAG\` is already contained in \`dev\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
;;
|
||||
normal-release)
|
||||
echo "outcome=noop" >> "$GITHUB_OUTPUT"
|
||||
echo "## Normal release: no backmerge" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "The released merge's integration parent is already contained in \`dev\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
;;
|
||||
hotfix) ;;
|
||||
*)
|
||||
echo "::error::Unknown release-backmerge plan: $plan"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
candidate_branch="chore/release-backmerge/${RELEASE_TAG}-${GITHUB_RUN_ID}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git switch --detach "$base_dev_sha"
|
||||
|
||||
set +e
|
||||
git merge --no-ff -m "chore: back-merge ${RELEASE_TAG}" "$release_commit"
|
||||
merge_status=$?
|
||||
set -e
|
||||
if [ "$merge_status" -ne 0 ]; then
|
||||
conflicts="$(git diff --name-only --diff-filter=U | paste -sd ', ' -)"
|
||||
echo "outcome=conflict" >> "$GITHUB_OUTPUT"
|
||||
echo "::error::Automatic backmerge conflicts: ${conflicts:-unknown}. Open a reconciliation PR."
|
||||
echo "## Manual reconciliation PR required" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "\`$RELEASE_TAG\` conflicts with current \`dev\`: ${conflicts:-unknown}." >> "$GITHUB_STEP_SUMMARY"
|
||||
git merge --abort || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
candidate_sha="$(git rev-parse HEAD)"
|
||||
first_parent="$(git rev-parse HEAD^1)"
|
||||
second_parent="$(git rev-parse HEAD^2)"
|
||||
if [ "$first_parent" != "$base_dev_sha" ] || [ "$second_parent" != "$release_commit" ]; then
|
||||
echo "::error::Candidate parents do not match dev + release commit"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git push origin "$candidate_sha:refs/heads/$candidate_branch"
|
||||
echo "outcome=candidate" >> "$GITHUB_OUTPUT"
|
||||
echo "candidate_branch=$candidate_branch" >> "$GITHUB_OUTPUT"
|
||||
echo "candidate_sha=$candidate_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "## Backmerge candidate prepared" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Release: \`$RELEASE_TAG\` (\`$release_commit\`)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Dev base: \`$base_dev_sha\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Candidate: \`$candidate_sha\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Temporary ref: \`$candidate_branch\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
gate:
|
||||
name: Run exact-tree required checks
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.outcome == 'candidate'
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
check_run_id: ${{ steps.gate.outputs.check_run_id }}
|
||||
steps:
|
||||
- name: Dispatch and await Required checks
|
||||
id: gate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BASE_DEV_SHA: ${{ needs.prepare.outputs.base_dev_sha }}
|
||||
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
|
||||
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh workflow run ci-required.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref "$CANDIDATE_BRANCH" \
|
||||
-f base_sha="$BASE_DEV_SHA" \
|
||||
-f head_sha="$CANDIDATE_SHA"
|
||||
|
||||
check_run_id=""
|
||||
for _ in {1..20}; do
|
||||
check_run_id="$(
|
||||
gh run list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--workflow ci-required.yml \
|
||||
--branch "$CANDIDATE_BRANCH" \
|
||||
--event workflow_dispatch \
|
||||
--limit 20 \
|
||||
--json databaseId,headSha \
|
||||
--jq ".[] | select(.headSha == \"$CANDIDATE_SHA\") | .databaseId" \
|
||||
| head -n 1
|
||||
)"
|
||||
if [ -n "$check_run_id" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
if [ -z "$check_run_id" ]; then
|
||||
echo "::error::Required checks dispatch was not observed for $CANDIDATE_SHA"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "check_run_id=$check_run_id" >> "$GITHUB_OUTPUT"
|
||||
gh run watch "$check_run_id" --repo "$GITHUB_REPOSITORY" --exit-status
|
||||
|
||||
promote:
|
||||
name: Compare-and-swap dev
|
||||
needs: [prepare, gate]
|
||||
if: needs.prepare.outputs.outcome == 'candidate' && needs.gate.result == 'success'
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Fast-forward dev to the tested candidate
|
||||
env:
|
||||
BASE_DEV_SHA: ${{ needs.prepare.outputs.base_dev_sha }}
|
||||
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
|
||||
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
|
||||
RELEASE_COMMIT: ${{ needs.prepare.outputs.release_commit }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin --no-tags \
|
||||
"+refs/heads/dev:refs/remotes/origin/dev" \
|
||||
"+refs/heads/$CANDIDATE_BRANCH:refs/remotes/origin/$CANDIDATE_BRANCH"
|
||||
current_dev="$(git rev-parse origin/dev)"
|
||||
remote_candidate="$(git rev-parse "origin/$CANDIDATE_BRANCH")"
|
||||
|
||||
if [ "$current_dev" != "$BASE_DEV_SHA" ]; then
|
||||
echo "::error::dev moved from $BASE_DEV_SHA to $current_dev; rerun or open a reconciliation PR"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$remote_candidate" != "$CANDIDATE_SHA" ]; then
|
||||
echo "::error::Candidate ref moved from $CANDIDATE_SHA to $remote_candidate"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(git rev-parse "$CANDIDATE_SHA^1")" != "$BASE_DEV_SHA" ] || \
|
||||
[ "$(git rev-parse "$CANDIDATE_SHA^2")" != "$RELEASE_COMMIT" ]; then
|
||||
echo "::error::Candidate ancestry changed after verification"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The explicit lease is the atomic stale-base guard. The update is a
|
||||
# fast-forward from BASE_DEV_SHA; no unrelated history can be replaced.
|
||||
git push \
|
||||
--force-with-lease="refs/heads/dev:$BASE_DEV_SHA" \
|
||||
origin "$CANDIDATE_SHA:refs/heads/dev"
|
||||
|
||||
git push origin --delete "$CANDIDATE_BRANCH" || \
|
||||
echo "::warning::Could not remove temporary branch $CANDIDATE_BRANCH"
|
||||
|
||||
echo "## Release backmerge complete" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Fast-forwarded \`dev\` from \`$BASE_DEV_SHA\` to tested merge \`$CANDIDATE_SHA\`." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
fallback:
|
||||
name: Report PR fallback
|
||||
needs: [prepare, gate, promote]
|
||||
if: always() && needs.prepare.outputs.outcome == 'candidate' && needs.promote.result != 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Preserve safe fallback instructions
|
||||
env:
|
||||
CANDIDATE_BRANCH: ${{ needs.prepare.outputs.candidate_branch }}
|
||||
CANDIDATE_SHA: ${{ needs.prepare.outputs.candidate_sha }}
|
||||
CHECK_RUN_ID: ${{ needs.gate.outputs.check_run_id }}
|
||||
run: |
|
||||
echo "## Automatic backmerge stopped" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "\`dev\` was not updated. Open or refresh a reconciliation PR after addressing the failed/stale gate." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Candidate ref: \`${CANDIDATE_BRANCH:-not-created}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Candidate SHA: \`${CANDIDATE_SHA:-n/a}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Required-check run: \`${CHECK_RUN_ID:-n/a}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -539,3 +539,22 @@ jobs:
|
||||
release-assets/cli-binaries/hermes-relay-darwin-arm64
|
||||
release-assets/cli-windows-installer/hermes-relay-windows-x64-setup.exe
|
||||
release-assets/SHA256SUMS.txt
|
||||
|
||||
request-backmerge:
|
||||
name: Request stable release backmerge
|
||||
needs: [validate-release, publish-release]
|
||||
if: ${{ !contains(needs.validate-release.outputs.version, '-') }}
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch fail-closed release reconciliation
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: desktop-v${{ needs.validate-release.outputs.version }}
|
||||
run: |
|
||||
gh workflow run release-backmerge.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref main \
|
||||
-f release_tag="$RELEASE_TAG"
|
||||
|
||||
@@ -138,3 +138,22 @@ jobs:
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
dist/SHA256SUMS.txt
|
||||
|
||||
request-backmerge:
|
||||
name: Request stable release backmerge
|
||||
needs: [validate, package]
|
||||
if: ${{ !contains(needs.validate.outputs.version, '-') }}
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch fail-closed release reconciliation
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: server-v${{ needs.validate.outputs.version }}
|
||||
run: |
|
||||
gh workflow run release-backmerge.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref main \
|
||||
-f release_tag="$RELEASE_TAG"
|
||||
|
||||
@@ -28,7 +28,7 @@ not redefine the branch, release, or hotfix policy here and in `RELEASE.md`.
|
||||
| Staging source | An exact tested `dev` SHA or release-candidate tag; staging is an environment, never a branch |
|
||||
| Production source | Immutable `android-v*`, `server-v*`, or `desktop-v*` tags, selected by surface |
|
||||
| Hotfix base | The immutable production tag for the affected surface |
|
||||
| Back-merge target | `dev`; merge `main` back immediately after every hotfix |
|
||||
| Back-merge target | `dev`; stable hotfixes reconcile automatically when the exact tested merge is conflict-free, otherwise through a PR |
|
||||
|
||||
Feature completion means merged and verified on `dev`; it does not mean
|
||||
released. A release train is separate work owned by a Forge release
|
||||
@@ -37,6 +37,14 @@ open the `dev` → `main` release PR, tag the resulting `main` tip, publish the
|
||||
surface artifacts, deploy or roll out, and verify the live result. Never create
|
||||
a staging branch.
|
||||
|
||||
A normal `dev` → `main` release needs no back-merge: the released integration
|
||||
parent is already in `dev`. A production-tag hotfix is different. After its
|
||||
stable release succeeds, `Release Backmerge` prepares a `dev`-first merge
|
||||
commit, runs the same path-aware required checks on that exact SHA, verifies
|
||||
that `dev` has not moved, and fast-forwards `dev`. Conflicts, failed checks,
|
||||
stale refs, or denied branch updates fail closed and require a reconciliation
|
||||
PR; never resolve those cases by choosing a side automatically.
|
||||
|
||||
### Local integration discipline
|
||||
|
||||
- Fetch `origin/dev` before creating a task branch or worktree; do not base new
|
||||
|
||||
+22
-1
@@ -6,6 +6,28 @@ 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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
|
||||
|
||||
## [Android 1.13.2] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android session rows stay neutral when optional live activity is unavailable or still loading.** Directory refreshes no longer restore a persistent Checking state, and full-row activity borders are reserved for actual Starting or Working turns.
|
||||
- **Returning from parent settings keeps Supervised Chat rendered.** Parent access now relocks without rebuilding the active navigation graph, and full Settings keeps a prominent shortcut back to Supervised Mode controls.
|
||||
|
||||
## [Android 1.13.1] - 2026-08-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android session activity now follows live Hermes runtime truth.** Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work no longer come from the Dashboard's five-minute recency hint, and only complete, unambiguously resolved live snapshots clear stale state.
|
||||
@@ -17,7 +39,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Provider usage and limits are available from top-level Settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden presentation modes. Provider credentials remain on the Hermes host.
|
||||
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
|
||||
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
|
||||
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+32
-10
@@ -200,6 +200,11 @@ never create a staging branch. Stable production tags are cut only from the new
|
||||
10. Build and publish that surface's artifacts, roll out or deploy from the
|
||||
immutable tag, and verify the release and live environment.
|
||||
|
||||
Do not back-merge a normal release. The `main` release merge already has the
|
||||
released `dev` tip as its integration parent, so merging it back only adds
|
||||
history noise. The release-backmerge workflow detects this topology and exits
|
||||
successfully without changing `dev`.
|
||||
|
||||
### Branch names
|
||||
|
||||
| Prefix | When | Example |
|
||||
@@ -258,7 +263,9 @@ The intended settings are:
|
||||
- **`main`** — PRs required; `Required checks` required and current; force push
|
||||
and deletion blocked. Normal work does not target this branch.
|
||||
- **`dev`** — PRs and `Required checks` required; force push and deletion
|
||||
blocked. This is the normal contribution target.
|
||||
blocked. This is the normal contribution target. The release-backmerge
|
||||
workflow is the sole exception: its automation identity may compare-and-swap
|
||||
`dev` to an exact checked merge commit after a stable hotfix release.
|
||||
- **Merge policy** — merge commits allowed; squash and rebase merges disabled so
|
||||
the no-ff contract cannot be bypassed in the GitHub UI.
|
||||
- **Default branch** — `main`, which remains the release-history branch and the
|
||||
@@ -922,8 +929,23 @@ When production has a bug, use the same invariant for every surface:
|
||||
4. Open the focused hotfix PR into `main` and merge with a merge commit/no-ff.
|
||||
5. Tag the new `main` tip with the affected surface's patch tag.
|
||||
6. Verify the artifacts and production rollout or deployment.
|
||||
7. Merge `main` back into `dev` immediately so integration inherits the fix and
|
||||
version history.
|
||||
7. Let the stable release workflow dispatch `Release Backmerge`. A
|
||||
conflict-free candidate runs the same path-aware `Required checks` against
|
||||
its exact SHA, then compare-and-swaps `dev` only if the base ref is unchanged.
|
||||
Conflicts, failed checks, stale refs, or a denied update require a normal
|
||||
reconciliation PR.
|
||||
|
||||
`Release Backmerge` accepts only published stable `android-v*`, `server-v*`, or
|
||||
`desktop-v*` SemVer tags contained in `main`. It exits without mutation for a
|
||||
normal release whose integration parent is already in `dev`. For a selective
|
||||
hotfix, it pushes a temporary merge ref, dispatches `Required checks` with full
|
||||
base/head SHAs, and updates `dev` with an explicit force-with-lease only after
|
||||
that exact candidate passes. The lease is a compare-and-swap guard, not
|
||||
permission to rewrite history: the candidate's first parent must be the
|
||||
unchanged `dev` tip and its second parent the released commit. The repository
|
||||
ruleset must allow this workflow's automation identity to perform that one
|
||||
checked branch update; if it does not, the workflow fails closed and the
|
||||
reconciliation uses a PR.
|
||||
|
||||
For an Android app hotfix:
|
||||
|
||||
@@ -938,21 +960,21 @@ For an Android app hotfix:
|
||||
6. `git tag android-v0.6.2` from the new `main` tip and `git push origin android-v0.6.2`
|
||||
so Android release CI builds and publishes.
|
||||
7. Verify the automated Play submission, GitHub artifacts, and rollout.
|
||||
8. Merge `main` back into `dev` (`git checkout dev && git merge --no-ff main`)
|
||||
so `dev` picks up the hotfix and the versionCode bump. Without this,
|
||||
`dev`'s `appVersionCode` lags behind `main` and the next app release
|
||||
bump collides.
|
||||
8. Verify the automated release backmerge completed. If it stopped, open a
|
||||
reconciliation PR so `dev` picks up the hotfix and versionCode bump. Without
|
||||
reconciliation, `dev`'s `appVersionCode` lags behind `main` and the next app
|
||||
release bump collides.
|
||||
|
||||
For a Plugin hotfix, branch from the affected `server-v*` tag, apply
|
||||
the fix, run `bash scripts/bump-plugin-version.sh <next-version>`, merge to
|
||||
`main`, tag `server-v<next-version>`, verify the package/deployment, and merge
|
||||
`main` back to `dev`. Do not touch
|
||||
`main`, tag `server-v<next-version>`, verify the package/deployment, and verify
|
||||
the automated release backmerge. Do not touch
|
||||
`gradle/libs.versions.toml` unless an Android app release is also shipping.
|
||||
|
||||
For a CLI+UI hotfix, branch from the affected `desktop-v*` tag, update only
|
||||
`desktop/package.json` and its generated lock/runtime/tray metadata, merge to
|
||||
`main`, tag `desktop-v<next-version>`, verify all binaries and the installer,
|
||||
then merge `main` back to `dev`.
|
||||
then verify the automated release backmerge or use the PR fallback.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
+8
-16
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.13.0
|
||||
# Hermes-Relay Android v1.13.2
|
||||
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.13.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.13.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,28 +12,20 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This feature release adds Bot Mode across saved Hermes gateways, provider usage and limits, and bounded Assistant screen context. It also settles stale Gateway composer state, improves onboarding, and keeps idle Sphere motion efficient.
|
||||
This release adds a parent-configured Supervised Mode and improves its return from full settings. It also keeps session rows neutral until live activity is confirmed.
|
||||
|
||||
## Added
|
||||
|
||||
- Use Bot Mode as one messenger-style workspace across saved Hermes gateways, with exact gateway/profile ownership and read-only group rooms.
|
||||
- Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage & limits screen.
|
||||
- Start a compatible unlocked Assistant invocation with bounded visible text and an available screenshot in the first Standard voice turn.
|
||||
|
||||
## Changed
|
||||
|
||||
- Follow the Dashboard-first setup path with current screenshots and clearer separation between standard Hermes and optional Relay extensions.
|
||||
- Use clear `Hermes-Relay Android` and isolated `HR Candidate` product names without changing package identities or update behavior.
|
||||
- Use a profile-pinned Supervised Mode with parent-controlled attachments, Standard voice, generated media, history, actions, and technical details. Device authentication protects full settings; this remains a client-side restricted view rather than a server-enforced account boundary.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Settle orphaned Gateway busy state automatically while preserving active or detached turns owned by another session.
|
||||
- Keep the visible idle Sphere gently animated without running hidden, backgrounded, or motion-disabled loops.
|
||||
- Retry Windows-hosted `MEDIA:` attachments through the Relay by-path route instead of treating drive-letter paths as expired tokens.
|
||||
- Keep session rows neutral while optional live activity is unavailable or still loading, and reserve full-row activity borders for actual Starting or Working turns.
|
||||
- Keep Supervised Chat rendered when parent access relocks after visiting full settings.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.13.0** (versionCode **49**).
|
||||
- App version: **1.13.2** (versionCode **51**).
|
||||
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
|
||||
- The optional Relay plugin enhances provider usage, media retry, and device surfaces but remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
|
||||
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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Bot 模式现在可将已保存 Hermes 网关中的机器人汇集到一个消息式工作区。设置新增统一的 Codex、Nous 和 OpenCode Go 用量视图。兼容的助手启动可在首个语音回合中包含受限的可见文本和可用截图。Gateway 聊天会自动清除过期的忙碌状态,引导更清晰,空闲 Sphere 动画也更省电。
|
||||
新增监督模式:家长可配置并固定到指定配置文件,设置受设备身份验证保护。家长可限制附件、标准语音、生成媒体、历史记录、操作和技术详情。实时活动不可用时会话行保持中性显示,从家长设置返回时监督聊天也不再空白。
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.13.2",
|
||||
"title": "Supervised Mode and clearer activity",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Use a supervised chat",
|
||||
"bullets": [
|
||||
"Configure a profile-pinned restricted chat with parent-controlled attachments, voice, media, history, actions, and technical details.",
|
||||
"Protect full settings with device authentication and keep Supervised Chat visible when parent access relocks."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Show only confirmed activity",
|
||||
"bullets": [
|
||||
"Keep session rows neutral while optional live activity is unavailable or still loading.",
|
||||
"Show full-row activity borders only during actual Starting or Working turns."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.13.1",
|
||||
"title": "Accurate session activity",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Follow live Hermes state",
|
||||
"bullets": [
|
||||
"Show Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work from live runtime state instead of a recent-activity estimate.",
|
||||
"Keep stale activity visible until a complete, unambiguous snapshot safely clears it."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.13.0",
|
||||
"title": "Bots, usage, and reliable chat",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
v1.13.0 - Bots, usage, and reliable chat
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
|
||||
* Use Bot Mode across saved Hermes gateways without changing the foreground connection.
|
||||
* Review Codex, Nous, and OpenCode Go usage from one provider-neutral screen.
|
||||
* Include bounded visible text and an available screenshot in compatible Assistant turns.
|
||||
* Keep the composer accurate when Gateway completion frames and visible bubbles settle separately.
|
||||
* 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,11 +28,17 @@ data class GitStatusCounts(
|
||||
val staged: Int = 0,
|
||||
val modified: Int = 0,
|
||||
val untracked: Int = 0,
|
||||
/** Unique changed paths. -1 means an older plugin did not provide it. */
|
||||
val changes: Int = -1,
|
||||
val additions: Int = 0,
|
||||
val deletions: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitStatusEntry(
|
||||
val path: String,
|
||||
val additions: Int? = null,
|
||||
val deletions: Int? = null,
|
||||
)
|
||||
|
||||
/** A branch from /git/branches. */
|
||||
|
||||
@@ -90,10 +90,10 @@ data class SessionActivityRecord(
|
||||
}
|
||||
}
|
||||
|
||||
/** Presentation projection that never labels uncertain or background activity as Working. */
|
||||
/** Presentation projection that never labels missing optional runtime data as session state. */
|
||||
fun presentationState(nowMillis: Long = Long.MIN_VALUE): SessionActivityState? = when (freshness) {
|
||||
SessionActivityFreshness.Revalidating -> SessionActivityState.Checking
|
||||
SessionActivityFreshness.Unavailable -> SessionActivityState.Unavailable
|
||||
SessionActivityFreshness.Revalidating -> null
|
||||
SessionActivityFreshness.Unavailable -> null
|
||||
SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) {
|
||||
SessionActivityPhase.Starting -> SessionActivityState.Starting
|
||||
SessionActivityPhase.Working -> SessionActivityState.Working
|
||||
@@ -294,7 +294,9 @@ data class SessionActivityRegistry(
|
||||
|
||||
private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry {
|
||||
val existing = records[update.owner]
|
||||
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
|
||||
// Directory rows establish ownership only. They are not live evidence and must not
|
||||
// turn an unsupported/failed active-list probe back into a permanent Checking row.
|
||||
if (existing != null) return this
|
||||
val observed = SessionActivityRecord(
|
||||
owner = update.owner,
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -180,6 +181,7 @@ import com.hermesandroid.relay.ui.screens.PluginsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginPageScreen
|
||||
import com.hermesandroid.relay.ui.screens.GitStateScreen
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import com.hermesandroid.relay.viewmodel.GitStateUiState
|
||||
import com.hermesandroid.relay.ui.screens.TerminalScreen
|
||||
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProactiveSettingsScreen
|
||||
@@ -897,6 +899,42 @@ fun RelayApp() {
|
||||
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)
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
val showWhatsNew by connectionViewModel.showWhatsNew.collectAsState()
|
||||
|
||||
@@ -1198,12 +1236,12 @@ fun RelayApp() {
|
||||
parentAccessForCurrentRoute,
|
||||
currentRoute,
|
||||
) {
|
||||
if (shouldRedirectSupervisedRoute(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
currentRoute = currentRoute,
|
||||
)
|
||||
) {
|
||||
val redirect = shouldRedirectSupervisedRoute(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
currentRoute = currentRoute,
|
||||
)
|
||||
if (redirect) {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
@@ -1212,6 +1250,12 @@ fun RelayApp() {
|
||||
}
|
||||
LaunchedEffect(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute) {
|
||||
if (shouldRelockParentAccess(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute)) {
|
||||
// Route-scoped authority is already false on Chat. Let Navigation
|
||||
// finish committing the new destination before clearing the raw
|
||||
// parent grant, otherwise the same-frame root recomposition can
|
||||
// leave a themed but contentless surface.
|
||||
withFrameNanos { }
|
||||
withFrameNanos { }
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
@@ -2288,6 +2332,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) {
|
||||
@@ -2606,7 +2655,7 @@ fun RelayApp() {
|
||||
connectionViewModel = connectionViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
supervisedPolicy = supervisedPolicy,
|
||||
parentAccessUnlocked = parentAccessUnlocked,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
onRequestParentAccess = { parentAccessUnlocked = true },
|
||||
onUpdateSupervisedPolicy = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
@@ -2621,6 +2670,9 @@ fun RelayApp() {
|
||||
onNavigateToSupervisedAppearance = {
|
||||
navController.navigate(Screen.SupervisedAppearanceSettings.route)
|
||||
},
|
||||
onNavigateToSupervisedControls = {
|
||||
navController.navigate(Screen.SupervisedControls.route)
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
// (The `onNavigateToChatWithAgentSheet` callback that
|
||||
// used to live here was removed 2026-04-21. Tapping
|
||||
@@ -2640,6 +2692,9 @@ fun RelayApp() {
|
||||
onNavigateToPlugins = {
|
||||
navController.navigate(Screen.Plugins.route)
|
||||
},
|
||||
onNavigateToGitWorkspace = {
|
||||
navController.navigate(Screen.GitState.route)
|
||||
},
|
||||
onNavigateToChatSettings = {
|
||||
navController.navigate(Screen.ChatSettings.route)
|
||||
},
|
||||
@@ -2696,7 +2751,7 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
composable(Screen.AdvancedSettings.route) {
|
||||
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
AdvancedSettingsScreen(
|
||||
@@ -2709,7 +2764,7 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedAppearanceSettings.route) {
|
||||
if (!supervisedPolicy.enabled && !parentAccessUnlocked) {
|
||||
if (!supervisedPolicy.enabled && !parentAccessForCurrentRoute) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedAppearanceSettingsScreen(
|
||||
@@ -2727,7 +2782,7 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedControls.route) {
|
||||
if (!parentAccessUnlocked && supervisedPolicy.enabled) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedControlsScreen(
|
||||
@@ -3239,7 +3294,7 @@ fun RelayApp() {
|
||||
AboutScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessUnlocked,
|
||||
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessForCurrentRoute,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1770,19 +1770,9 @@ private fun Modifier.sessionActivityBorder(
|
||||
state: SessionActivityState?,
|
||||
animated: Boolean,
|
||||
): Modifier {
|
||||
if (state == null) return this
|
||||
val color = when (state) {
|
||||
SessionActivityState.Starting,
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
SessionActivityState.BackgroundWork,
|
||||
SessionActivityState.Checking,
|
||||
SessionActivityState.Unavailable,
|
||||
-> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
val shouldRotate = animated && (
|
||||
state == SessionActivityState.Starting || state == SessionActivityState.Working
|
||||
)
|
||||
if (!sessionActivityShowsRowBorder(state)) return this
|
||||
val color = RelayRefresh.Relay
|
||||
val shouldRotate = animated
|
||||
val phase = if (shouldRotate) {
|
||||
val transition = rememberInfiniteTransition(label = "session-activity")
|
||||
transition.animateFloat(
|
||||
|
||||
@@ -84,6 +84,10 @@ internal fun sessionDrawerStatus(
|
||||
null -> SessionDrawerStatus.Idle
|
||||
}
|
||||
|
||||
/** Desktop-style row emphasis is reserved for an actual foreground turn. */
|
||||
internal fun sessionActivityShowsRowBorder(state: SessionActivityState?): Boolean =
|
||||
state == SessionActivityState.Starting || state == SessionActivityState.Working
|
||||
|
||||
/**
|
||||
* Normalizes live activity to the drawer's profile-scoped row identity.
|
||||
*
|
||||
|
||||
@@ -193,6 +193,9 @@ 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
|
||||
@@ -719,6 +722,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 +1073,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()
|
||||
@@ -2996,6 +3009,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),
|
||||
@@ -4457,6 +4476,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 },
|
||||
|
||||
@@ -391,6 +391,32 @@ fun ChatSettingsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
val showGitWorkspaceInChat by
|
||||
connectionViewModel.showGitWorkspaceInChat.collectAsState()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_settings_show_git_workspace),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.chat_settings_show_git_workspace_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = showGitWorkspaceInChat,
|
||||
onCheckedChange = connectionViewModel::setShowGitWorkspaceInChat,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
val recentPromptsEnabled by
|
||||
connectionViewModel.chatRecentPromptsEnabled.collectAsState()
|
||||
Row(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.automirrored.filled.Chat
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Message
|
||||
import androidx.compose.material.icons.filled.Analytics
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
@@ -165,6 +166,7 @@ fun SettingsScreen(
|
||||
onUpdateSupervisedPolicy: (SupervisedModePolicy) -> Unit = {},
|
||||
onNavigateToAdvancedSettings: () -> Unit = {},
|
||||
onNavigateToSupervisedAppearance: () -> Unit = {},
|
||||
onNavigateToSupervisedControls: () -> Unit = {},
|
||||
// Needed by the Active Agent summary card at the top of the screen — it
|
||||
// reads the current personality pick so the subtitle can render
|
||||
// `connection · model · personality` without re-reading ChatViewModel
|
||||
@@ -189,6 +191,7 @@ fun SettingsScreen(
|
||||
onNavigateToManage: () -> Unit,
|
||||
onNavigateToProviderUsage: () -> Unit,
|
||||
onNavigateToPlugins: () -> Unit,
|
||||
onNavigateToGitWorkspace: () -> Unit,
|
||||
onNavigateToChatSettings: () -> Unit,
|
||||
onNavigateToTerminal: () -> Unit,
|
||||
onNavigateToBridge: () -> Unit,
|
||||
@@ -455,6 +458,20 @@ fun SettingsScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (supervisedPolicy?.enabled == true && parentAccessUnlocked) {
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = "Supervised mode",
|
||||
subtitle = "On · ${supervisedPolicy.pinnedProfileName.orEmpty()}",
|
||||
badge = SettingsStatusPillModel(
|
||||
label = "On",
|
||||
tone = SettingsStatusTone.Good,
|
||||
),
|
||||
onClick = onNavigateToSupervisedControls,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Active Agent summary ───────────────────────────────────
|
||||
// Mirrors the ChatScreen TopAppBar title block (avatar + name
|
||||
// + one-line `connection · model · personality` subtitle).
|
||||
@@ -588,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),
|
||||
|
||||
@@ -388,6 +388,16 @@ fun SupervisedControlsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (policy.enabled) {
|
||||
SupervisedNavigationRow(
|
||||
icon = Icons.Filled.Lock,
|
||||
title = "Return to supervised view",
|
||||
subtitle = "Lock parent access and open the pinned agent chat",
|
||||
onClick = onReturnToSupervisedView,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"This mode restricts this Android client. The selected Hermes profile remains responsible for agent tools and content policy.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -746,15 +756,6 @@ fun SupervisedControlsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (policy.enabled) {
|
||||
TextButton(
|
||||
onClick = onReturnToSupervisedView,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Lock, contentDescription = null)
|
||||
Text("Return to supervised view", modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2412,6 +2412,16 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
val showGitWorkspaceInChat: StateFlow<Boolean> =
|
||||
chatInputPreferencesRepository.showGitWorkspaceInChat
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
|
||||
|
||||
fun setShowGitWorkspaceInChat(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
chatInputPreferencesRepository.setShowGitWorkspaceInChat(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Turn-complete notification (default ON). RelayApp mirrors this into
|
||||
// ChatViewModel.notifyOnTurnComplete; ChatSettingsScreen owns the toggle
|
||||
// + the POST_NOTIFICATIONS runtime request on first enable.
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface GitStateUiState {
|
||||
data object Loading : GitStateUiState
|
||||
data class Unavailable(val message: String) : GitStateUiState
|
||||
data class Error(val message: String) : GitStateUiState
|
||||
data class Ready(val repos: List<GitRepo>, val notice: String?) : GitStateUiState
|
||||
}
|
||||
@@ -106,6 +107,9 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
private val _writeGrant = MutableStateFlow(false)
|
||||
val writeGrant: StateFlow<Boolean> = _writeGrant.asStateFlow()
|
||||
|
||||
private val _selectedRepoId = MutableStateFlow<String?>(null)
|
||||
val selectedRepoId: StateFlow<String?> = _selectedRepoId.asStateFlow()
|
||||
|
||||
private var api: GitStateApiClient? = null
|
||||
private var reposJob: Job? = null
|
||||
private var detailJob: Job? = null
|
||||
@@ -114,13 +118,11 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
private var messageJob: Job? = null
|
||||
private var scopeKey: String? = null
|
||||
private var targetGeneration: Long = 0
|
||||
private var selectedRepoId: String? = null
|
||||
|
||||
fun selectedRepoIdForDisplay(): String? = selectedRepoId
|
||||
fun selectedRepoIdForDisplay(): String? = _selectedRepoId.value
|
||||
|
||||
fun currentTarget(): GitTarget? {
|
||||
val owner = scopeKey ?: return null
|
||||
val repo = selectedRepoId ?: return null
|
||||
val repo = _selectedRepoId.value ?: return null
|
||||
return GitTarget(owner, repo, targetGeneration)
|
||||
}
|
||||
|
||||
@@ -132,7 +134,7 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
messageJob?.cancel()
|
||||
targetGeneration += 1
|
||||
scopeKey = ownerKey
|
||||
selectedRepoId = null
|
||||
_selectedRepoId.value = null
|
||||
_writeGrant.value = false
|
||||
_detail.value = GitRepoDetailState.Idle
|
||||
_content.value = GitContentViewState.Idle
|
||||
@@ -168,7 +170,17 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Error(error.message ?: "Failed to load repositories")
|
||||
val message = error.message.orEmpty()
|
||||
_repos.value = if (
|
||||
message.contains("HTTP 404", ignoreCase = true) ||
|
||||
message.contains("No such API endpoint", ignoreCase = true)
|
||||
) {
|
||||
GitStateUiState.Unavailable(
|
||||
"Git isn't available on this Hermes host yet.",
|
||||
)
|
||||
} else {
|
||||
GitStateUiState.Error(message.ifBlank { "Failed to load repositories" })
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -178,7 +190,7 @@ class GitStateViewModel(application: Application) : AndroidViewModel(application
|
||||
fun selectRepo(repoId: String) {
|
||||
val client = api ?: return
|
||||
targetGeneration += 1
|
||||
selectedRepoId = repoId
|
||||
_selectedRepoId.value = repoId
|
||||
val target = currentTarget() ?: return
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
|
||||
@@ -4265,4 +4265,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>
|
||||
|
||||
@@ -4350,4 +4350,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>
|
||||
|
||||
@@ -4425,4 +4425,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>
|
||||
|
||||
@@ -4110,4 +4110,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>
|
||||
|
||||
@@ -4423,4 +4423,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>
|
||||
|
||||
@@ -4152,4 +4152,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>
|
||||
|
||||
@@ -791,6 +791,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>
|
||||
@@ -4435,4 +4445,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(
|
||||
|
||||
@@ -10,17 +10,19 @@ class SessionActivityRegistryTest {
|
||||
private val scope = SessionActivityScope.of("connection-a", "default")
|
||||
|
||||
@Test
|
||||
fun `directory owner is checking until status is unavailable or confirms idle`() {
|
||||
fun `directory owner stays neutral until status confirms live activity`() {
|
||||
val checking = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1),
|
||||
)
|
||||
assertEquals(SessionActivityPhase.Idle, checking.record(owner)?.phase())
|
||||
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
|
||||
assertEquals(SessionActivityFreshness.Revalidating, checking.record(owner)?.freshness)
|
||||
assertNull(checking.record(owner)?.presentationState())
|
||||
|
||||
val unavailable = checking.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2),
|
||||
)
|
||||
assertEquals(SessionActivityState.Unavailable, unavailable.record(owner)?.presentationState())
|
||||
assertEquals(SessionActivityFreshness.Unavailable, unavailable.record(owner)?.freshness)
|
||||
assertNull(unavailable.record(owner)?.presentationState())
|
||||
|
||||
val confirmedIdle = checking.reduce(activeList(scope, generation = 1))
|
||||
assertEquals(SessionActivityPhase.Idle, confirmedIdle.record(owner)?.phase())
|
||||
@@ -28,6 +30,17 @@ class SessionActivityRegistryTest {
|
||||
assertNull(confirmedIdle.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `directory refresh cannot restore checking after active status is unavailable`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 1))
|
||||
.reduce(SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2))
|
||||
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 3))
|
||||
|
||||
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `directory observation cannot downgrade confirmed live evidence`() {
|
||||
val state = SessionActivityRegistry()
|
||||
@@ -184,7 +197,7 @@ class SessionActivityRegistryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed or unsupported status refresh is unavailable rather than idle`() {
|
||||
fun `failed or unsupported status refresh preserves evidence but presents a neutral row`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(
|
||||
SessionActivityUpdate.LiveState(
|
||||
@@ -206,7 +219,7 @@ class SessionActivityRegistryTest {
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
|
||||
assertEquals(SessionActivityState.Unavailable, state.record(owner)?.presentationState())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -226,7 +239,7 @@ class SessionActivityRegistryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `presentation keeps starting background and revalidation distinct from working`() {
|
||||
fun `presentation keeps starting and background distinct while revalidation stays neutral`() {
|
||||
val starting = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
|
||||
)
|
||||
@@ -240,7 +253,7 @@ class SessionActivityRegistryTest {
|
||||
val checking = starting.reduce(
|
||||
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
|
||||
)
|
||||
assertEquals(SessionActivityState.Checking, checking.record(owner)?.presentationState())
|
||||
assertNull(checking.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -287,7 +300,7 @@ class SessionActivityRegistryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restored needs-input checkpoint stays checking until live confirmation`() {
|
||||
fun `restored needs-input checkpoint stays neutral until live confirmation`() {
|
||||
val state = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
@@ -298,7 +311,7 @@ class SessionActivityRegistryTest {
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -323,7 +336,7 @@ class SessionActivityRegistryTest {
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Checking, state.record(owner)?.presentationState())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
|
||||
state = state.reduce(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
|
||||
+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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionDrawerPolicyTest {
|
||||
@@ -144,6 +145,17 @@ class SessionDrawerPolicyTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `full row border is limited to foreground live work`() {
|
||||
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Starting))
|
||||
assertTrue(sessionActivityShowsRowBorder(SessionActivityState.Working))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.NeedsInput))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.BackgroundWork))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Checking))
|
||||
assertFalse(sessionActivityShowsRowBorder(SessionActivityState.Unavailable))
|
||||
assertFalse(sessionActivityShowsRowBorder(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile project status and pull request filters compose`() {
|
||||
val wanted = row(
|
||||
|
||||
+8
-3
@@ -333,7 +333,7 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsupportedActiveListProjectsUnavailableInsteadOfRestWorking() {
|
||||
fun unsupportedActiveListLeavesRowsNeutralAcrossDirectoryRefresh() {
|
||||
bindActivityTestDirectory()
|
||||
handler.updateSessions(
|
||||
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
|
||||
@@ -344,9 +344,14 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
|
||||
awaitCondition {
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
|
||||
SessionActivityState.Unavailable
|
||||
"default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
|
||||
}
|
||||
|
||||
viewModel.updateSessionActivityDirectory(
|
||||
rows = listOf("default" to STORED_SESSION_ID),
|
||||
)
|
||||
|
||||
assertFalse("default:$STORED_SESSION_ID" in viewModel.backgroundSessionActivityStates.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -85,6 +86,20 @@ class GitStateViewModelTest {
|
||||
assertTrue(state.message.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRepos maps missing plugin route to friendly unavailable state`() = runBlocking {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(404)
|
||||
.setBody("""{"detail":"No such API endpoint"}"""),
|
||||
)
|
||||
val vm = viewModel()
|
||||
val state = withTimeout(5_000) {
|
||||
vm.repos.filterIsInstance<GitStateUiState.Unavailable>().first()
|
||||
}
|
||||
assertEquals("Git isn't available on this Hermes host yet.", state.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectRepo loads status and branches and preserves truncation flag`() = runBlocking {
|
||||
enqueueJson(
|
||||
@@ -107,6 +122,10 @@ class GitStateViewModelTest {
|
||||
assertEquals(1, ready.status.counts.staged)
|
||||
assertEquals(2, ready.status.counts.modified)
|
||||
assertEquals(3, ready.status.counts.untracked)
|
||||
assertEquals(-1, ready.status.counts.changes)
|
||||
assertEquals(0, ready.status.counts.additions)
|
||||
assertEquals(0, ready.status.counts.deletions)
|
||||
assertNull(ready.status.staged.single().additions)
|
||||
assertTrue(ready.status.truncated)
|
||||
assertEquals("main", ready.branches.single().name)
|
||||
assertTrue(ready.branches.single().isCurrent)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -94,6 +94,17 @@ therefore cannot reach Relay management APIs or acquire executable backend behav
|
||||
The contribution ID `git` is reserved for the Relay plugin's native Git workspace;
|
||||
generated drafts cannot shadow or duplicate that route.
|
||||
|
||||
Android presents that reserved contribution as a first-class native surface rather
|
||||
than a generic declarative page. When the live plugin API confirms Git is available,
|
||||
Chat can show a compact branch/change rail and a dot-only context action; **Chat →
|
||||
Show Git workspace in Chat** hides those two Chat affordances without disabling the
|
||||
workspace. The full native workspace remains reachable from Settings and Plugins and
|
||||
owns repository/branch selection, diffs, staging, confirmed destructive actions,
|
||||
commits, and remotes. Session `git_repo_root`/`cwd` metadata selects an exact matching
|
||||
repository when possible; ambiguous catalogs require an explicit user choice. Mobile
|
||||
discovery alone is not treated as runtime readiness: a missing Git API route renders a
|
||||
retryable unavailable state instead of exposing the raw Dashboard error.
|
||||
|
||||
The Relay mobile manifest exposes drafts as preview pages under the authenticated
|
||||
`hermes-relay` plugin namespace. Android polls the catalog every five seconds while
|
||||
the Plugins hub is visible and polls a visible generated page every five seconds.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
|
||||
},
|
||||
"en": {
|
||||
"native_name": "English",
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -65,14 +65,14 @@
|
||||
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
|
||||
},
|
||||
"ja": {
|
||||
"native_name": "\u65e5\u672c\u8a9e",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -89,14 +89,14 @@
|
||||
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
|
||||
},
|
||||
"pt-BR": {
|
||||
"native_name": "Portugu\u00eas (Brasil)",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -113,14 +113,14 @@
|
||||
"guide/release-tracks.md": "1e793410f433b12f503ac1649afec8820a712aff74b38202693aa9a0d6ad0a26",
|
||||
"guide/troubleshooting.md": "43677454b94dd7810adc9b685266598353b851b549b151de31cfa053cf74f6c1"
|
||||
},
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
"website_source_sha256": "d2d244b8b4f51dbec1503e134acdbc5252574b38511fe1fcda1b7b0a63e5f9a3"
|
||||
},
|
||||
"ru": {
|
||||
"native_name": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"main": "098132a6a5c94d038fd44f4768e7f9287561f5970b420c034d1c05c6232ac4ab",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.13.0 - Bots, usage, and reliable chat
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
|
||||
Bot Mode now brings bots from saved Hermes gateways into one messenger-style workspace. Settings adds provider-neutral Codex, Nous, and OpenCode Go usage. Compatible Assistant launches can include bounded visible text and an available screenshot. Gateway chats now settle stale busy state automatically, onboarding is clearer, and idle Sphere motion uses less power.
|
||||
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.
|
||||
```
|
||||
## 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,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.13.0"
|
||||
appVersionCode = "49"
|
||||
appVersionName = "1.13.2"
|
||||
appVersionCode = "51"
|
||||
agp = "9.3.2"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
|
||||
+57
-10
@@ -281,11 +281,46 @@ def resolve_repo_path(repo: Path, path: str) -> str:
|
||||
def repo_status(repo: Path) -> dict[str, Any]:
|
||||
"""Return grouped working-tree status with counts and truncation flag."""
|
||||
porcelain = _git(repo, "status", "--porcelain=v1", "-z")
|
||||
staged: list[dict[str, str]] = []
|
||||
modified: list[dict[str, str]] = []
|
||||
untracked: list[dict[str, str]] = []
|
||||
staged: list[dict[str, Any]] = []
|
||||
modified: list[dict[str, Any]] = []
|
||||
untracked: list[dict[str, Any]] = []
|
||||
truncated = False
|
||||
|
||||
def _numstat(*args: str) -> dict[str, tuple[int, int]]:
|
||||
"""Return bounded text-line deltas keyed by the destination path.
|
||||
|
||||
``--numstat -z`` keeps tabs/newlines in filenames unambiguous. Binary
|
||||
files report ``-`` for each count and intentionally remain without a
|
||||
line delta in the UI.
|
||||
"""
|
||||
raw = _git(repo, "diff", "--numstat", "-z", *args)
|
||||
values = raw.split("\0")
|
||||
result: dict[str, tuple[int, int]] = {}
|
||||
index = 0
|
||||
while index < len(values):
|
||||
record = values[index]
|
||||
index += 1
|
||||
if not record:
|
||||
continue
|
||||
fields = record.split("\t", 2)
|
||||
if len(fields) != 3:
|
||||
continue
|
||||
added, deleted, path = fields
|
||||
if not path:
|
||||
# Rename/copy records place old and new paths in the next two
|
||||
# NUL-delimited fields. Status exposes the destination path.
|
||||
index += 1
|
||||
if index >= len(values):
|
||||
break
|
||||
path = values[index]
|
||||
index += 1
|
||||
if added.isdigit() and deleted.isdigit():
|
||||
result[path] = (int(added), int(deleted))
|
||||
return result
|
||||
|
||||
staged_numstat = _numstat("--cached", "--")
|
||||
modified_numstat = _numstat("--")
|
||||
|
||||
# -z separates records with NUL; each record is "<XY> <path>\0". A rename
|
||||
# or copy emits TWO records ("R new\0old\0"); the bare source-path record
|
||||
# must be skipped, not misparsed as an XY record.
|
||||
@@ -301,33 +336,45 @@ def repo_status(repo: Path) -> dict[str, Any]:
|
||||
if not path:
|
||||
continue
|
||||
if xy == "??":
|
||||
untracked.append({"path": path})
|
||||
untracked.append({"path": path, "additions": None, "deletions": None})
|
||||
# Independent checks: a file staged AND modified lands in both groups.
|
||||
if xy[0] in ("M", "A", "D", "R", "C"):
|
||||
staged.append({"path": path})
|
||||
additions, deletions = staged_numstat.get(path, (None, None))
|
||||
staged.append({"path": path, "additions": additions, "deletions": deletions})
|
||||
if xy[1] in ("M", "D"):
|
||||
modified.append({"path": path})
|
||||
additions, deletions = modified_numstat.get(path, (None, None))
|
||||
modified.append({"path": path, "additions": additions, "deletions": deletions})
|
||||
if xy[0] in ("R", "C"):
|
||||
# The immediately following record is the rename/copy source path;
|
||||
# skip it so it is not misparsed as an XY record.
|
||||
i += 1
|
||||
|
||||
def _bounded(items: list[dict[str, str]]) -> list[dict[str, str]]:
|
||||
def _bounded(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
nonlocal truncated
|
||||
if len(items) > MAX_STATUS_ENTRIES:
|
||||
truncated = True
|
||||
return items[:MAX_STATUS_ENTRIES]
|
||||
return items
|
||||
|
||||
staged_count = len(staged)
|
||||
modified_count = len(modified)
|
||||
untracked_count = len(untracked)
|
||||
changed_count = len({entry["path"] for entry in (*staged, *modified, *untracked)})
|
||||
additions = sum(added for added, _ in (*staged_numstat.values(), *modified_numstat.values()))
|
||||
deletions = sum(deleted for _, deleted in (*staged_numstat.values(), *modified_numstat.values()))
|
||||
|
||||
staged = _bounded(staged)
|
||||
modified = _bounded(modified)
|
||||
untracked = _bounded(untracked)
|
||||
|
||||
return {
|
||||
"counts": {
|
||||
"staged": len(staged),
|
||||
"modified": len(modified),
|
||||
"untracked": len(untracked),
|
||||
"staged": staged_count,
|
||||
"modified": modified_count,
|
||||
"untracked": untracked_count,
|
||||
"changes": changed_count,
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
},
|
||||
"staged": staged,
|
||||
"modified": modified,
|
||||
|
||||
@@ -150,9 +150,18 @@ class GitStateStatusTests(unittest.TestCase):
|
||||
self.assertEqual(1, status["counts"]["staged"])
|
||||
self.assertEqual(1, status["counts"]["modified"])
|
||||
self.assertEqual(1, status["counts"]["untracked"])
|
||||
self.assertEqual(3, status["counts"]["changes"])
|
||||
self.assertEqual(2, status["counts"]["additions"])
|
||||
self.assertEqual(2, status["counts"]["deletions"])
|
||||
self.assertEqual("tracked.txt", status["staged"][0]["path"])
|
||||
self.assertEqual(1, status["staged"][0]["additions"])
|
||||
self.assertEqual(1, status["staged"][0]["deletions"])
|
||||
self.assertEqual("README.md", status["modified"][0]["path"])
|
||||
self.assertEqual(1, status["modified"][0]["additions"])
|
||||
self.assertEqual(1, status["modified"][0]["deletions"])
|
||||
self.assertEqual("untracked.txt", status["untracked"][0]["path"])
|
||||
self.assertIsNone(status["untracked"][0]["additions"])
|
||||
self.assertIsNone(status["untracked"][0]["deletions"])
|
||||
self.assertFalse(status["truncated"])
|
||||
|
||||
def test_status_truncates_when_over_cap(self) -> None:
|
||||
@@ -160,6 +169,7 @@ class GitStateStatusTests(unittest.TestCase):
|
||||
(self.repo / f"file-{i}.txt").write_text("x", encoding="utf-8")
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertTrue(status["truncated"])
|
||||
self.assertEqual(git_state.MAX_STATUS_ENTRIES + 5, status["counts"]["changes"])
|
||||
self.assertLessEqual(
|
||||
len(status["untracked"]),
|
||||
git_state.MAX_STATUS_ENTRIES,
|
||||
@@ -193,6 +203,8 @@ class GitStateStatusTests(unittest.TestCase):
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual(["new.txt"], [e["path"] for e in status["staged"]])
|
||||
self.assertEqual(0, status["staged"][0]["additions"])
|
||||
self.assertEqual(0, status["staged"][0]["deletions"])
|
||||
self.assertEqual([], status["modified"])
|
||||
self.assertEqual([], status["untracked"])
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify whether a stable release commit needs reconciliation into dev."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
|
||||
def classify_release(
|
||||
release_commit: str,
|
||||
dev_commit: str,
|
||||
parents: Sequence[str],
|
||||
is_ancestor: Callable[[str, str], bool],
|
||||
) -> str:
|
||||
"""Return already-contained, normal-release, or hotfix."""
|
||||
if is_ancestor(release_commit, dev_commit):
|
||||
return "already-contained"
|
||||
if len(parents) < 2:
|
||||
raise ValueError("stable release commit is not a release/hotfix merge commit")
|
||||
if is_ancestor(parents[1], dev_commit):
|
||||
return "normal-release"
|
||||
return "hotfix"
|
||||
|
||||
|
||||
def git(*args: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def git_is_ancestor(older: str, newer: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", older, newer],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode not in {0, 1}:
|
||||
raise RuntimeError(result.stderr.strip() or "git merge-base failed")
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--release-commit", required=True)
|
||||
parser.add_argument("--dev-commit", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
release_commit = git("rev-parse", f"{args.release_commit}^{{commit}}")
|
||||
dev_commit = git("rev-parse", f"{args.dev_commit}^{{commit}}")
|
||||
parents = git("show", "-s", "--format=%P", release_commit).split()
|
||||
print(
|
||||
classify_release(
|
||||
release_commit,
|
||||
dev_commit,
|
||||
parents,
|
||||
git_is_ancestor,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from scripts.plan_release_backmerge import classify_release
|
||||
|
||||
|
||||
class ReleaseBackmergePlanTest(unittest.TestCase):
|
||||
def test_already_contained_release_is_a_noop(self) -> None:
|
||||
ancestry = {("release", "dev")}
|
||||
self.assertEqual(
|
||||
classify_release(
|
||||
"release",
|
||||
"dev",
|
||||
["main", "topic"],
|
||||
lambda older, newer: (older, newer) in ancestry,
|
||||
),
|
||||
"already-contained",
|
||||
)
|
||||
|
||||
def test_normal_release_with_dev_parent_is_a_noop(self) -> None:
|
||||
ancestry = {("released-dev", "dev")}
|
||||
self.assertEqual(
|
||||
classify_release(
|
||||
"release",
|
||||
"dev",
|
||||
["previous-main", "released-dev"],
|
||||
lambda older, newer: (older, newer) in ancestry,
|
||||
),
|
||||
"normal-release",
|
||||
)
|
||||
|
||||
def test_selective_hotfix_requires_backmerge(self) -> None:
|
||||
self.assertEqual(
|
||||
classify_release(
|
||||
"release",
|
||||
"dev",
|
||||
["previous-main", "hotfix-topic"],
|
||||
lambda _older, _newer: False,
|
||||
),
|
||||
"hotfix",
|
||||
)
|
||||
|
||||
def test_non_merge_release_commit_fails_closed(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "not a release/hotfix merge commit"):
|
||||
classify_release("release", "dev", ["parent"], lambda _older, _newer: False)
|
||||
|
||||
|
||||
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' },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Git workspace
|
||||
|
||||
Hermes-Relay Android can review and operate a host repository through a native
|
||||
Git workspace. The Android interface is first-party Compose UI; the optional
|
||||
Hermes-Relay plugin supplies the authenticated, repository-scoped Git API.
|
||||
|
||||

|
||||
|
||||
## Requirements
|
||||
|
||||
- The Hermes-Relay plugin must be installed and enabled on the selected Hermes
|
||||
connection and profile.
|
||||
- The Dashboard must expose the plugin's `git/*` API routes.
|
||||
- Repositories must be below the host's configured Git discovery root.
|
||||
- Write actions require the plugin's **Allow changes** grant.
|
||||
|
||||
If Android discovers the Git contribution but the live route is unavailable,
|
||||
the workspace shows a retryable availability message instead of a raw server
|
||||
response.
|
||||
|
||||
## Open the workspace
|
||||
|
||||
Use any of these entry points:
|
||||
|
||||
- Tap the branch indicator or change rail above the Chat composer.
|
||||
- Open **Settings → Git workspace**.
|
||||
- Open **Settings → Plugins → Hermes-Relay → Git**.
|
||||
|
||||
When the active chat session reports an exact repository root or working
|
||||
directory, Android selects the matching repository. If several repositories
|
||||
are possible, choose one from the repository picker.
|
||||
|
||||
## Chat controls
|
||||
|
||||
The compact Chat rail shows the current branch, unique changed-file count, and
|
||||
tracked line additions/deletions. Open **Settings → Chat** and turn off **Show
|
||||
Git workspace in Chat** to hide the Chat indicator and rail. This setting does
|
||||
not disable the full Git workspace.
|
||||
|
||||
## Review and change files
|
||||
|
||||
The workspace groups staged, modified, and untracked files and provides filters,
|
||||
tracked-file content, and staged or unstaged diffs. Binary and untracked files
|
||||
do not expose arbitrary working-tree content through the preview route.
|
||||
|
||||
With **Allow changes** enabled, you can:
|
||||
|
||||
- stage modified or untracked files and unstage staged files;
|
||||
- discard modified content or delete selected untracked files after confirmation;
|
||||
- create and switch branches, including a recoverable stash-and-switch path;
|
||||
- generate or enter a commit message and optionally push after committing;
|
||||
- fetch, fast-forward pull, and push.
|
||||
|
||||
Discard, push, and dirty checkout each require a fresh confirmation. Requests
|
||||
remain bound to the selected connection, profile, repository, and repository
|
||||
generation so switching context cannot redirect an already-reviewed action.
|
||||
@@ -22,6 +22,7 @@ A **<span class="track-badge track-badge--sideload">Sideload only</span>** badge
|
||||
| [Token Tracking](/features/tokens) | Per-message usage and cost |
|
||||
| [Tool Progress](/features/tools) | Configurable display — Off, Compact, or Detailed |
|
||||
| [Plugins](/features/plugins) | Native reactive pages, including Relay-assisted agent-created previews |
|
||||
| [Git workspace](/features/git-workspace) | Native repository status, diffs, branches, staging, commits, and remotes through the Relay plugin |
|
||||
|
||||
## Bridge Core
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -14,6 +14,10 @@ const assets = [
|
||||
source: resolve(repoRoot, 'assets/screenshots/07_connections.png'),
|
||||
destination: resolve(docsRoot, 'public/connections-demo.png'),
|
||||
},
|
||||
{
|
||||
source: resolve(repoRoot, 'assets/screenshots/supplemental/15_git_workspace.png'),
|
||||
destination: resolve(docsRoot, 'public/git-workspace.png'),
|
||||
},
|
||||
]
|
||||
|
||||
const desktopManifest = JSON.parse(
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ No analytics or third-party tracking scripts are included.
|
||||
|
||||
## Product screenshots
|
||||
|
||||
Chat, Voice, and Manage imagery comes from the repository's canonical,
|
||||
Chat, Voice, Manage, and Git workspace imagery comes from the repository's canonical,
|
||||
deterministically rendered Android scenes in `../assets/screenshots/`. The
|
||||
mapping is read from `../docs/media/screenshots.json`; files under
|
||||
`public/product/` are deployment copies, not an independent source of truth.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -12,6 +12,7 @@ const productAssets = [
|
||||
{ sceneId: '03_voice', destination: 'voice.png' },
|
||||
{ sceneId: '01_voice_conversation', destination: 'voice-conversation.png' },
|
||||
{ sceneId: '06_manage', destination: 'manage.png' },
|
||||
{ sceneId: '15_git_workspace', destination: 'git-workspace.png' },
|
||||
];
|
||||
|
||||
const mode = process.argv[2] ?? 'check';
|
||||
|
||||
@@ -15,6 +15,8 @@ const variants = [
|
||||
{ source: 'voice-conversation.png', destination: 'voice-conversation-720.webp', width: 720 },
|
||||
{ source: 'manage.png', destination: 'manage-360.webp', width: 360 },
|
||||
{ source: 'manage.png', destination: 'manage-720.webp', width: 720 },
|
||||
{ source: 'git-workspace.png', destination: 'git-workspace-360.webp', width: 360 },
|
||||
{ source: 'git-workspace.png', destination: 'git-workspace-720.webp', width: 720 },
|
||||
];
|
||||
|
||||
if (!['check', 'sync'].includes(mode)) {
|
||||
|
||||
@@ -25,6 +25,7 @@ type Copy = {
|
||||
surfaces: {
|
||||
kicker: string; title: string; lede: string; androidTitle: string; androidLede: string;
|
||||
chatDescription: string; voiceDescription: string; manageDescription: string; additional: string;
|
||||
gitCaption: string; gitAlt: string;
|
||||
cliTitle: string; cliLede: string; terminal: string; consentTitle: string; consentBody: string;
|
||||
capabilities: string; sessionsTitle: string; sessionsBody: string; daemonTitle: string; daemonBody: string;
|
||||
auditTitle: string; auditBody: string; uiTitle: string; uiBody: string; meetCli: string;
|
||||
@@ -84,6 +85,7 @@ export const translations: Record<Locale, Copy> = {
|
||||
androidLede: 'Take conversations on the go, speak naturally, and manage the Hermes you already run.',
|
||||
chatDescription: 'Streaming conversations and live agent work.', voiceDescription: 'Speak naturally, interrupt, and keep moving.',
|
||||
manageDescription: 'Profiles, skills, models, and automations.', additional: 'Additional Android surfaces',
|
||||
gitCaption: 'Git workspace · Relay', gitAlt: 'Hermes-Relay native Git workspace with changes, inline diff, and staging controls',
|
||||
cliTitle: 'Consent-gated access on your machine.', cliLede: 'Give Hermes hands on the machine — only when you allow it.',
|
||||
terminal: 'Hermes-Relay command line preview', consentTitle: 'Consent is explicit and local.',
|
||||
consentBody: 'You decide when Hermes can run commands, read files, or make changes.', capabilities: 'CLI capabilities',
|
||||
@@ -166,6 +168,7 @@ export const translations: Record<Locale, Copy> = {
|
||||
androidLede: 'Setze Gespräche unterwegs fort, sprich natürlich und verwalte den Hermes, den du bereits betreibst.',
|
||||
chatDescription: 'Streaming-Gespräche und laufende Agentenarbeit.', voiceDescription: 'Sprich natürlich, unterbrich und bleib in Bewegung.',
|
||||
manageDescription: 'Profile, Skills, Modelle und Automatisierungen.', additional: 'Weitere Android-Oberflächen',
|
||||
gitCaption: 'Git-Arbeitsbereich · Relay', gitAlt: 'Nativer Git-Arbeitsbereich von Hermes-Relay mit Änderungen, Inline-Diff und Staging-Steuerung',
|
||||
cliTitle: 'Zustimmungspflichtiger Zugriff auf deinen Rechner.', cliLede: 'Gib Hermes Hände auf dem Rechner — nur wenn du es erlaubst.',
|
||||
terminal: 'Hermes-Relay Kommandozeilenvorschau', consentTitle: 'Zustimmung ist ausdrücklich und lokal.',
|
||||
consentBody: 'Du entscheidest, wann Hermes Befehle ausführen, Dateien lesen oder Änderungen vornehmen darf.', capabilities: 'CLI-Funktionen',
|
||||
@@ -248,6 +251,7 @@ export const translations: Record<Locale, Copy> = {
|
||||
androidLede: 'Continúa conversaciones, habla con naturalidad y administra el Hermes que ya utilizas.',
|
||||
chatDescription: 'Conversaciones en streaming y trabajo del agente en vivo.', voiceDescription: 'Habla con naturalidad, interrumpe y sigue en movimiento.',
|
||||
manageDescription: 'Perfiles, skills, modelos y automatizaciones.', additional: 'Otras superficies de Android',
|
||||
gitCaption: 'Espacio de Git · Relay', gitAlt: 'Espacio de Git nativo de Hermes-Relay con cambios, diff en línea y controles de staging',
|
||||
cliTitle: 'Acceso a tu equipo sujeto a consentimiento.', cliLede: 'Dale manos a Hermes en el equipo, solo cuando tú lo permitas.',
|
||||
terminal: 'Vista previa de la línea de comandos de Hermes-Relay', consentTitle: 'El consentimiento es explícito y local.',
|
||||
consentBody: 'Tú decides cuándo Hermes puede ejecutar comandos, leer archivos o realizar cambios.', capabilities: 'Funciones de la CLI',
|
||||
@@ -330,6 +334,7 @@ export const translations: Record<Locale, Copy> = {
|
||||
androidLede: '外出先でも会話を続け、自然に話し、すでに動かしている Hermes を管理できます。',
|
||||
chatDescription: '会話と Agent の作業をリアルタイムにストリーミング。', voiceDescription: '自然に話し、割り込み、そのまま行動できます。',
|
||||
manageDescription: 'プロファイル、Skills、モデル、自動化を管理。', additional: 'その他の Android 画面',
|
||||
gitCaption: 'Git ワークスペース · Relay', gitAlt: '変更、インライン差分、ステージ操作を表示する Hermes-Relay のネイティブ Git ワークスペース',
|
||||
cliTitle: '同意で保護されたマシンアクセス。', cliLede: '許可したときだけ、Hermes にマシンを操作する手を与えます。',
|
||||
terminal: 'Hermes-Relay コマンドラインのプレビュー', consentTitle: '同意は明示的かつローカルです。',
|
||||
consentBody: 'Hermes がコマンドを実行し、ファイルを読み、変更できるタイミングはあなたが決めます。', capabilities: 'CLI の機能',
|
||||
@@ -412,6 +417,7 @@ export const translations: Record<Locale, Copy> = {
|
||||
androidLede: 'Continue conversas em qualquer lugar, fale naturalmente e gerencie o Hermes que você já executa.',
|
||||
chatDescription: 'Conversas por streaming e trabalho do agente em tempo real.', voiceDescription: 'Fale naturalmente, interrompa e continue em movimento.',
|
||||
manageDescription: 'Perfis, skills, modelos e automações.', additional: 'Outras interfaces Android',
|
||||
gitCaption: 'Espaço de trabalho Git · Relay', gitAlt: 'Espaço de trabalho Git nativo do Hermes-Relay com alterações, diff em linha e controles de staging',
|
||||
cliTitle: 'Acesso à sua máquina controlado por consentimento.', cliLede: 'Dê mãos ao Hermes na máquina — somente quando você permitir.',
|
||||
terminal: 'Prévia da linha de comando do Hermes-Relay', consentTitle: 'O consentimento é explícito e local.',
|
||||
consentBody: 'Você decide quando o Hermes pode executar comandos, ler arquivos ou fazer alterações.', capabilities: 'Recursos da CLI',
|
||||
@@ -494,6 +500,7 @@ export const translations: Record<Locale, Copy> = {
|
||||
androidLede: '随时继续对话,自然交谈,并管理您已在运行的 Hermes。',
|
||||
chatDescription: '流式对话和实时 Agent 工作。', voiceDescription: '自然交谈、随时打断,并保持移动。',
|
||||
manageDescription: '管理配置文件、Skills、模型和自动化。', additional: '其他 Android 界面',
|
||||
gitCaption: 'Git 工作区 · Relay', gitAlt: 'Hermes-Relay 原生 Git 工作区,显示更改、内联差异和暂存控件',
|
||||
cliTitle: '通过同意控制计算机访问。', cliLede: '只有在您允许时,才让 Hermes 操作计算机。',
|
||||
terminal: 'Hermes-Relay 命令行预览', consentTitle: '同意是明确且本地的。',
|
||||
consentBody: '由您决定 Hermes 何时可以运行命令、读取文件或进行更改。', capabilities: 'CLI 功能',
|
||||
|
||||
@@ -366,6 +366,19 @@ const structuredData = {
|
||||
/>
|
||||
<figcaption>Manage</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img
|
||||
src="/product/git-workspace.png"
|
||||
srcset="/product/git-workspace-360.webp 360w, /product/git-workspace-720.webp 720w, /product/git-workspace.png 1080w"
|
||||
sizes="(max-width: 720px) 45vw, (max-width: 980px) 38vw, 260px"
|
||||
width="1080"
|
||||
height="2160"
|
||||
alt={copy.surfaces.gitAlt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<figcaption>{copy.surfaces.gitCaption}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -730,7 +730,7 @@ img {
|
||||
|
||||
.screen-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
@@ -1701,6 +1701,7 @@ img {
|
||||
|
||||
.screen-strip {
|
||||
margin-left: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.desktop-ui-inline {
|
||||
|
||||
Reference in New Issue
Block a user