Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae9b22a9e6 | ||
|
|
96a9e8077e | ||
|
|
a97e6a2b14 | ||
|
|
4317da85fd | ||
|
|
45fde0ad9a | ||
|
|
94565e9d6d | ||
|
|
56c2e6fa07 | ||
|
|
28629f3d93 | ||
|
|
c9a5c767c6 | ||
|
|
0d1faf47a0 | ||
|
|
00288a2b3b | ||
|
|
8f52feffba | ||
|
|
524e319f95 | ||
|
|
647d1f9aea | ||
|
|
ee29e49361 | ||
|
|
e2073b7692 | ||
|
|
70b6d8ee5a | ||
|
|
1f5e50ccd7 | ||
|
|
5580c9d9bb | ||
|
|
2ebdf55501 | ||
|
|
ad107ea205 | ||
|
|
f5aeb27e5a | ||
|
|
fdaeb121d5 | ||
|
|
06c0df6304 | ||
|
|
71a2b3a7fb | ||
|
|
08545ed32d | ||
|
|
e791c6410b | ||
|
|
8c8c3975f2 | ||
|
|
41601d67ab | ||
|
|
366b424615 | ||
|
|
5cd9baaaab | ||
|
|
8acba9b353 | ||
|
|
26a612f088 | ||
|
|
65e48084cb | ||
|
|
1074ecc24f | ||
|
|
6dd6ce2d13 | ||
|
|
f2a23e32aa | ||
|
|
630cc6d316 | ||
|
|
e16205d82a | ||
|
|
676c37e5ca | ||
|
|
9b6fed9bdd | ||
|
|
4d90eef3d8 | ||
|
|
478323893a | ||
|
|
fcddeeb810 | ||
|
|
49002b7141 | ||
|
|
326eb47df3 | ||
|
|
ef1abdae3f | ||
|
|
eece12a815 | ||
|
|
0cdea3ad33 | ||
|
|
a8ca61297d | ||
|
|
5762cdf8af | ||
|
|
dff633c902 | ||
|
|
45d8a73609 | ||
|
|
390a4dd8d8 |
@@ -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.');
|
||||
|
||||
@@ -38,6 +38,10 @@ jobs:
|
||||
working-directory: plugin/dashboard
|
||||
run: npm run build
|
||||
|
||||
- name: Test dashboard source
|
||||
working-directory: plugin/dashboard
|
||||
run: npm test
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
@@ -54,7 +58,11 @@ jobs:
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx requests
|
||||
|
||||
- name: Run dashboard API tests
|
||||
run: python -m unittest plugin.dashboard.test_plugin_api
|
||||
run: >-
|
||||
python -m unittest
|
||||
plugin.dashboard.test_plugin_api
|
||||
plugin.dashboard.test_git_api
|
||||
plugin.dashboard.test_mobile_plugin_api
|
||||
|
||||
- name: Verify dashboard bundle outputs
|
||||
run: |
|
||||
|
||||
@@ -106,4 +106,8 @@ jobs:
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_native_layout_imports.py \
|
||||
plugin/tests/test_profile_discovery.py \
|
||||
plugin/tests/test_profiles_updated_broadcast.py
|
||||
plugin/tests/test_profiles_updated_broadcast.py \
|
||||
plugin/tests/test_git_state.py \
|
||||
plugin/tests/test_git_state_write.py \
|
||||
plugin/tests/test_git_state_extras.py \
|
||||
plugin/tests/test_mobile_plugin_store.py
|
||||
|
||||
@@ -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
|
||||
|
||||
+51
-12
@@ -6,28 +6,67 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
### Fixed
|
||||
|
||||
- **Android and Relay add top-level provider usage and limit settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden Settings presentation modes plus per-provider landing-page visibility. The authenticated Relay Dashboard plugin resolves the active Codex credential directly from the live session; paired standalone clients retain an explicitly enabled Relay fallback. The UI identifies Relay-plugin-enhanced data and explains which capabilities require the matching plugin. Provider credentials remain host-side.
|
||||
- **Desktop releases now include a Linux ARM64 CLI artifact.** The one-line installer, updater, checksums, release publication, architecture validation, and platform documentation all recognize the same `linux-arm64` binary.
|
||||
- **The public site now shows the real Windows CLI UI and guides each surface through first use.** Deterministic public-safe screenshots cover connection, host access, activity, computer control, and updates; Android and CLI paths now carry users from install through prerequisites, pairing, verification, and a concrete first-success action before the long-form reference material.
|
||||
- **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 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.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release and candidate names use one public product hierarchy.** Future releases use `Hermes-Relay Android`, `Hermes-Relay Plugin`, or `Hermes-Relay CLI+UI` display names, while isolated Android review and release-candidate installs use `HR Candidate`, without changing immutable tags, package identities, updater contracts, or artifact filenames.
|
||||
- **Review candidates are an explicit PR opt-in with one trusted handoff comment.** Maintainers can apply `review-candidate` for exact-head Android and Relay bundles; a separate reporter updates the PR with the artifact, expiry, source SHA, and bounded review instructions without executing fork code with write permission.
|
||||
- **Unlabeled PR updates no longer receive false candidate-failure comments.** The trusted reporter ignores skipped review-bundle workflow shells before reading artifacts or writing to a PR.
|
||||
## [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.
|
||||
|
||||
## [Android 1.13.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
|
||||
- **Android releases and review candidates use clear public product names.** Stable builds use `Hermes-Relay Android`, while isolated review installs use `HR Candidate` without changing package identities or update contracts.
|
||||
- **Review candidates are explicit and source-pinned.** Maintainers can opt a PR into a matched Android and Relay bundle with checksums, expiry, source SHA, and bounded review instructions.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Unlabeled PR updates no longer receive false candidate-failure comments.** The trusted reporter ignores skipped review-bundle workflow shells before reading artifacts or writing to a PR.
|
||||
- **Android chats no longer retain a stale busy composer.** A completed Gateway bubble settles automatically when its exact session has no live or detached turn, new-chat navigation clears stale visible ownership, and Stop remains an immediate escape hatch. (#416, #418)
|
||||
- **README and Google Play onboarding now match the Dashboard-first product path.** Public setup copy names the two separate Dashboard QR actions, treats the API server as an advanced fallback, explains the encouraged Hermes-Relay extension without implying Play includes Device Control, and ships one current deterministic Android screenshot set.
|
||||
- **Desktop install and update discovery remains reliable in a multi-surface release repository.** Every resolver paginates GitHub releases before choosing the SemVer maximum, Windows cooperative updates clean their released backup, unsigned preview installers retain the normal SmartScreen warning, and release smoke tests preserve real exit codes.
|
||||
- **The Android Sphere remains gently animated while visibly idle.** New chats and the ambient Sphere behind messages now use a low-cost layer breath, while hidden/backgrounded and motion-disabled surfaces stay still and active agent/voice states retain their full procedural animation.
|
||||
- **Android retries Windows-hosted `MEDIA:` attachments through Relay's by-path route.** A document deferred on cellular no longer treats `C:\...` as an opaque media token and reports it as expired.
|
||||
|
||||
## [Plugin 1.10.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay provides normalized provider usage without exposing credentials.** The authenticated Dashboard route resolves the active Codex pool entry, structured Nous balances, and OpenCode Go windows on the Hermes host; explicitly enabled paired clients receive the same provider-neutral schema.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Plugin releases use the `Hermes-Relay Plugin` public name.** The display name is aligned with Android and CLI+UI while the `server-v*` compatibility tag remains unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Relay profile discovery follows `HERMES_HOME` by default.** Custom Hermes installations surface their real default profile and persist Relay sessions beside the active config while retaining the explicit `RELAY_HERMES_CONFIG` override.
|
||||
|
||||
## [0.4.0-beta.5] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- **Desktop releases now include a Linux ARM64 CLI artifact.** The one-line installer, updater, checksums, release publication, architecture validation, and platform documentation all recognize the same `linux-arm64` binary.
|
||||
- **The public site now shows the real Windows CLI UI and guides each surface through first use.** Deterministic public-safe screenshots cover connection, host access, activity, computer control, and updates.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Desktop releases use the `Hermes-Relay CLI+UI` public name.** The beta keeps its existing `desktop-v*` tag and updater contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Desktop install and update discovery remains reliable in a multi-surface release repository.** Every resolver paginates GitHub releases before choosing the SemVer maximum, Windows cooperative updates clean their released backup, unsigned preview installers retain the normal SmartScreen warning, and release smoke tests preserve real exit codes.
|
||||
- **Desktop daemon connections recover instead of exiting after an interrupted Relay socket.** Healthy daemons retry through Relay restarts and repeated failed reconnect attempts, oversized desktop-tool results fail within a bounded response instead of closing the shared WebSocket, and terminal failures leave an accurate stopped status for the tray.
|
||||
- **Desktop computer control follows Hermes' current CUA Driver contract.** CUA Driver 0.20 and newer are accepted when their manifest, daemon/MCP arguments, required tools, and canonical path remain compatible, and Windows sessions use the manifest-declared direct standard-mode runtime instead of a potentially stale machine-wide daemon. Current 0.21 installations no longer fall back solely because of an obsolete upper version pin or daemon contract.
|
||||
|
||||
|
||||
+11
-4
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay CLI+UI v__VERSION__
|
||||
|
||||
**Release Date:** 2026-08-22
|
||||
**Release Date:** 2026-08-25
|
||||
|
||||
This release makes the Desktop connector resilient through Relay interruptions,
|
||||
aligns Windows computer control with current CUA Driver releases, and adds a
|
||||
native Linux ARM64 build.
|
||||
This beta makes the Desktop connector resilient through Relay interruptions,
|
||||
aligns Windows computer control with current CUA Driver releases, adds a native
|
||||
Linux ARM64 build, and hardens installation and update discovery.
|
||||
|
||||
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64; the management UI is Windows-only.
|
||||
|
||||
@@ -14,6 +14,13 @@ native Linux ARM64 build.
|
||||
|
||||
- **Linux ARM64 is a first-class release target.** The one-line installer,
|
||||
updater, checksums, and release artifacts now cover both Linux x64 and arm64.
|
||||
- **The public site shows the real Windows CLI UI.** Deterministic screenshots
|
||||
cover connections, host access, activity, computer control, and updates.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Public naming is aligned.** Releases use `Hermes-Relay CLI+UI` while the
|
||||
beta keeps its existing `desktop-v*` tag and updater contract.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+10
-5
@@ -1,21 +1,26 @@
|
||||
# Hermes-Relay Plugin v__VERSION__
|
||||
|
||||
**Release Date:** August 21, 2026
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Summary
|
||||
|
||||
This release makes delayed phone delivery and active Bridge access easier to understand. Relay now identifies messages flushed after reconnect, emits one completion signal for the backlog, and reports permanent, timed, and unlimited phone capabilities through status surfaces.
|
||||
This release adds a provider-neutral account-usage surface for Android and Dashboard clients. Relay resolves Codex credential pools, structured Nous balances, and OpenCode Go windows on the Hermes host without returning provider credentials.
|
||||
|
||||
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## Added
|
||||
|
||||
- **Reconnect backlog context.** Messages flushed from the bounded offline queue carry an explicit delayed-delivery marker, followed by one ordered completion event with the delivered count.
|
||||
- **Granular phone capability status.** Relay status and `android_phone_status` report permanent, timed, and unlimited Bridge capabilities alongside existing Android permissions and safety state.
|
||||
- **Provider-neutral usage snapshots.** Authenticated Dashboard clients can resolve the exact active Codex pool entry, Nous balances, and OpenCode Go account windows through one normalized schema.
|
||||
- **Bounded paired-client fallback.** Operators may explicitly enable the Relay usage route for paired standalone clients while credentials remain host-side.
|
||||
|
||||
## Changed
|
||||
|
||||
- **Phone surfacing semantics are explicit.** Default delivery persists to Threads and notifies, Inbox delivery remains silent, and Session delivery targets an available active conversation before falling back to a notification.
|
||||
- **Usage capabilities are explicit.** Responses identify Relay-enhanced credential pools, structured balances, and provider adapters instead of implying unsupported upstream data.
|
||||
- **Public product naming is aligned.** Releases use `Hermes-Relay Plugin` while retaining the `server-v*` tag and installation contract.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Custom Hermes homes resolve correctly.** Relay profile discovery and session persistence follow `HERMES_HOME` by default while preserving the explicit `RELAY_HERMES_CONFIG` override.
|
||||
|
||||
## Install / update
|
||||
|
||||
|
||||
+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
|
||||
|
||||
|
||||
+9
-10
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.12.1
|
||||
# Hermes-Relay Android v1.13.1
|
||||
|
||||
**Release Date:** August 22, 2026
|
||||
**Release Date:** August 25, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.12.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.13.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,18 +12,17 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch makes Android sharing and recovery dependable. Shared links, text, images, and files open as complete reviewable drafts; connection renewal no longer stalls; offline and history failures are visible; and secure-storage recovery appears in Diagnostics.
|
||||
This patch makes Android session activity follow live Hermes runtime state instead of a five-minute recency estimate. It keeps Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work accurate while preserving stale state until a complete, unambiguous snapshot can safely replace it.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Open shared links, text, images, files, and mixed or multi-item shares as a fresh reviewable draft without sending automatically.
|
||||
- Keep Add and Renew connection setup on the correct connection-scoped authentication store, with bounded Retry or Cancel recovery instead of an indefinite preparation screen.
|
||||
- Surface unavailable chat routes and profile-history failures clearly instead of silently dropping Send or presenting missing history as an empty conversation.
|
||||
- Report Android Keystore fallback, encrypted-store recovery, and temporary credential storage in Diagnostics without exposing credentials.
|
||||
- Derive session activity from the authoritative live runtime snapshot rather than Dashboard recency.
|
||||
- Preserve prior activity when a refresh is incomplete, unsupported, or ambiguously scoped.
|
||||
- Keep session drawer labels, timestamps, and active-turn ownership aligned with the exact profile and session.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.12.1** (versionCode **48**).
|
||||
- App version: **1.13.1** (versionCode **50**).
|
||||
- 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 is not required for standard Android chat, sharing, session continuity, or Gateway recovery.
|
||||
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
|
||||
@@ -6,6 +6,32 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Certify Android session activity across lifecycle and profile boundaries
|
||||
|
||||
The contract fixture now covers every upstream live status, complete-snapshot
|
||||
disappearance, client-side ownership of duplicate durable ids across profiles,
|
||||
and older Gateways without `session.active_list`. Before calling the status
|
||||
model device-certified:
|
||||
|
||||
- Exercise working, quiet tool-heavy work, each pending-input surface, normal
|
||||
completion, Stop, reconnect, app restart, and process recreation against
|
||||
current vanilla upstream.
|
||||
- Verify All Profiles with duplicate session ids across two profiles and two
|
||||
saved connections; no late snapshot or old socket generation may mark the
|
||||
wrong row live.
|
||||
- Confirm failed/unsupported refresh becomes Unavailable, restart revalidation
|
||||
remains Checking, ambiguous or partially
|
||||
resolved process-wide snapshots infer no absence, a complete empty snapshot
|
||||
settles every unambiguously owned scope, and REST `is_active=true` never
|
||||
renders as Working.
|
||||
- Run a background process that outlives its parent turn and verify Background
|
||||
work remains separate from the conversation's Idle state.
|
||||
- Pursue an upstream `session.active_list` profile field/filter or an aggregate
|
||||
activity route with explicit profile ownership so multi-profile clients do
|
||||
not need to resolve process-wide rows from durable keys.
|
||||
|
||||
---
|
||||
|
||||
## Bot Mode follow-ups after multi-gateway aggregation
|
||||
|
||||
Android Bot Mode now has an all-gateway roster, typed `(connectionId, profile)`
|
||||
|
||||
@@ -1 +1 @@
|
||||
Shared links, text, images, and files now open as complete reviewable drafts without sending automatically. Add and Renew connection setup no longer stalls. Offline chat and profile-history failures surface clear recovery guidance instead of doing nothing or showing empty history. Diagnostics now reports secure-storage fallback and recovery without exposing credentials.
|
||||
Session activity now follows live Hermes runtime state instead of a recent-activity estimate. Working, Starting, Needs input, Idle, Checking, Unavailable, and Background work stay accurate, and stale state clears only after a complete, unambiguous update.
|
||||
|
||||
@@ -1 +1 @@
|
||||
共享链接、文本、图片和文件现在会作为完整、可检查的草稿打开,不会自动发送。添加或续订连接时不再卡在准备阶段。离线聊天和配置文件历史记录失败会显示明确的恢复提示,而不是无响应或显示空历史记录。诊断现在会报告安全存储降级与恢复,且不会暴露凭据。
|
||||
会话活动现在依据 Hermes 的实时运行状态,而不是最近活动时间估算。工作中、启动中、需要输入、空闲、检查中、不可用和后台工作等状态会保持准确;只有完整且明确的更新才会清除旧状态。
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"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",
|
||||
"date": "2026-08-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Talk across saved gateways",
|
||||
"bullets": [
|
||||
"Use Bot Mode as one messenger-style workspace for bots and read-only groups across saved Hermes gateways.",
|
||||
"Keep every Bot Chat bound to its exact gateway and profile without changing the foreground connection."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Understand account limits",
|
||||
"bullets": [
|
||||
"Review Codex credential pools, Nous balances, and OpenCode Go windows from one provider-neutral Usage and limits screen.",
|
||||
"Choose Summary, Expanded, or Hidden presentation while provider credentials remain on the Hermes host."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Keep chat and voice in context",
|
||||
"bullets": [
|
||||
"Settle orphaned Gateway busy state automatically while preserving another session's active or detached turn.",
|
||||
"Include bounded visible text and an available screenshot in the first compatible Assistant voice turn."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.12.1",
|
||||
"title": "Sharing and recovery that work",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
v1.12.1 - Sharing and recovery that work
|
||||
v1.13.1 - Accurate session activity
|
||||
|
||||
* Open shared links, text, images, and files as a reviewable draft without auto-sending.
|
||||
* Add or renew a connection without getting stuck during secure setup.
|
||||
* See clear recovery guidance when chat or profile history is unavailable.
|
||||
* Find secret-free secure-storage fallback and recovery evidence in Diagnostics.
|
||||
* Follow live Hermes runtime state for Working, Starting, Needs input, and Idle.
|
||||
* Keep stale activity visible until a complete, unambiguous snapshot clears it.
|
||||
* Distinguish Checking, Unavailable, and Background work in the session drawer.
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.hermesandroid.relay.data.replaceHermesReachCredential
|
||||
import com.hermesandroid.relay.data.sameBrokerAuthority
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.isSafeProfileUiMeta
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
@@ -18,6 +19,8 @@ import com.hermesandroid.relay.network.shared.InvalidCredentialException
|
||||
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
@@ -53,6 +56,39 @@ sealed class AuthState {
|
||||
data class Failed(val reason: String) : AuthState()
|
||||
}
|
||||
|
||||
internal fun relaySupervisedModePayload(policy: SupervisedModePolicy): JsonObject {
|
||||
if (!policy.isActive) return buildJsonObject { put("active", false) }
|
||||
val capabilities = buildList {
|
||||
add("text_chat")
|
||||
if (policy.capabilities.newChat) add("new_chat")
|
||||
if (policy.capabilities.cancelResponse) add("cancel")
|
||||
if (policy.capabilities.steerResponse) add("steer")
|
||||
if (policy.capabilities.attachments) add("attachments")
|
||||
if (policy.capabilities.voice) add("voice")
|
||||
if (policy.capabilities.generatedImages) add("generated_images")
|
||||
if (policy.capabilities.shareGeneratedImages) add("share_images")
|
||||
if (policy.capabilities.copyResponses) add("copy")
|
||||
if (policy.capabilities.retryResponse) add("retry")
|
||||
if (policy.capabilities.quoteReplies) add("quote_reply")
|
||||
if (policy.visibility.resolved().showTimestamps) add("timestamps")
|
||||
}.take(12)
|
||||
return buildJsonObject {
|
||||
put("active", true)
|
||||
put("profile_label", policy.pinnedProfileName.orEmpty().take(80))
|
||||
put("capabilities", JsonArray(capabilities.map(::JsonPrimitive)))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun relaySupervisedModeUpdateEnvelope(
|
||||
policy: SupervisedModePolicy,
|
||||
): Envelope = Envelope(
|
||||
channel = "system",
|
||||
type = "supervised.update",
|
||||
payload = buildJsonObject {
|
||||
put("supervised_mode", relaySupervisedModePayload(policy))
|
||||
},
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectionAuthSecrets(
|
||||
val sessionToken: String? = null,
|
||||
@@ -120,6 +156,60 @@ class AuthManager(
|
||||
private val eagerHydrate: Boolean = true,
|
||||
) : ChannelMultiplexer.ChannelHandler {
|
||||
|
||||
@Volatile
|
||||
private var supervisedMode: SupervisedModePolicy = SupervisedModePolicy()
|
||||
|
||||
@Volatile
|
||||
private var supervisedMetadataReconnectFallback: (() -> Unit)? = null
|
||||
private var pendingSupervisedUpdateId: String? = null
|
||||
private var supervisedUpdateFallbackJob: Job? = null
|
||||
|
||||
/**
|
||||
* Update the public client-mode tag sent on Relay auth. This does not grant
|
||||
* authority: Relay labels enforcement_owner=android_client and the Android
|
||||
* policy remains the enforcing surface.
|
||||
*/
|
||||
fun updateSupervisedMode(policy: SupervisedModePolicy) {
|
||||
if (supervisedMode == policy) return
|
||||
supervisedMode = policy
|
||||
if (_authState.value is AuthState.Paired) sendSupervisedModeUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the narrow compatibility path used when an older Relay ignores
|
||||
* `system/supervised.update`. Reopening the authenticated socket causes
|
||||
* the current policy to travel through the legacy `system/auth` payload.
|
||||
*/
|
||||
fun setSupervisedMetadataReconnectFallback(callback: () -> Unit) {
|
||||
supervisedMetadataReconnectFallback = callback
|
||||
}
|
||||
|
||||
private fun sendSupervisedModeUpdate() {
|
||||
val envelope = relaySupervisedModeUpdateEnvelope(supervisedMode)
|
||||
pendingSupervisedUpdateId = envelope.id
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
multiplexer.send(envelope)
|
||||
supervisedUpdateFallbackJob = scope.launch {
|
||||
delay(SUPERVISED_UPDATE_ACK_TIMEOUT_MS)
|
||||
if (pendingSupervisedUpdateId == envelope.id) {
|
||||
pendingSupervisedUpdateId = null
|
||||
Log.i(TAG, "supervised.update unsupported or unacknowledged; refreshing Relay socket")
|
||||
supervisedMetadataReconnectFallback?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleSupervisedModeUpdate(envelope: Envelope, unsupported: Boolean) {
|
||||
if (envelope.id != pendingSupervisedUpdateId) return
|
||||
pendingSupervisedUpdateId = null
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
supervisedUpdateFallbackJob = null
|
||||
if (unsupported) {
|
||||
Log.i(TAG, "supervised.update rejected; refreshing Relay socket for compatibility")
|
||||
supervisedMetadataReconnectFallback?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthManager"
|
||||
private const val KEY_SESSION_TOKEN = "session_token"
|
||||
@@ -134,6 +224,7 @@ class AuthManager(
|
||||
// migration has run, so we never rebuild the legacy keyset to re-check.
|
||||
private const val KEY_LEGACY_MIGRATED = "legacy_migrated"
|
||||
private const val PAIRING_CODE_LENGTH = 6
|
||||
private const val SUPERVISED_UPDATE_ACK_TIMEOUT_MS = 2_000L
|
||||
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
|
||||
/**
|
||||
@@ -835,6 +926,10 @@ class AuthManager(
|
||||
put("device_form_factor", "phone")
|
||||
}
|
||||
|
||||
private fun JsonObjectBuilder.putSupervisedMode() {
|
||||
put("supervised_mode", relaySupervisedModePayload(supervisedMode))
|
||||
}
|
||||
|
||||
private fun relayDeviceName(): String {
|
||||
val configured = runCatching {
|
||||
Settings.Global.getString(context.contentResolver, "device_name")
|
||||
@@ -890,6 +985,7 @@ class AuthManager(
|
||||
put("device_id", deviceId)
|
||||
putRelayDeviceIdentity()
|
||||
putRelayClientSupports()
|
||||
putSupervisedMode()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -906,6 +1002,7 @@ class AuthManager(
|
||||
put("device_id", deviceId)
|
||||
putRelayDeviceIdentity()
|
||||
putRelayClientSupports()
|
||||
putSupervisedMode()
|
||||
pendingTtlSeconds?.let { put("ttl_seconds", it) }
|
||||
pendingGrants?.let { grants ->
|
||||
val obj = buildJsonObject {
|
||||
@@ -985,6 +1082,8 @@ class AuthManager(
|
||||
when (envelope.type) {
|
||||
"auth.ok" -> handleAuthOk(envelope)
|
||||
"auth.fail" -> handleAuthFail(envelope)
|
||||
"supervised.updated" -> settleSupervisedModeUpdate(envelope, unsupported = false)
|
||||
"error" -> settleSupervisedModeUpdate(envelope, unsupported = true)
|
||||
// `profiles.updated` push — sent by the v0.7.1+ relay on
|
||||
// the "pairing" channel whenever its in-memory profile
|
||||
// snapshot changes (file-watcher, SIGHUP, or a manual
|
||||
@@ -1129,6 +1228,11 @@ class AuthManager(
|
||||
get() = _authState.value is AuthState.Paired
|
||||
|
||||
private fun handleAuthOk(envelope: Envelope) {
|
||||
// A successful auth always carries the latest client report, including
|
||||
// after the compatibility reconnect used for older Relay versions.
|
||||
pendingSupervisedUpdateId = null
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
supervisedUpdateFallbackJob = null
|
||||
scope.launch {
|
||||
try {
|
||||
val payload = envelope.payload
|
||||
|
||||
@@ -451,7 +451,8 @@ data class ChatSession(
|
||||
val outputTokens: Int = 0,
|
||||
val actualCostUsd: Double? = null,
|
||||
val estimatedCostUsd: Double? = null,
|
||||
val isActive: Boolean = false,
|
||||
/** Upstream REST five-minute recency hint; never evidence that a turn is running. */
|
||||
val recentlyActive: Boolean = false,
|
||||
val updatedAt: Long = 0L,
|
||||
val startedAt: Long = 0L,
|
||||
val lastActivityAt: Long = 0L,
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.plugins.runtime.ScopedPluginApiClient
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
// Read + write client for the Hermes-Relay Git State endpoints.
|
||||
// All requests are confined to the ``hermes-relay`` plugin namespace and the
|
||||
// ``git/*`` sub-path via ScopedPluginApiClient, which rejects traversal and
|
||||
// encodes query values.
|
||||
|
||||
private fun pathsArray(paths: List<String>) = buildJsonArray { paths.forEach { add(JsonPrimitive(it)) } }
|
||||
|
||||
class GitStateApiClient(
|
||||
dashboard: DashboardApiClient,
|
||||
) {
|
||||
private val scoped = ScopedPluginApiClient("hermes-relay", dashboard)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun repos(): Result<List<GitRepo>> = scoped
|
||||
.get("git/repos")
|
||||
.mapCatching { element ->
|
||||
json.decodeFromJsonElement<ReposResponse>(element).repos
|
||||
}
|
||||
|
||||
suspend fun status(repo: String): Result<GitStatus> = scoped
|
||||
.get("git/status", mapOf("repo" to repo))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitStatus>(element) }
|
||||
|
||||
suspend fun branches(repo: String): Result<List<GitBranch>> = scoped
|
||||
.get("git/branches", mapOf("repo" to repo))
|
||||
.mapCatching { element ->
|
||||
json.decodeFromJsonElement<BranchesResponse>(element).branches
|
||||
}
|
||||
|
||||
suspend fun diff(repo: String, path: String, kind: String): Result<GitDiff> = scoped
|
||||
.get("git/diff", mapOf("repo" to repo, "path" to path, "kind" to kind))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitDiff>(element) }
|
||||
|
||||
suspend fun file(repo: String, path: String): Result<GitFile> = scoped
|
||||
.get("git/file", mapOf("repo" to repo, "path" to path))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitFile>(element) }
|
||||
|
||||
// ── Write operations ───────────────────────────────────────────────────
|
||||
// Every write requires the plugin.api.write grant, which the app enforces
|
||||
// (see GitStateViewModel: a POST is never sent without the grant). The
|
||||
// server additionally enforces per-use confirmation strings for destructive
|
||||
// ops (discard/push/dirty-checkout) — the caller passes the echoed token.
|
||||
|
||||
suspend fun stage(repo: String, paths: List<String>): Result<GitMutationResult> =
|
||||
scoped.post("git/stage", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun unstage(repo: String, paths: List<String>): Result<GitMutationResult> =
|
||||
scoped.post("git/unstage", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun discard(
|
||||
repo: String,
|
||||
paths: List<String>,
|
||||
confirmation: String,
|
||||
deleteUntracked: Boolean = false,
|
||||
): Result<GitMutationResult> = scoped.post("git/discard", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
put("confirmation", confirmation)
|
||||
put("delete_untracked", deleteUntracked)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun commit(repo: String, message: String): Result<GitMutationResult> =
|
||||
scoped.post("git/commit", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("message", message)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun commitSelected(
|
||||
repo: String,
|
||||
message: String,
|
||||
paths: List<String>,
|
||||
): Result<GitMutationResult> = scoped.post("git/commit_selected", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("message", message)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun fetch(repo: String, remote: String = "origin"): Result<GitMutationResult> =
|
||||
scoped.post("git/fetch", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun pull(repo: String, remote: String = "origin", branch: String = ""): Result<GitMutationResult> =
|
||||
scoped.post("git/pull", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
put("branch", branch)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun push(
|
||||
repo: String,
|
||||
confirmation: String,
|
||||
remote: String = "origin",
|
||||
branch: String = "",
|
||||
): Result<GitMutationResult> = scoped.post("git/push", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
put("branch", branch)
|
||||
put("confirmation", confirmation)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun checkout(
|
||||
repo: String,
|
||||
ref: String,
|
||||
confirmation: String? = null,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
): Result<GitMutationResult> = scoped.post("git/checkout", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("ref", ref)
|
||||
if (confirmation != null) put("confirmation", confirmation)
|
||||
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
|
||||
put("track", track)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────
|
||||
|
||||
/** Generate a commit-message suggestion from the staged diff. */
|
||||
suspend fun commitMessage(repo: String): Result<GitCommitMessage> =
|
||||
scoped.post("git/commit_message", buildJsonObject {
|
||||
put("repo", repo)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
|
||||
|
||||
/** Generate a commit-message suggestion from the given paths' staged diff. */
|
||||
suspend fun commitMessageSelected(
|
||||
repo: String,
|
||||
paths: List<String>,
|
||||
): Result<GitCommitMessage> = scoped.post("git/commit_message_selected", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
|
||||
|
||||
/** Checkout that auto-stashes a dirty tree first. */
|
||||
suspend fun stashCheckout(
|
||||
repo: String,
|
||||
ref: String,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
): Result<GitStashCheckoutResult> = scoped.post("git/stash_checkout", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("ref", ref)
|
||||
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
|
||||
put("track", track)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitStashCheckoutResult>(it) }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** A repository discovered by the plugin's /git/repos endpoint. */
|
||||
@Serializable
|
||||
data class GitRepo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val root: String,
|
||||
@SerialName("current_branch") val currentBranch: String? = null,
|
||||
val dirty: Boolean = false,
|
||||
)
|
||||
|
||||
/** Working-tree status from /git/status. */
|
||||
@Serializable
|
||||
data class GitStatus(
|
||||
val counts: GitStatusCounts = GitStatusCounts(),
|
||||
val staged: List<GitStatusEntry> = emptyList(),
|
||||
val modified: List<GitStatusEntry> = emptyList(),
|
||||
val untracked: List<GitStatusEntry> = emptyList(),
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitStatusCounts(
|
||||
val staged: Int = 0,
|
||||
val modified: Int = 0,
|
||||
val untracked: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitStatusEntry(
|
||||
val path: String,
|
||||
)
|
||||
|
||||
/** A branch from /git/branches. */
|
||||
@Serializable
|
||||
data class GitBranch(
|
||||
val name: String,
|
||||
val upstream: String? = null,
|
||||
val ahead: Int = 0,
|
||||
val behind: Int = 0,
|
||||
@SerialName("is_current") val isCurrent: Boolean = false,
|
||||
)
|
||||
|
||||
/** A per-file diff from /git/diff. */
|
||||
@Serializable
|
||||
data class GitDiff(
|
||||
val path: String,
|
||||
val kind: String,
|
||||
val diff: String,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** A tracked-file read from /git/file. */
|
||||
@Serializable
|
||||
data class GitFile(
|
||||
val path: String,
|
||||
val content: String,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** Wrapper for /git/repos response. */
|
||||
@Serializable
|
||||
internal data class ReposResponse(
|
||||
val repos: List<GitRepo> = emptyList(),
|
||||
val notice: String? = null,
|
||||
)
|
||||
|
||||
/** Wrapper for /git/branches response. */
|
||||
@Serializable
|
||||
internal data class BranchesResponse(
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
)
|
||||
|
||||
/** A mutation response: fresh HEAD oid + working-tree status (+ branches). */
|
||||
@Serializable
|
||||
data class GitMutationResult(
|
||||
val head: String = "",
|
||||
val status: GitStatus = GitStatus(),
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
)
|
||||
|
||||
/** A /git/commit_message suggestion: generated message + optional notice. */
|
||||
@Serializable
|
||||
data class GitCommitMessage(
|
||||
val message: String = "",
|
||||
val notice: String = "",
|
||||
)
|
||||
|
||||
/** A /git/stash_checkout result: standard mutation shape + stash flag/message. */
|
||||
@Serializable
|
||||
data class GitStashCheckoutResult(
|
||||
val head: String = "",
|
||||
val status: GitStatus = GitStatus(),
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
val stashed: Boolean = false,
|
||||
@SerialName("stash_message") val stashMessage: String = "",
|
||||
)
|
||||
@@ -0,0 +1,552 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/** Stable ownership boundary for live activity. Runtime ids are aliases, never owners. */
|
||||
@ConsistentCopyVisibility
|
||||
data class SessionActivityOwner private constructor(
|
||||
val connectionId: String,
|
||||
val profile: String,
|
||||
val storedSessionId: String,
|
||||
) {
|
||||
companion object {
|
||||
fun of(connectionId: String, profile: String, storedSessionId: String) =
|
||||
SessionActivityOwner(
|
||||
connectionId = connectionId.trim(),
|
||||
profile = profile.trim().lowercase(Locale.ROOT),
|
||||
storedSessionId = storedSessionId.trim(),
|
||||
).also {
|
||||
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
|
||||
require(it.profile.isNotEmpty()) { "profile must not be blank" }
|
||||
require(it.storedSessionId.isNotEmpty()) { "storedSessionId must not be blank" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ConsistentCopyVisibility
|
||||
data class SessionActivityScope private constructor(
|
||||
val connectionId: String,
|
||||
val profile: String,
|
||||
) {
|
||||
companion object {
|
||||
fun of(connectionId: String, profile: String) = SessionActivityScope(
|
||||
connectionId = connectionId.trim(),
|
||||
profile = profile.trim().lowercase(Locale.ROOT),
|
||||
).also {
|
||||
require(it.connectionId.isNotEmpty()) { "connectionId must not be blank" }
|
||||
require(it.profile.isNotEmpty()) { "profile must not be blank" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SessionActivityPhase {
|
||||
Starting,
|
||||
Working,
|
||||
NeedsInput,
|
||||
BackgroundWork,
|
||||
Idle,
|
||||
}
|
||||
|
||||
enum class SessionActivityFreshness {
|
||||
Confirmed,
|
||||
Revalidating,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
enum class SessionActivityEvidenceSource {
|
||||
Directory,
|
||||
LocalSend,
|
||||
ActiveList,
|
||||
SessionEvent,
|
||||
PendingInput,
|
||||
Terminal,
|
||||
Checkpoint,
|
||||
Process,
|
||||
}
|
||||
|
||||
data class SessionActivityEvidence(
|
||||
val source: SessionActivityEvidenceSource,
|
||||
val generation: Long,
|
||||
val observedAtMillis: Long,
|
||||
)
|
||||
|
||||
data class SessionActivityRecord(
|
||||
val owner: SessionActivityOwner,
|
||||
/** Authoritative turn state before exact pending-input and background-process overlays. */
|
||||
val turnPhase: SessionActivityPhase,
|
||||
val freshness: SessionActivityFreshness,
|
||||
val evidence: SessionActivityEvidence,
|
||||
val runtimeId: String? = null,
|
||||
val pendingInputs: Map<String, Long?> = emptyMap(),
|
||||
val backgroundProcessIds: Set<String> = emptySet(),
|
||||
) {
|
||||
fun phase(nowMillis: Long = Long.MIN_VALUE): SessionActivityPhase {
|
||||
val hasPendingInput = pendingInputs.any { (_, expiresAt) -> expiresAt == null || expiresAt > nowMillis }
|
||||
return when {
|
||||
hasPendingInput -> SessionActivityPhase.NeedsInput
|
||||
turnPhase != SessionActivityPhase.Idle -> turnPhase
|
||||
backgroundProcessIds.isNotEmpty() -> SessionActivityPhase.BackgroundWork
|
||||
else -> SessionActivityPhase.Idle
|
||||
}
|
||||
}
|
||||
|
||||
/** Presentation projection that never labels missing optional runtime data as session state. */
|
||||
fun presentationState(nowMillis: Long = Long.MIN_VALUE): SessionActivityState? = when (freshness) {
|
||||
SessionActivityFreshness.Revalidating -> null
|
||||
SessionActivityFreshness.Unavailable -> null
|
||||
SessionActivityFreshness.Confirmed -> when (phase(nowMillis)) {
|
||||
SessionActivityPhase.Starting -> SessionActivityState.Starting
|
||||
SessionActivityPhase.Working -> SessionActivityState.Working
|
||||
SessionActivityPhase.NeedsInput -> SessionActivityState.NeedsInput
|
||||
SessionActivityPhase.BackgroundWork -> SessionActivityState.BackgroundWork
|
||||
SessionActivityPhase.Idle -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class SessionLiveStatus {
|
||||
Starting,
|
||||
Working,
|
||||
Waiting,
|
||||
Idle,
|
||||
}
|
||||
|
||||
data class SessionLiveRuntime(
|
||||
/** Null when transport data cannot be resolved uniquely to a stored session owner. */
|
||||
val owner: SessionActivityOwner?,
|
||||
val runtimeId: String,
|
||||
val status: SessionLiveStatus,
|
||||
)
|
||||
|
||||
sealed interface SessionActivityUpdate {
|
||||
val generation: Long
|
||||
val observedAtMillis: Long
|
||||
|
||||
data class BeginGeneration(
|
||||
val scope: SessionActivityScope,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class ObserveOwner(
|
||||
val owner: SessionActivityOwner,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class LocalSend(
|
||||
val owner: SessionActivityOwner,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class LiveState(
|
||||
val owner: SessionActivityOwner,
|
||||
val runtimeId: String?,
|
||||
val status: SessionLiveStatus,
|
||||
val source: SessionActivityEvidenceSource = SessionActivityEvidenceSource.SessionEvent,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class RuntimeState(
|
||||
val scope: SessionActivityScope,
|
||||
val runtimeId: String,
|
||||
val status: SessionLiveStatus,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class ActiveList(
|
||||
val scope: SessionActivityScope,
|
||||
val runtimes: List<SessionLiveRuntime>,
|
||||
/** True only when every upstream row was safely attributable for this scope. */
|
||||
val isCompleteForScope: Boolean,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class StatusUnavailable(
|
||||
val scope: SessionActivityScope,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class Terminal(
|
||||
val owner: SessionActivityOwner,
|
||||
val runtimeId: String? = null,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class RestoreCheckpoint(
|
||||
val owner: SessionActivityOwner,
|
||||
val runtimeId: String?,
|
||||
val phase: SessionActivityPhase,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class PendingInputOpened(
|
||||
val owner: SessionActivityOwner,
|
||||
val requestId: String,
|
||||
val expiresAtMillis: Long? = null,
|
||||
val confirmed: Boolean = true,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class PendingInputClosed(
|
||||
val owner: SessionActivityOwner,
|
||||
val requestId: String,
|
||||
val confirmed: Boolean = true,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class ProcessState(
|
||||
val owner: SessionActivityOwner,
|
||||
val processId: String,
|
||||
val running: Boolean,
|
||||
override val generation: Long,
|
||||
override val observedAtMillis: Long,
|
||||
) : SessionActivityUpdate
|
||||
|
||||
data class Tick(
|
||||
val nowMillis: Long,
|
||||
override val generation: Long = Long.MAX_VALUE,
|
||||
override val observedAtMillis: Long = nowMillis,
|
||||
) : SessionActivityUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reducer for session activity. Every update is generation-gated per connection/profile.
|
||||
* An unsuccessful/unsupported refresh never manufactures an idle result.
|
||||
*/
|
||||
data class SessionActivityRegistry(
|
||||
val records: Map<SessionActivityOwner, SessionActivityRecord> = emptyMap(),
|
||||
private val runtimeAliases: Map<RuntimeAlias, SessionActivityOwner> = emptyMap(),
|
||||
private val generations: Map<SessionActivityScope, Long> = emptyMap(),
|
||||
) {
|
||||
fun record(owner: SessionActivityOwner): SessionActivityRecord? = records[owner]
|
||||
|
||||
fun ownerForRuntime(scope: SessionActivityScope, runtimeId: String): SessionActivityOwner? =
|
||||
runtimeAliases[RuntimeAlias(scope, runtimeId.trim(), generations[scope] ?: return null)]
|
||||
|
||||
fun presentationStates(nowMillis: Long = Long.MIN_VALUE): Map<SessionActivityOwner, SessionActivityState> =
|
||||
records.mapNotNull { (owner, record) -> record.presentationState(nowMillis)?.let { owner to it } }.toMap()
|
||||
|
||||
fun reduce(update: SessionActivityUpdate): SessionActivityRegistry {
|
||||
if (update is SessionActivityUpdate.Tick) return expirePendingInputs(update.nowMillis)
|
||||
val scope = update.scope()
|
||||
val currentGeneration = generations[scope]
|
||||
if (currentGeneration != null && update.generation < currentGeneration) return this
|
||||
|
||||
var state = this
|
||||
if (currentGeneration == null || update.generation > currentGeneration) {
|
||||
state = state.beginGeneration(scope, update.generation)
|
||||
}
|
||||
|
||||
return when (update) {
|
||||
is SessionActivityUpdate.BeginGeneration -> state
|
||||
is SessionActivityUpdate.ObserveOwner -> state.observeOwner(update)
|
||||
is SessionActivityUpdate.LocalSend -> state.putTurn(
|
||||
update.owner, null, SessionActivityPhase.Starting, SessionActivityFreshness.Confirmed,
|
||||
SessionActivityEvidenceSource.LocalSend, update.generation, update.observedAtMillis,
|
||||
)
|
||||
is SessionActivityUpdate.LiveState -> state.putLiveState(update)
|
||||
is SessionActivityUpdate.RuntimeState -> {
|
||||
val owner = state.ownerForRuntime(update.scope, update.runtimeId) ?: return state
|
||||
state.putTurn(
|
||||
owner, update.runtimeId, update.status.phase(), SessionActivityFreshness.Confirmed,
|
||||
SessionActivityEvidenceSource.SessionEvent, update.generation, update.observedAtMillis,
|
||||
)
|
||||
}
|
||||
is SessionActivityUpdate.ActiveList -> state.applyActiveList(update)
|
||||
is SessionActivityUpdate.StatusUnavailable -> state.markUnavailable(update.scope)
|
||||
is SessionActivityUpdate.Terminal -> state.settleTerminal(update)
|
||||
is SessionActivityUpdate.RestoreCheckpoint -> state.restoreCheckpoint(update)
|
||||
is SessionActivityUpdate.PendingInputOpened -> state.updatePendingInput(
|
||||
update.owner, update.requestId, update.expiresAtMillis, true,
|
||||
update.confirmed, update.generation, update.observedAtMillis,
|
||||
)
|
||||
is SessionActivityUpdate.PendingInputClosed -> state.updatePendingInput(
|
||||
update.owner, update.requestId, null, false,
|
||||
update.confirmed, update.generation, update.observedAtMillis,
|
||||
)
|
||||
is SessionActivityUpdate.ProcessState -> state.updateProcess(update)
|
||||
is SessionActivityUpdate.Tick -> state
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginGeneration(scope: SessionActivityScope, generation: Long): SessionActivityRegistry {
|
||||
val refreshedRecords = records.mapValues { (owner, record) ->
|
||||
if (owner.scope() == scope) {
|
||||
record.copy(freshness = SessionActivityFreshness.Revalidating)
|
||||
} else record
|
||||
}
|
||||
return copy(
|
||||
records = refreshedRecords,
|
||||
runtimeAliases = runtimeAliases.filterKeys { it.scope != scope },
|
||||
generations = generations + (scope to generation),
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeOwner(update: SessionActivityUpdate.ObserveOwner): SessionActivityRegistry {
|
||||
val existing = records[update.owner]
|
||||
// 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,
|
||||
freshness = SessionActivityFreshness.Revalidating,
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.Directory,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)
|
||||
return copy(records = records + (update.owner to observed))
|
||||
}
|
||||
|
||||
private fun putLiveState(update: SessionActivityUpdate.LiveState): SessionActivityRegistry = putTurn(
|
||||
owner = update.owner,
|
||||
runtimeId = update.runtimeId,
|
||||
phase = update.status.phase(),
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
source = update.source,
|
||||
generation = update.generation,
|
||||
observedAtMillis = update.observedAtMillis,
|
||||
)
|
||||
|
||||
private fun putTurn(
|
||||
owner: SessionActivityOwner,
|
||||
runtimeId: String?,
|
||||
phase: SessionActivityPhase,
|
||||
freshness: SessionActivityFreshness,
|
||||
source: SessionActivityEvidenceSource,
|
||||
generation: Long,
|
||||
observedAtMillis: Long,
|
||||
): SessionActivityRegistry {
|
||||
val previous = records[owner]
|
||||
val record = SessionActivityRecord(
|
||||
owner = owner,
|
||||
turnPhase = phase,
|
||||
freshness = freshness,
|
||||
evidence = SessionActivityEvidence(source, generation, observedAtMillis),
|
||||
runtimeId = runtimeId ?: previous?.runtimeId,
|
||||
pendingInputs = previous?.pendingInputs.orEmpty(),
|
||||
backgroundProcessIds = previous?.backgroundProcessIds.orEmpty(),
|
||||
)
|
||||
val alias = runtimeId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return copy(
|
||||
records = records + (owner to record),
|
||||
runtimeAliases = if (alias == null) runtimeAliases else {
|
||||
runtimeAliases + (RuntimeAlias(owner.scope(), alias, generation) to owner)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyActiveList(update: SessionActivityUpdate.ActiveList): SessionActivityRegistry {
|
||||
require(update.runtimes.all { it.owner == null || it.owner.scope() == update.scope }) {
|
||||
"Active-list rows must belong to the snapshot scope"
|
||||
}
|
||||
var state = copy(runtimeAliases = runtimeAliases.filterKeys { it.scope != update.scope })
|
||||
val resolvedRuntimes = update.runtimes.filter { it.owner != null }
|
||||
val observedOwners = resolvedRuntimes.mapTo(mutableSetOf()) { requireNotNull(it.owner) }
|
||||
resolvedRuntimes.forEach { runtime ->
|
||||
val resolvedOwner = requireNotNull(runtime.owner)
|
||||
state = state.putTurn(
|
||||
resolvedOwner, runtime.runtimeId, runtime.status.phase(), SessionActivityFreshness.Confirmed,
|
||||
SessionActivityEvidenceSource.ActiveList, update.generation, update.observedAtMillis,
|
||||
)
|
||||
if (runtime.status == SessionLiveStatus.Idle) {
|
||||
val idleRecord = requireNotNull(state.records[resolvedOwner]).copy(pendingInputs = emptyMap())
|
||||
state = state.copy(records = state.records + (resolvedOwner to idleRecord))
|
||||
}
|
||||
}
|
||||
val snapshotCanSettle = update.isCompleteForScope && resolvedRuntimes.size == update.runtimes.size
|
||||
if (!snapshotCanSettle) return state
|
||||
val settled = state.records.mapValues { (owner, record) ->
|
||||
if (
|
||||
owner.scope() == update.scope && owner !in observedOwners &&
|
||||
record.shouldSettleWhenAbsent()
|
||||
) {
|
||||
record.copy(
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
runtimeId = null,
|
||||
pendingInputs = emptyMap(),
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.ActiveList,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)
|
||||
} else record
|
||||
}
|
||||
return state.copy(records = settled)
|
||||
}
|
||||
|
||||
private fun markUnavailable(scope: SessionActivityScope): SessionActivityRegistry = copy(
|
||||
records = records.mapValues { (owner, record) ->
|
||||
if (
|
||||
owner.scope() == scope && record.evidence.source in setOf(
|
||||
SessionActivityEvidenceSource.ActiveList,
|
||||
SessionActivityEvidenceSource.Directory,
|
||||
SessionActivityEvidenceSource.Checkpoint,
|
||||
)
|
||||
) {
|
||||
record.copy(freshness = SessionActivityFreshness.Unavailable)
|
||||
} else record
|
||||
},
|
||||
)
|
||||
|
||||
private fun settleTerminal(update: SessionActivityUpdate.Terminal): SessionActivityRegistry {
|
||||
val settled = putTurn(
|
||||
update.owner,
|
||||
runtimeId = null,
|
||||
phase = SessionActivityPhase.Idle,
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
source = SessionActivityEvidenceSource.Terminal,
|
||||
generation = update.generation,
|
||||
observedAtMillis = update.observedAtMillis,
|
||||
)
|
||||
val record = requireNotNull(settled.records[update.owner]).copy(
|
||||
runtimeId = null,
|
||||
pendingInputs = emptyMap(),
|
||||
)
|
||||
return settled.copy(
|
||||
records = settled.records + (update.owner to record),
|
||||
runtimeAliases = settled.runtimeAliases.filterNot { (alias, owner) ->
|
||||
alias.scope == update.owner.scope() && owner == update.owner &&
|
||||
(update.runtimeId == null || alias.runtimeId == update.runtimeId)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun restoreCheckpoint(update: SessionActivityUpdate.RestoreCheckpoint): SessionActivityRegistry {
|
||||
val existing = records[update.owner]
|
||||
if (existing?.freshness == SessionActivityFreshness.Confirmed) return this
|
||||
return putTurn(
|
||||
update.owner, update.runtimeId, update.phase, SessionActivityFreshness.Revalidating,
|
||||
SessionActivityEvidenceSource.Checkpoint, update.generation, update.observedAtMillis,
|
||||
)
|
||||
}
|
||||
|
||||
private fun updatePendingInput(
|
||||
owner: SessionActivityOwner,
|
||||
requestId: String,
|
||||
expiresAtMillis: Long?,
|
||||
opened: Boolean,
|
||||
confirmed: Boolean,
|
||||
generation: Long,
|
||||
observedAtMillis: Long,
|
||||
): SessionActivityRegistry {
|
||||
val previous = records[owner] ?: SessionActivityRecord(
|
||||
owner = owner,
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
freshness = if (confirmed) {
|
||||
SessionActivityFreshness.Confirmed
|
||||
} else {
|
||||
SessionActivityFreshness.Revalidating
|
||||
},
|
||||
evidence = SessionActivityEvidence(
|
||||
if (confirmed) {
|
||||
SessionActivityEvidenceSource.PendingInput
|
||||
} else {
|
||||
SessionActivityEvidenceSource.Checkpoint
|
||||
},
|
||||
generation,
|
||||
observedAtMillis,
|
||||
),
|
||||
)
|
||||
val pending = if (opened) {
|
||||
previous.pendingInputs + (requestId to expiresAtMillis)
|
||||
} else {
|
||||
previous.pendingInputs - requestId
|
||||
}
|
||||
return copy(records = records + (owner to previous.copy(
|
||||
pendingInputs = pending,
|
||||
freshness = if (confirmed) SessionActivityFreshness.Confirmed else previous.freshness,
|
||||
evidence = if (confirmed) {
|
||||
SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.PendingInput,
|
||||
generation,
|
||||
observedAtMillis,
|
||||
)
|
||||
} else previous.evidence,
|
||||
)))
|
||||
}
|
||||
|
||||
private fun updateProcess(update: SessionActivityUpdate.ProcessState): SessionActivityRegistry {
|
||||
val previous = records[update.owner] ?: SessionActivityRecord(
|
||||
owner = update.owner,
|
||||
turnPhase = SessionActivityPhase.Idle,
|
||||
freshness = SessionActivityFreshness.Confirmed,
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.Process,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)
|
||||
val processes = if (update.running) {
|
||||
previous.backgroundProcessIds + update.processId
|
||||
} else {
|
||||
previous.backgroundProcessIds - update.processId
|
||||
}
|
||||
return copy(records = records + (update.owner to previous.copy(
|
||||
backgroundProcessIds = processes,
|
||||
evidence = SessionActivityEvidence(
|
||||
SessionActivityEvidenceSource.Process,
|
||||
update.generation,
|
||||
update.observedAtMillis,
|
||||
),
|
||||
)))
|
||||
}
|
||||
|
||||
private fun expirePendingInputs(nowMillis: Long): SessionActivityRegistry = copy(
|
||||
records = records.mapValues { (_, record) ->
|
||||
record.copy(pendingInputs = record.pendingInputs.filterValues { it == null || it > nowMillis })
|
||||
},
|
||||
)
|
||||
|
||||
private fun SessionActivityRecord.shouldSettleWhenAbsent(): Boolean =
|
||||
runtimeId != null || evidence.source in setOf(
|
||||
SessionActivityEvidenceSource.ActiveList,
|
||||
SessionActivityEvidenceSource.Checkpoint,
|
||||
SessionActivityEvidenceSource.Directory,
|
||||
)
|
||||
|
||||
private fun SessionActivityUpdate.scope(): SessionActivityScope = when (this) {
|
||||
is SessionActivityUpdate.BeginGeneration -> scope
|
||||
is SessionActivityUpdate.ObserveOwner -> owner.scope()
|
||||
is SessionActivityUpdate.RuntimeState -> scope
|
||||
is SessionActivityUpdate.ActiveList -> scope
|
||||
is SessionActivityUpdate.StatusUnavailable -> scope
|
||||
is SessionActivityUpdate.Terminal -> owner.scope()
|
||||
is SessionActivityUpdate.LocalSend -> owner.scope()
|
||||
is SessionActivityUpdate.LiveState -> owner.scope()
|
||||
is SessionActivityUpdate.RestoreCheckpoint -> owner.scope()
|
||||
is SessionActivityUpdate.PendingInputOpened -> owner.scope()
|
||||
is SessionActivityUpdate.PendingInputClosed -> owner.scope()
|
||||
is SessionActivityUpdate.ProcessState -> owner.scope()
|
||||
is SessionActivityUpdate.Tick -> error("Tick has no scope")
|
||||
}
|
||||
|
||||
private fun SessionActivityOwner.scope() = SessionActivityScope.of(connectionId, profile)
|
||||
|
||||
private fun SessionLiveStatus.phase(): SessionActivityPhase = when (this) {
|
||||
SessionLiveStatus.Starting -> SessionActivityPhase.Starting
|
||||
SessionLiveStatus.Working -> SessionActivityPhase.Working
|
||||
SessionLiveStatus.Waiting -> SessionActivityPhase.NeedsInput
|
||||
SessionLiveStatus.Idle -> SessionActivityPhase.Idle
|
||||
}
|
||||
|
||||
data class RuntimeAlias(
|
||||
val scope: SessionActivityScope,
|
||||
val runtimeId: String,
|
||||
val generation: Long,
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package com.hermesandroid.relay.data
|
||||
|
||||
/** Live activity surfaced beside a session without conflating it with selection. */
|
||||
enum class SessionActivityState {
|
||||
Starting,
|
||||
Working,
|
||||
NeedsInput,
|
||||
BackgroundWork,
|
||||
Checking,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import com.hermesandroid.relay.ui.theme.AppThemes
|
||||
|
||||
/**
|
||||
* Parent-configured restrictions for the official Android client.
|
||||
*
|
||||
* This policy deliberately describes a client presentation mode, not a server
|
||||
* authorization boundary. The pinned profile is expected to have already been
|
||||
* configured with the appropriate server-side tool and content restrictions.
|
||||
*/
|
||||
@Serializable
|
||||
data class SupervisedModePolicy(
|
||||
val enabled: Boolean = false,
|
||||
val pinnedProfileName: String? = null,
|
||||
val capabilities: SupervisedCapabilities = SupervisedCapabilities(),
|
||||
val appearance: SupervisedAppearance = SupervisedAppearance(),
|
||||
val visibility: SupervisedVisibility = SupervisedVisibility(),
|
||||
val parentAccess: SupervisedParentAccess = SupervisedParentAccess(),
|
||||
) {
|
||||
/** A saved policy is usable only when it names a concrete Hermes profile. */
|
||||
val isConfigured: Boolean
|
||||
get() = !pinnedProfileName.isNullOrBlank()
|
||||
|
||||
/** Consumers should use this instead of treating [enabled] alone as sufficient. */
|
||||
val isActive: Boolean
|
||||
get() = enabled && isConfigured
|
||||
|
||||
internal fun normalized(): SupervisedModePolicy = copy(
|
||||
pinnedProfileName = pinnedProfileName?.trim()?.takeIf { it.isNotEmpty() },
|
||||
capabilities = capabilities.normalized(),
|
||||
appearance = appearance.normalized(),
|
||||
parentAccess = parentAccess.normalized(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Actions and content types the supervised chat surface may expose. */
|
||||
@Serializable
|
||||
data class SupervisedCapabilities(
|
||||
val attachments: Boolean = false,
|
||||
val voice: Boolean = false,
|
||||
val generatedImages: Boolean = true,
|
||||
val conversationHistory: Boolean = false,
|
||||
val newChat: Boolean = true,
|
||||
val cancelResponse: Boolean = true,
|
||||
val steerResponse: Boolean = true,
|
||||
val retryResponse: Boolean = true,
|
||||
val copyResponses: Boolean = true,
|
||||
val quoteReplies: Boolean = true,
|
||||
val editAndResend: Boolean = false,
|
||||
val shareGeneratedImages: Boolean = false,
|
||||
val sessionActions: SupervisedSessionActions = SupervisedSessionActions(),
|
||||
val attachmentMaxCount: Int = DEFAULT_ATTACHMENT_MAX_COUNT,
|
||||
val attachmentMaxFileMb: Int = DEFAULT_ATTACHMENT_MAX_FILE_MB,
|
||||
val attachmentCategories: Set<SupervisedAttachmentCategory> = setOf(
|
||||
SupervisedAttachmentCategory.Images,
|
||||
),
|
||||
) {
|
||||
internal fun normalized(): SupervisedCapabilities = copy(
|
||||
attachmentMaxCount = attachmentMaxCount.coerceIn(1, MAX_ATTACHMENT_COUNT),
|
||||
attachmentMaxFileMb = attachmentMaxFileMb.coerceIn(1, MAX_ATTACHMENT_FILE_MB),
|
||||
attachmentCategories = attachmentCategories.ifEmpty {
|
||||
setOf(SupervisedAttachmentCategory.Images)
|
||||
},
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_ATTACHMENT_MAX_COUNT = 4
|
||||
const val DEFAULT_ATTACHMENT_MAX_FILE_MB = 10
|
||||
const val MAX_ATTACHMENT_COUNT = 10
|
||||
const val MAX_ATTACHMENT_FILE_MB = 100
|
||||
}
|
||||
}
|
||||
|
||||
/** Appearance applied only while the supervised root is locked. */
|
||||
@Serializable
|
||||
data class SupervisedAppearance(
|
||||
val appThemeId: String = AppThemes.DEFAULT_ID,
|
||||
val themePreference: String = "auto",
|
||||
val showPet: Boolean = false,
|
||||
val allowProfileIconChanges: Boolean = false,
|
||||
val allowBackgroundChanges: Boolean = false,
|
||||
) {
|
||||
internal fun normalized(): SupervisedAppearance = copy(
|
||||
appThemeId = AppThemes.byId(appThemeId).id,
|
||||
themePreference = themePreference.takeIf { it in VALID_THEME_PREFERENCES } ?: "auto",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val VALID_THEME_PREFERENCES = setOf("auto", "light", "dark")
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable operations available from a supervised conversation-history row. */
|
||||
@Serializable
|
||||
data class SupervisedSessionActions(
|
||||
val pin: Boolean = false,
|
||||
val rename: Boolean = false,
|
||||
val archive: Boolean = false,
|
||||
val delete: Boolean = false,
|
||||
val shareTranscript: Boolean = false,
|
||||
) {
|
||||
val enabledCount: Int
|
||||
get() = listOf(pin, rename, archive, delete, shareTranscript).count { it }
|
||||
|
||||
val allEnabled: Boolean
|
||||
get() = enabledCount == TOTAL
|
||||
|
||||
val noneEnabled: Boolean
|
||||
get() = enabledCount == 0
|
||||
|
||||
fun withAll(enabled: Boolean): SupervisedSessionActions = SupervisedSessionActions(
|
||||
pin = enabled,
|
||||
rename = enabled,
|
||||
archive = enabled,
|
||||
delete = enabled,
|
||||
shareTranscript = enabled,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val TOTAL = 5
|
||||
}
|
||||
}
|
||||
|
||||
enum class SupervisedSessionAction {
|
||||
Pin,
|
||||
Rename,
|
||||
Archive,
|
||||
Delete,
|
||||
ShareTranscript,
|
||||
}
|
||||
|
||||
fun SupervisedModePolicy.allowsSessionAction(action: SupervisedSessionAction): Boolean {
|
||||
if (!enabled) return true
|
||||
if (!capabilities.conversationHistory) return false
|
||||
return when (action) {
|
||||
SupervisedSessionAction.Pin -> capabilities.sessionActions.pin
|
||||
SupervisedSessionAction.Rename -> capabilities.sessionActions.rename
|
||||
SupervisedSessionAction.Archive -> capabilities.sessionActions.archive
|
||||
SupervisedSessionAction.Delete -> capabilities.sessionActions.delete
|
||||
SupervisedSessionAction.ShareTranscript -> capabilities.sessionActions.shareTranscript
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SupervisedAttachmentCategory {
|
||||
@SerialName("images")
|
||||
Images,
|
||||
|
||||
@SerialName("documents")
|
||||
Documents,
|
||||
|
||||
@SerialName("audio")
|
||||
Audio,
|
||||
|
||||
@SerialName("video")
|
||||
Video,
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls which metadata and conversation affordances are rendered.
|
||||
*
|
||||
* [Simple] is the quiet default. [Transparent] is a useful preset for older or
|
||||
* technical users, while [Custom] tells the UI to honor every stored toggle.
|
||||
*/
|
||||
@Serializable
|
||||
data class SupervisedVisibility(
|
||||
val preset: SupervisedVisibilityPreset = SupervisedVisibilityPreset.Simple,
|
||||
val showAgentIdentity: Boolean = true,
|
||||
val showModelName: Boolean = false,
|
||||
val showProfileName: Boolean = false,
|
||||
val showConnectionStatus: Boolean = true,
|
||||
val showTechnicalRoute: Boolean = false,
|
||||
val showTimestamps: Boolean = true,
|
||||
val showToolNames: Boolean = false,
|
||||
val showToolDetails: Boolean = false,
|
||||
val showWorkingStatus: Boolean = true,
|
||||
val showReasoning: Boolean = false,
|
||||
val showUsage: Boolean = false,
|
||||
) {
|
||||
/** Resolve presets to the concrete flags consumed by chat presentation. */
|
||||
fun resolved(): SupervisedVisibility = when (preset) {
|
||||
SupervisedVisibilityPreset.Simple -> SIMPLE
|
||||
SupervisedVisibilityPreset.Transparent -> TRANSPARENT
|
||||
SupervisedVisibilityPreset.Custom -> this
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SIMPLE = SupervisedVisibility(preset = SupervisedVisibilityPreset.Simple)
|
||||
|
||||
val TRANSPARENT = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Transparent,
|
||||
showModelName = true,
|
||||
showProfileName = true,
|
||||
showTechnicalRoute = true,
|
||||
showToolNames = true,
|
||||
showUsage = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SupervisedVisibilityPreset {
|
||||
@SerialName("simple")
|
||||
Simple,
|
||||
|
||||
@SerialName("transparent")
|
||||
Transparent,
|
||||
|
||||
@SerialName("custom")
|
||||
Custom,
|
||||
}
|
||||
|
||||
/** Device-authentication and automatic relock behavior for parent access. */
|
||||
@Serializable
|
||||
data class SupervisedParentAccess(
|
||||
/** Reserved for forward-compatible persistence; normalization never permits an auth bypass. */
|
||||
val requireDeviceAuthentication: Boolean = true,
|
||||
val relockOnBackground: Boolean = true,
|
||||
val timeoutMinutes: Int = DEFAULT_TIMEOUT_MINUTES,
|
||||
) {
|
||||
internal fun normalized(): SupervisedParentAccess = copy(
|
||||
requireDeviceAuthentication = true,
|
||||
timeoutMinutes = timeoutMinutes.coerceIn(MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_TIMEOUT_MINUTES = 5
|
||||
const val MIN_TIMEOUT_MINUTES = 1
|
||||
const val MAX_TIMEOUT_MINUTES = 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Persists one independent [SupervisedModePolicy] per Hermes connection. */
|
||||
class SupervisedModeStore private constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
private val serializer = MapSerializer(String.serializer(), SupervisedModePolicy.serializer())
|
||||
|
||||
fun policyFlow(connectionId: String): Flow<SupervisedModePolicy> =
|
||||
dataStore.data.map { preferences ->
|
||||
val decoded = decode(preferences[KEY_POLICIES])
|
||||
if (decoded.corrupt) {
|
||||
// A malformed persisted policy must never silently reopen the
|
||||
// unrestricted app. Enabled + unconfigured renders the
|
||||
// supervised recovery surface until an authenticated user
|
||||
// repairs or clears the policy.
|
||||
SupervisedModePolicy(enabled = true)
|
||||
} else {
|
||||
decoded.policies[connectionId]?.normalized() ?: SupervisedModePolicy()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPolicy(connectionId: String, policy: SupervisedModePolicy) {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
policies[connectionId] = policy.normalized()
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePolicy(
|
||||
connectionId: String,
|
||||
transform: (SupervisedModePolicy) -> SupervisedModePolicy,
|
||||
) {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
val current = policies[connectionId]?.normalized() ?: SupervisedModePolicy()
|
||||
policies[connectionId] = transform(current).normalized()
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setEnabled(connectionId: String, enabled: Boolean) {
|
||||
updatePolicy(connectionId) { it.copy(enabled = enabled) }
|
||||
}
|
||||
|
||||
suspend fun clear(connectionId: String) {
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
policies.remove(connectionId)
|
||||
if (policies.isEmpty()) {
|
||||
preferences.remove(KEY_POLICIES)
|
||||
} else {
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear supervised policies without disturbing unrelated app settings. */
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { preferences -> preferences.remove(KEY_POLICIES) }
|
||||
}
|
||||
|
||||
private fun decode(raw: String?): DecodeResult {
|
||||
if (raw.isNullOrBlank()) return DecodeResult(emptyMap(), corrupt = false)
|
||||
return try {
|
||||
DecodeResult(json.decodeFromString(serializer, raw), corrupt = false)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Unable to decode supervised-mode policies; failing closed", error)
|
||||
DecodeResult(emptyMap(), corrupt = true)
|
||||
}
|
||||
}
|
||||
|
||||
private data class DecodeResult(
|
||||
val policies: Map<String, SupervisedModePolicy>,
|
||||
val corrupt: Boolean,
|
||||
)
|
||||
|
||||
internal companion object {
|
||||
private const val TAG = "SupervisedModeStore"
|
||||
private val KEY_POLICIES = stringPreferencesKey("supervised_mode_policies_v1")
|
||||
|
||||
fun forTesting(dataStore: DataStore<Preferences>): SupervisedModeStore =
|
||||
SupervisedModeStore(dataStore)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ class ChannelMultiplexer {
|
||||
)
|
||||
send(pong)
|
||||
}
|
||||
"auth.ok", "auth.fail" -> {
|
||||
"auth.ok", "auth.fail", "supervised.updated", "error" -> {
|
||||
// Delegate to system handler if registered
|
||||
handlers["system"]?.onMessage(envelope)
|
||||
}
|
||||
|
||||
@@ -442,6 +442,35 @@ class ConnectionManager(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen the current authenticated Relay socket without discarding pair
|
||||
* state. Used only as a compatibility fallback when an older Relay does
|
||||
* not acknowledge a post-auth metadata update; the replacement socket's
|
||||
* normal `system/auth` frame carries the latest metadata.
|
||||
*/
|
||||
fun reconnectForAuthenticatedMetadataUpdate(): Boolean {
|
||||
val targetUrl = serverUrl?.takeIf { it.isNotBlank() } ?: return false
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "metadata reconnect: preserving active rate-limit backoff")
|
||||
return false
|
||||
}
|
||||
val previousSocket = webSocket
|
||||
if (previousSocket == null) {
|
||||
connect(targetUrl)
|
||||
} else {
|
||||
doConnect(
|
||||
targetUrl,
|
||||
previousSocketToClose = previousSocket,
|
||||
replaceReason = "Relay metadata compatibility refresh",
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [connect] but bypasses the resolver — used by the network-
|
||||
* change callback when we've already picked a winner and just want to
|
||||
|
||||
@@ -48,6 +48,9 @@ interface VoiceAudioClient {
|
||||
val effectiveRoute: VoiceAudioRoute
|
||||
get() = route
|
||||
|
||||
/** Temporary client-policy override; the shared router honors it before user prefs. */
|
||||
fun setRouteOverride(route: VoiceAudioRoute?) = Unit
|
||||
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
|
||||
@@ -82,8 +85,15 @@ class AutoVoiceAudioClient(
|
||||
private val standardReadyProvider: () -> Boolean,
|
||||
private val relayReadyProvider: () -> Boolean,
|
||||
) : VoiceAudioClient {
|
||||
@Volatile
|
||||
private var routeOverride: VoiceAudioRoute? = null
|
||||
|
||||
override fun setRouteOverride(route: VoiceAudioRoute?) {
|
||||
routeOverride = route
|
||||
}
|
||||
|
||||
override val route: VoiceAudioRoute
|
||||
get() = routeProvider()
|
||||
get() = routeOverride ?: routeProvider()
|
||||
|
||||
/**
|
||||
* Resolve the configured preference to the backend a call would land on:
|
||||
@@ -92,7 +102,7 @@ class AutoVoiceAudioClient(
|
||||
* decide whether standard-only limitations (global TTS) currently apply.
|
||||
*/
|
||||
override val effectiveRoute: VoiceAudioRoute
|
||||
get() = when (routeProvider()) {
|
||||
get() = when (route) {
|
||||
VoiceAudioRoute.Standard -> VoiceAudioRoute.Standard
|
||||
VoiceAudioRoute.Relay -> VoiceAudioRoute.Relay
|
||||
VoiceAudioRoute.Auto ->
|
||||
@@ -114,7 +124,7 @@ class AutoVoiceAudioClient(
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
return when (routeProvider()) {
|
||||
return when (route) {
|
||||
VoiceAudioRoute.Standard -> {
|
||||
if (!standardReadyProvider()) {
|
||||
Result.failure(
|
||||
|
||||
@@ -2075,7 +2075,7 @@ class ChatHandler {
|
||||
outputTokens = item.outputTokens ?: 0,
|
||||
actualCostUsd = item.actualCostUsd,
|
||||
estimatedCostUsd = item.estimatedCostUsd,
|
||||
isActive = item.isActive,
|
||||
recentlyActive = item.isActive,
|
||||
updatedAt = activityAtMs,
|
||||
startedAt = startedAtMs,
|
||||
lastActivityAt = lastActivityAtMs,
|
||||
|
||||
@@ -52,6 +52,7 @@ import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
@@ -278,6 +279,12 @@ class GatewayChatClient(
|
||||
private val _processCapability = MutableStateFlow(GatewayProcessCapability.Unknown)
|
||||
val processCapability: StateFlow<GatewayProcessCapability> = _processCapability.asStateFlow()
|
||||
|
||||
/** Per-socket capability for upstream's process-wide live-session snapshot. */
|
||||
private val _activeSessionCapability =
|
||||
MutableStateFlow(GatewayActiveSessionCapability.Unknown)
|
||||
val activeSessionCapability: StateFlow<GatewayActiveSessionCapability> =
|
||||
_activeSessionCapability.asStateFlow()
|
||||
|
||||
/**
|
||||
* Active personality the gateway is applying, as a config value ("none" when
|
||||
* the overlay is cleared, otherwise the personality name). Tracks the
|
||||
@@ -806,6 +813,21 @@ class GatewayChatClient(
|
||||
fun currentLiveSessionId(storedId: String): String? =
|
||||
liveSessionId?.takeIf { storedSessionId == storedId }
|
||||
|
||||
/**
|
||||
* Exact durable/profile owner already held by this client for [runtimeId].
|
||||
* Unlike `session.active_list`, this mapping is safe for multiplexed profiles
|
||||
* because Android recorded it when the runtime was created/resumed/detached.
|
||||
*/
|
||||
fun knownSessionOwner(runtimeId: String): GatewayKnownSessionOwner? {
|
||||
if (runtimeId == liveSessionId) {
|
||||
val storedId = storedSessionId ?: return null
|
||||
return GatewayKnownSessionOwner(storedId, liveSessionProfile)
|
||||
}
|
||||
return backgroundTurns[runtimeId]?.let { owner ->
|
||||
GatewayKnownSessionOwner(owner.storedSessionId, owner.profile)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Point this client at a new dashboard route (e.g. LAN→Tailscale after a
|
||||
* sustained network change). If a turn is in flight, the current socket is
|
||||
@@ -2065,6 +2087,47 @@ class GatewayChatClient(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch authoritative in-memory execution states from current upstream
|
||||
* Hermes. `session.active_list` is process-wide: it accepts only an optional
|
||||
* current runtime id and does not profile-filter its rows. Accordingly this
|
||||
* transport returns rows unscoped and never derives activity from REST
|
||||
* `is_active` or stamps the selected profile onto a row.
|
||||
*/
|
||||
suspend fun listActiveSessions(): GatewayActiveSessionsResult {
|
||||
if (_activeSessionCapability.value == GatewayActiveSessionCapability.Unsupported) {
|
||||
return GatewayActiveSessionsResult.Unsupported
|
||||
}
|
||||
try {
|
||||
connectMutex.withLock { ensureConnected() }
|
||||
} catch (error: Exception) {
|
||||
return GatewayActiveSessionsResult.TransientFailure(error)
|
||||
}
|
||||
val result = rpc(
|
||||
"session.active_list",
|
||||
buildJsonObject {
|
||||
liveSessionId?.let { put("current_session_id", it) }
|
||||
},
|
||||
)
|
||||
val error = result.exceptionOrNull()
|
||||
if (error.isMethodNotFound()) {
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unsupported
|
||||
return GatewayActiveSessionsResult.Unsupported
|
||||
}
|
||||
if (error != null) {
|
||||
return GatewayActiveSessionsResult.TransientFailure(error)
|
||||
}
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Supported
|
||||
return try {
|
||||
val payload = result.getOrThrow()
|
||||
val rows = payload["sessions"] as? JsonArray
|
||||
?: throw GatewayRpcException("session.active_list returned no sessions array")
|
||||
GatewayActiveSessionsResult.Success(rows.map(::parseGatewayActiveSession))
|
||||
} catch (parseError: Exception) {
|
||||
GatewayActiveSessionsResult.TransientFailure(parseError)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop one process owned by the current live gateway session. */
|
||||
suspend fun killProcess(processId: String): Result<Unit> {
|
||||
if (processId.isBlank()) {
|
||||
@@ -2503,6 +2566,7 @@ class GatewayChatClient(
|
||||
private suspend fun connectOnce() {
|
||||
val connectStart = System.nanoTime()
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.MintingTicket
|
||||
val ticket = dashboardClient.requestWsTicket().getOrElse { e ->
|
||||
@@ -2749,6 +2813,28 @@ class GatewayChatClient(
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseGatewayActiveSession(
|
||||
element: kotlinx.serialization.json.JsonElement,
|
||||
): GatewayActiveSession {
|
||||
val row = element as? JsonObject
|
||||
?: throw GatewayRpcException("session.active_list returned a non-object row")
|
||||
val runtimeId = row.stringField("id")?.takeIf(String::isNotBlank)
|
||||
?: throw GatewayRpcException("session.active_list row returned no runtime id")
|
||||
val storedId = row.stringField("session_key")?.takeIf(String::isNotBlank)
|
||||
?: throw GatewayRpcException("session.active_list row returned no session key")
|
||||
val status = GatewayActiveSessionStatus.fromWire(row.stringField("status"))
|
||||
?: throw GatewayRpcException("session.active_list row returned an unknown status")
|
||||
val lastActive = (row["last_active"] as? JsonPrimitive)?.doubleOrNull
|
||||
?: throw GatewayRpcException("session.active_list row returned no last_active")
|
||||
return GatewayActiveSession(
|
||||
runtimeSessionId = runtimeId,
|
||||
storedSessionId = storedId,
|
||||
status = status,
|
||||
lastActiveEpochSeconds = lastActive,
|
||||
profile = row.stringField("profile")?.trim()?.takeIf(String::isNotEmpty),
|
||||
)
|
||||
}
|
||||
|
||||
private fun markProcessUnsupportedIfNeeded(error: Throwable?) {
|
||||
if (error.isMethodNotFound()) {
|
||||
_processCapability.value = GatewayProcessCapability.Unsupported
|
||||
@@ -3222,6 +3308,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
pendingRpcs.values.forEach {
|
||||
@@ -3407,6 +3494,7 @@ class GatewayChatClient(
|
||||
attachMethodForSocket = null
|
||||
commandsCatalogCache = null
|
||||
_processCapability.value = GatewayProcessCapability.Unknown
|
||||
_activeSessionCapability.value = GatewayActiveSessionCapability.Unknown
|
||||
_approvalModeCapability.value = GatewayApprovalModeCapability.Unknown
|
||||
_connectionState.value = GatewayConnectionState.Idle
|
||||
}
|
||||
|
||||
@@ -320,6 +320,69 @@ enum class GatewayProcessCapability {
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/** Authoritative execution state reported by upstream `session.active_list`. */
|
||||
enum class GatewayActiveSessionStatus(val wireValue: String) {
|
||||
Idle("idle"),
|
||||
Starting("starting"),
|
||||
Working("working"),
|
||||
Waiting("waiting");
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String?): GatewayActiveSessionStatus? = when (value?.trim()?.lowercase()) {
|
||||
"idle" -> Idle
|
||||
"starting" -> Starting
|
||||
"working" -> Working
|
||||
"waiting" -> Waiting
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-memory runtime returned by upstream `session.active_list`.
|
||||
*
|
||||
* The RPC is process-wide in current upstream Hermes. Its rows do not normally
|
||||
* identify their profile, so [profile] stays null unless a future gateway
|
||||
* explicitly sends one. Callers must resolve [storedSessionId] against their
|
||||
* own profile-scoped session registry and fail closed when ownership is
|
||||
* ambiguous; the transport never synthesizes profile attribution.
|
||||
*/
|
||||
data class GatewayActiveSession(
|
||||
/** Per-process runtime id used by live Gateway events and session RPCs. */
|
||||
val runtimeSessionId: String,
|
||||
/** Durable history id (`session_key`) used by the REST/session database. */
|
||||
val storedSessionId: String,
|
||||
val status: GatewayActiveSessionStatus,
|
||||
/** Unix epoch seconds from upstream's in-memory runtime record. */
|
||||
val lastActiveEpochSeconds: Double,
|
||||
/** Future-compatible only; null for the current upstream contract. */
|
||||
val profile: String? = null,
|
||||
)
|
||||
|
||||
/** Exact owner already known by this client for a foreground or detached runtime. */
|
||||
data class GatewayKnownSessionOwner(
|
||||
val storedSessionId: String,
|
||||
val profile: String?,
|
||||
)
|
||||
|
||||
/** Whether the current Gateway socket exposes `session.active_list`. */
|
||||
enum class GatewayActiveSessionCapability {
|
||||
Unknown,
|
||||
Supported,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of one process-wide live-session snapshot request. Unsupported is
|
||||
* intentionally distinct from transport/protocol failure so callers can use
|
||||
* another source only for older gateways, while failures remain Unknown.
|
||||
*/
|
||||
sealed interface GatewayActiveSessionsResult {
|
||||
data class Success(val sessions: List<GatewayActiveSession>) : GatewayActiveSessionsResult
|
||||
data object Unsupported : GatewayActiveSessionsResult
|
||||
data class TransientFailure(val error: Throwable) : GatewayActiveSessionsResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection-level background-process events. These are deliberately separate
|
||||
* from [GatewayTurnCallbacks]: output and completion notifications can arrive
|
||||
|
||||
@@ -250,6 +250,7 @@ data class SessionItem(
|
||||
@SerialName("output_tokens") val outputTokens: Int? = null,
|
||||
@SerialName("actual_cost_usd") val actualCostUsd: Double? = null,
|
||||
@SerialName("estimated_cost_usd") val estimatedCostUsd: Double? = null,
|
||||
/** REST recency heuristic from upstream; not live Gateway execution state. */
|
||||
@SerialName("is_active") val isActive: Boolean = false,
|
||||
@SerialName("has_model_config")
|
||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||
|
||||
@@ -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
|
||||
@@ -133,6 +134,8 @@ import com.hermesandroid.relay.data.CandidateBuild
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedModeStore
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
@@ -144,6 +147,7 @@ import com.hermesandroid.relay.util.HumanError
|
||||
import kotlinx.coroutines.delay
|
||||
import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
|
||||
import com.hermesandroid.relay.ui.screens.AboutScreen
|
||||
import com.hermesandroid.relay.ui.screens.AdvancedSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.AnalyticsScreen
|
||||
import com.hermesandroid.relay.ui.screens.AppearanceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.CustomThemeScreen
|
||||
@@ -170,9 +174,13 @@ import com.hermesandroid.relay.ui.screens.PermissionsStatusScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
|
||||
import com.hermesandroid.relay.ui.screens.RealtimeVoiceTestScreen
|
||||
import com.hermesandroid.relay.ui.screens.SettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.SupervisedControlsScreen
|
||||
import com.hermesandroid.relay.ui.screens.SupervisedAppearanceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginPageScreen
|
||||
import com.hermesandroid.relay.ui.screens.GitStateScreen
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import com.hermesandroid.relay.ui.screens.TerminalScreen
|
||||
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProactiveSettingsScreen
|
||||
@@ -192,7 +200,9 @@ import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.plugins.runtime.PLUGIN_API_WRITE_CAPABILITY
|
||||
import com.hermesandroid.relay.viewmodel.PluginsViewModel
|
||||
import com.hermesandroid.relay.viewmodel.PluginsHubState
|
||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
@@ -440,6 +450,7 @@ sealed class Screen(
|
||||
}
|
||||
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||
data object Plugins : Screen("plugins", "Plugins", Icons.Filled.Extension)
|
||||
data object GitState : Screen("git_state", "Git", Icons.Filled.Code)
|
||||
data object PluginPage : Screen(
|
||||
"plugins/{pluginId}/pages/{pageId}",
|
||||
"Plugin",
|
||||
@@ -531,6 +542,17 @@ sealed class Screen(
|
||||
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
|
||||
// above for the surviving route.)
|
||||
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
|
||||
data object AdvancedSettings : Screen("settings/advanced", "Advanced", Icons.Filled.Settings)
|
||||
data object SupervisedAppearanceSettings : Screen(
|
||||
"settings/supervised/appearance",
|
||||
"Appearance",
|
||||
Icons.Filled.Settings,
|
||||
)
|
||||
data object SupervisedControls : Screen(
|
||||
"settings/supervised",
|
||||
"Supervised mode",
|
||||
Icons.Filled.Settings,
|
||||
)
|
||||
data object ProviderUsage : Screen("settings/usage", "Usage & limits", Icons.Filled.Settings)
|
||||
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
|
||||
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
|
||||
@@ -589,6 +611,24 @@ sealed class Screen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupervisedStartupLoadingScreen() {
|
||||
HermesRelayTheme(themePreference = "dark") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Loading protected settings…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayApp() {
|
||||
val applicationContext = LocalContext.current.applicationContext
|
||||
@@ -597,13 +637,17 @@ fun RelayApp() {
|
||||
val chatViewModel: ChatViewModel = processRuntime.chatViewModel
|
||||
val terminalViewModel: TerminalViewModel = viewModel()
|
||||
val pluginsViewModel: PluginsViewModel = viewModel()
|
||||
val gitStateViewModel: GitStateViewModel = viewModel()
|
||||
val voiceViewModel: VoiceViewModel = processRuntime.voiceViewModel
|
||||
val runtimeInitializationState by processRuntime.initializationState.collectAsState()
|
||||
|
||||
LaunchedEffect(processRuntime) {
|
||||
processRuntime.ensureInitialized()
|
||||
}
|
||||
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) return
|
||||
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) {
|
||||
SupervisedStartupLoadingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
val voiceClient: RelayVoiceClient = processRuntime.relayVoiceClient
|
||||
val voicePreferences = processRuntime.voicePreferences
|
||||
@@ -700,6 +744,72 @@ fun RelayApp() {
|
||||
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val connectionStoreHydrated by
|
||||
connectionViewModel.connectionStore.isHydrated.collectAsState()
|
||||
val supervisedModeStore = remember(applicationContext) {
|
||||
SupervisedModeStore(applicationContext)
|
||||
}
|
||||
val supervisedPolicyState = produceState<Pair<String?, SupervisedModePolicy>?>(
|
||||
initialValue = null,
|
||||
key1 = activeConnectionId,
|
||||
key2 = supervisedModeStore,
|
||||
) {
|
||||
val connectionId = activeConnectionId
|
||||
if (connectionId == null) {
|
||||
value = null to SupervisedModePolicy()
|
||||
} else {
|
||||
supervisedModeStore.policyFlow(connectionId).collect { policy ->
|
||||
value = connectionId to policy
|
||||
}
|
||||
}
|
||||
}
|
||||
val ownedSupervisedPolicyState = supervisedPolicyState.value
|
||||
?.takeIf { (ownerConnectionId, _) -> ownerConnectionId == activeConnectionId }
|
||||
// Fail closed across process restoration. activeConnectionId starts as
|
||||
// null while ConnectionStore reads DataStore, so null alone cannot prove
|
||||
// this is a fresh install with no supervised policy to restore.
|
||||
if (!isRelayNavigationHydrated(
|
||||
connectionStoreHydrated = connectionStoreHydrated,
|
||||
activeConnectionId = activeConnectionId,
|
||||
supervisedPolicyHydrated = ownedSupervisedPolicyState != null,
|
||||
)
|
||||
) {
|
||||
SupervisedStartupLoadingScreen()
|
||||
return
|
||||
}
|
||||
val supervisedPolicy = ownedSupervisedPolicyState?.second ?: SupervisedModePolicy()
|
||||
val supervisedPinnedProfile = supervisedPolicy.pinnedProfileName?.let { name ->
|
||||
agentProfiles.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
}
|
||||
val supervisedProfileConfirmed = !supervisedPolicy.enabled || (
|
||||
profileSelectionSettled &&
|
||||
supervisedPinnedProfile != null &&
|
||||
selectedProfile?.name.equals(supervisedPinnedProfile.name, ignoreCase = true)
|
||||
)
|
||||
val chatSupervisedPolicy = if (supervisedPolicy.enabled && !supervisedProfileConfirmed) {
|
||||
supervisedPolicy.copy(pinnedProfileName = null)
|
||||
} else supervisedPolicy
|
||||
var parentAccessUnlocked by remember(activeConnectionId) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(
|
||||
activeConnectionId,
|
||||
supervisedPolicy,
|
||||
agentProfiles,
|
||||
selectedProfile,
|
||||
profileSelectionSettled,
|
||||
) {
|
||||
chatViewModel.updateSupervisedModePolicy(chatSupervisedPolicy)
|
||||
connectionViewModel.authManager.updateSupervisedMode(chatSupervisedPolicy)
|
||||
if (!supervisedPolicy.enabled) {
|
||||
parentAccessUnlocked = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val pinned = supervisedPinnedProfile ?: return@LaunchedEffect
|
||||
if (!selectedProfile?.name.equals(pinned.name, ignoreCase = true)) {
|
||||
connectionViewModel.selectProfile(pinned)
|
||||
chatViewModel.activateGatewayProfile(pinned)
|
||||
}
|
||||
}
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
@@ -746,6 +856,11 @@ fun RelayApp() {
|
||||
val serverCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val gitOwnerKey = activeConnectionId?.takeIf { it.isNotBlank() }?.let { connectionId ->
|
||||
effectiveDashboardUrl.takeIf { it.isNotBlank() }?.let { dashboardUrl ->
|
||||
"$connectionId\u0000${effectiveSessionProfileName.orEmpty()}\u0000$dashboardUrl"
|
||||
}
|
||||
}
|
||||
LaunchedEffect(
|
||||
activeConnectionId,
|
||||
effectiveDashboardUrl,
|
||||
@@ -760,6 +875,28 @@ fun RelayApp() {
|
||||
sessionId = currentChatSessionId,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(gitOwnerKey) {
|
||||
val dashboard = effectiveDashboardUrl
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { connectionViewModel.dashboardClientForActive(it) }
|
||||
gitStateViewModel.configure(dashboard, gitOwnerKey)
|
||||
}
|
||||
|
||||
// Mirror the plugin.api.write grant into the Git view model so write
|
||||
// mutations are refused client-side until the user grants write access
|
||||
// (matches the plug-in's grant gating in PluginsViewModel).
|
||||
val pluginsHubState by pluginsViewModel.hubState.collectAsState()
|
||||
LaunchedEffect(pluginsHubState, gitOwnerKey) {
|
||||
val ready = pluginsHubState as? PluginsHubState.Ready
|
||||
val granted = ready
|
||||
?.takeIf { it.ownerKey == gitOwnerKey }
|
||||
?.plugins
|
||||
?.firstOrNull { it.catalog.id == "hermes-relay" }
|
||||
?.preferences
|
||||
?.grants
|
||||
?.contains(PLUGIN_API_WRITE_CAPABILITY) == true
|
||||
gitStateViewModel.setWriteGrant(gitOwnerKey, granted)
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
val showWhatsNew by connectionViewModel.showWhatsNew.collectAsState()
|
||||
@@ -784,6 +921,22 @@ fun RelayApp() {
|
||||
val appearanceAccent by connectionViewModel.appearanceAccent.collectAsState()
|
||||
val appearanceShape by connectionViewModel.appearanceShape.collectAsState()
|
||||
val activeCustomTheme by connectionViewModel.activeCustomTheme.collectAsState()
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val parentAccessForCurrentRoute = parentAccessUnlocked &&
|
||||
!shouldRelockParentAccess(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessUnlocked,
|
||||
route = currentRoute,
|
||||
)
|
||||
val resolvedTheme = resolveSupervisedTheme(
|
||||
policy = supervisedPolicy,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
globalAppThemeId = appThemeId,
|
||||
globalThemePreference = themePreference,
|
||||
)
|
||||
val supervisedAppearanceLocked = supervisedPolicy.enabled && !parentAccessForCurrentRoute
|
||||
|
||||
// Resolve the active sphere skin (built-in / adaptive / user-loaded) and
|
||||
// publish it + the full available set so every MorphingSphere picks it up
|
||||
@@ -800,10 +953,10 @@ fun RelayApp() {
|
||||
value = SphereRegistry.builtIns +
|
||||
withContext(Dispatchers.IO) { SphereSkinLoader.loadUserSkins(sphereContext) }
|
||||
}
|
||||
val activeSphereSkin = remember(sphereSkinId, appThemeId, availableSphereSkins) {
|
||||
val activeSphereSkin = remember(sphereSkinId, resolvedTheme.appThemeId, availableSphereSkins) {
|
||||
SphereRegistry.resolve(
|
||||
selectedId = sphereSkinId,
|
||||
themeDefaultSkinId = AppThemes.byId(appThemeId).defaultSphereSkinId,
|
||||
themeDefaultSkinId = AppThemes.byId(resolvedTheme.appThemeId).defaultSphereSkinId,
|
||||
available = availableSphereSkins,
|
||||
)
|
||||
}
|
||||
@@ -938,37 +1091,19 @@ fun RelayApp() {
|
||||
),
|
||||
)
|
||||
HermesRelayTheme(
|
||||
appThemeId = appThemeId,
|
||||
themePreference = themePreference,
|
||||
appThemeId = resolvedTheme.appThemeId,
|
||||
themePreference = resolvedTheme.themePreference,
|
||||
fontScale = fontScale,
|
||||
appFontId = appFontId,
|
||||
accentHex = appearanceAccent,
|
||||
accentHex = appearanceAccent.takeIf { resolvedTheme.useGlobalCustomTheme },
|
||||
shapeId = appearanceShape,
|
||||
customTheme = activeCustomTheme,
|
||||
customTheme = activeCustomTheme.takeIf { resolvedTheme.useGlobalCustomTheme },
|
||||
) {
|
||||
// Surface a crash report from a previous session, if any. Renders a
|
||||
// platform Dialog (own window) so tree position is z-order-agnostic;
|
||||
// it just needs to be inside the theme for Material colors.
|
||||
CrashReportGate()
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
|
||||
// Collect navigation requests posted by external launchers (e.g., the
|
||||
// BridgeForegroundService notification's "Settings" action). The
|
||||
// service sets EXTRA_NAV_ROUTE on its launch intent → MainActivity's
|
||||
// onCreate / onNewIntent reads it and pumps it onto NavRouteRequest →
|
||||
// we forward each emission to the NavController. Single observer at
|
||||
// the app root so every screen benefits.
|
||||
LaunchedEffect(navController) {
|
||||
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
|
||||
// Wire the proactive "session" surfacing once: a message with
|
||||
// surfacing="session" is injected into the active chat conversation.
|
||||
// ChatViewModel isn't available where ConnectionViewModel builds the
|
||||
@@ -1039,8 +1174,74 @@ fun RelayApp() {
|
||||
// restart cleanly lands back in setup.
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
// The unlock remains useful while moving between parent-only settings,
|
||||
// but never follows an enrolled device user back into supervised chat.
|
||||
// Cross-layer requests (notifications, services, deep links) use the
|
||||
// route-scoped unlock. As soon as Chat is current, the parent grant is
|
||||
// ineffective even before the state-clearing effect runs.
|
||||
LaunchedEffect(
|
||||
navController,
|
||||
supervisedPolicy.enabled,
|
||||
parentAccessForCurrentRoute,
|
||||
) {
|
||||
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
|
||||
if (
|
||||
supervisedPolicy.enabled &&
|
||||
!isSupervisedRouteAllowed(route, parentAccessForCurrentRoute)
|
||||
) return@collect
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(
|
||||
supervisedPolicy.enabled,
|
||||
parentAccessForCurrentRoute,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
LaunchedEffect(parentAccessUnlocked, supervisedPolicy.parentAccess.timeoutMinutes) {
|
||||
if (parentAccessUnlocked) {
|
||||
delay(supervisedPolicy.parentAccess.timeoutMinutes * 60_000L)
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
DisposableEffect(lifecycleOwner, supervisedPolicy.enabled, parentAccessUnlocked) {
|
||||
val relockObserver = LifecycleEventObserver { _, event ->
|
||||
if (
|
||||
event == Lifecycle.Event.ON_PAUSE &&
|
||||
supervisedPolicy.enabled &&
|
||||
parentAccessUnlocked &&
|
||||
supervisedPolicy.parentAccess.relockOnBackground
|
||||
) {
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(relockObserver)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(relockObserver) }
|
||||
}
|
||||
val suppressGlobalChrome = shouldSuppressGlobalChrome(
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
isDemoMode = isDemoMode,
|
||||
@@ -1719,6 +1920,8 @@ fun RelayApp() {
|
||||
!suppressGlobalChrome &&
|
||||
!isKeyboardVisible &&
|
||||
!showStartupSphere &&
|
||||
(!supervisedPolicy.enabled ||
|
||||
supervisedPolicy.visibility.resolved().showTechnicalRoute) &&
|
||||
shouldShowConnectionFooter(voiceUiState.voiceMode, voicePresentationMode)
|
||||
) {
|
||||
val footerRoute = resolveFooterRouteCandidate(
|
||||
@@ -1793,12 +1996,16 @@ fun RelayApp() {
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
val routeContentAllowed = isSupervisedRouteContentAllowed(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
currentRoute = currentRoute,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
composable(Screen.Onboarding.route) {
|
||||
// The wizard inside OnboardingScreen now owns credential
|
||||
@@ -1885,15 +2092,43 @@ fun RelayApp() {
|
||||
// sheet.
|
||||
val openAgentSheetArg = backStackEntry.arguments
|
||||
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
|
||||
val requestedSessionId = backStackEntry.arguments
|
||||
val rawRequestedSessionId = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_SESSION_ID)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val requestedProfileRoute = backStackEntry.arguments
|
||||
val rawRequestedProfileRoute = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_PROFILE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val requestedProactiveChatId = backStackEntry.arguments
|
||||
val rawRequestedProactiveChatId = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_PROACTIVE_CHAT_ID)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
// Nav/deep-link arguments are not ownership evidence. The
|
||||
// supervised drawer uses profile-scoped session rows
|
||||
// directly; external args stay discarded until an
|
||||
// owner-aware source can explicitly prove the binding.
|
||||
val sanitizedRouteArgs = sanitizeSupervisedChatRouteArgs(
|
||||
policy = supervisedPolicy,
|
||||
args = SupervisedChatRouteArgs(
|
||||
sessionId = rawRequestedSessionId,
|
||||
profile = rawRequestedProfileRoute,
|
||||
proactiveChatId = rawRequestedProactiveChatId,
|
||||
),
|
||||
pinnedProfileOwnershipProven = false,
|
||||
)
|
||||
val requestedSessionId = sanitizedRouteArgs.sessionId
|
||||
val requestedProfileRoute = sanitizedRouteArgs.profile
|
||||
val requestedProactiveChatId = sanitizedRouteArgs.proactiveChatId
|
||||
LaunchedEffect(
|
||||
supervisedPolicy.enabled,
|
||||
rawRequestedSessionId,
|
||||
rawRequestedProfileRoute,
|
||||
rawRequestedProactiveChatId,
|
||||
) {
|
||||
if (supervisedPolicy.enabled) {
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROACTIVE_CHAT_ID, null)
|
||||
}
|
||||
}
|
||||
val proactiveInboxEntries by connectionViewModel.inboxMessages.collectAsState()
|
||||
val phoneThreadChatIds by connectionViewModel.phoneThreadChatIds.collectAsState()
|
||||
LaunchedEffect(
|
||||
@@ -2056,6 +2291,7 @@ fun RelayApp() {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
supervisedPolicy = chatSupervisedPolicy,
|
||||
onNavigateToBotMode = {
|
||||
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
|
||||
},
|
||||
@@ -2376,6 +2612,25 @@ fun RelayApp() {
|
||||
SettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
supervisedPolicy = supervisedPolicy,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
onRequestParentAccess = { parentAccessUnlocked = true },
|
||||
onUpdateSupervisedPolicy = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToAdvancedSettings = {
|
||||
navController.navigate(Screen.AdvancedSettings.route)
|
||||
},
|
||||
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
|
||||
@@ -2450,6 +2705,62 @@ fun RelayApp() {
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.AdvancedSettings.route) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
AdvancedSettingsScreen(
|
||||
supervisedPolicy = supervisedPolicy,
|
||||
onNavigateToSupervisedControls = {
|
||||
navController.navigate(Screen.SupervisedControls.route)
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedAppearanceSettings.route) {
|
||||
if (!supervisedPolicy.enabled && !parentAccessForCurrentRoute) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedAppearanceSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
onPolicyChange = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedControls.route) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedControlsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
profiles = agentProfiles.filterNot { it.isDefault },
|
||||
onPolicyChange = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
onReturnToSupervisedView = {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(Screen.Chat.route) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.ProviderUsage.route) {
|
||||
UsageLimitsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
@@ -2462,10 +2773,20 @@ fun RelayApp() {
|
||||
viewModel = pluginsViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenPage = { pluginId, pageId ->
|
||||
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
|
||||
if (pluginId == "hermes-relay" && pageId == "git") {
|
||||
navController.navigate(Screen.GitState.route)
|
||||
} else {
|
||||
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.GitState.route) {
|
||||
GitStateScreen(
|
||||
viewModel = gitStateViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.PluginPage.route,
|
||||
arguments = listOf(
|
||||
@@ -2927,7 +3248,8 @@ fun RelayApp() {
|
||||
composable(Screen.About.route) {
|
||||
AboutScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
onBack = { navController.popBackStack() },
|
||||
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessForCurrentRoute,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
@@ -3030,6 +3352,12 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!routeContentAllowed) {
|
||||
// Keep the graph mounted so the redirect can complete, but
|
||||
// cover restored parent-only content with an opaque fail-closed surface.
|
||||
SupervisedStartupLoadingScreen()
|
||||
}
|
||||
}
|
||||
} // end bridge-return wrapper column
|
||||
} // end CompositionLocalProvider
|
||||
}
|
||||
@@ -3042,6 +3370,7 @@ fun RelayApp() {
|
||||
val petSurfaceOwner = petSurfaceOwnerForRoute(currentRoute)
|
||||
val petActivity = petCompanionCoordinator.activityFor(petSurfaceOwner)
|
||||
val showFloatingPet = activeFloatingPet != null &&
|
||||
shouldShowPetInSupervisedMode(supervisedPolicy, parentAccessForCurrentRoute) &&
|
||||
floatingPetAllowedOnRoute(currentRoute) &&
|
||||
!petActivity.hidden &&
|
||||
!suppressGlobalChrome &&
|
||||
@@ -3072,6 +3401,7 @@ fun RelayApp() {
|
||||
),
|
||||
animationEnabled = animationEnabled,
|
||||
appForeground = appIsForeground,
|
||||
interactive = !supervisedAppearanceLocked,
|
||||
route = roamingRoute,
|
||||
visitRequest = petCompanionCoordinator.pendingVisitRequest,
|
||||
onVisitRequestConsumed = petCompanionCoordinator::clearVisitRequest,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
internal data class ResolvedSupervisedTheme(
|
||||
val appThemeId: String,
|
||||
val themePreference: String,
|
||||
val useGlobalCustomTheme: Boolean,
|
||||
)
|
||||
|
||||
/** Keep the supervised palette isolated from the parent's ordinary app theme. */
|
||||
internal fun resolveSupervisedTheme(
|
||||
policy: SupervisedModePolicy,
|
||||
parentAccessUnlocked: Boolean,
|
||||
globalAppThemeId: String,
|
||||
globalThemePreference: String,
|
||||
): ResolvedSupervisedTheme = if (policy.enabled && !parentAccessUnlocked) {
|
||||
ResolvedSupervisedTheme(
|
||||
appThemeId = policy.appearance.appThemeId,
|
||||
themePreference = policy.appearance.themePreference,
|
||||
useGlobalCustomTheme = false,
|
||||
)
|
||||
} else {
|
||||
ResolvedSupervisedTheme(
|
||||
appThemeId = globalAppThemeId,
|
||||
themePreference = globalThemePreference,
|
||||
useGlobalCustomTheme = true,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldShowPetInSupervisedMode(
|
||||
policy: SupervisedModePolicy,
|
||||
parentAccessUnlocked: Boolean,
|
||||
): Boolean = !policy.enabled || parentAccessUnlocked || policy.appearance.showPet
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.ConnectionStore
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
/** Allowlist applied to external, deep-link, and programmatic navigation. */
|
||||
internal fun isSupervisedRouteAllowed(route: String?, parentAccessUnlocked: Boolean): Boolean {
|
||||
if (parentAccessUnlocked) return true
|
||||
val normalized = route?.substringBefore('?') ?: return false
|
||||
return normalized == "chat" ||
|
||||
normalized == Screen.Settings.route ||
|
||||
normalized == Screen.SupervisedAppearanceSettings.route
|
||||
}
|
||||
|
||||
/** Do not inspect or mutate a NavController until its first destination exists. */
|
||||
internal fun shouldRedirectSupervisedRoute(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
currentRoute: String?,
|
||||
): Boolean = currentRoute != null &&
|
||||
supervisedEnabled &&
|
||||
!isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
|
||||
|
||||
/** A null route is Navigation's pre-graph bootstrap state, not a forbidden destination. */
|
||||
internal fun isSupervisedRouteContentAllowed(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
currentRoute: String?,
|
||||
): Boolean = currentRoute == null ||
|
||||
!supervisedEnabled ||
|
||||
isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
|
||||
|
||||
/**
|
||||
* Cold-start gate for the app navigation graph.
|
||||
*
|
||||
* A null active connection is also the seed value used while [ConnectionStore]
|
||||
* is reading DataStore. Callers must therefore wait for the store's explicit
|
||||
* hydration signal before treating null as "no connection" and composing the
|
||||
* unrestricted onboarding/settings graph.
|
||||
*/
|
||||
internal fun isRelayNavigationHydrated(
|
||||
connectionStoreHydrated: Boolean,
|
||||
activeConnectionId: String?,
|
||||
supervisedPolicyHydrated: Boolean,
|
||||
): Boolean = connectionStoreHydrated &&
|
||||
(activeConnectionId == null || supervisedPolicyHydrated)
|
||||
|
||||
/** A parent unlock never follows the user back into the supervised chat root. */
|
||||
internal fun shouldRelockParentAccess(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
route: String?,
|
||||
): Boolean = supervisedEnabled &&
|
||||
parentAccessUnlocked &&
|
||||
route?.substringBefore('?') == "chat"
|
||||
|
||||
/**
|
||||
* External chat route arguments are untrusted. A session may be restored only
|
||||
* after an owner-aware source has proved that it belongs to the pinned profile.
|
||||
*/
|
||||
internal fun mayRestoreSupervisedSessionRoute(
|
||||
policy: SupervisedModePolicy,
|
||||
requestedSessionId: String?,
|
||||
requestedProfile: String?,
|
||||
pinnedProfileOwnershipProven: Boolean,
|
||||
): Boolean = policy.isActive &&
|
||||
policy.capabilities.conversationHistory &&
|
||||
pinnedProfileOwnershipProven &&
|
||||
!requestedSessionId.isNullOrBlank() &&
|
||||
!requestedProfile.isNullOrBlank() &&
|
||||
requestedProfile.equals(policy.pinnedProfileName, ignoreCase = true)
|
||||
|
||||
internal data class SupervisedChatRouteArgs(
|
||||
val sessionId: String? = null,
|
||||
val profile: String? = null,
|
||||
val proactiveChatId: String? = null,
|
||||
)
|
||||
|
||||
/** Strip external chat targeting before any destination effect can dispatch it. */
|
||||
internal fun sanitizeSupervisedChatRouteArgs(
|
||||
policy: SupervisedModePolicy,
|
||||
args: SupervisedChatRouteArgs,
|
||||
pinnedProfileOwnershipProven: Boolean,
|
||||
): SupervisedChatRouteArgs {
|
||||
if (!policy.enabled) return args
|
||||
val allowSession = mayRestoreSupervisedSessionRoute(
|
||||
policy = policy,
|
||||
requestedSessionId = args.sessionId,
|
||||
requestedProfile = args.profile,
|
||||
pinnedProfileOwnershipProven = pinnedProfileOwnershipProven,
|
||||
)
|
||||
return if (allowSession) {
|
||||
args.copy(proactiveChatId = null)
|
||||
} else {
|
||||
SupervisedChatRouteArgs()
|
||||
}
|
||||
}
|
||||
|
||||
/** A disabled policy may become active only after an enrolled credential succeeds. */
|
||||
internal fun mayEnableSupervisedMode(
|
||||
policy: SupervisedModePolicy,
|
||||
deviceSecure: Boolean,
|
||||
deviceCredentialConfirmed: Boolean,
|
||||
): Boolean = !policy.enabled &&
|
||||
policy.isConfigured &&
|
||||
deviceSecure &&
|
||||
deviceCredentialConfirmed
|
||||
@@ -125,6 +125,7 @@ fun AttachmentGallery(
|
||||
if (attachments.size < 2) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
val revealed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
@@ -189,7 +190,7 @@ fun AttachmentGallery(
|
||||
)
|
||||
}
|
||||
|
||||
if (!blurred) {
|
||||
if (!blurred && exportAllowed) {
|
||||
SaveOverlayButton(
|
||||
onClick = {
|
||||
scope.launch { saveAttachment(context, attachment) }
|
||||
@@ -201,7 +202,7 @@ fun AttachmentGallery(
|
||||
}
|
||||
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
|
||||
@@ -312,6 +312,8 @@ fun AttachmentViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
@@ -405,6 +407,7 @@ fun AttachmentViewer(
|
||||
title = title,
|
||||
busy = busy,
|
||||
actionsEnabled = !blurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
@@ -448,6 +451,7 @@ internal fun AttachmentGalleryViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
@@ -584,6 +588,7 @@ internal fun AttachmentGalleryViewer(
|
||||
title = toolbarTitle,
|
||||
busy = busy,
|
||||
actionsEnabled = !currentBlurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
@@ -613,6 +618,7 @@ private fun MediaViewerToolbar(
|
||||
title: String,
|
||||
busy: Boolean,
|
||||
actionsEnabled: Boolean = true,
|
||||
exportAllowed: Boolean = true,
|
||||
onShare: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onOpenExternal: () -> Unit,
|
||||
@@ -653,11 +659,13 @@ private fun MediaViewerToolbar(
|
||||
) {
|
||||
Icon(Icons.Filled.OpenInNew, contentDescription = stringResource(R.string.attachment_open_externally_a11y))
|
||||
}
|
||||
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
|
||||
}
|
||||
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
|
||||
if (exportAllowed) {
|
||||
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
|
||||
}
|
||||
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ fun ChatFailurePanel(
|
||||
onDetails: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
showDetails: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
@@ -72,8 +73,10 @@ fun ChatFailurePanel(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onDetails) {
|
||||
Text(stringResource(R.string.chat_failure_details))
|
||||
if (showDetails) {
|
||||
TextButton(onClick = onDetails) {
|
||||
Text(stringResource(R.string.chat_failure_details))
|
||||
}
|
||||
}
|
||||
if (failure.recoverable) {
|
||||
TextButton(onClick = onRetry) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -42,6 +43,9 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Whether the current conversation policy permits copying image bytes out of the app. */
|
||||
val LocalImageExportAllowed = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* What the [ChatImageViewer] displays and how it obtains bytes for Save/Share.
|
||||
*
|
||||
@@ -104,6 +108,7 @@ fun ChatImageViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -156,60 +161,72 @@ fun ChatImageViewer(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
|
||||
val cdShare = stringResource(R.string.cd_share)
|
||||
val cdSave = stringResource(R.string.cd_save)
|
||||
val cdClose = stringResource(R.string.cd_close_viewer)
|
||||
val errorMsg = context.getString(R.string.image_viewer_error)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
busy = false
|
||||
if (bytes == null) {
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = cdShare)
|
||||
}
|
||||
val savedFmt = context.getString(R.string.image_viewer_saved)
|
||||
val failedFmt = context.getString(R.string.image_viewer_failed)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
if (bytes == null) {
|
||||
if (exportAllowed) {
|
||||
val cdShare = stringResource(R.string.cd_share)
|
||||
val cdSave = stringResource(R.string.cd_save)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
busy = false
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
if (bytes == null) {
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = cdShare)
|
||||
}
|
||||
val savedFmt = context.getString(R.string.image_viewer_saved)
|
||||
val failedFmt = context.getString(R.string.image_viewer_failed)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
toast(context, savedFmt.format(result.location))
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, failedFmt.format(result.message))
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
busy = false
|
||||
toast(context, savedFmt.format(result.location))
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, failedFmt.format(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDismiss, colors = tint) {
|
||||
Icon(Icons.Filled.Close, contentDescription = cdClose)
|
||||
|
||||
@@ -483,6 +483,7 @@ fun FloatingPetCompanion(
|
||||
compact: Boolean,
|
||||
animationEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
interactive: Boolean = true,
|
||||
route: String?,
|
||||
visitRequest: PetVisitRequest?,
|
||||
onVisitRequestConsumed: (String) -> Unit,
|
||||
@@ -2318,13 +2319,14 @@ fun FloatingPetCompanion(
|
||||
}
|
||||
.pointerInput(
|
||||
pet.id,
|
||||
interactive,
|
||||
safeBounds,
|
||||
roamingRails,
|
||||
settledHabitat,
|
||||
positioned,
|
||||
surfaceScrolling,
|
||||
) {
|
||||
if (!floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
|
||||
if (!interactive || !floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
|
||||
return@pointerInput
|
||||
}
|
||||
detectDragGesturesAfterLongPress(
|
||||
@@ -2399,16 +2401,16 @@ fun FloatingPetCompanion(
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
enabled = floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
|
||||
enabled = interactive && floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
|
||||
) {
|
||||
tapReactionNonce += 1
|
||||
setMenuExpanded(true)
|
||||
}
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = Role.Button
|
||||
if (interactive) role = Role.Button
|
||||
contentDescription = companionDescription
|
||||
stateDescription = stateLabel
|
||||
customActions = buildList {
|
||||
customActions = if (interactive) buildList {
|
||||
add(CustomAccessibilityAction(moveStartLabel) {
|
||||
onPlacementChanged(placement.copy(edge = PetLogicalEdge.Start)); true
|
||||
})
|
||||
@@ -2438,7 +2440,7 @@ fun FloatingPetCompanion(
|
||||
add(CustomAccessibilityAction(resetLabel) { onResetPlacement(); true })
|
||||
add(CustomAccessibilityAction(appearanceLabel) { onOpenAppearance(); true })
|
||||
add(CustomAccessibilityAction(hideLabel) { onHide(); true })
|
||||
}
|
||||
} else emptyList()
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -2482,7 +2484,7 @@ fun FloatingPetCompanion(
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = interactive && menuExpanded,
|
||||
onDismissRequest = { setMenuExpanded(false) },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
|
||||
+18
-13
@@ -270,6 +270,7 @@ private fun ImageRender(
|
||||
maxWidth: Dp
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// Decode OFF the main thread — a large inbound image would otherwise block
|
||||
// composition. Null while decoding (placeholder); decodeFailed → file card.
|
||||
@@ -356,14 +357,14 @@ private fun ImageRender(
|
||||
}
|
||||
// One-tap save overlay — hidden while the blur cover is up so it
|
||||
// doesn't sit over the "tap to reveal" prompt.
|
||||
if (!blurred) {
|
||||
if (!blurred && exportAllowed) {
|
||||
SaveOverlayButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
|
||||
)
|
||||
}
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
@@ -380,6 +381,8 @@ private fun FileCardRender(
|
||||
maxWidth: Dp
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
val scope = rememberCoroutineScope()
|
||||
val (emoji, typeLabel) = emojiAndLabelFor(attachment.renderMode, attachment.contentType)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
@@ -463,21 +466,23 @@ private fun FileCardRender(
|
||||
}
|
||||
}
|
||||
// Visible one-tap save affordance (B2).
|
||||
IconButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = stringResource(R.string.inbound_attach_cd_save),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
if (exportAllowed) {
|
||||
IconButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = stringResource(R.string.inbound_attach_cd_save),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
|
||||
@@ -94,6 +94,14 @@ import java.util.Date
|
||||
internal const val CHAT_PET_IDENTITY_OBSTACLE_PREFIX = "chat-message-identity:"
|
||||
private val MESSAGE_REACTIONS = listOf("❤️", "👍", "👎", "😂", "‼️", "❓")
|
||||
|
||||
internal fun assistantImageContent(
|
||||
content: String,
|
||||
showImages: Boolean,
|
||||
): Pair<String, List<ChatInlineImage>> {
|
||||
val (body, images) = extractChatInlineImages(content)
|
||||
return body to if (showImages) images else emptyList()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
@@ -101,6 +109,13 @@ fun MessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
maxBubbleWidth: Dp = 300.dp,
|
||||
showThinking: Boolean = true,
|
||||
showAgentIdentity: Boolean = true,
|
||||
showTimestamps: Boolean = true,
|
||||
showWorkingStatus: Boolean = true,
|
||||
showUsage: Boolean = true,
|
||||
showTechnicalBadges: Boolean = true,
|
||||
showAssistantImages: Boolean = true,
|
||||
allowAssistantImageExport: Boolean = true,
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
onCopyMessage: (String) -> Unit = {},
|
||||
@@ -238,18 +253,24 @@ fun MessageBubble(
|
||||
// content so they render as real images (remote URLs via Coil) or a
|
||||
// graceful inline notice — not the blank element the markdown renderer
|
||||
// emits for an image link. User/system bubbles keep their raw content.
|
||||
val (markdownBody, inlineImages) = remember(visibleMessageContent, isUser, isSystem) {
|
||||
val (markdownBody, inlineImages) = remember(
|
||||
visibleMessageContent,
|
||||
isUser,
|
||||
isSystem,
|
||||
showAssistantImages,
|
||||
) {
|
||||
if (isUser || isSystem) {
|
||||
visibleMessageContent to emptyList()
|
||||
} else {
|
||||
extractChatInlineImages(visibleMessageContent)
|
||||
assistantImageContent(visibleMessageContent, showAssistantImages)
|
||||
}
|
||||
}
|
||||
val showImageGeneration = shouldShowImageGenerationPlaceholder(
|
||||
val showImageGeneration = showAssistantImages && showWorkingStatus && shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = message.toolCalls,
|
||||
isStreaming = message.isStreaming,
|
||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||
)
|
||||
val actionContent = if (!isUser && !isSystem) markdownBody else visibleMessageContent
|
||||
val streamingStatusLabel = if (
|
||||
!isUser &&
|
||||
!isSystem &&
|
||||
@@ -297,7 +318,10 @@ fun MessageBubble(
|
||||
val blurRepo = remember(context) { MediaSettingsRepository(context.applicationContext) }
|
||||
val blurMode by blurRepo.blurMode.collectAsState(initial = BlurMode.FLAGGED)
|
||||
|
||||
CompositionLocalProvider(LocalMediaBlurMode provides blurMode) {
|
||||
CompositionLocalProvider(
|
||||
LocalMediaBlurMode provides blurMode,
|
||||
LocalImageExportAllowed provides allowAssistantImageExport,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = alignment,
|
||||
@@ -305,7 +329,7 @@ fun MessageBubble(
|
||||
// Keep sender identity in the first-message label rather than a
|
||||
// persistent leading column. Long responses and every follow-up in the
|
||||
// group therefore retain the full bubble-width allowance.
|
||||
if (!isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
|
||||
if (showAgentIdentity && !isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
@@ -336,7 +360,7 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUser && !isSystem && message.badges.isNotEmpty()) {
|
||||
if (showTechnicalBadges && !isUser && !isSystem && message.badges.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
@@ -415,7 +439,7 @@ fun MessageBubble(
|
||||
// is rendered directly in the conversation
|
||||
// lane below, without an opaque bubble. Cards and attachments still own
|
||||
// a normal bubble even when response prose has not arrived yet.
|
||||
streamingStatusLabel?.let { streamingStatus ->
|
||||
streamingStatusLabel?.takeIf { showWorkingStatus }?.let { streamingStatus ->
|
||||
StandaloneStreamingStatus(
|
||||
status = streamingStatus,
|
||||
accessibilityDescription = a11yDescription,
|
||||
@@ -508,7 +532,7 @@ fun MessageBubble(
|
||||
text = { Text(stringResource(R.string.msg_bubble_copy)) },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
},
|
||||
)
|
||||
if (onQuoteMessage != null) {
|
||||
@@ -516,7 +540,7 @@ fun MessageBubble(
|
||||
text = { Text(stringResource(R.string.msg_bubble_quote)) },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onQuoteMessage(message.copy(content = visibleMessageContent))
|
||||
onQuoteMessage(message.copy(content = actionContent))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -531,7 +555,7 @@ fun MessageBubble(
|
||||
},
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onSpeakMessage?.invoke(visibleMessageContent)
|
||||
onSpeakMessage?.invoke(actionContent)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -601,7 +625,7 @@ fun MessageBubble(
|
||||
) {
|
||||
showMessageActions = true
|
||||
} else {
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -798,7 +822,7 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
val hasTokenUsage = !isUser &&
|
||||
val hasTokenUsage = showUsage && !isUser &&
|
||||
(message.inputTokens != null || message.outputTokens != null)
|
||||
|
||||
// Timestamp — only on the LAST bubble of a same-author run so a
|
||||
@@ -808,13 +832,13 @@ fun MessageBubble(
|
||||
// This row is reserved from the first streaming frame. Completion
|
||||
// can reveal both timestamp and token usage without adding a new
|
||||
// footer line or changing the bubble's measured height.
|
||||
if (isLastInGroup) {
|
||||
if (isLastInGroup && (showTimestamps || hasTokenUsage)) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
if (showTimestamps) Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
|
||||
@@ -883,15 +907,15 @@ fun MessageBubble(
|
||||
showEdit = showEditAction,
|
||||
onCopy = {
|
||||
showInlineActions = false
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
},
|
||||
onQuote = {
|
||||
showInlineActions = false
|
||||
onQuoteMessage?.invoke(message.copy(content = visibleMessageContent))
|
||||
onQuoteMessage?.invoke(message.copy(content = actionContent))
|
||||
},
|
||||
onSpeak = {
|
||||
showInlineActions = false
|
||||
onSpeakMessage?.invoke(visibleMessageContent)
|
||||
onSpeakMessage?.invoke(actionContent)
|
||||
},
|
||||
onStopSpeaking = {
|
||||
showInlineActions = false
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.SweepGradient
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -81,6 +82,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -105,6 +109,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SupervisedSessionActions
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
|
||||
@@ -116,6 +121,7 @@ import com.hermesandroid.relay.ui.theme.resolveProfileAccent
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal enum class SessionDrawerFilter {
|
||||
All,
|
||||
@@ -203,6 +209,8 @@ fun SessionDrawerContent(
|
||||
animationEnabled: Boolean = true,
|
||||
autoTitlesSupported: Boolean = true,
|
||||
archiveSupported: Boolean = true,
|
||||
supervisedSessionActions: SupervisedSessionActions? = null,
|
||||
newChatEnabled: Boolean = true,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
|
||||
onOpenBotMode: (() -> Unit)? = null,
|
||||
@@ -277,9 +285,16 @@ fun SessionDrawerContent(
|
||||
}
|
||||
val scopedRows = (sessions + provisionalSessions).map { ProfileSessionRow(activeProfileName, it) }
|
||||
val sourceRows = if (showAllProfiles) allProfileSessions else scopedRows
|
||||
val scopedActivityStates = scopedSessionActivityStates(
|
||||
rows = sourceRows,
|
||||
activityStates = activityStates,
|
||||
allowBareSessionIds = !showAllProfiles,
|
||||
)
|
||||
val sourceSessions = sourceRows.map { it.session }
|
||||
val showThreads = threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) }
|
||||
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, archiveSupported)
|
||||
val showThreads = supervisedSessionActions == null &&
|
||||
(threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) })
|
||||
val effectiveArchiveSupported = archiveSupported && supervisedSessionActions?.archive != false
|
||||
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, effectiveArchiveSupported)
|
||||
// External gateway sources present (discord/telegram/cron/…) for the source
|
||||
// filter dropdown. Own chats (tui/api_server) + phone Threads aren't listed.
|
||||
val presentSources = sourceSessions
|
||||
@@ -319,8 +334,13 @@ fun SessionDrawerContent(
|
||||
sessionWorkLabels(session).any { it.contains(needle, ignoreCase = true) }
|
||||
}
|
||||
.toList()
|
||||
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, activityStates)
|
||||
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, activityStates)
|
||||
val visibleRows = filterAndSortSessionRows(categoryRows, viewOptions, scopedActivityStates)
|
||||
val groupedRows = groupSessionRows(visibleRows, viewOptions.grouping, scopedActivityStates)
|
||||
val drawerNowMillis = rememberDrawerClock(
|
||||
isEnabled = isOpen && (
|
||||
viewOptions.showUpdated || viewOptions.grouping == SessionDrawerGrouping.Project
|
||||
),
|
||||
)
|
||||
val drawerTitle = if (showAllProfiles) {
|
||||
stringResource(R.string.drawer_all_profiles)
|
||||
} else {
|
||||
@@ -390,7 +410,7 @@ fun SessionDrawerContent(
|
||||
)
|
||||
// Source filter — show/hide gateway sources (default hides the
|
||||
// noisy cron+webhook). Only when external sources are present.
|
||||
if (onToggleSourceHidden != null && presentSources.isNotEmpty()) {
|
||||
if (supervisedSessionActions == null && onToggleSourceHidden != null && presentSources.isNotEmpty()) {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { sourceFilterOpen = true },
|
||||
@@ -451,7 +471,7 @@ fun SessionDrawerContent(
|
||||
// Threads affordance — a clean thread-spool that toggles the Threads
|
||||
// filter. Shown only when the Threads capability is active (or a Thread is
|
||||
// already present), so an ordinary no-relay drawer is visually unchanged.
|
||||
if (showThreads) {
|
||||
if (supervisedSessionActions == null && showThreads) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
filter = if (filter == SessionDrawerFilter.Threads) {
|
||||
@@ -522,7 +542,8 @@ fun SessionDrawerContent(
|
||||
onNewChat()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = newChatEnabled,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -587,8 +608,10 @@ fun SessionDrawerContent(
|
||||
}
|
||||
SessionDrawerFilter.entries
|
||||
.filter { item ->
|
||||
(item != SessionDrawerFilter.Threads || showThreads) &&
|
||||
(item != SessionDrawerFilter.Archive || archiveSupported)
|
||||
(item != SessionDrawerFilter.Threads ||
|
||||
(supervisedSessionActions == null && showThreads)) &&
|
||||
(item != SessionDrawerFilter.Archive ||
|
||||
effectiveArchiveSupported)
|
||||
}
|
||||
.forEach { item ->
|
||||
FilterChip(
|
||||
@@ -620,17 +643,19 @@ fun SessionDrawerContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = { customizeOpen = true },
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.drawer_customize_sessions))
|
||||
if (supervisedSessionActions == null) {
|
||||
TextButton(
|
||||
onClick = { customizeOpen = true },
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.drawer_customize_sessions))
|
||||
}
|
||||
}
|
||||
// "+ New Thread" — Discord-style user-created thread, shown when the
|
||||
// Threads filter is active. The first message opens the conversation.
|
||||
@@ -728,6 +753,7 @@ fun SessionDrawerContent(
|
||||
ProjectGroupHeader(
|
||||
label = label,
|
||||
rows = group.rows,
|
||||
nowMillis = drawerNowMillis,
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
expandedProjectGroups = if (expanded) {
|
||||
@@ -750,9 +776,7 @@ fun SessionDrawerContent(
|
||||
if (expanded) items(group.rows, key = ::sessionRowKey) { row ->
|
||||
val session = row.session
|
||||
val provisional = session.sessionId.startsWith(PROVISIONAL_THREAD_PREFIX)
|
||||
val activityState = activityStates[sessionRowKey(row)]
|
||||
?: activityStates[session.sessionId]
|
||||
?: if (session.isActive) SessionActivityState.Working else null
|
||||
val activityState = scopedActivityStates[sessionRowKey(row)]
|
||||
SessionItem(
|
||||
modifier = if (isProjectGroup) Modifier.padding(start = 42.dp) else Modifier,
|
||||
session = session,
|
||||
@@ -764,13 +788,21 @@ fun SessionDrawerContent(
|
||||
showUpdated = viewOptions.showUpdated,
|
||||
showTokens = viewOptions.showTokens,
|
||||
showCost = viewOptions.showCost,
|
||||
actionsEnabled = !provisional,
|
||||
nowMillis = drawerNowMillis,
|
||||
actionsEnabled = !provisional && (
|
||||
supervisedSessionActions == null ||
|
||||
supervisedSessionActions.pin ||
|
||||
supervisedSessionActions.rename ||
|
||||
supervisedSessionActions.delete ||
|
||||
(supervisedSessionActions.archive && archiveSupported)
|
||||
),
|
||||
isActive = !showAllProfiles && session.sessionId == currentSessionId,
|
||||
activityState = activityState,
|
||||
animationEnabled = animationEnabled && isOpen,
|
||||
pinned = session.pinned,
|
||||
archived = session.archived,
|
||||
archiveSupported = archiveSupported,
|
||||
supervisedSessionActions = supervisedSessionActions,
|
||||
onClick = {
|
||||
if (showAllProfiles) {
|
||||
onSelectProfileSession?.invoke(row.profile, session.sessionId)
|
||||
@@ -1217,7 +1249,11 @@ private fun SessionDrawerOrdering.label(): String = when (this) {
|
||||
|
||||
private fun SessionDrawerStatus.label(): String = when (this) {
|
||||
SessionDrawerStatus.NeedsInput -> "Needs input"
|
||||
SessionDrawerStatus.Starting -> "Starting"
|
||||
SessionDrawerStatus.Working -> "Working"
|
||||
SessionDrawerStatus.BackgroundWork -> "Background work"
|
||||
SessionDrawerStatus.Checking -> "Checking"
|
||||
SessionDrawerStatus.Unavailable -> "Unavailable"
|
||||
SessionDrawerStatus.Idle -> "Idle"
|
||||
}
|
||||
|
||||
@@ -1242,6 +1278,7 @@ private fun compactMetric(value: Double): String = when {
|
||||
private fun ProjectGroupHeader(
|
||||
label: String,
|
||||
rows: List<ProfileSessionRow>,
|
||||
nowMillis: Long,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
@@ -1290,7 +1327,7 @@ private fun ProjectGroupHeader(
|
||||
append(context.resources.getQuantityString(R.plurals.drawer_project_session_count, rows.size, rows.size))
|
||||
if (latestActivity > 0L) {
|
||||
append(" · ")
|
||||
append(formatTimestamp(latestActivity, locale, context))
|
||||
append(formatTimestamp(latestActivity, locale, context, nowMillis))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -1319,6 +1356,7 @@ private fun SessionItem(
|
||||
showUpdated: Boolean,
|
||||
showTokens: Boolean,
|
||||
showCost: Boolean,
|
||||
nowMillis: Long,
|
||||
actionsEnabled: Boolean,
|
||||
isActive: Boolean,
|
||||
activityState: SessionActivityState?,
|
||||
@@ -1326,6 +1364,7 @@ private fun SessionItem(
|
||||
pinned: Boolean,
|
||||
archived: Boolean,
|
||||
archiveSupported: Boolean,
|
||||
supervisedSessionActions: SupervisedSessionActions?,
|
||||
onClick: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onToggleArchived: () -> Unit,
|
||||
@@ -1337,11 +1376,7 @@ private fun SessionItem(
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val context = LocalContext.current
|
||||
val untitledLabel = stringResource(R.string.drawer_untitled)
|
||||
val activityLabel = when (activityState) {
|
||||
SessionActivityState.Working -> stringResource(R.string.drawer_activity_working)
|
||||
SessionActivityState.NeedsInput -> stringResource(R.string.drawer_activity_needs_input)
|
||||
null -> null
|
||||
}
|
||||
val activityLabel = activityState?.let { stringResource(sessionActivityLabelResource(it)) }
|
||||
val motion = rememberAccessibleMotionState()
|
||||
val backgroundColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
@@ -1441,7 +1476,7 @@ private fun SessionItem(
|
||||
sourceBadge(session.source)?.let { badge ->
|
||||
SourceChip(badge)
|
||||
}
|
||||
if (showUpdated) sessionTimestampText(session, locale, context)?.let { timestamp ->
|
||||
if (showUpdated) sessionTimestampText(session, locale, context, nowMillis)?.let { timestamp ->
|
||||
Text(
|
||||
text = timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -1493,7 +1528,7 @@ private fun SessionItem(
|
||||
expanded = menuOpen,
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.pin != false) DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (pinned) {
|
||||
@@ -1519,7 +1554,7 @@ private fun SessionItem(
|
||||
onTogglePinned()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions == null) DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_copy_session_id)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.ContentCopy, contentDescription = null)
|
||||
@@ -1529,7 +1564,7 @@ private fun SessionItem(
|
||||
onCopySessionId()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.rename != false) DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.drawer_rename)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Edit, contentDescription = null)
|
||||
@@ -1539,7 +1574,7 @@ private fun SessionItem(
|
||||
onRename()
|
||||
},
|
||||
)
|
||||
if (archiveSupported) {
|
||||
if (archiveSupported && supervisedSessionActions?.archive != false) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
|
||||
leadingIcon = {
|
||||
@@ -1559,7 +1594,7 @@ private fun SessionItem(
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.delete != false) DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.drawer_delete),
|
||||
@@ -1583,6 +1618,16 @@ private fun SessionItem(
|
||||
}
|
||||
}
|
||||
|
||||
@StringRes
|
||||
internal fun sessionActivityLabelResource(state: SessionActivityState): Int = when (state) {
|
||||
SessionActivityState.Starting -> R.string.drawer_activity_starting
|
||||
SessionActivityState.Working -> R.string.drawer_activity_working
|
||||
SessionActivityState.NeedsInput -> R.string.drawer_activity_needs_input
|
||||
SessionActivityState.BackgroundWork -> R.string.drawer_activity_background_work
|
||||
SessionActivityState.Checking -> R.string.drawer_activity_checking
|
||||
SessionActivityState.Unavailable -> R.string.drawer_activity_unavailable
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionWorkBadgeChip(badge: SessionWorkBadge) {
|
||||
val icon: ImageVector = when (badge.kind) {
|
||||
@@ -1687,18 +1732,29 @@ private fun ProfileBadge(
|
||||
@Composable
|
||||
private fun SessionActivityIndicator(state: SessionActivityState, label: String) {
|
||||
val color = when (state) {
|
||||
SessionActivityState.Starting,
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
SessionActivityState.BackgroundWork,
|
||||
SessionActivityState.Checking,
|
||||
SessionActivityState.Unavailable,
|
||||
-> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(7.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(color),
|
||||
modifier = if (state == SessionActivityState.BackgroundWork) {
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.border(1.dp, color, CircleShape)
|
||||
} else {
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
@@ -1714,12 +1770,9 @@ private fun Modifier.sessionActivityBorder(
|
||||
state: SessionActivityState?,
|
||||
animated: Boolean,
|
||||
): Modifier {
|
||||
if (state == null) return this
|
||||
val color = when (state) {
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
}
|
||||
val shouldRotate = animated && 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(
|
||||
@@ -1825,20 +1878,45 @@ private fun Modifier.sessionActivityBorder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
|
||||
@Composable
|
||||
private fun rememberDrawerClock(isEnabled: Boolean): Long {
|
||||
var nowMillis by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(isEnabled, lifecycleOwner) {
|
||||
if (!isEnabled) return@LaunchedEffect
|
||||
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (true) {
|
||||
nowMillis = System.currentTimeMillis()
|
||||
delay(MINUTE_MILLIS - (nowMillis % MINUTE_MILLIS))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nowMillis
|
||||
}
|
||||
|
||||
internal fun sessionTimestampText(
|
||||
session: ChatSession,
|
||||
locale: Locale,
|
||||
context: Context,
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
): String? {
|
||||
val timestamp = session.activityTimestamp
|
||||
if (timestamp <= 0L) return null
|
||||
val hasDistinctActivity =
|
||||
session.lastActivityAt > 0L &&
|
||||
session.startTimestamp > 0L &&
|
||||
session.lastActivityAt != session.startTimestamp
|
||||
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_active) else context.getString(R.string.drawer_timestamp_started)
|
||||
return "$prefix ${formatTimestamp(timestamp, locale, context)}"
|
||||
val prefix = if (hasDistinctActivity) context.getString(R.string.drawer_timestamp_updated) else context.getString(R.string.drawer_timestamp_started)
|
||||
return "$prefix ${formatTimestamp(timestamp, locale, context, nowMillis)}"
|
||||
}
|
||||
|
||||
private fun formatTimestamp(millis: Long, locale: Locale, context: Context): String {
|
||||
val now = System.currentTimeMillis()
|
||||
val diff = now - millis
|
||||
private fun formatTimestamp(
|
||||
millis: Long,
|
||||
locale: Locale,
|
||||
context: Context,
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
): String {
|
||||
val diff = nowMillis - millis
|
||||
return when {
|
||||
diff < 60_000 -> context.getString(R.string.drawer_just_now)
|
||||
diff < 3_600_000 -> "${diff / 60_000}m ago"
|
||||
@@ -1846,3 +1924,5 @@ private fun formatTimestamp(millis: Long, locale: Locale, context: Context): Str
|
||||
else -> SimpleDateFormat("MMM d", locale).format(Date(millis))
|
||||
}
|
||||
}
|
||||
|
||||
private const val MINUTE_MILLIS = 60_000L
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import java.util.Locale
|
||||
@@ -23,7 +24,11 @@ internal enum class SessionDrawerOrdering {
|
||||
|
||||
internal enum class SessionDrawerStatus {
|
||||
NeedsInput,
|
||||
Starting,
|
||||
Working,
|
||||
BackgroundWork,
|
||||
Checking,
|
||||
Unavailable,
|
||||
Idle,
|
||||
}
|
||||
|
||||
@@ -55,7 +60,7 @@ internal data class SessionDrawerGroup(
|
||||
)
|
||||
|
||||
internal fun sessionRowKey(row: ProfileSessionRow): String =
|
||||
"${row.profile.lowercase(Locale.ROOT)}:${row.session.sessionId}"
|
||||
"${AgentDisplay.profileSessionKey(row.profile).lowercase(Locale.ROOT)}:${row.session.sessionId}"
|
||||
|
||||
internal fun sessionProjectLabel(session: ChatSession): String {
|
||||
val raw = (session.gitRepoRoot ?: session.workingDirectory)
|
||||
@@ -69,12 +74,38 @@ internal fun sessionProjectLabel(session: ChatSession): String {
|
||||
internal fun sessionDrawerStatus(
|
||||
row: ProfileSessionRow,
|
||||
activityStates: Map<String, SessionActivityState>,
|
||||
): SessionDrawerStatus = when (
|
||||
activityStates[sessionRowKey(row)] ?: activityStates[row.session.sessionId]
|
||||
) {
|
||||
): SessionDrawerStatus = when (activityStates[sessionRowKey(row)]) {
|
||||
SessionActivityState.NeedsInput -> SessionDrawerStatus.NeedsInput
|
||||
SessionActivityState.Starting -> SessionDrawerStatus.Starting
|
||||
SessionActivityState.Working -> SessionDrawerStatus.Working
|
||||
null -> if (row.session.isActive) SessionDrawerStatus.Working else SessionDrawerStatus.Idle
|
||||
SessionActivityState.BackgroundWork -> SessionDrawerStatus.BackgroundWork
|
||||
SessionActivityState.Checking -> SessionDrawerStatus.Checking
|
||||
SessionActivityState.Unavailable -> SessionDrawerStatus.Unavailable
|
||||
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.
|
||||
*
|
||||
* A selected-profile drawer may accept the legacy bare session id because every row belongs
|
||||
* to that one explicit profile. All Profiles must use composite keys exclusively: session ids
|
||||
* are only unique inside their owning profile.
|
||||
*/
|
||||
internal fun scopedSessionActivityStates(
|
||||
rows: List<ProfileSessionRow>,
|
||||
activityStates: Map<String, SessionActivityState>,
|
||||
allowBareSessionIds: Boolean,
|
||||
): Map<String, SessionActivityState> = buildMap {
|
||||
rows.forEach { row ->
|
||||
val rowKey = sessionRowKey(row)
|
||||
val state = activityStates[rowKey]
|
||||
?: activityStates[row.session.sessionId].takeIf { allowBareSessionIds }
|
||||
state?.let { put(rowKey, it) }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sessionDrawerPrState(session: ChatSession): SessionDrawerPrState = when {
|
||||
@@ -98,8 +129,12 @@ internal fun filterAndSortSessionRows(
|
||||
.toList()
|
||||
val statusRank = mapOf(
|
||||
SessionDrawerStatus.NeedsInput to 0,
|
||||
SessionDrawerStatus.Working to 1,
|
||||
SessionDrawerStatus.Idle to 2,
|
||||
SessionDrawerStatus.Starting to 1,
|
||||
SessionDrawerStatus.Working to 2,
|
||||
SessionDrawerStatus.BackgroundWork to 3,
|
||||
SessionDrawerStatus.Checking to 4,
|
||||
SessionDrawerStatus.Unavailable to 5,
|
||||
SessionDrawerStatus.Idle to 6,
|
||||
)
|
||||
val comparator = when (options.ordering) {
|
||||
SessionDrawerOrdering.Updated -> compareByDescending<ProfileSessionRow> { it.session.activityTimestamp }
|
||||
@@ -149,7 +184,11 @@ internal fun groupSessionRows(
|
||||
private val SessionDrawerStatus.displayLabel: String
|
||||
get() = when (this) {
|
||||
SessionDrawerStatus.NeedsInput -> "Needs input"
|
||||
SessionDrawerStatus.Starting -> "Starting"
|
||||
SessionDrawerStatus.Working -> "Working"
|
||||
SessionDrawerStatus.BackgroundWork -> "Background work"
|
||||
SessionDrawerStatus.Checking -> "Checking"
|
||||
SessionDrawerStatus.Unavailable -> "Unavailable"
|
||||
SessionDrawerStatus.Idle -> "Idle"
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@ fun AboutScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onUnlockDeveloperOptions: () -> Unit = {},
|
||||
/** Supervised clients may read About without gaining a settings mutation backdoor. */
|
||||
allowDeveloperUnlock: Boolean = true,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -218,7 +220,7 @@ fun AboutScreen(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
.clickable(enabled = allowDeveloperUnlock) {
|
||||
if (devOptionsUnlocked) return@clickable
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastTapTime > 2000) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
|
||||
/** Optional and specialized features kept off the primary Settings surface. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdvancedSettingsScreen(
|
||||
supervisedPolicy: SupervisedModePolicy,
|
||||
onNavigateToSupervisedControls: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = { Text(stringResource(R.string.settings_advanced)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_advanced_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.settings_supervised_mode),
|
||||
subtitle = when {
|
||||
supervisedPolicy.isActive -> stringResource(
|
||||
R.string.settings_supervised_on_profile,
|
||||
supervisedPolicy.pinnedProfileName.orEmpty(),
|
||||
)
|
||||
supervisedPolicy.isConfigured -> stringResource(
|
||||
R.string.settings_supervised_ready_profile,
|
||||
supervisedPolicy.pinnedProfileName.orEmpty(),
|
||||
)
|
||||
else -> stringResource(R.string.settings_supervised_desc)
|
||||
},
|
||||
badge = supervisedPolicy.takeIf { it.isActive }?.let {
|
||||
SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_supervised_on),
|
||||
tone = SettingsStatusTone.Good,
|
||||
)
|
||||
},
|
||||
onClick = onNavigateToSupervisedControls,
|
||||
isDarkTheme = isDarkTheme,
|
||||
petPerchKey = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-18
@@ -1487,26 +1487,27 @@ private fun AppearanceSummaryRow(
|
||||
|
||||
/** Representative, theme-live chat sample so presets are judged in context. */
|
||||
@Composable
|
||||
private fun AppearanceLivePreview(
|
||||
internal fun AppearanceLivePreview(
|
||||
palette: BrandPalette,
|
||||
shapeScale: AppearanceShapeScale,
|
||||
restricted: Boolean = false,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalBrand provides palette,
|
||||
LocalAppearanceShapeScale provides shapeScale,
|
||||
) {
|
||||
MaterialTheme(colorScheme = palette.toColorScheme(), shapes = shapeScale.asMaterialShapes()) {
|
||||
AppearanceLivePreviewContent()
|
||||
AppearanceLivePreviewContent(restricted = restricted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppearanceLivePreviewContent() {
|
||||
private fun AppearanceLivePreviewContent(restricted: Boolean) {
|
||||
val backgroundEnabled = LocalBackgroundVisualizationEnabled.current
|
||||
val backgroundAvatar = LocalAgentAvatar.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().height(294.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(if (restricted) 258.dp else 294.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
@@ -1648,14 +1649,16 @@ private fun AppearanceLivePreviewContent() {
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_preview_tool_meta),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
color = LocalBrand.current.green,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
if (!restricted) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_preview_tool_meta),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
color = LocalBrand.current.green,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.padding(start = 6.dp).size(38.dp), contentAlignment = Alignment.Center) {
|
||||
@@ -1678,10 +1681,12 @@ private fun AppearanceLivePreviewContent() {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
if (!restricted) {
|
||||
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.appearance_preview_message_placeholder),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
@@ -1691,7 +1696,7 @@ private fun AppearanceLivePreviewContent() {
|
||||
Icon(Icons.Filled.GraphicEq, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
if (!restricted) Surface(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
shape = appearanceRoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
@@ -1785,7 +1790,7 @@ private fun FontOptionRow(
|
||||
* are added.
|
||||
*/
|
||||
@Composable
|
||||
private fun ThemeSwatchChip(
|
||||
internal fun ThemeSwatchChip(
|
||||
appTheme: AppTheme,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
@@ -147,6 +148,7 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
@@ -180,6 +182,10 @@ import com.hermesandroid.relay.data.PhysicalKeyboardEnterBehavior
|
||||
import com.hermesandroid.relay.data.ProfilePresentationPolicy
|
||||
import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedSessionAction
|
||||
import com.hermesandroid.relay.data.allowsSessionAction
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
@@ -297,21 +303,6 @@ private const val CHAT_AUTOCOMPLETE_PET_OBSTACLE = "chat-autocomplete-obstacle"
|
||||
private const val CHAT_RECENT_PROMPTS_PET_OBSTACLE = "chat-recent-prompts-obstacle"
|
||||
private val CHAT_PET_ROUTES = setOf("chat")
|
||||
|
||||
internal fun resolveSessionActivityStates(
|
||||
background: Map<String, SessionActivityState>,
|
||||
currentSessionId: String?,
|
||||
isStreaming: Boolean,
|
||||
needsInput: Boolean,
|
||||
): Map<String, SessionActivityState> = background.toMutableMap().apply {
|
||||
currentSessionId?.let { sessionId ->
|
||||
when {
|
||||
needsInput -> put(sessionId, SessionActivityState.NeedsInput)
|
||||
isStreaming -> put(sessionId, SessionActivityState.Working)
|
||||
else -> remove(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resolveChatHeaderSubtitle(
|
||||
isStreaming: Boolean,
|
||||
statusText: String,
|
||||
@@ -726,8 +717,40 @@ fun ChatScreen(
|
||||
// existing test/preview call sites keep compiling.
|
||||
onNavigateToVoiceSettings: () -> Unit = {},
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
supervisedPolicy: SupervisedModePolicy = SupervisedModePolicy(),
|
||||
onNavigateToBotMode: () -> Unit = {},
|
||||
) {
|
||||
val supervised = supervisedPolicy.enabled
|
||||
val supervisedVisibility = supervisedPolicy.visibility.resolved()
|
||||
LaunchedEffect(supervisedPolicy) {
|
||||
voiceViewModel.updateSupervisedModePolicy(supervisedPolicy)
|
||||
}
|
||||
if (supervised && !supervisedPolicy.isActive) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Supervised chat unavailable") },
|
||||
actions = {
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"The supervised profile is unavailable. Parent access is required to update this connection.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val responseSpeechActive by voiceViewModel.responseSpeechActive.collectAsState()
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
@@ -750,7 +773,6 @@ fun ChatScreen(
|
||||
LaunchedEffect(voiceUiState.voiceMode) {
|
||||
if (!voiceUiState.voiceMode) voicePresentationOverride = null
|
||||
}
|
||||
|
||||
// Route classified chat errors (media cache, streaming failures, …) to
|
||||
// the app-wide snackbar. Same pattern every VM-bound screen uses.
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
@@ -812,7 +834,22 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val rawMessages by chatViewModel.messages.collectAsState()
|
||||
val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
|
||||
if (!supervised) rawMessages
|
||||
else rawMessages.map { message ->
|
||||
if (message.role == MessageRole.ASSISTANT) {
|
||||
message.copy(
|
||||
attachments = if (supervisedPolicy.capabilities.generatedImages) {
|
||||
message.attachments.filter { it.isImage }
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
cards = emptyList(),
|
||||
)
|
||||
} else message
|
||||
}
|
||||
}
|
||||
val messageReactionsSupported by chatViewModel.messageReactionsSupported.collectAsState()
|
||||
val newestReactableMessageKeys = remember(messages) {
|
||||
setOfNotNull(
|
||||
@@ -839,10 +876,17 @@ fun ChatScreen(
|
||||
// Stable voice can use the standard Hermes dashboard audio routes or the
|
||||
// optional Relay voice routes. Gate the mic on either route being usable;
|
||||
// availability picks the actionable toast when neither is.
|
||||
val voiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val connectionVoiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val voiceReady = if (supervised) {
|
||||
supervisedPolicy.capabilities.voice &&
|
||||
standardVoiceAvailability ==
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Ready
|
||||
} else {
|
||||
connectionVoiceReady
|
||||
}
|
||||
val chatSpeakResponseActionsEnabled =
|
||||
shouldOfferChatSpeakAction(voiceReady, voiceUiState.state)
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val standardVoiceSignInRouteHint by
|
||||
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
||||
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
|
||||
@@ -871,15 +915,9 @@ fun ChatScreen(
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
|
||||
val sessionActivityStates = remember(
|
||||
backgroundSessionActivityStates,
|
||||
currentSessionId,
|
||||
isStreaming,
|
||||
pendingAsk,
|
||||
) {
|
||||
resolveSessionActivityStates(
|
||||
background = backgroundSessionActivityStates,
|
||||
currentSessionId = currentSessionId,
|
||||
val sessionActivityStates = backgroundSessionActivityStates
|
||||
LaunchedEffect(currentSessionId, isStreaming, pendingAsk) {
|
||||
chatViewModel.updateCurrentSessionActivity(
|
||||
isStreaming = isStreaming,
|
||||
needsInput = pendingAsk != null,
|
||||
)
|
||||
@@ -907,6 +945,54 @@ fun ChatScreen(
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
|
||||
var allProfileSessionsLoading by remember { mutableStateOf(false) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
suspend fun refreshAllProfileSessions(showError: Boolean) {
|
||||
if (isProfileLocked || allProfileSessionsLoading) return
|
||||
allProfileSessionsLoading = true
|
||||
val result = connectionViewModel.listAllProfileSessions()
|
||||
result?.fold(
|
||||
onSuccess = { items ->
|
||||
allProfileSessions = items.mapNotNull { item ->
|
||||
val owner = item.profile?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
ProfileSessionRow(
|
||||
profile = owner,
|
||||
session = com.hermesandroid.relay.data.ChatSession(
|
||||
sessionId = item.id,
|
||||
title = item.title ?: item.preview,
|
||||
model = item.model,
|
||||
messageCount = item.messageCount ?: 0,
|
||||
inputTokens = item.inputTokens ?: 0,
|
||||
outputTokens = item.outputTokens ?: 0,
|
||||
actualCostUsd = item.actualCostUsd,
|
||||
estimatedCostUsd = item.estimatedCostUsd,
|
||||
recentlyActive = item.isActive,
|
||||
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
|
||||
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
|
||||
source = item.source,
|
||||
pinned = item.pinned,
|
||||
archived = item.archived,
|
||||
workingDirectory = item.cwd,
|
||||
gitBranch = item.gitBranch,
|
||||
gitRepoRoot = item.gitRepoRoot,
|
||||
pullRequestNumber = item.pullRequest?.number,
|
||||
pullRequestUrl = item.pullRequest?.url,
|
||||
pullRequestState = item.pullRequest?.state,
|
||||
pullRequestDraft = item.pullRequest?.draft == true,
|
||||
),
|
||||
)
|
||||
}
|
||||
chatViewModel.updateSessionActivityDirectory(
|
||||
rows = allProfileSessions.map { it.profile to it.session.sessionId },
|
||||
)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (showError) snackbarHostState.showSnackbar(
|
||||
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
allProfileSessionsLoading = false
|
||||
}
|
||||
val conversationBinding by chatViewModel.conversationBinding.collectAsState()
|
||||
val explicitBindingProfileName = conversationBinding.profileName
|
||||
.takeIf { conversationBinding.hasExplicitOwner }
|
||||
@@ -964,8 +1050,15 @@ fun ChatScreen(
|
||||
?: sessionModelState.pickerModel?.let { model ->
|
||||
modelProviders.singleOrNull { model in it.models }?.slug
|
||||
}
|
||||
val showThinking by connectionViewModel.showThinking.collectAsState()
|
||||
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
val configuredShowThinking by connectionViewModel.showThinking.collectAsState()
|
||||
val configuredToolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
val showThinking = configuredShowThinking &&
|
||||
(!supervised || supervisedVisibility.showReasoning)
|
||||
val toolDisplay = if (!supervised) configuredToolDisplay else when {
|
||||
supervisedVisibility.showToolDetails -> "detailed"
|
||||
supervisedVisibility.showToolNames -> "compact"
|
||||
else -> "off"
|
||||
}
|
||||
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
|
||||
val closeDrawerOnSend by connectionViewModel.closeDrawerOnSend.collectAsState()
|
||||
val keepComposerFocusedOnSend by
|
||||
@@ -984,7 +1077,10 @@ fun ChatScreen(
|
||||
// marker so the user knows approvals are off without opening the agent drawer.
|
||||
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
|
||||
val pendingAttachments by chatViewModel.pendingAttachments.collectAsState()
|
||||
val maxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
|
||||
val configuredMaxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
|
||||
val maxAttachmentMb = if (supervised) {
|
||||
minOf(configuredMaxAttachmentMb, supervisedPolicy.capabilities.attachmentMaxFileMb)
|
||||
} else configuredMaxAttachmentMb
|
||||
val charLimit by connectionViewModel.maxMessageLength.collectAsState()
|
||||
|
||||
// === Gateway desktop-parity state ===
|
||||
@@ -993,6 +1089,9 @@ fun ChatScreen(
|
||||
val contextWindow by chatViewModel.contextWindow.collectAsState()
|
||||
// Injected-context audit sheet (opened by tapping the context meter).
|
||||
var showContextSheet by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(supervised) {
|
||||
if (supervised) showContextSheet = false
|
||||
}
|
||||
val steerableTurn by chatViewModel.steerableTurn.collectAsState()
|
||||
val steerNotice by chatViewModel.steerNotice.collectAsState()
|
||||
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
|
||||
@@ -1306,6 +1405,13 @@ fun ChatScreen(
|
||||
val listState = rememberLazyListState()
|
||||
val userScrolledAwayState = remember(currentSessionId) { mutableStateOf(false) }
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
LaunchedEffect(chatViewModel, drawerState) {
|
||||
chatViewModel.sessionDirectoryRefreshRequests.collect {
|
||||
if (drawerState.isOpen || allProfileSessions.isNotEmpty()) {
|
||||
refreshAllProfileSessions(showError = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
PetInteractionLayer(
|
||||
owner = "chat-interaction-layer",
|
||||
active = shouldHideChatPet(
|
||||
@@ -1393,7 +1499,6 @@ fun ChatScreen(
|
||||
}
|
||||
val clipboard = LocalClipboard.current
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val handleCardAction: (String, String, HermesCardAction) -> Unit =
|
||||
remember(chatViewModel, context) {
|
||||
{ messageId, cardKey, action ->
|
||||
@@ -1988,9 +2093,9 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val showAutocomplete by remember(filteredCommands, inputText) {
|
||||
val showAutocomplete by remember(filteredCommands, inputText, supervised) {
|
||||
derivedStateOf {
|
||||
inputText.startsWith("/") && filteredCommands.isNotEmpty()
|
||||
!supervised && inputText.startsWith("/") && filteredCommands.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2022,6 +2127,7 @@ fun ChatScreen(
|
||||
// shows up without a manual reload. Cheap dashboard read; the optimistic
|
||||
// row for the active session is preserved by ChatHandler.updateSessions.
|
||||
LaunchedEffect(drawerState.isOpen) {
|
||||
chatViewModel.setSessionActivityDrawerOpen(drawerState.isOpen)
|
||||
if (drawerState.isOpen && chatReady) {
|
||||
chatViewModel.refreshSessions()
|
||||
}
|
||||
@@ -2262,7 +2368,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
val selectedProfileKey = AgentDisplay.profileSessionKey(selectedProfile?.name)
|
||||
val profileShelfAvailable = ProfilePresentationPolicy.shouldShowShelf(
|
||||
val profileShelfAvailable = !supervised && ProfilePresentationPolicy.shouldShowShelf(
|
||||
profiles = agentProfiles,
|
||||
presentation = profilePresentation,
|
||||
selectedKey = selectedProfileKey,
|
||||
@@ -2289,7 +2395,7 @@ fun ChatScreen(
|
||||
// Material routes scrim taps through the drawer's gesture handler.
|
||||
// Keep it enabled so tapping outside always dismisses the drawer; the
|
||||
// voice overlay already owns input while voice mode is visible.
|
||||
gesturesEnabled = true,
|
||||
gesturesEnabled = !supervised || supervisedPolicy.capabilities.conversationHistory,
|
||||
drawerContent = {
|
||||
val drawerProfileName = explicitBindingProfileName ?: effectiveProfile?.name
|
||||
val drawerTitle = if (drawerProfileName != null) {
|
||||
@@ -2333,7 +2439,9 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
SessionDrawerContent(
|
||||
sessions = sessions,
|
||||
sessions = if (
|
||||
supervised && !supervisedPolicy.capabilities.conversationHistory
|
||||
) emptyList() else sessions,
|
||||
currentSessionId = currentSessionId,
|
||||
scopeTitle = drawerTitle,
|
||||
scopeSubtitle = drawerSubtitle,
|
||||
@@ -2344,14 +2452,19 @@ fun ChatScreen(
|
||||
animationEnabled = animationEnabled,
|
||||
autoTitlesSupported = serverAutoTitles,
|
||||
archiveSupported = sessionArchivingSupported,
|
||||
supervisedSessionActions = supervisedPolicy.capabilities.sessionActions
|
||||
.takeIf { supervised },
|
||||
newChatEnabled = !supervised || supervisedPolicy.capabilities.newChat,
|
||||
onRefresh = { chatViewModel.refreshSessions() },
|
||||
onOpenBotMode = {
|
||||
scope.launch { drawerState.close() }
|
||||
onNavigateToBotMode()
|
||||
},
|
||||
onNewChat = {
|
||||
chatViewModel.createNewChat()
|
||||
scope.launch { drawerState.close() }
|
||||
if (!supervised || supervisedPolicy.capabilities.newChat) {
|
||||
chatViewModel.createNewChat()
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
},
|
||||
onNewDefaultChat = {
|
||||
if (isProfileLocked) return@SessionDrawerContent
|
||||
@@ -2377,6 +2490,9 @@ fun ChatScreen(
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onDeleteSession = { sessionId ->
|
||||
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Delete)) {
|
||||
return@SessionDrawerContent
|
||||
}
|
||||
val connectionId = activeConnection?.id
|
||||
val profileId = explicitBindingProfileName ?: selectedProfile?.name
|
||||
chatViewModel.deleteSession(sessionId) {
|
||||
@@ -2390,10 +2506,21 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onRenameSession = { sessionId, title ->
|
||||
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Rename)) {
|
||||
return@SessionDrawerContent
|
||||
}
|
||||
chatViewModel.renameSession(sessionId, title)
|
||||
},
|
||||
onSetSessionPinned = chatViewModel::setSessionPinned,
|
||||
onSetSessionArchived = chatViewModel::setSessionArchived,
|
||||
onSetSessionPinned = { sessionId, pinned ->
|
||||
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Pin)) {
|
||||
chatViewModel.setSessionPinned(sessionId, pinned)
|
||||
}
|
||||
},
|
||||
onSetSessionArchived = { sessionId, archived ->
|
||||
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Archive)) {
|
||||
chatViewModel.setSessionArchived(sessionId, archived)
|
||||
}
|
||||
},
|
||||
onCopySessionId = { sessionId ->
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
@@ -2423,7 +2550,7 @@ fun ChatScreen(
|
||||
onToggleSourceHidden = { source, hidden ->
|
||||
connectionViewModel.setSourceHidden(source, hidden)
|
||||
},
|
||||
allProfilesSupported = !isProfileLocked &&
|
||||
allProfilesSupported = !supervised && !isProfileLocked &&
|
||||
!activeConnection?.resolvedDashboardUrl.isNullOrBlank(),
|
||||
allProfileSessions = allProfileSessions,
|
||||
allProfileSessionsLoading = allProfileSessionsLoading,
|
||||
@@ -2431,48 +2558,7 @@ fun ChatScreen(
|
||||
onProfileColorChange = connectionViewModel::setProfileColor,
|
||||
onRefreshAllProfiles = {
|
||||
if (!isProfileLocked && !allProfileSessionsLoading) scope.launch {
|
||||
allProfileSessionsLoading = true
|
||||
val result = connectionViewModel.listAllProfileSessions()
|
||||
result?.fold(
|
||||
onSuccess = { items ->
|
||||
allProfileSessions = items.mapNotNull { item ->
|
||||
val owner = item.profile?.takeIf { it.isNotBlank() }
|
||||
?: return@mapNotNull null
|
||||
ProfileSessionRow(
|
||||
profile = owner,
|
||||
session = com.hermesandroid.relay.data.ChatSession(
|
||||
sessionId = item.id,
|
||||
title = item.title ?: item.preview,
|
||||
model = item.model,
|
||||
messageCount = item.messageCount ?: 0,
|
||||
inputTokens = item.inputTokens ?: 0,
|
||||
outputTokens = item.outputTokens ?: 0,
|
||||
actualCostUsd = item.actualCostUsd,
|
||||
estimatedCostUsd = item.estimatedCostUsd,
|
||||
isActive = item.isActive,
|
||||
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
|
||||
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
|
||||
source = item.source,
|
||||
pinned = item.pinned,
|
||||
archived = item.archived,
|
||||
workingDirectory = item.cwd,
|
||||
gitBranch = item.gitBranch,
|
||||
gitRepoRoot = item.gitRepoRoot,
|
||||
pullRequestNumber = item.pullRequest?.number,
|
||||
pullRequestUrl = item.pullRequest?.url,
|
||||
pullRequestState = item.pullRequest?.state,
|
||||
pullRequestDraft = item.pullRequest?.draft == true,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
snackbarHostState.showSnackbar(
|
||||
"Couldn't load all profiles: ${error.message ?: "unsupported"}",
|
||||
)
|
||||
},
|
||||
)
|
||||
allProfileSessionsLoading = false
|
||||
refreshAllProfileSessions(showError = true)
|
||||
}
|
||||
},
|
||||
onSelectProfileSession = { profileName, sessionId ->
|
||||
@@ -2585,8 +2671,14 @@ fun ChatScreen(
|
||||
// Top bar — messaging app style with avatar, name, model subtitle
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
|
||||
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
|
||||
}
|
||||
} else if (supervisedPolicy.capabilities.newChat) {
|
||||
IconButton(onClick = { chatViewModel.createNewChat() }) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = "New chat")
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
@@ -2618,8 +2710,10 @@ fun ChatScreen(
|
||||
// style subtitle status.
|
||||
var everConnected by remember { mutableStateOf(false) }
|
||||
if (headerChatReady) everConnected = true
|
||||
val showStreamingState = isStreaming &&
|
||||
(!supervised || supervisedVisibility.showWorkingStatus)
|
||||
val statusText = when {
|
||||
headerChatReady -> if (isStreaming) {
|
||||
headerChatReady -> if (showStreamingState) {
|
||||
stringResource(R.string.chat_streaming)
|
||||
} else {
|
||||
stringResource(R.string.chat_connected_label)
|
||||
@@ -2669,6 +2763,14 @@ fun ChatScreen(
|
||||
// personality label.
|
||||
val subtitleText = if (!headerChatReady) {
|
||||
statusText
|
||||
} else if (supervised) {
|
||||
buildList {
|
||||
if (supervisedVisibility.showProfileName) {
|
||||
conversationProfile?.name?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
}
|
||||
if (supervisedVisibility.showModelName && !modelName.isNullOrBlank()) add(modelName)
|
||||
if (isEmpty() && supervisedVisibility.showConnectionStatus) add(statusText)
|
||||
}.joinToString(" · ")
|
||||
} else {
|
||||
resolveChatHeaderSubtitle(
|
||||
isStreaming = isStreaming,
|
||||
@@ -2687,7 +2789,7 @@ fun ChatScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
.clickable(enabled = !supervised) {
|
||||
if (profileShelfAvailable) {
|
||||
showProfileShelf = !showProfileShelf
|
||||
} else {
|
||||
@@ -2711,7 +2813,7 @@ fun ChatScreen(
|
||||
// Avatar — a plain 40dp circle whose letter swaps to the
|
||||
// active agent (profile or personality). No overlay ring:
|
||||
// the letter itself is the indicator.
|
||||
Box(modifier = Modifier.size(40.dp)) {
|
||||
if (!supervised || supervisedVisibility.showAgentIdentity) Box(modifier = Modifier.size(40.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = CircleShape,
|
||||
@@ -2761,14 +2863,16 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
ConnectionStatusBadge(
|
||||
isConnected = headerChatReady,
|
||||
isConnecting = isConnecting,
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
size = 10.dp
|
||||
)
|
||||
if (!supervised || supervisedVisibility.showConnectionStatus) {
|
||||
ConnectionStatusBadge(
|
||||
isConnected = headerChatReady,
|
||||
isConnecting = isConnecting,
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
size = 10.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Name + single-line subtitle.
|
||||
@@ -2809,7 +2913,13 @@ fun ChatScreen(
|
||||
} else {
|
||||
Column {
|
||||
Text(
|
||||
text = if (agentDisplayName.isNotBlank()) agentDisplayName else stringResource(R.string.chat_agent_default),
|
||||
text = if (supervised && !supervisedVisibility.showAgentIdentity) {
|
||||
stringResource(R.string.screen_chat_label)
|
||||
} else if (agentDisplayName.isNotBlank()) {
|
||||
agentDisplayName
|
||||
} else {
|
||||
stringResource(R.string.chat_agent_default)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
@@ -2846,7 +2956,7 @@ fun ChatScreen(
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
if (isStreaming && animationEnabled) {
|
||||
if (showStreamingState && animationEnabled) {
|
||||
StreamingDots(
|
||||
color = subtitleColor,
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
@@ -2867,7 +2977,7 @@ fun ChatScreen(
|
||||
// full explanation (global mode / --yolo / per-session)
|
||||
// lives. Keeps the risk visible without eating subtitle
|
||||
// width on every turn.
|
||||
if (yoloEnabled == true) {
|
||||
if (!supervised && yoloEnabled == true) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Bolt,
|
||||
contentDescription = stringResource(R.string.cd_approvals_off),
|
||||
@@ -2885,12 +2995,14 @@ fun ChatScreen(
|
||||
// tappable → Connections, so the affordance moved with the
|
||||
// info. Dropping it here declutters the actions row and frees
|
||||
// width for the title subtitle.)
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = stringResource(R.string.cd_terminal),
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
if (!supervised) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = stringResource(R.string.cd_terminal),
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = stringResource(R.string.cd_settings),
|
||||
@@ -2903,7 +3015,11 @@ fun ChatScreen(
|
||||
// Settings — which is what was squeezing the title subtitle.
|
||||
// Session identity is useful before the first message; sharing only appears
|
||||
// once the conversation has content.
|
||||
if (messages.isNotEmpty() || !currentSessionId.isNullOrBlank()) {
|
||||
if (
|
||||
(!supervised && (messages.isNotEmpty() || !currentSessionId.isNullOrBlank())) ||
|
||||
(supervised && messages.isNotEmpty() &&
|
||||
supervisedPolicy.allowsSessionAction(SupervisedSessionAction.ShareTranscript))
|
||||
) {
|
||||
var showOverflowMenu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
RelayChromeIconButton(
|
||||
@@ -2916,7 +3032,7 @@ fun ChatScreen(
|
||||
expanded = showOverflowMenu,
|
||||
onDismissRequest = { showOverflowMenu = false },
|
||||
) {
|
||||
currentSessionId?.takeIf { it.isNotBlank() }?.let { sessionId ->
|
||||
currentSessionId?.takeIf { !supervised && it.isNotBlank() }?.let { sessionId ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(copySessionIdLabel) },
|
||||
leadingIcon = {
|
||||
@@ -2941,7 +3057,7 @@ fun ChatScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (messages.isNotEmpty()) {
|
||||
if (!supervised && messages.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_search_conversation)) },
|
||||
leadingIcon = {
|
||||
@@ -2965,6 +3081,22 @@ fun ChatScreen(
|
||||
shareConversation(context, messages)
|
||||
},
|
||||
)
|
||||
} else if (
|
||||
messages.isNotEmpty() &&
|
||||
supervisedPolicy.allowsSessionAction(
|
||||
SupervisedSessionAction.ShareTranscript,
|
||||
)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_share_conversation)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Share, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
showOverflowMenu = false
|
||||
shareConversation(context, messages)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3006,13 +3138,15 @@ fun ChatScreen(
|
||||
// and the mode strip — slim bar + `NN% · used/max` token readout,
|
||||
// color-graded by fullness. Composes to nothing until the server
|
||||
// reports a context_max for the session.
|
||||
ContextMeterBar(
|
||||
usedFraction = contextUsage,
|
||||
usedTokens = contextWindow?.usedTokens,
|
||||
maxTokens = contextWindow?.maxTokens,
|
||||
onClick = { showContextSheet = true },
|
||||
)
|
||||
if (showContextSheet) {
|
||||
if (!supervised || supervisedVisibility.showUsage) {
|
||||
ContextMeterBar(
|
||||
usedFraction = contextUsage,
|
||||
usedTokens = contextWindow?.usedTokens,
|
||||
maxTokens = contextWindow?.maxTokens,
|
||||
onClick = if (supervised) null else ({ showContextSheet = true }),
|
||||
)
|
||||
}
|
||||
if (!supervised && showContextSheet) {
|
||||
// Live audit of the exact extra context the agent will be
|
||||
// injected with on the next turn (transparency / auditability).
|
||||
InjectedContextSheet(
|
||||
@@ -3073,7 +3207,31 @@ fun ChatScreen(
|
||||
},
|
||||
label = "chatEmptyStatePhaseTransition",
|
||||
) { targetConnectState ->
|
||||
if (targetConnectState == ChatConnectState.Connecting) {
|
||||
if (supervised && targetConnectState != ChatConnectState.Ready) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (supervisedVisibility.showConnectionStatus) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (targetConnectState == ChatConnectState.Connecting) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
Text(
|
||||
text = if (targetConnectState == ChatConnectState.Connecting) {
|
||||
stringResource(R.string.chat_connecting_dots)
|
||||
} else {
|
||||
stringResource(R.string.chat_disconnected_label)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (targetConnectState == ChatConnectState.Connecting) {
|
||||
ChatColdStartLoadingState(
|
||||
animationEnabled = animationEnabled,
|
||||
streamingIntensity = streamingIntensity,
|
||||
@@ -3113,7 +3271,10 @@ fun ChatScreen(
|
||||
Spacer(modifier = Modifier.weight(0.15f))
|
||||
|
||||
// ASCII sphere (constrained to square aspect)
|
||||
if (LocalBackgroundVisualizationEnabled.current) {
|
||||
if (
|
||||
LocalBackgroundVisualizationEnabled.current &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -3141,7 +3302,10 @@ fun ChatScreen(
|
||||
// thread itself (not just the header) -
|
||||
// the desktop's intro.
|
||||
ChatConnectState.Ready ->
|
||||
if (effectiveProfile != null) {
|
||||
if (
|
||||
effectiveProfile != null &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
|
||||
} else {
|
||||
stringResource(R.string.chat_start_conversation)
|
||||
@@ -3159,7 +3323,11 @@ fun ChatScreen(
|
||||
val profileBlurb = effectiveProfile?.description
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals(agentDisplayName, ignoreCase = true) }
|
||||
if (targetConnectState == ChatConnectState.Ready && profileBlurb != null) {
|
||||
if (
|
||||
targetConnectState == ChatConnectState.Ready &&
|
||||
profileBlurb != null &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = profileBlurb,
|
||||
@@ -3276,6 +3444,7 @@ fun ChatScreen(
|
||||
// Ambient avatar behind messages
|
||||
if (
|
||||
LocalBackgroundVisualizationEnabled.current &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity) &&
|
||||
animationBehindChat &&
|
||||
!ambientMode
|
||||
) {
|
||||
@@ -3297,8 +3466,13 @@ fun ChatScreen(
|
||||
// /media/by-path route when a relay session is paired,
|
||||
// instead of degrading to the "image is on the server"
|
||||
// notice. Null when no relay (standard no-plugin) → notice.
|
||||
val relayServerImageResolver = remember(chatViewModel) {
|
||||
RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
|
||||
val relayServerImageResolver = remember(
|
||||
chatViewModel,
|
||||
supervised,
|
||||
supervisedPolicy.capabilities.generatedImages,
|
||||
) {
|
||||
if (supervised && !supervisedPolicy.capabilities.generatedImages) null
|
||||
else RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
|
||||
}
|
||||
val thinkingIndicatorConfig = remember(
|
||||
thinkingIndicatorStyle,
|
||||
@@ -3353,6 +3527,7 @@ fun ChatScreen(
|
||||
items(messages.size, key = { messages[it].uiKey }) { index ->
|
||||
val message = messages[index]
|
||||
val processNotification = message.hermesProcessNotificationOrNull()
|
||||
?.takeIf { !supervised || supervisedVisibility.showToolNames }
|
||||
|
||||
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
|
||||
// no attachments). Attachments keep the bubble alive for inbound media;
|
||||
@@ -3377,7 +3552,10 @@ fun ChatScreen(
|
||||
messages[index + 1].timestamp - message.timestamp > GROUP_GAP_MS
|
||||
|
||||
// Date separator
|
||||
if (index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp)) {
|
||||
if (
|
||||
(!supervised || supervisedVisibility.showTimestamps) &&
|
||||
(index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp))
|
||||
) {
|
||||
DateSeparator(timestamp = message.timestamp)
|
||||
}
|
||||
|
||||
@@ -3389,7 +3567,9 @@ fun ChatScreen(
|
||||
message.attachments.isNotEmpty() ||
|
||||
message.cards.isNotEmpty()
|
||||
|
||||
message.backgroundTask?.let { task ->
|
||||
message.backgroundTask
|
||||
?.takeIf { !supervised || supervisedVisibility.showWorkingStatus }
|
||||
?.let { task ->
|
||||
val taskModifier = Modifier.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
|
||||
@@ -3447,6 +3627,14 @@ fun ChatScreen(
|
||||
},
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
showThinking = showThinking,
|
||||
showAgentIdentity = !supervised || supervisedVisibility.showAgentIdentity,
|
||||
showTimestamps = !supervised || supervisedVisibility.showTimestamps,
|
||||
showWorkingStatus = !supervised || supervisedVisibility.showWorkingStatus,
|
||||
showUsage = !supervised || supervisedVisibility.showUsage,
|
||||
showTechnicalBadges = !supervised || supervisedVisibility.showTechnicalRoute,
|
||||
showAssistantImages = !supervised || supervisedPolicy.capabilities.generatedImages,
|
||||
allowAssistantImageExport = !supervised ||
|
||||
supervisedPolicy.capabilities.shareGeneratedImages,
|
||||
isFirstInGroup = isFirstInGroup,
|
||||
isLastInGroup = isLastInGroup,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
@@ -3459,9 +3647,9 @@ fun ChatScreen(
|
||||
onAttachmentManualFetch = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onCardAction = handleCardAction,
|
||||
onCardInput = handleCardInput,
|
||||
onSessionReference = { reference ->
|
||||
onCardAction = if (supervised) ({ _, _, _ -> }) else handleCardAction,
|
||||
onCardInput = if (supervised) ({ _, _, _ -> }) else handleCardInput,
|
||||
onSessionReference = if (supervised) null else { reference ->
|
||||
val target = agentProfiles.firstOrNull {
|
||||
it.name.equals(reference.profile, ignoreCase = true)
|
||||
}
|
||||
@@ -3479,6 +3667,7 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onReact = if (
|
||||
!supervised &&
|
||||
isGatewayTransport &&
|
||||
messageReactionsSupported &&
|
||||
!message.isStreaming &&
|
||||
@@ -3492,6 +3681,7 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
onEditMessage = if (
|
||||
(!supervised || supervisedPolicy.capabilities.editAndResend) &&
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
message.role == MessageRole.USER &&
|
||||
@@ -3510,10 +3700,14 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
animationEnabled = animationEnabled,
|
||||
onQuoteMessage = { quoted ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
quotedMessage = quoted
|
||||
},
|
||||
onQuoteMessage = if (
|
||||
!supervised || supervisedPolicy.capabilities.quoteReplies
|
||||
) {
|
||||
{ quoted ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
quotedMessage = quoted
|
||||
}
|
||||
} else null,
|
||||
onNavigateToMessage = { messageId ->
|
||||
val targetIndex = messages.indexOfFirst { it.id == messageId }
|
||||
if (targetIndex >= 0) {
|
||||
@@ -3524,7 +3718,10 @@ fun ChatScreen(
|
||||
scope.launch { listState.animateScrollToItem(targetIndex + 1) }
|
||||
}
|
||||
},
|
||||
onSpeakMessage = if (chatSpeakResponseActionsEnabled) {
|
||||
onSpeakMessage = if (
|
||||
chatSpeakResponseActionsEnabled &&
|
||||
(!supervised || supervisedPolicy.capabilities.voice)
|
||||
) {
|
||||
{ text -> voiceViewModel.speakResponse(text) }
|
||||
} else {
|
||||
null
|
||||
@@ -3535,6 +3732,9 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
onCopyMessage = { text ->
|
||||
if (supervised && !supervisedPolicy.capabilities.copyResponses) {
|
||||
return@MessageBubble
|
||||
}
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
// The new Clipboard API is suspend-based, so the
|
||||
// setClipEntry call has to live inside a coroutine.
|
||||
@@ -4003,7 +4203,8 @@ fun ChatScreen(
|
||||
// Gateway redirect is text-only. Attachment-bearing follow-ups must
|
||||
// retain their files in the session-owned queue instead of showing
|
||||
// a correction action that cannot carry them.
|
||||
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty()
|
||||
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty() &&
|
||||
(!supervised || supervisedPolicy.capabilities.steerResponse)
|
||||
val trailing = when {
|
||||
!isStreaming && hasContent -> ChatInputTrailing.SEND
|
||||
!isStreaming -> ChatInputTrailing.VOICE
|
||||
@@ -4136,7 +4337,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val modelControl = modelPickerOptions.takeIf { it.isNotEmpty() }?.let {
|
||||
val modelControl = modelPickerOptions.takeIf { !supervised && it.isNotEmpty() }?.let {
|
||||
ChatInputPickerControl(
|
||||
value = compactModelChipLabel(currentModelForInput, modelDefaultLabel),
|
||||
contentDescription = stringResource(R.string.cd_select_model),
|
||||
@@ -4187,6 +4388,7 @@ fun ChatScreen(
|
||||
// is definitively unreachable (SSE-only) — the agent sheet carries the
|
||||
// disabled-with-reason version there.
|
||||
val effortControl = if (
|
||||
!supervised &&
|
||||
chatGatewayAvailability != GatewayAvailability.Unreachable &&
|
||||
effortAvailability.supported != false &&
|
||||
effortPickerOptions.isNotEmpty()
|
||||
@@ -4203,7 +4405,13 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
visibleChatFailure?.let { failure ->
|
||||
val failureRouteLabel = when (failure.route) {
|
||||
val displayFailure = if (!supervised) failure else failure.copy(
|
||||
model = failure.model.takeIf { supervisedVisibility.showModelName },
|
||||
provider = failure.provider.takeIf { supervisedVisibility.showTechnicalRoute },
|
||||
)
|
||||
val failureRouteLabel = if (
|
||||
supervised && !supervisedVisibility.showTechnicalRoute
|
||||
) "" else when (failure.route) {
|
||||
ChatFailureRoute.GATEWAY ->
|
||||
stringResource(R.string.chat_failure_route_gateway)
|
||||
ChatFailureRoute.API_FALLBACK ->
|
||||
@@ -4211,23 +4419,28 @@ fun ChatScreen(
|
||||
null -> ""
|
||||
}
|
||||
ChatFailurePanel(
|
||||
failure = failure,
|
||||
failure = displayFailure,
|
||||
routeLabel = failureRouteLabel,
|
||||
onDetails = { showChatFailureDetails = true },
|
||||
onRetry = { chatViewModel.retryLastMessage() },
|
||||
onRetry = {
|
||||
if (!supervised || supervisedPolicy.capabilities.retryResponse) {
|
||||
chatViewModel.retryLastMessage()
|
||||
}
|
||||
},
|
||||
onDismiss = chatViewModel::dismissChatFailure,
|
||||
showDetails = !supervised || supervisedVisibility.showTechnicalRoute,
|
||||
)
|
||||
if (showChatFailureDetails) {
|
||||
ChatFailureDetailsDialog(
|
||||
failure = failure,
|
||||
failure = displayFailure,
|
||||
routeLabel = failureRouteLabel,
|
||||
onCopy = {
|
||||
val details = buildString {
|
||||
append(failureRouteLabel)
|
||||
failure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
failure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
displayFailure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
displayFailure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
append("\n\n")
|
||||
append(failure.rawError)
|
||||
append(displayFailure.rawError)
|
||||
}
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
@@ -4309,6 +4522,9 @@ fun ChatScreen(
|
||||
)
|
||||
},
|
||||
onStop = {
|
||||
if (supervised && !supervisedPolicy.capabilities.cancelResponse) {
|
||||
return@ChatInputBar
|
||||
}
|
||||
chatViewModel.cancelStream()
|
||||
// Firm haptic (LongPress — TextHandleMove was near-
|
||||
// imperceptible) plus a "Stopped" badge stamped on the turn
|
||||
@@ -4324,14 +4540,44 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onAttachPhotos = {
|
||||
photoPickerLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
val allowed = !supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories &&
|
||||
pendingAttachments.size < supervisedPolicy.capabilities.attachmentMaxCount
|
||||
)
|
||||
if (allowed) {
|
||||
photoPickerLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
}
|
||||
},
|
||||
onAttachFiles = { filePickerLauncher.launch(arrayOf("*/*")) },
|
||||
onAttachCamera = requestCameraCapture,
|
||||
onPasteImage = pasteImageFromClipboard,
|
||||
onLongPressAttach = { showCommandPalette = true },
|
||||
onAttachFiles = {
|
||||
if (!supervised || supervisedPolicy.capabilities.attachments) {
|
||||
val mimeTypes = if (!supervised) arrayOf("*/*") else buildList {
|
||||
val categories = supervisedPolicy.capabilities.attachmentCategories
|
||||
if (SupervisedAttachmentCategory.Images in categories) add("image/*")
|
||||
if (SupervisedAttachmentCategory.Audio in categories) add("audio/*")
|
||||
if (SupervisedAttachmentCategory.Video in categories) add("video/*")
|
||||
if (SupervisedAttachmentCategory.Documents in categories) {
|
||||
add("text/*")
|
||||
add("application/pdf")
|
||||
}
|
||||
}.toTypedArray()
|
||||
if (mimeTypes.isNotEmpty()) filePickerLauncher.launch(mimeTypes)
|
||||
}
|
||||
},
|
||||
onAttachCamera = if (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
)) requestCameraCapture else ({ }),
|
||||
onPasteImage = if (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
)) pasteImageFromClipboard else ({ }),
|
||||
onLongPressAttach = { if (!supervised) showCommandPalette = true },
|
||||
charLimit = charLimit,
|
||||
caption = turnStatus ?: inputCaption,
|
||||
voiceReady = voiceReady,
|
||||
@@ -4343,8 +4589,13 @@ fun ChatScreen(
|
||||
submitEnabled = pendingAttachments.none {
|
||||
it.state == com.hermesandroid.relay.data.AttachmentState.LOADING
|
||||
},
|
||||
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS
|
||||
.takeIf { convertLargePastesToAttachments },
|
||||
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS.takeIf {
|
||||
convertLargePastesToAttachments && (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Documents in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
))
|
||||
},
|
||||
onLargePaste = { pastedText ->
|
||||
val owner = activeComposerDraftKey ?: composerDraftKey
|
||||
val sizeBytes = pastedText.toByteArray(Charsets.UTF_8).size.toLong()
|
||||
@@ -4662,7 +4913,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// Command palette bottom sheet
|
||||
if (showCommandPalette) {
|
||||
if (showCommandPalette && !supervised) {
|
||||
CommandPalette(
|
||||
commands = allCommands,
|
||||
onSelect = { cmd ->
|
||||
@@ -4690,7 +4941,7 @@ fun ChatScreen(
|
||||
// personality, connection summary). Replaces the old AlertDialog and the
|
||||
// two top-bar chips (ProfilePicker + PersonalityPicker). Tap target is
|
||||
// the title Row in the TopAppBar above.
|
||||
if (showAgentInfo) {
|
||||
if (showAgentInfo && !supervised) {
|
||||
AgentInfoSheet(
|
||||
connectionViewModel = connectionViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.GitBranch
|
||||
import com.hermesandroid.relay.data.GitDiff
|
||||
import com.hermesandroid.relay.data.GitFile
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.data.GitStatus
|
||||
import com.hermesandroid.relay.viewmodel.GitConfirmationStrings
|
||||
import com.hermesandroid.relay.viewmodel.GitContentViewState
|
||||
import com.hermesandroid.relay.viewmodel.GitMessageGenerationState
|
||||
import com.hermesandroid.relay.viewmodel.GitMutationState
|
||||
import com.hermesandroid.relay.viewmodel.GitRepoDetailState
|
||||
import com.hermesandroid.relay.viewmodel.GitStateUiState
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import com.hermesandroid.relay.viewmodel.GitTarget
|
||||
|
||||
/**
|
||||
* Git State screen (read + write): repo picker → working-tree status/branches →
|
||||
* per-file diff or content. Writes require the ``plugin.api.write`` grant and
|
||||
* destructive ops (discard/push/dirty-checkout) require an explicit per-use
|
||||
* confirmation dialog; the fixed confirmation token is sent only on confirm.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GitStateScreen(
|
||||
viewModel: GitStateViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val reposState by viewModel.repos.collectAsState()
|
||||
val detailState by viewModel.detail.collectAsState()
|
||||
val contentState by viewModel.content.collectAsState()
|
||||
val mutationState by viewModel.mutation.collectAsState()
|
||||
val hasGrant by viewModel.writeGrant.collectAsState()
|
||||
|
||||
// Hoisted at screen level so confirmation/commit dialogs are modal.
|
||||
var pendingConfirm by remember { mutableStateOf<ConfirmationRequest?>(null) }
|
||||
var showCommitDialog by remember { mutableStateOf(false) }
|
||||
var pushAfterCommit by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Staged paths for the AI magic-wand (commit_message_selected) + commit.
|
||||
val stagedPaths = (detailState as? GitRepoDetailState.Ready)
|
||||
?.status?.staged?.map { it.path } ?: emptyList()
|
||||
|
||||
val messageGenerationState by viewModel.messageGeneration.collectAsState()
|
||||
val stashNotice by viewModel.stashNotice.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.git_state_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
stringResource(R.string.git_state_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val repos = reposState
|
||||
when (repos) {
|
||||
GitStateUiState.Loading -> CenteredSpinner()
|
||||
is GitStateUiState.Error -> ErrorText(repos.message)
|
||||
is GitStateUiState.Ready -> {
|
||||
repos.notice?.let { ErrorText(it, warning = true) }
|
||||
RepoPicker(
|
||||
repos = repos.repos,
|
||||
selectedId = viewModel.selectedRepoIdForDisplay(),
|
||||
onSelect = viewModel::selectRepo,
|
||||
)
|
||||
when (val current = detailState) {
|
||||
GitRepoDetailState.Idle -> Unit
|
||||
GitRepoDetailState.Loading -> CenteredSpinner()
|
||||
is GitRepoDetailState.Error -> ErrorText(current.message)
|
||||
is GitRepoDetailState.Ready -> {
|
||||
MutationBanner(
|
||||
mutation = mutationState,
|
||||
onClear = viewModel::clearMutationError,
|
||||
)
|
||||
stashNotice?.let { notice ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
notice,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!hasGrant) {
|
||||
WriteGrantNotice()
|
||||
}
|
||||
RepoDetail(
|
||||
status = current.status,
|
||||
branches = current.branches,
|
||||
hasGrant = hasGrant,
|
||||
onShowDiff = viewModel::loadDiff,
|
||||
onShowFile = viewModel::loadFile,
|
||||
onStage = { path -> viewModel.stage(listOf(path)) },
|
||||
onUnstage = { path -> viewModel.unstage(listOf(path)) },
|
||||
onDiscard = { paths, deleteUntracked ->
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
pendingConfirm = ConfirmationRequest.Discard(paths, deleteUntracked, target)
|
||||
}
|
||||
},
|
||||
onCommitRequest = { showCommitDialog = true },
|
||||
onFetch = { viewModel.fetch() },
|
||||
onPull = { viewModel.pull() },
|
||||
onPush = {
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
pendingConfirm = ConfirmationRequest.Push(target)
|
||||
}
|
||||
},
|
||||
onSwitchBranch = { ref ->
|
||||
val dirty = current.status.counts.staged > 0 ||
|
||||
current.status.counts.modified > 0 ||
|
||||
current.status.counts.untracked > 0
|
||||
if (dirty) {
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
pendingConfirm = ConfirmationRequest.DirtyCheckout(ref, target)
|
||||
}
|
||||
} else {
|
||||
viewModel.checkout(ref)
|
||||
}
|
||||
},
|
||||
onStashSwitchBranch = { ref ->
|
||||
viewModel.stashCheckout(ref)
|
||||
},
|
||||
onCreateBranch = { name, track ->
|
||||
viewModel.checkout("", newBranch = name, track = track)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
ContentView(state = contentState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCommitDialog) {
|
||||
CommitDialog(
|
||||
onDismiss = { showCommitDialog = false },
|
||||
hasStaged = stagedPaths.isNotEmpty(),
|
||||
generatingMessage = messageGenerationState is GitMessageGenerationState.Loading,
|
||||
onGenerate = {
|
||||
viewModel.generateCommitMessage(
|
||||
if (stagedPaths.isNotEmpty()) stagedPaths else null,
|
||||
)
|
||||
},
|
||||
generatedMessage = (messageGenerationState as? GitMessageGenerationState.Ready)?.message ?: "",
|
||||
generationNotice = (messageGenerationState as? GitMessageGenerationState.Ready)?.notice,
|
||||
pushAfterCommit = pushAfterCommit,
|
||||
onPushAfterCommitChange = { pushAfterCommit = it },
|
||||
onCommit = { message ->
|
||||
showCommitDialog = false
|
||||
viewModel.commit(message) { committedTarget ->
|
||||
if (pushAfterCommit) {
|
||||
pendingConfirm = ConfirmationRequest.Push(committedTarget)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingConfirm?.let { request ->
|
||||
val onDismiss = { pendingConfirm = null }
|
||||
when (request) {
|
||||
is ConfirmationRequest.Discard -> AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_confirm_discard_title)) },
|
||||
text = { Text(stringResource(R.string.git_state_confirm_discard_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingConfirm = null
|
||||
viewModel.discard(
|
||||
request.paths,
|
||||
GitConfirmationStrings.DISCARD,
|
||||
request.deleteUntracked,
|
||||
request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_discard_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
is ConfirmationRequest.Push -> AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_confirm_push_title)) },
|
||||
text = { Text(stringResource(R.string.git_state_confirm_push_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingConfirm = null
|
||||
viewModel.push(
|
||||
GitConfirmationStrings.PUSH,
|
||||
expectedTarget = request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_push_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
is ConfirmationRequest.DirtyCheckout -> AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_confirm_checkout_dirty_title)) },
|
||||
text = { Text(stringResource(R.string.git_state_confirm_checkout_dirty_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingConfirm = null
|
||||
viewModel.checkout(
|
||||
request.ref,
|
||||
GitConfirmationStrings.DIRTY_CHECKOUT,
|
||||
expectedTarget = request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_checkout_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A destructive action awaiting explicit user confirmation. */
|
||||
private sealed interface ConfirmationRequest {
|
||||
data class Discard(
|
||||
val paths: List<String>,
|
||||
val deleteUntracked: Boolean,
|
||||
val target: GitTarget,
|
||||
) : ConfirmationRequest
|
||||
|
||||
data class Push(val target: GitTarget) : ConfirmationRequest
|
||||
data class DirtyCheckout(val ref: String, val target: GitTarget) : ConfirmationRequest
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommitDialog(
|
||||
onDismiss: () -> Unit,
|
||||
hasStaged: Boolean,
|
||||
generatingMessage: Boolean,
|
||||
onGenerate: () -> Unit,
|
||||
generatedMessage: String,
|
||||
generationNotice: String?,
|
||||
pushAfterCommit: Boolean,
|
||||
onPushAfterCommitChange: (Boolean) -> Unit,
|
||||
onCommit: (String) -> Unit,
|
||||
) {
|
||||
var message by rememberSaveable { mutableStateOf("") }
|
||||
// Pre-fill with the latest generated suggestion when it arrives.
|
||||
if (generatedMessage.isNotEmpty() && message.isBlank()) {
|
||||
message = generatedMessage
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_commit_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
label = { Text(stringResource(R.string.git_state_commit_message_hint)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = false,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onGenerate, enabled = hasStaged && !generatingMessage) {
|
||||
Icon(
|
||||
Icons.Filled.AutoAwesome,
|
||||
stringResource(R.string.git_state_generate_message),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (generatingMessage) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_generating_message),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
generationNotice?.let { notice ->
|
||||
Text(
|
||||
notice,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = pushAfterCommit, onCheckedChange = onPushAfterCommitChange)
|
||||
Text(stringResource(R.string.git_state_push_after_commit))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onCommit(message) },
|
||||
enabled = message.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_commit_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepoPicker(
|
||||
repos: List<GitRepo>,
|
||||
selectedId: String?,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
repos.forEach { repo ->
|
||||
AssistChip(
|
||||
onClick = { onSelect(repo.id) },
|
||||
label = {
|
||||
Text(
|
||||
if (repo.dirty) "${repo.name} •" else repo.name,
|
||||
fontWeight = if (repo.id == selectedId) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutationBanner(
|
||||
mutation: GitMutationState,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
when (mutation) {
|
||||
GitMutationState.Idle -> Unit
|
||||
is GitMutationState.InProgress -> Card(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp)
|
||||
Text(
|
||||
stringResource(R.string.git_state_mutation_in_progress, displayLabel(mutation.label)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
is GitMutationState.Success -> Card(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.git_state_mutation_success,
|
||||
displayLabel(mutation.label),
|
||||
mutation.head,
|
||||
),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
is GitMutationState.Error -> Card(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.git_state_mutation_failed,
|
||||
displayLabel(mutation.label),
|
||||
mutation.message,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
TextButton(onClick = onClear) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayLabel(label: String): String =
|
||||
label.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
|
||||
@Composable
|
||||
private fun WriteGrantNotice() {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_write_grant_required),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepoDetail(
|
||||
status: GitStatus,
|
||||
branches: List<GitBranch>,
|
||||
hasGrant: Boolean,
|
||||
onShowDiff: (String, String) -> Unit,
|
||||
onShowFile: (String) -> Unit,
|
||||
onStage: (String) -> Unit,
|
||||
onUnstage: (String) -> Unit,
|
||||
onDiscard: (List<String>, Boolean) -> Unit,
|
||||
onCommitRequest: () -> Unit,
|
||||
onFetch: () -> Unit,
|
||||
onPull: () -> Unit,
|
||||
onPush: () -> Unit,
|
||||
onSwitchBranch: (String) -> Unit,
|
||||
onStashSwitchBranch: (String) -> Unit,
|
||||
onCreateBranch: (String, Boolean) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"${stringResource(R.string.git_state_staged)} ${status.counts.staged} · " +
|
||||
"${stringResource(R.string.git_state_modified)} ${status.counts.modified} · " +
|
||||
"${stringResource(R.string.git_state_untracked)} ${status.counts.untracked}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
if (status.truncated) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_truncated),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
status.staged.takeIf { it.isNotEmpty() }?.let { staged ->
|
||||
GroupHeader(stringResource(R.string.git_state_staged))
|
||||
staged.forEach { file ->
|
||||
StatusRow(
|
||||
path = file.path,
|
||||
primaryLabel = stringResource(R.string.git_state_unstage),
|
||||
onPrimary = { onUnstage(file.path) },
|
||||
secondaryLabel = stringResource(R.string.git_state_discard),
|
||||
onSecondary = { onDiscard(listOf(file.path), false) },
|
||||
onOpen = { onShowDiff(file.path, "staged") },
|
||||
enabled = hasGrant,
|
||||
)
|
||||
}
|
||||
}
|
||||
status.modified.takeIf { it.isNotEmpty() }?.let { modified ->
|
||||
GroupHeader(stringResource(R.string.git_state_modified))
|
||||
modified.forEach { file ->
|
||||
StatusRow(
|
||||
path = file.path,
|
||||
primaryLabel = stringResource(R.string.git_state_stage),
|
||||
onPrimary = { onStage(file.path) },
|
||||
secondaryLabel = stringResource(R.string.git_state_discard),
|
||||
onSecondary = { onDiscard(listOf(file.path), false) },
|
||||
onOpen = { onShowDiff(file.path, "unstaged") },
|
||||
enabled = hasGrant,
|
||||
)
|
||||
}
|
||||
}
|
||||
status.untracked.takeIf { it.isNotEmpty() }?.let { untracked ->
|
||||
GroupHeader(stringResource(R.string.git_state_untracked))
|
||||
untracked.forEach { file ->
|
||||
StatusRow(
|
||||
path = file.path,
|
||||
primaryLabel = stringResource(R.string.git_state_stage),
|
||||
onPrimary = { onStage(file.path) },
|
||||
secondaryLabel = stringResource(R.string.git_state_discard),
|
||||
onSecondary = { onDiscard(listOf(file.path), true) },
|
||||
onOpen = { onShowFile(file.path) },
|
||||
enabled = hasGrant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit + sync controls (writes; all gated by the grant).
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onFetch,
|
||||
enabled = hasGrant && status.counts.untracked == 0,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_fetch))
|
||||
}
|
||||
OutlinedButton(onClick = onPull, enabled = hasGrant) {
|
||||
Text(stringResource(R.string.git_state_pull))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onPush,
|
||||
enabled = hasGrant && status.counts.staged == 0,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_push))
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = onCommitRequest,
|
||||
enabled = hasGrant && status.counts.staged > 0,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_commit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (branches.isNotEmpty()) {
|
||||
BranchCard(
|
||||
branches = branches,
|
||||
hasGrant = hasGrant,
|
||||
onSwitch = onSwitchBranch,
|
||||
onStashSwitch = onStashSwitchBranch,
|
||||
onCreate = onCreateBranch,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusRow(
|
||||
path: String,
|
||||
primaryLabel: String,
|
||||
onPrimary: () -> Unit,
|
||||
secondaryLabel: String,
|
||||
onSecondary: () -> Unit,
|
||||
onOpen: () -> Unit,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onOpen, modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
path,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onPrimary, enabled = enabled) {
|
||||
Text(primaryLabel)
|
||||
}
|
||||
TextButton(onClick = onSecondary, enabled = enabled) {
|
||||
Text(secondaryLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BranchCard(
|
||||
branches: List<GitBranch>,
|
||||
hasGrant: Boolean,
|
||||
onSwitch: (String) -> Unit,
|
||||
onStashSwitch: (String) -> Unit,
|
||||
onCreate: (String, Boolean) -> Unit,
|
||||
) {
|
||||
var newBranchName by rememberSaveable { mutableStateOf("") }
|
||||
var track by rememberSaveable { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_branches),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
branches.forEach { branch ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
branchLabel(branch),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
if (branch.isCurrent) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_current),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { onSwitch(branch.name) },
|
||||
enabled = hasGrant,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_switch))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onStashSwitch(branch.name) },
|
||||
enabled = hasGrant,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_switch_stash))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new branch (optionally tracking the remote).
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newBranchName,
|
||||
onValueChange = { newBranchName = it },
|
||||
label = { Text(stringResource(R.string.git_state_new_branch_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val name = newBranchName.trim()
|
||||
if (name.isNotEmpty()) {
|
||||
onCreate(name, track)
|
||||
newBranchName = ""
|
||||
track = false
|
||||
}
|
||||
},
|
||||
enabled = hasGrant && newBranchName.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_create_branch))
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = track, onCheckedChange = { track = it }, enabled = hasGrant)
|
||||
Text(stringResource(R.string.git_state_track_remote))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun branchLabel(branch: GitBranch): String {
|
||||
val base = branch.name
|
||||
if (branch.upstream == null) return base
|
||||
val track =
|
||||
if (branch.ahead > 0 || branch.behind > 0) {
|
||||
" (ahead ${branch.ahead}, behind ${branch.behind})"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return "$base → ${branch.upstream}$track"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupHeader(label: String) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentView(state: GitContentViewState) {
|
||||
when (state) {
|
||||
GitContentViewState.Idle -> Unit
|
||||
GitContentViewState.Loading -> CenteredSpinner()
|
||||
is GitContentViewState.Error -> ErrorText(state.message)
|
||||
is GitContentViewState.Diff -> MonospaceBlock(state.diff)
|
||||
is GitContentViewState.File -> MonospaceBlock(state.file)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonospaceBlock(diff: GitDiff) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"${diff.path} (${diff.kind})",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
if (diff.truncated) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_truncated),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
diff.diff.ifEmpty { stringResource(R.string.git_state_no_changes) },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonospaceBlock(file: GitFile) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
file.path,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
if (file.truncated) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_truncated),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
file.content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredSpinner() {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorText(message: String, warning: Boolean = false) {
|
||||
Text(
|
||||
message,
|
||||
color = if (warning) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
@@ -101,6 +101,7 @@ import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProviderUsageLandingMode
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferences
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
@@ -157,6 +158,14 @@ fun SettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
/** Header back affordance — Settings is a pushed destination, not a tab. */
|
||||
onBack: (() -> Unit)? = null,
|
||||
supervisedPolicy: SupervisedModePolicy? = null,
|
||||
parentAccessUnlocked: Boolean = false,
|
||||
/** Called only after the restricted surface completes device authentication. */
|
||||
onRequestParentAccess: () -> Unit = {},
|
||||
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
|
||||
@@ -206,6 +215,21 @@ fun SettingsScreen(
|
||||
// discoverable before a pair-and-pick happens.
|
||||
onNavigateToProfileInspector: (profileName: String) -> Unit,
|
||||
) {
|
||||
// Keep the restricted root when an enabled policy becomes temporarily
|
||||
// unusable (for example, its profile was renamed). Parent authentication,
|
||||
// not a configuration error, is what unlocks the full settings surface.
|
||||
if (supervisedPolicy?.enabled == true && !parentAccessUnlocked) {
|
||||
SupervisedSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
onPolicyChange = onUpdateSupervisedPolicy,
|
||||
onBack = onBack,
|
||||
onNavigateToAppearance = onNavigateToSupervisedAppearance,
|
||||
onParentAccessGranted = onRequestParentAccess,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
@@ -432,6 +456,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).
|
||||
@@ -497,6 +535,7 @@ fun SettingsScreen(
|
||||
modifier = Modifier.settingsPetSurface("settings-card:profile-lock"),
|
||||
)
|
||||
|
||||
|
||||
// ── Quick Controls ─────────────────────────────────────────
|
||||
// The switches flipped most often, pinned to the top-level Settings
|
||||
// landing instead of buried in a sub-screen. Persistent connection is
|
||||
@@ -666,6 +705,24 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.settings_advanced),
|
||||
subtitle = when {
|
||||
supervisedPolicy?.isActive == true -> "On · ${supervisedPolicy.pinnedProfileName}"
|
||||
supervisedPolicy?.isConfigured == true -> "Ready · ${supervisedPolicy.pinnedProfileName}"
|
||||
else -> stringResource(R.string.settings_advanced_desc)
|
||||
},
|
||||
badge = supervisedPolicy?.takeIf { it.isActive }?.let {
|
||||
SettingsStatusPillModel(
|
||||
label = "On",
|
||||
tone = SettingsStatusTone.Good,
|
||||
)
|
||||
},
|
||||
onClick = onNavigateToAdvancedSettings,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Analytics,
|
||||
title = stringResource(R.string.settings_analytics),
|
||||
@@ -1280,12 +1337,12 @@ private fun ProfileLockOptionRow(
|
||||
}
|
||||
}
|
||||
|
||||
private data class SettingsStatusPillModel(
|
||||
internal data class SettingsStatusPillModel(
|
||||
val label: String,
|
||||
val tone: SettingsStatusTone = SettingsStatusTone.Neutral,
|
||||
)
|
||||
|
||||
private enum class SettingsStatusTone {
|
||||
internal enum class SettingsStatusTone {
|
||||
Neutral,
|
||||
Good,
|
||||
Info,
|
||||
@@ -1483,18 +1540,22 @@ private fun SettingsSectionHeader(
|
||||
* mega-SettingsScreen.
|
||||
*/
|
||||
@Composable
|
||||
private fun SettingsCategoryRow(
|
||||
internal fun SettingsCategoryRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
badge: SettingsStatusPillModel? = null,
|
||||
petPerchKey: String = title,
|
||||
petPerchKey: String? = title,
|
||||
) {
|
||||
val surfaceModifier = if (petPerchKey != null) {
|
||||
Modifier.settingsPetSurface("settings-category:$petPerchKey")
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.settingsPetSurface("settings-category:$petPerchKey")
|
||||
modifier = surfaceModifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = appearanceRoundedCornerShape(12.dp),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,18 @@ import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SessionActivityFreshness
|
||||
import com.hermesandroid.relay.data.SessionActivityOwner
|
||||
import com.hermesandroid.relay.data.SessionActivityPhase
|
||||
import com.hermesandroid.relay.data.SessionActivityRegistry
|
||||
import com.hermesandroid.relay.data.SessionActivityScope
|
||||
import com.hermesandroid.relay.data.SessionActivityUpdate
|
||||
import com.hermesandroid.relay.data.SessionLiveRuntime
|
||||
import com.hermesandroid.relay.data.SessionLiveStatus
|
||||
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedSessionAction
|
||||
import com.hermesandroid.relay.data.allowsSessionAction
|
||||
import com.hermesandroid.relay.data.ToolCallEvent
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
@@ -57,6 +69,9 @@ import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionsResult
|
||||
import com.hermesandroid.relay.network.upstream.GatewayApprovalMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayApprovalModeCapability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayBackgroundInteractionEvent
|
||||
@@ -257,6 +272,57 @@ internal fun shouldSuppressPassiveSessionError(context: String?, error: Throwabl
|
||||
"unauthorized" in message || "forbidden" in message
|
||||
}
|
||||
|
||||
internal data class ResolvedGatewayActiveSessions(
|
||||
val runtimes: List<SessionLiveRuntime>,
|
||||
val ambiguous: Boolean,
|
||||
val ambiguousForCurrent: Boolean,
|
||||
)
|
||||
|
||||
/** Resolve process-wide runtime rows without ever inventing a profile owner. */
|
||||
internal fun resolveGatewayActiveSessions(
|
||||
sessions: List<GatewayActiveSession>,
|
||||
directory: Set<SessionActivityOwner>,
|
||||
currentOwner: SessionActivityOwner?,
|
||||
currentRuntimeId: String? = null,
|
||||
knownOwnersByRuntime: Map<String, SessionActivityOwner> = emptyMap(),
|
||||
): ResolvedGatewayActiveSessions {
|
||||
var ambiguous = false
|
||||
var ambiguousForCurrent = false
|
||||
val runtimes = sessions.map { row ->
|
||||
val explicitProfile = row.profile?.trim()?.takeIf(String::isNotEmpty)
|
||||
?.let(AgentDisplay::profileSessionKey)
|
||||
val candidates = directory.filter { owner ->
|
||||
owner.storedSessionId == row.storedSessionId &&
|
||||
(explicitProfile == null || owner.profile.equals(explicitProfile, ignoreCase = true))
|
||||
}
|
||||
val owner = when {
|
||||
knownOwnersByRuntime[row.runtimeSessionId]
|
||||
?.takeIf { it.storedSessionId == row.storedSessionId } != null ->
|
||||
knownOwnersByRuntime.getValue(row.runtimeSessionId)
|
||||
explicitProfile == null && currentOwner != null && currentRuntimeId != null &&
|
||||
currentRuntimeId == row.runtimeSessionId &&
|
||||
currentOwner.storedSessionId == row.storedSessionId -> currentOwner
|
||||
explicitProfile != null && candidates.size == 1 -> candidates.single()
|
||||
else -> null
|
||||
}
|
||||
if (owner == null) {
|
||||
ambiguous = true
|
||||
if (currentOwner?.storedSessionId == row.storedSessionId) ambiguousForCurrent = true
|
||||
}
|
||||
SessionLiveRuntime(
|
||||
owner = owner,
|
||||
runtimeId = row.runtimeSessionId,
|
||||
status = when (row.status) {
|
||||
GatewayActiveSessionStatus.Idle -> SessionLiveStatus.Idle
|
||||
GatewayActiveSessionStatus.Starting -> SessionLiveStatus.Starting
|
||||
GatewayActiveSessionStatus.Working -> SessionLiveStatus.Working
|
||||
GatewayActiveSessionStatus.Waiting -> SessionLiveStatus.Waiting
|
||||
},
|
||||
)
|
||||
}
|
||||
return ResolvedGatewayActiveSessions(runtimes, ambiguous, ambiguousForCurrent)
|
||||
}
|
||||
|
||||
sealed interface VoiceMessageSubmissionResult {
|
||||
data class Submitted(val userUiKey: String) : VoiceMessageSubmissionResult
|
||||
data class Rejected(val reason: String) : VoiceMessageSubmissionResult
|
||||
@@ -274,6 +340,24 @@ internal fun voiceTurnTransportRejection(
|
||||
}
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
/**
|
||||
* Active Android-only supervision policy. RelayApp replaces this snapshot
|
||||
* whenever the active connection changes. Enforcement belongs here as well
|
||||
* as in Compose so alternate UI entry points cannot bypass the restrictions.
|
||||
*/
|
||||
@Volatile
|
||||
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
|
||||
|
||||
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
|
||||
supervisedModePolicy = policy
|
||||
if (policy.enabled) {
|
||||
_pendingAttachments.update { attachments ->
|
||||
attachments.filterIndexed { index, attachment ->
|
||||
isAttachmentAllowedBySupervision(attachment, index)
|
||||
}.take(policy.capabilities.attachmentMaxCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var apiClient: HermesApiClient? = null
|
||||
private var chatHandler: ChatHandler? = null
|
||||
@@ -320,27 +404,126 @@ class ChatViewModel : ViewModel() {
|
||||
val backgroundSessionActivityStates: StateFlow<Map<String, SessionActivityState>> =
|
||||
_backgroundSessionActivityStates.asStateFlow()
|
||||
|
||||
private fun publishBackgroundSessionActivity() {
|
||||
val contextKey = activeProfileContextKey
|
||||
_backgroundSessionActivityStates.value = if (contextKey == null) {
|
||||
private val sessionActivityRegistry = MutableStateFlow(SessionActivityRegistry())
|
||||
private val sessionActivityGeneration = AtomicLong(0L)
|
||||
private val sessionActivityPollMutex = Mutex()
|
||||
private var sessionActivityPollJob: Job? = null
|
||||
private var sessionActivityDirectory: Set<SessionActivityOwner> = emptySet()
|
||||
private var lastProjectedProcessIds: Set<String> = emptySet()
|
||||
private var lastProjectedProcessOwner: SessionActivityOwner? = null
|
||||
private var lastLocalActivityOwner: SessionActivityOwner? = null
|
||||
private var lastLocalStreaming = false
|
||||
private var lastSessionActivityScope: SessionActivityScope? = null
|
||||
private val _sessionDirectoryRefreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val sessionDirectoryRefreshRequests: SharedFlow<Unit> =
|
||||
_sessionDirectoryRefreshRequests.asSharedFlow()
|
||||
|
||||
private fun activityScope(contextKey: String? = activeProfileContextKey): SessionActivityScope? {
|
||||
val raw = contextKey?.trim().orEmpty()
|
||||
val separator = raw.lastIndexOf("::")
|
||||
if (separator <= 0 || separator >= raw.lastIndex) return null
|
||||
return SessionActivityScope.of(raw.substring(0, separator), raw.substring(separator + 2))
|
||||
}
|
||||
|
||||
private fun activityOwner(
|
||||
sessionId: String?,
|
||||
contextKey: String? = activeProfileContextKey,
|
||||
): SessionActivityOwner? {
|
||||
val scope = activityScope(contextKey) ?: return null
|
||||
val storedId = sessionId?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
return SessionActivityOwner.of(scope.connectionId, scope.profile, storedId)
|
||||
}
|
||||
|
||||
private fun reduceSessionActivity(update: SessionActivityUpdate) {
|
||||
sessionActivityRegistry.update { it.reduce(update) }
|
||||
publishSessionActivityProjection()
|
||||
}
|
||||
|
||||
private fun reduceSessionActivities(updates: Iterable<SessionActivityUpdate>) {
|
||||
sessionActivityRegistry.update { current ->
|
||||
updates.fold(current) { state, update -> state.reduce(update) }
|
||||
}
|
||||
publishSessionActivityProjection()
|
||||
}
|
||||
|
||||
private fun activateSessionActivityScope() {
|
||||
val scope = activityScope() ?: return
|
||||
if (scope == lastSessionActivityScope) return
|
||||
clearProjectedBackgroundProcesses()
|
||||
lastSessionActivityScope = scope
|
||||
val generation = sessionActivityGeneration.incrementAndGet()
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = null
|
||||
lastLocalActivityOwner = null
|
||||
lastLocalStreaming = false
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.BeginGeneration(
|
||||
scope = scope,
|
||||
generation = generation,
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun publishSessionActivityProjection() {
|
||||
val activeConnectionId = activityScope()?.connectionId
|
||||
_backgroundSessionActivityStates.value = if (activeConnectionId == null) {
|
||||
emptyMap()
|
||||
} else {
|
||||
backgroundTurnCheckpoints.keys
|
||||
.asSequence()
|
||||
.filter { it.contextKey == contextKey }
|
||||
.associate { key ->
|
||||
key.sessionId to if (
|
||||
key in backgroundNeedsInputKeys ||
|
||||
backgroundPendingInteractions.containsKey(key)
|
||||
) {
|
||||
SessionActivityState.NeedsInput
|
||||
} else {
|
||||
SessionActivityState.Working
|
||||
}
|
||||
sessionActivityRegistry.value.presentationStates(System.currentTimeMillis())
|
||||
.filterKeys { it.connectionId == activeConnectionId }
|
||||
.mapKeys { (owner, _) ->
|
||||
val displayProfile = owner.profile.takeUnless {
|
||||
it == AgentDisplay.SERVER_DEFAULT_PROFILE_KEY
|
||||
} ?: "default"
|
||||
"$displayProfile:${owner.storedSessionId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishBackgroundSessionActivity() {
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
backgroundTurnCheckpoints.forEach { (key, checkpoint) ->
|
||||
val owner = activityOwner(key.sessionId, key.contextKey) ?: return@forEach
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = checkpoint.liveSessionId,
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
if (key in backgroundNeedsInputKeys || backgroundPendingInteractions.containsKey(key)) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
|
||||
confirmed = backgroundPendingInteractions.containsKey(key),
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
} else if (sessionActivityRegistry.value.record(owner)
|
||||
?.pendingInputs
|
||||
?.containsKey("checkpoint:${key.contextKey}:${key.sessionId}") == true
|
||||
) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:${key.contextKey}:${key.sessionId}",
|
||||
confirmed = false,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
publishSessionActivityProjection()
|
||||
}
|
||||
|
||||
private fun TurnCheckpointKey.keepAliveKey(): String = "$contextKey::$sessionId"
|
||||
|
||||
private fun activeTurnCheckpointKey(): TurnCheckpointKey? =
|
||||
@@ -667,7 +850,10 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun addAttachment(attachment: Attachment) {
|
||||
_pendingAttachments.update { it + attachment }
|
||||
_pendingAttachments.update { current ->
|
||||
if (!isAttachmentAllowedBySupervision(attachment, current.size)) current
|
||||
else current + attachment
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAttachment(index: Int) {
|
||||
@@ -677,11 +863,20 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun replacePendingAttachments(attachments: List<Attachment>) {
|
||||
_pendingAttachments.value = attachments.toList()
|
||||
val policy = supervisedModePolicy
|
||||
_pendingAttachments.value = if (!policy.enabled) {
|
||||
attachments.toList()
|
||||
} else {
|
||||
attachments.filter { isAttachmentAllowedBySupervision(it, 0) }
|
||||
.take(policy.capabilities.attachmentMaxCount)
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceAttachment(composerId: String, attachment: Attachment) {
|
||||
_pendingAttachments.update { attachments ->
|
||||
if (!isAttachmentAllowedBySupervision(attachment, (attachments.size - 1).coerceAtLeast(0))) {
|
||||
return@update attachments.filterNot { it.composerId == composerId }
|
||||
}
|
||||
var replaced = false
|
||||
val updated = attachments.map { current ->
|
||||
if (current.composerId == composerId) {
|
||||
@@ -711,6 +906,23 @@ class ChatViewModel : ViewModel() {
|
||||
_pendingAttachments.value = emptyList()
|
||||
}
|
||||
|
||||
private fun isAttachmentAllowedBySupervision(attachment: Attachment, existingCount: Int): Boolean {
|
||||
val policy = supervisedModePolicy
|
||||
if (!policy.enabled) return true
|
||||
val capabilities = policy.capabilities
|
||||
if (!policy.isActive || !capabilities.attachments) return false
|
||||
if (existingCount >= capabilities.attachmentMaxCount) return false
|
||||
val maxBytes = capabilities.attachmentMaxFileMb.toLong() * 1024L * 1024L
|
||||
if ((attachment.fileSize ?: 0L) > maxBytes) return false
|
||||
val category = when {
|
||||
attachment.contentType.startsWith("image/") -> SupervisedAttachmentCategory.Images
|
||||
attachment.contentType.startsWith("audio/") -> SupervisedAttachmentCategory.Audio
|
||||
attachment.contentType.startsWith("video/") -> SupervisedAttachmentCategory.Video
|
||||
else -> SupervisedAttachmentCategory.Documents
|
||||
}
|
||||
return category in capabilities.attachmentCategories
|
||||
}
|
||||
|
||||
// Server-side personality selection
|
||||
private val _selectedPersonality = MutableStateFlow("default")
|
||||
val selectedPersonality: StateFlow<String> = _selectedPersonality.asStateFlow()
|
||||
@@ -1894,10 +2106,329 @@ class ChatViewModel : ViewModel() {
|
||||
gatewayProcessController.dismiss(processId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the known profile/session directory used to attribute process-wide
|
||||
* `session.active_list` rows. A row is projected only when ownership is exact.
|
||||
*/
|
||||
fun updateSessionActivityDirectory(
|
||||
rows: Collection<Pair<String, String>>,
|
||||
) {
|
||||
val scope = activityScope() ?: return
|
||||
sessionActivityDirectory = rows.mapNotNullTo(mutableSetOf()) { (profile, sessionId) ->
|
||||
runCatching {
|
||||
SessionActivityOwner.of(
|
||||
scope.connectionId,
|
||||
AgentDisplay.profileSessionKey(profile),
|
||||
sessionId,
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
reduceSessionActivities(
|
||||
sessionActivityDirectory.map { owner ->
|
||||
SessionActivityUpdate.ObserveOwner(owner, generation, now)
|
||||
},
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun updateCurrentProfileActivityDirectory(sessions: Collection<ChatSession>) {
|
||||
val scope = activityScope() ?: return
|
||||
sessionActivityDirectory = sessionActivityDirectory
|
||||
.filterNotTo(mutableSetOf()) {
|
||||
it.connectionId == scope.connectionId && it.profile == scope.profile
|
||||
}
|
||||
.apply {
|
||||
sessions.forEach { row ->
|
||||
add(SessionActivityOwner.of(scope.connectionId, scope.profile, row.sessionId))
|
||||
}
|
||||
}
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
reduceSessionActivities(
|
||||
sessionActivityDirectory
|
||||
.filter { it.connectionId == scope.connectionId && it.profile == scope.profile }
|
||||
.map { owner -> SessionActivityUpdate.ObserveOwner(owner, generation, now) },
|
||||
)
|
||||
}
|
||||
|
||||
fun setSessionActivityDrawerOpen(open: Boolean) {
|
||||
if (open) requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
/** Local UI edges are immediate evidence, then the active-list poll confirms them. */
|
||||
fun updateCurrentSessionActivity(isStreaming: Boolean, needsInput: Boolean) {
|
||||
val owner = activityOwner(chatHandler?.currentSessionId?.value) ?: return
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
val previousAskId = "current-pending-input"
|
||||
lastLocalActivityOwner?.takeIf { it != owner }?.let { previousOwner ->
|
||||
if (sessionActivityRegistry.value.record(previousOwner)
|
||||
?.pendingInputs
|
||||
?.containsKey(previousAskId) == true
|
||||
) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
previousOwner,
|
||||
previousAskId,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
val pendingWasOpen = sessionActivityRegistry.value.record(owner)
|
||||
?.pendingInputs
|
||||
?.containsKey(previousAskId) == true
|
||||
if (needsInput || pendingWasOpen) {
|
||||
reduceSessionActivity(
|
||||
if (needsInput) {
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner, previousAskId, generation = generation, observedAtMillis = now,
|
||||
)
|
||||
} else {
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
owner, previousAskId, generation = generation, observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
val streamingEdge = lastLocalActivityOwner == owner && lastLocalStreaming != isStreaming
|
||||
val alreadyStarting = sessionActivityRegistry.value.record(owner)
|
||||
?.phase(now) == SessionActivityPhase.Starting
|
||||
if ((isStreaming && !alreadyStarting) ||
|
||||
(lastLocalActivityOwner == owner && lastLocalStreaming)
|
||||
) {
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.LiveState(
|
||||
owner = owner,
|
||||
runtimeId = null,
|
||||
status = if (isStreaming) SessionLiveStatus.Working else SessionLiveStatus.Idle,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
),
|
||||
)
|
||||
}
|
||||
lastLocalActivityOwner = owner
|
||||
lastLocalStreaming = isStreaming
|
||||
if (streamingEdge) _sessionDirectoryRefreshRequests.tryEmit(Unit)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun markSessionActivityStarting(sessionId: String?) {
|
||||
val owner = activityOwner(sessionId) ?: return
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.LocalSend(
|
||||
owner = owner,
|
||||
generation = sessionActivityGeneration.get(),
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
private fun settleSessionActivity(sessionId: String?, runtimeId: String? = null) {
|
||||
val owner = activityOwner(sessionId) ?: return
|
||||
reduceSessionActivity(
|
||||
SessionActivityUpdate.Terminal(
|
||||
owner = owner,
|
||||
runtimeId = runtimeId,
|
||||
generation = sessionActivityGeneration.get(),
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
_sessionDirectoryRefreshRequests.tryEmit(Unit)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
fun requestSessionActivityRefresh() {
|
||||
val client = gatewayClient ?: return
|
||||
if (streamingEndpoint != "gateway" || !chatVisible) return
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = viewModelScope.launch {
|
||||
pollSessionActivity(client)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollSessionActivity(client: GatewayChatClient) {
|
||||
sessionActivityPollMutex.withLock {
|
||||
if (gatewayClient !== client || !chatVisible || streamingEndpoint != "gateway") return
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val currentScope = activityScope() ?: return
|
||||
val currentOwner = activityOwner(chatHandler?.currentSessionId?.value)
|
||||
val directory = buildSet {
|
||||
addAll(sessionActivityDirectory.filter { it.connectionId == currentScope.connectionId })
|
||||
currentOwner?.let { add(it) }
|
||||
chatHandler?.sessions?.value.orEmpty().forEach { row ->
|
||||
add(SessionActivityOwner.of(
|
||||
currentScope.connectionId,
|
||||
currentScope.profile,
|
||||
row.sessionId,
|
||||
))
|
||||
}
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
when (val result = client.listActiveSessions()) {
|
||||
is GatewayActiveSessionsResult.Success -> {
|
||||
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
|
||||
val resolved = resolveGatewayActiveSessions(
|
||||
sessions = result.sessions,
|
||||
directory = directory,
|
||||
currentOwner = currentOwner,
|
||||
currentRuntimeId = currentOwner?.let {
|
||||
client.currentLiveSessionId(it.storedSessionId)
|
||||
},
|
||||
knownOwnersByRuntime = result.sessions.mapNotNull { row ->
|
||||
client.knownSessionOwner(row.runtimeSessionId)?.let { known ->
|
||||
val knownProfile = when {
|
||||
!known.profile.isNullOrBlank() ->
|
||||
AgentDisplay.profileSessionKey(known.profile)
|
||||
currentOwner != null &&
|
||||
row.runtimeSessionId == client.currentLiveSessionId(
|
||||
currentOwner.storedSessionId,
|
||||
) &&
|
||||
known.storedSessionId == currentOwner.storedSessionId ->
|
||||
currentOwner.profile
|
||||
else -> directory.singleOrNull { owner ->
|
||||
owner.storedSessionId == known.storedSessionId &&
|
||||
owner.profile in setOf(
|
||||
"default",
|
||||
AgentDisplay.SERVER_DEFAULT_PROFILE_KEY,
|
||||
)
|
||||
}?.profile ?: return@let null
|
||||
}
|
||||
row.runtimeSessionId to SessionActivityOwner.of(
|
||||
currentScope.connectionId,
|
||||
knownProfile,
|
||||
known.storedSessionId,
|
||||
)
|
||||
}
|
||||
}.toMap(),
|
||||
)
|
||||
val scopes = directory.mapTo(mutableSetOf()) {
|
||||
SessionActivityScope.of(it.connectionId, it.profile)
|
||||
}
|
||||
sessionActivityRegistry.value.records.keys
|
||||
.filterTo(mutableSetOf()) { it.connectionId == currentScope.connectionId }
|
||||
.mapTo(scopes) { SessionActivityScope.of(it.connectionId, it.profile) }
|
||||
resolved.runtimes.mapNotNullTo(scopes) { runtime ->
|
||||
runtime.owner?.let { SessionActivityScope.of(it.connectionId, it.profile) }
|
||||
}
|
||||
if (scopes.isEmpty()) scopes += currentScope
|
||||
reduceSessionActivities(
|
||||
scopes.map { scope ->
|
||||
SessionActivityUpdate.ActiveList(
|
||||
scope = scope,
|
||||
runtimes = resolved.runtimes.filter { it.owner?.let { owner ->
|
||||
owner.connectionId == scope.connectionId && owner.profile == scope.profile
|
||||
} == true },
|
||||
isCompleteForScope = !resolved.ambiguous,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
GatewayActiveSessionsResult.Unsupported,
|
||||
is GatewayActiveSessionsResult.TransientFailure -> {
|
||||
if (gatewayClient !== client || generation != sessionActivityGeneration.get()) return
|
||||
val scopes = directory.mapTo(mutableSetOf()) {
|
||||
SessionActivityScope.of(it.connectionId, it.profile)
|
||||
}.apply { add(currentScope) }
|
||||
reduceSessionActivities(
|
||||
scopes.map { scope ->
|
||||
SessionActivityUpdate.StatusUnavailable(scope, generation, now)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
projectCurrentBackgroundProcesses(generation, now)
|
||||
}
|
||||
|
||||
if (gatewayClient !== client || !chatVisible) return
|
||||
val hasConfirmedLiveWork = sessionActivityRegistry.value.records.values.any { record ->
|
||||
record.freshness == SessionActivityFreshness.Confirmed &&
|
||||
record.phase(System.currentTimeMillis()) != SessionActivityPhase.Idle
|
||||
}
|
||||
val delayMs = if (hasConfirmedLiveWork) 1_500L else 30_000L
|
||||
sessionActivityPollJob = viewModelScope.launch {
|
||||
delay(delayMs)
|
||||
if (gatewayClient === client && chatVisible) pollSessionActivity(client)
|
||||
}
|
||||
}
|
||||
|
||||
private fun projectCurrentBackgroundProcesses(generation: Long, now: Long) {
|
||||
val sessionId = chatHandler?.currentSessionId?.value ?: return
|
||||
val owner = activityOwner(sessionId) ?: return
|
||||
val previousOwner = lastProjectedProcessOwner
|
||||
if (previousOwner != null && previousOwner != owner) {
|
||||
reduceSessionActivities(
|
||||
lastProjectedProcessIds.map { processId ->
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = previousOwner,
|
||||
processId = processId,
|
||||
running = false,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
lastProjectedProcessIds = emptySet()
|
||||
}
|
||||
lastProjectedProcessOwner = owner
|
||||
if (!gatewayProcessController.ownsSnapshot(sessionId, activeProfileContextKey)) {
|
||||
lastProjectedProcessIds = emptySet()
|
||||
return
|
||||
}
|
||||
val activeIds = backgroundProcesses.value.filter { it.isRunning }.mapTo(mutableSetOf()) { it.id }
|
||||
reduceSessionActivities(
|
||||
(lastProjectedProcessIds + activeIds).map { processId ->
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = owner,
|
||||
processId = processId,
|
||||
running = processId in activeIds,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
lastProjectedProcessIds = activeIds
|
||||
}
|
||||
|
||||
private fun clearProjectedBackgroundProcesses() {
|
||||
val owner = lastProjectedProcessOwner
|
||||
if (owner != null && lastProjectedProcessIds.isNotEmpty()) {
|
||||
val generation = sessionActivityGeneration.get()
|
||||
val now = System.currentTimeMillis()
|
||||
reduceSessionActivities(
|
||||
lastProjectedProcessIds.map { processId ->
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = owner,
|
||||
processId = processId,
|
||||
running = false,
|
||||
generation = generation,
|
||||
observedAtMillis = now,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
lastProjectedProcessIds = emptySet()
|
||||
lastProjectedProcessOwner = null
|
||||
}
|
||||
|
||||
fun updateGatewayClient(client: GatewayChatClient?) {
|
||||
val previousClient = gatewayClient
|
||||
val changed = previousClient !== client
|
||||
if (changed) {
|
||||
clearProjectedBackgroundProcesses()
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = null
|
||||
sessionActivityGeneration.incrementAndGet()
|
||||
sessionActivityDirectory = emptySet()
|
||||
lastLocalActivityOwner = null
|
||||
lastLocalStreaming = false
|
||||
lastSessionActivityScope = null
|
||||
gatewayVisibleReattachJob?.cancel()
|
||||
gatewayVisibleReattachJob = null
|
||||
previousClient?.setUnsolicitedTurnProvider(null)
|
||||
@@ -1916,6 +2447,7 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId = chatHandler?.currentSessionId?.value,
|
||||
scopeKey = activeProfileContextKey,
|
||||
)
|
||||
activateSessionActivityScope()
|
||||
}
|
||||
// Bind each gateway session.create/resume to the currently-selected
|
||||
// profile (pulled live) — the upstream gateway builds the agent from it.
|
||||
@@ -2063,6 +2595,7 @@ class ChatViewModel : ViewModel() {
|
||||
) {
|
||||
prewarmGateway()
|
||||
}
|
||||
if (changed && client != null) requestSessionActivityRefresh()
|
||||
}
|
||||
|
||||
/** Remove the detached sibling's recovery snapshot after server completion. */
|
||||
@@ -2083,7 +2616,20 @@ class ChatViewModel : ViewModel() {
|
||||
backgroundPendingInteractions.remove(key)
|
||||
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
|
||||
}
|
||||
reduceSessionActivities(
|
||||
matching.mapNotNull { key ->
|
||||
activityOwner(key.sessionId, key.contextKey)?.let { owner ->
|
||||
SessionActivityUpdate.Terminal(
|
||||
owner = owner,
|
||||
runtimeId = completion.liveSessionId,
|
||||
generation = sessionActivityGeneration.get(),
|
||||
observedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
publishBackgroundSessionActivity()
|
||||
requestSessionActivityRefresh()
|
||||
chatTurnCheckpointStore?.let { store ->
|
||||
viewModelScope.launch {
|
||||
checkpointMutex.withLock {
|
||||
@@ -2529,7 +3075,13 @@ class ChatViewModel : ViewModel() {
|
||||
fun setChatVisible(visible: Boolean) {
|
||||
val changed = chatVisible != visible
|
||||
chatVisible = visible
|
||||
if (visible && changed) prewarmGateway()
|
||||
if (visible && changed) {
|
||||
prewarmGateway()
|
||||
requestSessionActivityRefresh()
|
||||
} else if (!visible) {
|
||||
sessionActivityPollJob?.cancel()
|
||||
sessionActivityPollJob = null
|
||||
}
|
||||
}
|
||||
|
||||
// === Gateway desktop-parity state ===
|
||||
@@ -3077,6 +3629,16 @@ class ChatViewModel : ViewModel() {
|
||||
gatewayStateSyncJob?.cancel()
|
||||
lastSurfacedCredentialWarning = null
|
||||
gatewayStateSyncJob = viewModelScope.launch {
|
||||
launch {
|
||||
backgroundProcesses.collect {
|
||||
if (gatewayClient !== client) return@collect
|
||||
projectCurrentBackgroundProcesses(
|
||||
generation = sessionActivityGeneration.get(),
|
||||
now = System.currentTimeMillis(),
|
||||
)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.serverPersonality.collect { value ->
|
||||
if (gatewayClient !== client || value == null) return@collect
|
||||
@@ -3784,6 +4346,7 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId = sessionId,
|
||||
)
|
||||
}
|
||||
activateSessionActivityScope()
|
||||
handler.activeAgentName = currentAgentDisplayName()
|
||||
if (
|
||||
previousBinding.contextKey == contextKey &&
|
||||
@@ -4051,6 +4614,8 @@ class ChatViewModel : ViewModel() {
|
||||
currentSessionProfileName() == profileName
|
||||
) {
|
||||
handler.updateSessions(sessions)
|
||||
updateCurrentProfileActivityDirectory(handler.sessions.value)
|
||||
requestSessionActivityRefresh()
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
@@ -4110,6 +4675,7 @@ class ChatViewModel : ViewModel() {
|
||||
onReady: ((String?) -> Unit)? = null,
|
||||
onFailure: (() -> Unit)? = null,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.newChat) return
|
||||
val handler = chatHandler ?: return
|
||||
recordPreResetEvidence(handler, "new_chat")
|
||||
clearOpenedSessionOwner()
|
||||
@@ -4442,6 +5008,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun deleteSession(sessionId: String, onDeleted: () -> Unit = {}) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Delete)) return
|
||||
val handler = chatHandler ?: return
|
||||
val client = apiClient
|
||||
if (streamingEndpoint != "gateway" && client == null) return
|
||||
@@ -4506,6 +5073,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun renameSession(sessionId: String, newTitle: String) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Rename)) return
|
||||
val handler = chatHandler ?: return
|
||||
val client = apiClient
|
||||
if (streamingEndpoint != "gateway" && client == null) return
|
||||
@@ -4550,6 +5118,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun setSessionPinned(sessionId: String, pinned: Boolean) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Pin)) return
|
||||
val expectedContextKey = activeProfileContextKey
|
||||
val profileName = currentSessionProfileName()
|
||||
mutateSessionFlag(
|
||||
@@ -4568,6 +5137,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun setSessionArchived(sessionId: String, archived: Boolean) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Archive)) return
|
||||
if (!_sessionArchivingSupported.value) {
|
||||
emitError(
|
||||
UnsupportedOperationException("Archive and restore require Dashboard sessions"),
|
||||
@@ -4636,6 +5206,22 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
supervisedMessageBlockReason(supervisedModePolicy, text)?.let { reason ->
|
||||
chatHandler?.addSystemNotice(reason)
|
||||
return
|
||||
}
|
||||
if (supervisedModePolicy.enabled) {
|
||||
val attachments = _pendingAttachments.value
|
||||
if (attachments.any { attachment ->
|
||||
!isAttachmentAllowedBySupervision(attachment, attachments.indexOf(attachment))
|
||||
}
|
||||
) {
|
||||
chatHandler?.addSystemNotice(
|
||||
"One or more attachments are unavailable under the supervised policy.",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
recordRecentPrompt(text)
|
||||
|
||||
// Demo / Explore mode: there is no server, but a silently dead Send
|
||||
@@ -4904,6 +5490,10 @@ class ChatViewModel : ViewModel() {
|
||||
action: com.hermesandroid.relay.data.HermesCardAction,
|
||||
) {
|
||||
val handler = chatHandler ?: return
|
||||
if (supervisedModePolicy.enabled) {
|
||||
handler.addSystemNotice("This action is unavailable in supervised mode.")
|
||||
return
|
||||
}
|
||||
// Ask answers route straight to the gateway respond RPCs —
|
||||
// answerAsk records its own (sanitized) dispatch stamp, so don't
|
||||
// double-stamp here.
|
||||
@@ -4942,6 +5532,10 @@ class ChatViewModel : ViewModel() {
|
||||
ask: GatewayAsk,
|
||||
restored: ChatTurnAskCheckpoint? = null,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled) {
|
||||
denySupervisedInteraction(handler, ask)
|
||||
return
|
||||
}
|
||||
val sessionId = handler.currentSessionId.value
|
||||
val contextKey = activeProfileContextKey
|
||||
val existing = _pendingAsk.value
|
||||
@@ -5070,6 +5664,33 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId?.let { maybeNotifyInteraction(it, ask) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervised Chat never exposes approval, clarification, sudo, or secret
|
||||
* inputs. Settle the upstream interaction immediately with its safest
|
||||
* negative/empty response; if that cannot be confirmed, interrupt the turn
|
||||
* so a hidden card cannot leave the session waiting indefinitely.
|
||||
*/
|
||||
private fun denySupervisedInteraction(handler: ChatHandler, ask: GatewayAsk) {
|
||||
val gateway = gatewayClient
|
||||
if (gateway == null) {
|
||||
handler.addSystemNotice("An interactive request was blocked by supervised mode.")
|
||||
cancelStream()
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val response: Result<GatewayAskResponse>? = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> gateway.respondApproval(choice = "deny")
|
||||
GatewayAsk.Kind.CLARIFY -> ask.requestId?.let {
|
||||
gateway.respondClarify(it, "This supervised client cannot answer interactive requests.")
|
||||
}
|
||||
GatewayAsk.Kind.SUDO -> ask.requestId?.let { gateway.respondSudo(it, "") }
|
||||
GatewayAsk.Kind.SECRET -> ask.requestId?.let { gateway.respondSecret(it, "") }
|
||||
}
|
||||
handler.addSystemNotice("An interactive request was denied by supervised mode.")
|
||||
if (response == null || response.isFailure) cancelStream()
|
||||
}
|
||||
}
|
||||
|
||||
/** Render only upstream-supported approval values; old servers retain Approve/Deny. */
|
||||
private fun approvalActions(ask: GatewayAsk): List<HermesCardAction> {
|
||||
val advertised = ask.choices.orEmpty()
|
||||
@@ -6426,6 +7047,7 @@ class ChatViewModel : ViewModel() {
|
||||
private fun finalizeTurnSideEffects(handler: ChatHandler, messageId: String) {
|
||||
val completedOwner = activeQueueOwnerRunId
|
||||
handler.onStreamComplete(messageId)
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
if (completedOwner != null && queuedMessageItems.any { it.ownerRunId == completedOwner }) {
|
||||
completedQueueOwnerRuns += completedOwner
|
||||
}
|
||||
@@ -6454,6 +7076,7 @@ class ChatViewModel : ViewModel() {
|
||||
private fun finalizeFailedTurnSideEffects(handler: ChatHandler, messageId: String) {
|
||||
handler.onStreamComplete(messageId)
|
||||
handler.markError(messageId)
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
clearTurnCheckpoint()
|
||||
activeStream = null
|
||||
_steerableTurn.value = false
|
||||
@@ -6496,6 +7119,7 @@ class ChatViewModel : ViewModel() {
|
||||
} else {
|
||||
handler.clearStreamingStatus()
|
||||
}
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6999,6 +7623,9 @@ class ChatViewModel : ViewModel() {
|
||||
badges = listOf("Realtime Agent"),
|
||||
)
|
||||
)
|
||||
if (streamingEndpoint == "gateway") {
|
||||
markSessionActivityStarting(handler.currentSessionId.value)
|
||||
}
|
||||
return assistantMessageId
|
||||
}
|
||||
|
||||
@@ -7889,6 +8516,9 @@ class ChatViewModel : ViewModel() {
|
||||
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
|
||||
)
|
||||
)
|
||||
if (streamingEndpoint == "gateway") {
|
||||
markSessionActivityStarting(handler.currentSessionId.value)
|
||||
}
|
||||
|
||||
val streamDeltas = StreamDeltaCoalescer(
|
||||
scope = viewModelScope,
|
||||
@@ -8206,6 +8836,7 @@ class ChatViewModel : ViewModel() {
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(errorSessionId)
|
||||
} else if (
|
||||
dispatchedSseEndpoint == "sessions" &&
|
||||
errorSessionId != null &&
|
||||
@@ -8255,6 +8886,7 @@ class ChatViewModel : ViewModel() {
|
||||
),
|
||||
)
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(errorSessionId)
|
||||
}
|
||||
} else {
|
||||
AppAnalytics.onStreamError()
|
||||
@@ -8295,6 +8927,7 @@ class ChatViewModel : ViewModel() {
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(errorSessionId)
|
||||
}
|
||||
}
|
||||
val onPreflightErrorCb = { error: Throwable ->
|
||||
@@ -8326,6 +8959,7 @@ class ChatViewModel : ViewModel() {
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearTurnCheckpoint()
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
}
|
||||
|
||||
// === v0.4.1 voice-intent + v0.7.x card-dispatch session sync ===
|
||||
@@ -8604,6 +9238,7 @@ class ChatViewModel : ViewModel() {
|
||||
),
|
||||
)
|
||||
handler.setSessionId(sid)
|
||||
markSessionActivityStarting(sid)
|
||||
updateTurnCheckpointSession(sid)
|
||||
selectBackgroundProcessSession(sid)
|
||||
gatewayProcessController.sessionReady(sid)
|
||||
@@ -8722,6 +9357,7 @@ class ChatViewModel : ViewModel() {
|
||||
// don't resurrect the turn on SSE.
|
||||
intentionallyCancelled = false
|
||||
activeStream = null
|
||||
settleSessionActivity(handler.currentSessionId.value)
|
||||
} else {
|
||||
// Nothing started server-side — rerun this turn on
|
||||
// the SSE fallback. Callbacks land on the main
|
||||
@@ -8875,6 +9511,9 @@ class ChatViewModel : ViewModel() {
|
||||
* so we shouldn't see duplicate calls here.
|
||||
*/
|
||||
fun onMediaAttachmentRequested(messageId: String, token: String) {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient
|
||||
val repo = mediaSettingsRepo
|
||||
@@ -8932,6 +9571,9 @@ class ChatViewModel : ViewModel() {
|
||||
* token and uses [RelayHttpClient.fetchMedia].
|
||||
*/
|
||||
fun manualFetchAttachment(messageId: String, attachmentIndex: Int) {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient ?: return
|
||||
val repo = mediaSettingsRepo ?: return
|
||||
@@ -9005,6 +9647,9 @@ class ChatViewModel : ViewModel() {
|
||||
* it into the markdown-image renderer, which previously ignored the relay.
|
||||
*/
|
||||
suspend fun resolveServerImage(serverPath: String): ServerImageResult {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return ServerImageResult.Failure("Generated images are disabled in supervised mode")
|
||||
val relay = relayHttpClient
|
||||
?: return ServerImageResult.Failure("Relay not configured on this connection")
|
||||
// fetchMediaByPath returns Result<MediaBytes>; fold it ONCE, right here,
|
||||
@@ -9047,6 +9692,11 @@ class ChatViewModel : ViewModel() {
|
||||
expectedRole: MessageRole,
|
||||
unavailableMessage: String,
|
||||
) {
|
||||
if (
|
||||
expectedRole == MessageRole.ASSISTANT &&
|
||||
supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient
|
||||
val repo = mediaSettingsRepo
|
||||
|
||||
@@ -2749,6 +2749,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
private fun installAuthManager(am: AuthManager) {
|
||||
am.setActiveEndpointProvider { connectionManager.activeRelayEndpoint.value }
|
||||
am.setSupervisedMetadataReconnectFallback {
|
||||
connectionManager.reconnectForAuthenticatedMetadataUpdate()
|
||||
}
|
||||
authManager = am
|
||||
// Push into the flow so the flatMapLatest chains on authState /
|
||||
// pairingCode / currentPairedSession repoint to the new manager.
|
||||
@@ -3591,6 +3594,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// ConnectionStore's EncryptedSharedPrefs.
|
||||
profileController.profileSelectionStore.clear(connectionId)
|
||||
profileController.profileLockStore.clear(connectionId)
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clear(connectionId)
|
||||
profileController.profilePresentationStore.clear(connectionId)
|
||||
profileController.profileSessionStore.clearConnection(connectionId)
|
||||
profileController.profileDisplayAliasStore.clearConnection(connectionId)
|
||||
@@ -3630,6 +3635,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
|
||||
init {
|
||||
authManager.setSupervisedMetadataReconnectFallback {
|
||||
connectionManager.reconnectForAuthenticatedMetadataUpdate()
|
||||
}
|
||||
// Wire multiplexer to connection manager (for relay/bridge/terminal)
|
||||
multiplexer.setSendCallback { envelope ->
|
||||
connectionManager.send(envelope)
|
||||
@@ -4096,6 +4104,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
connectionStore.removeConnection(duplicate.id)
|
||||
profileController.profileSelectionStore.clear(duplicate.id)
|
||||
profileController.profileLockStore.clear(duplicate.id)
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clear(duplicate.id)
|
||||
profileController.profilePresentationStore.clear(duplicate.id)
|
||||
profileController.profileSessionStore.clearConnection(duplicate.id)
|
||||
}
|
||||
@@ -7190,6 +7200,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
check(dataManager.resetAppData()) { "App data store reset failed" }
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
|
||||
@@ -110,6 +110,10 @@ internal class GatewayProcessController(
|
||||
resetForSession(sessionId, scopeKey)
|
||||
}
|
||||
|
||||
/** True only when the published process snapshot belongs to this exact owner. */
|
||||
fun ownsSnapshot(sessionId: String, scopeKey: String?): Boolean =
|
||||
selectedSessionId == sessionId && selectedScopeKey == scopeKey && readySessionId == sessionId
|
||||
|
||||
/**
|
||||
* Admit process RPCs for [sessionId] after the gateway has created/resumed
|
||||
* that chat's live session. Stale ready callbacks are ignored.
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.relay.data.GitBranch
|
||||
import com.hermesandroid.relay.data.GitDiff
|
||||
import com.hermesandroid.relay.data.GitFile
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.data.GitStateApiClient
|
||||
import com.hermesandroid.relay.data.GitStatus
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface GitStateUiState {
|
||||
data object Loading : GitStateUiState
|
||||
data class Error(val message: String) : GitStateUiState
|
||||
data class Ready(val repos: List<GitRepo>, val notice: String?) : GitStateUiState
|
||||
}
|
||||
|
||||
sealed interface GitRepoDetailState {
|
||||
data object Idle : GitRepoDetailState
|
||||
data object Loading : GitRepoDetailState
|
||||
data class Error(val message: String) : GitRepoDetailState
|
||||
data class Ready(
|
||||
val status: GitStatus,
|
||||
val branches: List<GitBranch>,
|
||||
) : GitRepoDetailState
|
||||
}
|
||||
|
||||
sealed interface GitContentViewState {
|
||||
data object Idle : GitContentViewState
|
||||
data object Loading : GitContentViewState
|
||||
data class Error(val message: String) : GitContentViewState
|
||||
data class Diff(val diff: GitDiff) : GitContentViewState
|
||||
data class File(val file: GitFile) : GitContentViewState
|
||||
}
|
||||
|
||||
/** A single in-flight or completed write mutation on the selected repo. */
|
||||
sealed interface GitMutationState {
|
||||
data object Idle : GitMutationState
|
||||
data class InProgress(val label: String) : GitMutationState
|
||||
data class Error(val label: String, val message: String) : GitMutationState
|
||||
data class Success(val label: String, val head: String) : GitMutationState
|
||||
}
|
||||
|
||||
/** A commit-message generation attempt (AI magic-wand). */
|
||||
sealed interface GitMessageGenerationState {
|
||||
data object Idle : GitMessageGenerationState
|
||||
data object Loading : GitMessageGenerationState
|
||||
data class Ready(val message: String, val notice: String) : GitMessageGenerationState
|
||||
}
|
||||
|
||||
/** Fixed per-use confirmation tokens matching the plugin's server constants. */
|
||||
object GitConfirmationStrings {
|
||||
const val DISCARD = "discard"
|
||||
const val PUSH = "push"
|
||||
const val DIRTY_CHECKOUT = "checkout-dirty"
|
||||
}
|
||||
|
||||
data class GitTarget(
|
||||
val scopeKey: String,
|
||||
val repoId: String,
|
||||
val generation: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* View model for the Git State Android surface (read + write).
|
||||
*
|
||||
* Loads the scanned repo list from the Hermes-Relay plugin and, on selection,
|
||||
* fetches working-tree status + branches. Mutations (stage/unstage/discard/
|
||||
* commit/fetch/pull/push/checkout) all require the ``plugin.api.write`` grant:
|
||||
* ``configure`` binds one connection/profile/Dashboard owner and every mutation
|
||||
* refuses (surfacing a readable message, never a POST) when that owner's grant
|
||||
* is absent. Destructive ops
|
||||
* (discard/push/dirty-checkout) additionally require a per-use confirmation
|
||||
* string the caller echoes from GitConfirmationStrings.
|
||||
*/
|
||||
class GitStateViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _repos = MutableStateFlow<GitStateUiState>(GitStateUiState.Loading)
|
||||
val repos: StateFlow<GitStateUiState> = _repos.asStateFlow()
|
||||
|
||||
private val _detail = MutableStateFlow<GitRepoDetailState>(GitRepoDetailState.Idle)
|
||||
val detail: StateFlow<GitRepoDetailState> = _detail.asStateFlow()
|
||||
|
||||
private val _content = MutableStateFlow<GitContentViewState>(GitContentViewState.Idle)
|
||||
val content: StateFlow<GitContentViewState> = _content.asStateFlow()
|
||||
|
||||
private val _mutation = MutableStateFlow<GitMutationState>(GitMutationState.Idle)
|
||||
val mutation: StateFlow<GitMutationState> = _mutation.asStateFlow()
|
||||
|
||||
private val _messageGeneration =
|
||||
MutableStateFlow<GitMessageGenerationState>(GitMessageGenerationState.Idle)
|
||||
val messageGeneration: StateFlow<GitMessageGenerationState> = _messageGeneration.asStateFlow()
|
||||
|
||||
private val _pushAfterCommit = MutableStateFlow(false)
|
||||
val pushAfterCommit: StateFlow<Boolean> = _pushAfterCommit.asStateFlow()
|
||||
|
||||
private val _stashNotice = MutableStateFlow<String?>(null)
|
||||
val stashNotice: StateFlow<String?> = _stashNotice.asStateFlow()
|
||||
|
||||
private val _writeGrant = MutableStateFlow(false)
|
||||
val writeGrant: StateFlow<Boolean> = _writeGrant.asStateFlow()
|
||||
|
||||
private var api: GitStateApiClient? = null
|
||||
private var reposJob: Job? = null
|
||||
private var detailJob: Job? = null
|
||||
private var contentJob: Job? = null
|
||||
private var mutationJob: Job? = null
|
||||
private var messageJob: Job? = null
|
||||
private var scopeKey: String? = null
|
||||
private var targetGeneration: Long = 0
|
||||
private var selectedRepoId: String? = null
|
||||
|
||||
fun selectedRepoIdForDisplay(): String? = selectedRepoId
|
||||
|
||||
fun currentTarget(): GitTarget? {
|
||||
val owner = scopeKey ?: return null
|
||||
val repo = selectedRepoId ?: return null
|
||||
return GitTarget(owner, repo, targetGeneration)
|
||||
}
|
||||
|
||||
fun configure(dashboard: DashboardApiClient?, ownerKey: String?) {
|
||||
reposJob?.cancel()
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob?.cancel()
|
||||
messageJob?.cancel()
|
||||
targetGeneration += 1
|
||||
scopeKey = ownerKey
|
||||
selectedRepoId = null
|
||||
_writeGrant.value = false
|
||||
_detail.value = GitRepoDetailState.Idle
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
_messageGeneration.value = GitMessageGenerationState.Idle
|
||||
_stashNotice.value = null
|
||||
api = dashboard?.let(::GitStateApiClient)
|
||||
loadRepos()
|
||||
}
|
||||
|
||||
/** Grants the plugin.api.write capability for this connection/profile. */
|
||||
fun setWriteGrant(ownerKey: String?, granted: Boolean) {
|
||||
if (ownerKey != scopeKey) return
|
||||
_writeGrant.value = granted
|
||||
}
|
||||
|
||||
fun hasWriteGrant(): Boolean = _writeGrant.value
|
||||
|
||||
fun loadRepos() {
|
||||
val client = api ?: run {
|
||||
_repos.value = GitStateUiState.Error("Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val expectedScope = scopeKey
|
||||
reposJob?.cancel()
|
||||
reposJob = viewModelScope.launch {
|
||||
_repos.value = GitStateUiState.Loading
|
||||
client.repos().fold(
|
||||
onSuccess = { list ->
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Ready(list, null)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Error(error.message ?: "Failed to load repositories")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectRepo(repoId: String) {
|
||||
val client = api ?: return
|
||||
targetGeneration += 1
|
||||
selectedRepoId = repoId
|
||||
val target = currentTarget() ?: return
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
detailJob = viewModelScope.launch {
|
||||
_detail.value = GitRepoDetailState.Loading
|
||||
val statusResult = client.status(repoId)
|
||||
val branchesResult = client.branches(repoId)
|
||||
if (currentTarget() != target) return@launch
|
||||
if (statusResult.isFailure) {
|
||||
_detail.value = GitRepoDetailState.Error(
|
||||
statusResult.exceptionOrNull()?.message ?: "Failed to load status",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val status: GitStatus = statusResult.getOrThrow()
|
||||
val branches: List<GitBranch> = branchesResult.getOrDefault(emptyList())
|
||||
_detail.value = GitRepoDetailState.Ready(status, branches)
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one owner/repository-bound mutation without cancelling another mutation. */
|
||||
private fun runMutation(
|
||||
label: String,
|
||||
expectedTarget: GitTarget? = null,
|
||||
onSuccess: (GitTarget) -> Unit = {},
|
||||
block: suspend (GitStateApiClient, String) -> Result<GitMutationState>,
|
||||
) {
|
||||
val client = api ?: run {
|
||||
_mutation.value = GitMutationState.Error(label, "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error(label, "No repository selected")
|
||||
return
|
||||
}
|
||||
if (expectedTarget != null && expectedTarget != target) {
|
||||
_mutation.value = GitMutationState.Error(label, "Repository context changed; review the action again.")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
if (mutationJob?.isActive == true) {
|
||||
_mutation.value = GitMutationState.Error(label, "Another Git action is still in progress.")
|
||||
return
|
||||
}
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob = viewModelScope.launch {
|
||||
_mutation.value = GitMutationState.InProgress(label)
|
||||
block(client, target.repoId).fold(
|
||||
onSuccess = {
|
||||
if (currentTarget() != target) return@fold
|
||||
_mutation.value = it
|
||||
_content.value = GitContentViewState.Idle
|
||||
refreshDetail(client, target)
|
||||
onSuccess(target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDetail(client: GitStateApiClient, target: GitTarget) {
|
||||
val statusResult = client.status(target.repoId)
|
||||
val branchesResult = client.branches(target.repoId)
|
||||
if (currentTarget() == target && statusResult.isSuccess) {
|
||||
_detail.value = GitRepoDetailState.Ready(
|
||||
statusResult.getOrDefault(GitStatus()),
|
||||
branchesResult.getOrDefault(emptyList()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read operations ────────────────────────────────────────────────────
|
||||
|
||||
fun loadDiff(path: String, kind: String) {
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.diff(target.repoId, path, kind).fold(
|
||||
onSuccess = { diff ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Diff(diff)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load diff",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFile(path: String) {
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.file(target.repoId, path).fold(
|
||||
onSuccess = { file ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.File(file)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load file",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write operations ───────────────────────────────────────────────────
|
||||
|
||||
fun stage(paths: List<String>) = runMutation("Stage") { c, r ->
|
||||
c.stage(r, paths).map { GitMutationState.Success("stage", it.head) }
|
||||
}
|
||||
|
||||
fun unstage(paths: List<String>) = runMutation("Unstage") { c, r ->
|
||||
c.unstage(r, paths).map { GitMutationState.Success("unstage", it.head) }
|
||||
}
|
||||
|
||||
fun discard(
|
||||
paths: List<String>,
|
||||
confirmation: String,
|
||||
deleteUntracked: Boolean = false,
|
||||
expectedTarget: GitTarget? = null,
|
||||
) =
|
||||
runMutation("Discard", expectedTarget = expectedTarget) { c, r ->
|
||||
c.discard(r, paths, confirmation, deleteUntracked)
|
||||
.map { GitMutationState.Success("discard", it.head) }
|
||||
}
|
||||
|
||||
fun commit(message: String, onSuccess: (GitTarget) -> Unit = {}) =
|
||||
runMutation("Commit", onSuccess = onSuccess) { c, r ->
|
||||
c.commit(r, message).map {
|
||||
GitMutationState.Success("commit", it.head)
|
||||
}
|
||||
}
|
||||
|
||||
fun commitSelected(message: String, paths: List<String>) = runMutation("Commit") { c, r ->
|
||||
c.commitSelected(r, message, paths).map {
|
||||
GitMutationState.Success("commit", it.head)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetch(remote: String = "origin") = runMutation("Fetch") { c, r ->
|
||||
c.fetch(r, remote).map { GitMutationState.Success("fetch", it.head) }
|
||||
}
|
||||
|
||||
fun pull(remote: String = "origin", branch: String = "") = runMutation("Pull") { c, r ->
|
||||
c.pull(r, remote, branch).map { GitMutationState.Success("pull", it.head) }
|
||||
}
|
||||
|
||||
fun push(
|
||||
confirmation: String,
|
||||
remote: String = "origin",
|
||||
branch: String = "",
|
||||
expectedTarget: GitTarget? = null,
|
||||
) =
|
||||
runMutation("Push", expectedTarget = expectedTarget) { c, r ->
|
||||
c.push(r, confirmation, remote, branch).map { GitMutationState.Success("push", it.head) }
|
||||
}
|
||||
|
||||
fun checkout(
|
||||
ref: String,
|
||||
confirmation: String? = null,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
expectedTarget: GitTarget? = null,
|
||||
) = runMutation("Checkout", expectedTarget = expectedTarget) { c, r ->
|
||||
c.checkout(r, ref, confirmation, newBranch, track)
|
||||
.map { GitMutationState.Success("checkout", it.head) }
|
||||
}
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────
|
||||
|
||||
/** Toggle the push-after-commit flow (default OFF; never bypasses confirm). */
|
||||
fun setPushAfterCommit(enabled: Boolean) {
|
||||
_pushAfterCommit.value = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a commit-message suggestion from the staged diff (AI magic-wand).
|
||||
* Empty staged diff / model-unavailable degrade to a notice, never an error.
|
||||
* Uses the shared write grant gate (a POST is never sent without the grant).
|
||||
*/
|
||||
fun generateCommitMessage(paths: List<String>? = null) {
|
||||
val client = api ?: run {
|
||||
_messageGeneration.value =
|
||||
GitMessageGenerationState.Ready("", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready("", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(
|
||||
"",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
messageJob?.cancel()
|
||||
messageJob = viewModelScope.launch {
|
||||
_messageGeneration.value = GitMessageGenerationState.Loading
|
||||
val result = if (paths != null) {
|
||||
client.commitMessageSelected(target.repoId, paths)
|
||||
} else {
|
||||
client.commitMessage(target.repoId)
|
||||
}
|
||||
if (currentTarget() != target) return@launch
|
||||
result.fold(
|
||||
onSuccess = { msg ->
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(msg.message, msg.notice)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(
|
||||
"",
|
||||
error.message ?: "Could not generate a commit message.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout that auto-stashes a dirty tree first. No confirmation is needed
|
||||
* because a stash is recoverable. Surfaces the stash message via [stashNotice].
|
||||
*/
|
||||
fun stashCheckout(ref: String, newBranch: String = "", track: Boolean = false) {
|
||||
val client = api ?: run {
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
_stashNotice.value = null
|
||||
if (mutationJob?.isActive == true) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
"Another Git action is still in progress.",
|
||||
)
|
||||
return
|
||||
}
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob = viewModelScope.launch {
|
||||
_mutation.value = GitMutationState.InProgress("Stash Checkout")
|
||||
client.stashCheckout(target.repoId, ref, newBranch, track).fold(
|
||||
onSuccess = { result ->
|
||||
if (currentTarget() != target) return@fold
|
||||
if (result.stashed) {
|
||||
_stashNotice.value =
|
||||
"Stashed changes on $ref as \"${result.stashMessage}\". Use \"git stash pop\" to restore them."
|
||||
}
|
||||
_mutation.value = GitMutationState.Success("stash-checkout", result.head)
|
||||
_content.value = GitContentViewState.Idle
|
||||
refreshDetail(client, target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearStashNotice() {
|
||||
_stashNotice.value = null
|
||||
}
|
||||
|
||||
/** True when the push-after-commit toggle is currently enabled. */
|
||||
fun isPushAfterCommitEnabled(): Boolean = _pushAfterCommit.value
|
||||
|
||||
/** True when the named destructive op needs a confirmation echo. */
|
||||
fun requiresConfirmation(op: String): Boolean = op in setOf("discard", "push", "dirty-checkout")
|
||||
|
||||
/** Fixed confirmation token for a destructive op (matches the server). */
|
||||
fun confirmationFor(op: String): String? = when (op) {
|
||||
"discard" -> GitConfirmationStrings.DISCARD
|
||||
"push" -> GitConfirmationStrings.PUSH
|
||||
"dirty-checkout" -> GitConfirmationStrings.DIRTY_CHECKOUT
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun clearMutationError() {
|
||||
if (_mutation.value is GitMutationState.Error) {
|
||||
_mutation.value = GitMutationState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ sealed interface PluginsHubState {
|
||||
data object Disconnected : PluginsHubState
|
||||
data object Loading : PluginsHubState
|
||||
data class Ready(
|
||||
val ownerKey: String,
|
||||
val plugins: List<PluginHubItem>,
|
||||
val preview: PluginCatalogPreview,
|
||||
val refreshing: Boolean = false,
|
||||
@@ -220,6 +221,7 @@ class PluginsViewModel(application: Application) : AndroidViewModel(application)
|
||||
_hubState.value = result.fold(
|
||||
onSuccess = { items ->
|
||||
PluginsHubState.Ready(
|
||||
ownerKey = expectedKey,
|
||||
plugins = items,
|
||||
preview = catalogPreview(items),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
/**
|
||||
* Fail-closed dispatch policy for Android Supervised Mode.
|
||||
*
|
||||
* This intentionally runs before demo handling, route selection, slash.exec,
|
||||
* command.dispatch, steering, and queueing. Kotlin's default trim recognizes
|
||||
* Unicode whitespace, preventing an indented slash command from bypassing the
|
||||
* client restriction.
|
||||
*/
|
||||
internal fun supervisedMessageBlockReason(
|
||||
policy: SupervisedModePolicy,
|
||||
text: String,
|
||||
): String? {
|
||||
if (!policy.enabled) return null
|
||||
if (!policy.isConfigured) {
|
||||
return "Supervised mode is unavailable until the parent selects a profile."
|
||||
}
|
||||
if (text.trimStart().startsWith('/')) {
|
||||
return "Slash commands are unavailable in supervised mode."
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import com.hermesandroid.relay.data.DEFAULT_VOICE_STOP_PHRASES
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
@@ -279,6 +281,23 @@ internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
|
||||
*/
|
||||
enum class InteractionMode { TapToTalk, HoldToTalk, Continuous }
|
||||
|
||||
internal fun isVoiceCommandAllowed(
|
||||
action: VoiceCommandAction,
|
||||
policy: SupervisedModePolicy,
|
||||
): Boolean {
|
||||
if (!policy.enabled) return true
|
||||
val capabilities: SupervisedCapabilities = policy.capabilities
|
||||
return when (action) {
|
||||
VoiceCommandAction.StartNewChat -> capabilities.newChat
|
||||
VoiceCommandAction.StopResponse,
|
||||
VoiceCommandAction.CancelBackgroundTask -> capabilities.cancelResponse
|
||||
VoiceCommandAction.EndVoiceChat,
|
||||
VoiceCommandAction.PauseContinuousListening,
|
||||
VoiceCommandAction.ResumeContinuousListening,
|
||||
VoiceCommandAction.RepeatBackgroundAnswer -> capabilities.voice
|
||||
}
|
||||
}
|
||||
|
||||
internal fun InteractionMode.storageValue(): String = when (this) {
|
||||
InteractionMode.TapToTalk -> "tap"
|
||||
InteractionMode.HoldToTalk -> "hold"
|
||||
@@ -723,6 +742,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var voicePreferences: VoicePreferencesRepository? = null
|
||||
private var voicePreferencesJob: Job? = null
|
||||
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
|
||||
private var voiceStopPhrases: List<String> = DEFAULT_VOICE_STOP_PHRASES
|
||||
private var finalAnswerOnly: Boolean = false
|
||||
private var realtimeTraceDetails: Boolean = false
|
||||
@@ -1380,6 +1400,28 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the active Android client policy at the voice coordinator boundary. */
|
||||
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
|
||||
supervisedModePolicy = policy
|
||||
val supervised = policy.enabled
|
||||
voiceAudioClient?.setRouteOverride(if (supervised) VoiceAudioRoute.Standard else null)
|
||||
if (supervised) {
|
||||
if (voiceEngineMode == VoiceEngineMode.RealtimeAgent) closeRealtimeSession()
|
||||
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||
_voiceStats.update {
|
||||
it.copy(
|
||||
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput.storageValue,
|
||||
)
|
||||
}
|
||||
if (!policy.capabilities.voice && _uiState.value.voiceMode) exitVoiceMode()
|
||||
} else {
|
||||
val prefs = voicePreferences ?: return
|
||||
viewModelScope.launch {
|
||||
prefs.settings.firstOrNull()?.let { applyVoiceSettingsSnapshot(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistInteractionMode(mode: InteractionMode) {
|
||||
val prefs = voicePreferences ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -1485,7 +1527,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
) {
|
||||
closeRealtimeSession()
|
||||
}
|
||||
voiceEngineMode = nextEngineMode
|
||||
voiceEngineMode = if (supervisedModePolicy.enabled) {
|
||||
VoiceEngineMode.HermesVoiceOutput
|
||||
} else {
|
||||
nextEngineMode
|
||||
}
|
||||
voiceStopPhrases = settings.stopPhrases
|
||||
finalAnswerOnly = settings.finalAnswerOnly
|
||||
realtimeTraceDetails = settings.realtimeTraceDetails
|
||||
@@ -1506,7 +1552,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
it.copy(
|
||||
vadThresholdMs = settings.silenceThresholdMs,
|
||||
interactionMode = settings.interactionMode,
|
||||
voiceEngineMode = settings.engineMode,
|
||||
voiceEngineMode = voiceEngineMode.storageValue,
|
||||
realtimeModel = settings.realtimeModel,
|
||||
realtimeVoice = settings.realtimeVoice,
|
||||
)
|
||||
@@ -1540,6 +1586,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
activationId: String? = null,
|
||||
expectScreenContext: Boolean = false,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.voice) return
|
||||
val freshEntry = !_uiState.value.voiceMode
|
||||
val orphanedRun = _uiState.value
|
||||
.takeIf { freshEntry }
|
||||
@@ -2430,6 +2477,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (!canSpeakSettledResponse(state, providerRealtimeAgentTurnActive.get())) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
supervisedModePolicy.enabled &&
|
||||
voiceAudioClient?.effectiveRoute != VoiceAudioRoute.Standard
|
||||
) return false
|
||||
|
||||
val spoken = sanitizeForTts(text)
|
||||
if (spoken.isBlank()) return false
|
||||
@@ -3007,6 +3058,17 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isVoiceCommandAllowed(action, supervisedModePolicy)) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Idle,
|
||||
outputAudioActive = false,
|
||||
responseText = "That voice action is disabled by Parent controls.",
|
||||
)
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
Log.i(TAG, "Hands-free voice command action=$action source=${if (fromRealtime) "realtime" else "stt"}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
@@ -3076,12 +3138,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
setError("Voice pipeline not initialized")
|
||||
return
|
||||
}
|
||||
if (
|
||||
supervisedModePolicy.enabled &&
|
||||
audioClient.effectiveRoute != VoiceAudioRoute.Standard
|
||||
) {
|
||||
setError("Supervised voice requires the Standard Hermes voice route")
|
||||
return
|
||||
}
|
||||
currentTurnPcm = inputPcm
|
||||
currentTurnPcmSampleRate = inputSampleRate
|
||||
resetBrokeredToolSpeechState()
|
||||
resetRealtimeSpeechCoalescer()
|
||||
resetTtsTurnStats()
|
||||
val engineModeForTurn = voiceEngineMode
|
||||
val engineModeForTurn = if (supervisedModePolicy.enabled) {
|
||||
VoiceEngineMode.HermesVoiceOutput
|
||||
} else {
|
||||
voiceEngineMode
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"Processing voice input engine=${engineModeForTurn.storageValue} " +
|
||||
@@ -3230,7 +3303,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// ever mis-edited. If/when a `BuildFlavor.bridgeTier3` compile-
|
||||
// time constant exists we should still short-circuit here for
|
||||
// clarity, but today the factory already does the right thing.
|
||||
val bridgeHandler = voiceBridgeIntentHandler
|
||||
val bridgeHandler = voiceBridgeIntentHandler.takeUnless { supervisedModePolicy.enabled }
|
||||
|
||||
// === PHASE3-voice-cancel-midcountdown ===
|
||||
// Voice-in-voice cancel: if a destructive action is currently
|
||||
@@ -4906,7 +4979,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
private fun shouldPreferRealtimeVoice(): Boolean =
|
||||
voiceOutputAvailable != false &&
|
||||
!supervisedModePolicy.enabled &&
|
||||
voiceOutputAvailable != false &&
|
||||
realtimePcmPlayer != null &&
|
||||
voiceClient != null &&
|
||||
// Use the RESOLVED route: AutoVoiceAudioClient.effectiveRoute maps
|
||||
|
||||
@@ -657,6 +657,14 @@
|
||||
<string name="settings_analytics_desc">Estatísticas de uso, TTFT, tokens e integridade</string>
|
||||
<string name="settings_diagnostics">Diagnóstico</string>
|
||||
<string name="settings_diagnostics_desc">Verificações de status e atividade recente da API, do relay, da sessão e da voz</string>
|
||||
<string name="settings_advanced">Avançado</string>
|
||||
<string name="settings_advanced_desc">Modo supervisionado e outros recursos opcionais</string>
|
||||
<string name="settings_advanced_intro">Recursos opcionais e especializados ficam aqui para manter a tela principal de Configurações organizada.</string>
|
||||
<string name="settings_supervised_mode">Modo supervisionado</string>
|
||||
<string name="settings_supervised_desc">Escolha um perfil e os recursos de chat permitidos</string>
|
||||
<string name="settings_supervised_on">Ativado</string>
|
||||
<string name="settings_supervised_on_profile">Ativado · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Pronto · %1$s</string>
|
||||
<string name="settings_developer_options">Opções do desenvolvedor</string>
|
||||
<string name="settings_developer_options_desc">Flags de recursos, gerenciamento de dados e opções experimentais</string>
|
||||
<string name="settings_whats_new">Novidades</string>
|
||||
@@ -876,6 +884,10 @@
|
||||
<string name="drawer_search_sessions">Pesquisar sessões</string>
|
||||
<string name="drawer_activity_working">Em andamento</string>
|
||||
<string name="drawer_activity_needs_input">Precisa de resposta</string>
|
||||
<string name="drawer_activity_starting">Iniciando</string>
|
||||
<string name="drawer_activity_background_work">Trabalho em segundo plano</string>
|
||||
<string name="drawer_activity_checking">Verificando</string>
|
||||
<string name="drawer_activity_unavailable">Indisponível</string>
|
||||
<string name="drawer_new_thread">Nova Thread</string>
|
||||
<string name="drawer_chats_not_named">Os chats não recebem nomes automáticos nesta conexão — use ⋮ → Renomear.</string>
|
||||
<string name="drawer_loading_sessions">Carregando sessões…</string>
|
||||
@@ -905,7 +917,7 @@
|
||||
<string name="drawer_filter_pinned">Fixadas</string>
|
||||
<string name="drawer_filter_archive">Arquivo</string>
|
||||
<string name="drawer_filter_sessions">Sessões</string>
|
||||
<string name="drawer_timestamp_active">Ativa</string>
|
||||
<string name="drawer_timestamp_updated">Atualizada</string>
|
||||
<string name="drawer_timestamp_started">Iniciada</string>
|
||||
<string name="drawer_just_now">Agora mesmo</string>
|
||||
<!-- P1: BridgeScreen -->
|
||||
@@ -3846,6 +3858,48 @@
|
||||
<string name="plugins_keep">Manter</string>
|
||||
<string name="plugins_remove">Remover</string>
|
||||
<string name="plugins_remove_confirm">Remover “%1$s”? Esta página do plugin não aparecerá mais nos dispositivos Android conectados.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Voltar</string>
|
||||
<string name="git_state_staged">Preparados</string>
|
||||
<string name="git_state_modified">Modificados</string>
|
||||
<string name="git_state_untracked">Não rastreados</string>
|
||||
<string name="git_state_branches">Ramos</string>
|
||||
<string name="git_state_truncated">Resultados truncados — apenas as primeiras entradas são exibidas.</string>
|
||||
<string name="git_state_no_changes">(sem alterações)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Alterações de gravação exigem a permissão de alterações de plugin. Ative "Permitir alterações de plugins (plugin.api.write)" nas configurações de permissão de Plugins.</string>
|
||||
<string name="git_state_stage">Preparar</string>
|
||||
<string name="git_state_unstage">Despreparar</string>
|
||||
<string name="git_state_discard">Descartar</string>
|
||||
<string name="git_state_commit">Confirmar</string>
|
||||
<string name="git_state_commit_title">Confirmar alterações preparadas</string>
|
||||
<string name="git_state_commit_message_hint">Mensagem do commit</string>
|
||||
<string name="git_state_commit_confirm">Confirmar</string>
|
||||
<string name="git_state_cancel">Cancelar</string>
|
||||
<string name="git_state_fetch">Buscar</string>
|
||||
<string name="git_state_pull">Puxar</string>
|
||||
<string name="git_state_push">Enviar</string>
|
||||
<string name="git_state_new_branch_hint">Nome da nova ramificação</string>
|
||||
<string name="git_state_create_branch">Criar ramificação</string>
|
||||
<string name="git_state_track_remote">Rastrear ramificação remota</string>
|
||||
<string name="git_state_switch">Alternar</string>
|
||||
<string name="git_state_current">Atual</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s em andamento…</string>
|
||||
<string name="git_state_mutation_success">%1$s concluído. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s falhou: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Descartar alterações?</string>
|
||||
<string name="git_state_confirm_discard_text">As alterações locais dos arquivos selecionados serão descartadas e não poderão ser recuperadas.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Descartar</string>
|
||||
<string name="git_state_confirm_push_title">Enviar alterações?</string>
|
||||
<string name="git_state_confirm_push_text">Envia a ramificação atual para o repositório remoto. Isso publica os commits locais no repositório remoto.</string>
|
||||
<string name="git_state_confirm_push_confirm">Enviar</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Alternar ramificação com alterações locais?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">A árvore de trabalho tem alterações não confirmadas. A alternância pode carregá-las para a ramificação de destino.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Alternar</string>
|
||||
<string name="git_state_generate_message">Gerar mensagem de commit</string>
|
||||
<string name="git_state_generating_message">Gerando mensagem de commit…</string>
|
||||
<string name="git_state_push_after_commit">Enviar após o commit</string>
|
||||
<string name="git_state_switch_stash">Alternar (guardar se houver alterações)</string>
|
||||
<string name="support_bundle_review">Revisar informações de suporte</string>
|
||||
<string name="support_bundle_title">Informações de suporte</string>
|
||||
<string name="support_bundle_privacy">Nada é enviado automaticamente. Revise a exportação local com até %1$d relatórios.</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">使用统计、TTFT、token、健康状态</string>
|
||||
<string name="settings_diagnostics">诊断</string>
|
||||
<string name="settings_diagnostics_desc">状态检查,以及最近的 API、Relay、会话和语音活动</string>
|
||||
<string name="settings_advanced">高级</string>
|
||||
<string name="settings_advanced_desc">受监督模式和其他可选功能</string>
|
||||
<string name="settings_advanced_intro">可选和专用功能集中在此,以保持主设置界面简洁。</string>
|
||||
<string name="settings_supervised_mode">受监督模式</string>
|
||||
<string name="settings_supervised_desc">选择配置文件和允许的聊天功能</string>
|
||||
<string name="settings_supervised_on">已开启</string>
|
||||
<string name="settings_supervised_on_profile">已开启 · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">已就绪 · %1$s</string>
|
||||
<string name="settings_developer_options">开发者选项</string>
|
||||
<string name="settings_developer_options_desc">功能标志、数据管理、实验性</string>
|
||||
<string name="settings_whats_new">新功能</string>
|
||||
@@ -922,6 +930,10 @@
|
||||
<string name="drawer_search_sessions">搜索会话</string>
|
||||
<string name="drawer_activity_working">正在处理</string>
|
||||
<string name="drawer_activity_needs_input">需要输入</string>
|
||||
<string name="drawer_activity_starting">正在启动</string>
|
||||
<string name="drawer_activity_background_work">后台工作</string>
|
||||
<string name="drawer_activity_checking">正在检查</string>
|
||||
<string name="drawer_activity_unavailable">不可用</string>
|
||||
<string name="drawer_new_thread">新话题</string>
|
||||
<string name="drawer_chats_not_named">此连接上的对话不会自动命名——使用 ⋮ → 重命名。</string>
|
||||
<string name="drawer_loading_sessions">正在加载会话…</string>
|
||||
@@ -951,7 +963,7 @@
|
||||
<string name="drawer_filter_pinned">已固定</string>
|
||||
<string name="drawer_filter_archive">归档</string>
|
||||
<string name="drawer_filter_sessions">会话</string>
|
||||
<string name="drawer_timestamp_active">活跃</string>
|
||||
<string name="drawer_timestamp_updated">更新</string>
|
||||
<string name="drawer_timestamp_started">已开始</string>
|
||||
<string name="drawer_just_now">刚刚</string>
|
||||
|
||||
@@ -3934,6 +3946,48 @@
|
||||
<string name="plugins_keep">保留</string>
|
||||
<string name="plugins_remove">移除</string>
|
||||
<string name="plugins_remove_confirm">要移除“%1$s”吗?此插件页面将不再显示在已连接的 Android 设备上。</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">返回</string>
|
||||
<string name="git_state_staged">已暂存</string>
|
||||
<string name="git_state_modified">已修改</string>
|
||||
<string name="git_state_untracked">未跟踪</string>
|
||||
<string name="git_state_branches">分支</string>
|
||||
<string name="git_state_truncated">结果已截断 — 仅显示前几条。</string>
|
||||
<string name="git_state_no_changes">(无更改)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">写入更改需要插件更改权限。请在“插件”的权限设置中启用“允许插件更改 (plugin.api.write)”。</string>
|
||||
<string name="git_state_stage">暂存</string>
|
||||
<string name="git_state_unstage">取消暂存</string>
|
||||
<string name="git_state_discard">丢弃</string>
|
||||
<string name="git_state_commit">提交</string>
|
||||
<string name="git_state_commit_title">提交已暂存的更改</string>
|
||||
<string name="git_state_commit_message_hint">提交消息</string>
|
||||
<string name="git_state_commit_confirm">提交</string>
|
||||
<string name="git_state_cancel">取消</string>
|
||||
<string name="git_state_fetch">获取</string>
|
||||
<string name="git_state_pull">拉取</string>
|
||||
<string name="git_state_push">推送</string>
|
||||
<string name="git_state_new_branch_hint">新分支名称</string>
|
||||
<string name="git_state_create_branch">创建分支</string>
|
||||
<string name="git_state_track_remote">跟踪远程分支</string>
|
||||
<string name="git_state_switch">切换</string>
|
||||
<string name="git_state_current">当前</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s 正在进行…</string>
|
||||
<string name="git_state_mutation_success">%1$s 已完成。HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s 失败:%2$s</string>
|
||||
<string name="git_state_confirm_discard_title">丢弃更改?</string>
|
||||
<string name="git_state_confirm_discard_text">所选文件的本地更改将被丢弃,且无法恢复。</string>
|
||||
<string name="git_state_confirm_discard_confirm">丢弃</string>
|
||||
<string name="git_state_confirm_push_title">推送更改?</string>
|
||||
<string name="git_state_confirm_push_text">将当前分支推送到其远程仓库。这会把本地提交发送到远程仓库。</string>
|
||||
<string name="git_state_confirm_push_confirm">推送</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">切换包含本地更改的分支?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">工作区有未提交的更改。切换后,这些更改可能会被带到目标分支。</string>
|
||||
<string name="git_state_confirm_checkout_confirm">切换</string>
|
||||
<string name="git_state_generate_message">生成提交信息</string>
|
||||
<string name="git_state_generating_message">正在生成提交信息…</string>
|
||||
<string name="git_state_push_after_commit">提交后推送</string>
|
||||
<string name="git_state_switch_stash">切换(有更改时暂存)</string>
|
||||
<string name="support_bundle_review">查看支持信息</string>
|
||||
<string name="support_bundle_title">支持信息</string>
|
||||
<string name="support_bundle_privacy">不会自动上传任何内容。请查看最多包含 %1$d 个报告的本地导出。</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">Nutzungsstatistiken, TTFT, Token, Status</string>
|
||||
<string name="settings_diagnostics">Diagnose</string>
|
||||
<string name="settings_diagnostics_desc">Statusprüfungen sowie letzte API-, Relay-, Sitzungs- und Sprachaktivitäten</string>
|
||||
<string name="settings_advanced">Erweitert</string>
|
||||
<string name="settings_advanced_desc">Beaufsichtigter Modus und weitere optionale Funktionen</string>
|
||||
<string name="settings_advanced_intro">Optionale und spezielle Funktionen befinden sich hier, damit die Haupteinstellungen übersichtlich bleiben.</string>
|
||||
<string name="settings_supervised_mode">Beaufsichtigter Modus</string>
|
||||
<string name="settings_supervised_desc">Profil und erlaubte Chatfunktionen auswählen</string>
|
||||
<string name="settings_supervised_on">Ein</string>
|
||||
<string name="settings_supervised_on_profile">Ein · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Bereit · %1$s</string>
|
||||
<string name="settings_developer_options">Entwickleroptionen</string>
|
||||
<string name="settings_developer_options_desc">Funktionsschalter, Datenverwaltung, Experimente</string>
|
||||
<string name="settings_whats_new">Neuigkeiten</string>
|
||||
@@ -925,6 +933,10 @@
|
||||
<string name="drawer_search_sessions">Sitzungen durchsuchen</string>
|
||||
<string name="drawer_activity_working">Wird bearbeitet</string>
|
||||
<string name="drawer_activity_needs_input">Eingabe erforderlich</string>
|
||||
<string name="drawer_activity_starting">Wird gestartet</string>
|
||||
<string name="drawer_activity_background_work">Hintergrundarbeit</string>
|
||||
<string name="drawer_activity_checking">Wird geprüft</string>
|
||||
<string name="drawer_activity_unavailable">Nicht verfügbar</string>
|
||||
<string name="drawer_new_thread">Neuer Thread</string>
|
||||
<string name="drawer_chats_not_named">Chats werden bei dieser Verbindung nicht automatisch benannt — verwende ⋮ → Umbenennen.</string>
|
||||
<string name="drawer_loading_sessions">Sitzungen werden geladen…</string>
|
||||
@@ -954,7 +966,7 @@
|
||||
<string name="drawer_filter_pinned">Angeheftet</string>
|
||||
<string name="drawer_filter_archive">Archiv</string>
|
||||
<string name="drawer_filter_sessions">Sitzungen</string>
|
||||
<string name="drawer_timestamp_active">Aktiv</string>
|
||||
<string name="drawer_timestamp_updated">Aktualisiert</string>
|
||||
<string name="drawer_timestamp_started">Gestartet</string>
|
||||
<string name="drawer_just_now">Gerade eben</string>
|
||||
|
||||
@@ -4006,6 +4018,48 @@
|
||||
<string name="plugins_keep">Behalten</string>
|
||||
<string name="plugins_remove">Entfernen</string>
|
||||
<string name="plugins_remove_confirm">„%1$s“ entfernen? Diese Plugin-Seite wird auf verbundenen Android-Geräten nicht mehr angezeigt.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Zurück</string>
|
||||
<string name="git_state_staged">Gestaged</string>
|
||||
<string name="git_state_modified">Geändert</string>
|
||||
<string name="git_state_untracked">Unverfolgt</string>
|
||||
<string name="git_state_branches">Branches</string>
|
||||
<string name="git_state_truncated">Ergebnisse abgeschnitten – nur die ersten Einträge werden angezeigt.</string>
|
||||
<string name="git_state_no_changes">(keine Änderungen)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Für Schreibvorgänge ist die Plugin-Änderungsberechtigung erforderlich. Aktivieren Sie „Änderungen durch Plugins zulassen (plugin.api.write)“ in den Plugin-Berechtigungseinstellungen.</string>
|
||||
<string name="git_state_stage">Bereitstellen</string>
|
||||
<string name="git_state_unstage">Zurücksetzen</string>
|
||||
<string name="git_state_discard">Verwerfen</string>
|
||||
<string name="git_state_commit">Committen</string>
|
||||
<string name="git_state_commit_title">Bereitgestellte Änderungen committen</string>
|
||||
<string name="git_state_commit_message_hint">Commit-Nachricht</string>
|
||||
<string name="git_state_commit_confirm">Committen</string>
|
||||
<string name="git_state_cancel">Abbrechen</string>
|
||||
<string name="git_state_fetch">Abrufen</string>
|
||||
<string name="git_state_pull">Pullen</string>
|
||||
<string name="git_state_push">Pushen</string>
|
||||
<string name="git_state_new_branch_hint">Name des neuen Branches</string>
|
||||
<string name="git_state_create_branch">Branch erstellen</string>
|
||||
<string name="git_state_track_remote">Remote-Branch verfolgen</string>
|
||||
<string name="git_state_switch">Wechseln</string>
|
||||
<string name="git_state_current">Aktuell</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s läuft…</string>
|
||||
<string name="git_state_mutation_success">%1$s abgeschlossen. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s fehlgeschlagen: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Änderungen verwerfen?</string>
|
||||
<string name="git_state_confirm_discard_text">Lokale Änderungen an den ausgewählten Dateien werden verworfen und können nicht wiederhergestellt werden.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Verwerfen</string>
|
||||
<string name="git_state_confirm_push_title">Änderungen pushen?</string>
|
||||
<string name="git_state_confirm_push_text">Pushen Sie den aktuellen Branch zu seinem Remote. Dabei werden lokale Commits an das Remote-Repository gesendet.</string>
|
||||
<string name="git_state_confirm_push_confirm">Pushen</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Branch mit lokalen Änderungen wechseln?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">Der Arbeitsbaum enthält nicht committete Änderungen. Beim Wechseln werden sie möglicherweise auf den Zielbranch übertragen.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Wechseln</string>
|
||||
<string name="git_state_generate_message">Commit-Nachricht generieren</string>
|
||||
<string name="git_state_generating_message">Commit-Nachricht wird generiert…</string>
|
||||
<string name="git_state_push_after_commit">Nach Commit pushen</string>
|
||||
<string name="git_state_switch_stash">Wechseln (bei Änderungen stashen)</string>
|
||||
<string name="support_bundle_review">Supportinformationen prüfen</string>
|
||||
<string name="support_bundle_title">Supportinformationen</string>
|
||||
<string name="support_bundle_privacy">Nichts wird automatisch hochgeladen. Prüfen Sie den lokalen Export mit bis zu %1$d Berichten.</string>
|
||||
|
||||
@@ -625,6 +625,14 @@
|
||||
<string name="settings_analytics_desc">Estadísticas de uso, TTFT, tokens, salud</string>
|
||||
<string name="settings_diagnostics">Diagnóstico</string>
|
||||
<string name="settings_diagnostics_desc">Verificaciones de estado, además de actividad reciente de API, relay, sesión y voz</string>
|
||||
<string name="settings_advanced">Avanzado</string>
|
||||
<string name="settings_advanced_desc">Modo supervisado y otras funciones opcionales</string>
|
||||
<string name="settings_advanced_intro">Las funciones opcionales y especializadas están aquí para mantener despejada la pantalla principal de Ajustes.</string>
|
||||
<string name="settings_supervised_mode">Modo supervisado</string>
|
||||
<string name="settings_supervised_desc">Elige un perfil y las funciones de chat permitidas</string>
|
||||
<string name="settings_supervised_on">Activado</string>
|
||||
<string name="settings_supervised_on_profile">Activado · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Listo · %1$s</string>
|
||||
<string name="settings_developer_options">Opciones de desarrollador</string>
|
||||
<string name="settings_developer_options_desc">Indicadores de funciones, gestión de datos, experimental.</string>
|
||||
<string name="settings_whats_new">Novedades</string>
|
||||
@@ -840,6 +848,10 @@
|
||||
<string name="drawer_search_sessions">Buscar sesiones</string>
|
||||
<string name="drawer_activity_working">En curso</string>
|
||||
<string name="drawer_activity_needs_input">Requiere intervención</string>
|
||||
<string name="drawer_activity_starting">Iniciando</string>
|
||||
<string name="drawer_activity_background_work">Trabajo en segundo plano</string>
|
||||
<string name="drawer_activity_checking">Comprobando</string>
|
||||
<string name="drawer_activity_unavailable">No disponible</string>
|
||||
<string name="drawer_new_thread">Nuevo hilo</string>
|
||||
<string name="drawer_chats_not_named">Los chats no tienen nombre automático en esta conexión. Utiliza «→Renombrar».</string>
|
||||
<string name="drawer_loading_sessions">Cargando sesiones…</string>
|
||||
@@ -869,7 +881,7 @@
|
||||
<string name="drawer_filter_pinned">Fijado</string>
|
||||
<string name="drawer_filter_archive">Archivo</string>
|
||||
<string name="drawer_filter_sessions">Sesiones</string>
|
||||
<string name="drawer_timestamp_active">Activo</string>
|
||||
<string name="drawer_timestamp_updated">Actualizado</string>
|
||||
<string name="drawer_timestamp_started">Comenzó</string>
|
||||
<string name="drawer_just_now">En este momento</string>
|
||||
<string name="bridge_back">Atrás</string>
|
||||
@@ -3691,6 +3703,48 @@
|
||||
<string name="plugins_keep">Conservar</string>
|
||||
<string name="plugins_remove">Eliminar</string>
|
||||
<string name="plugins_remove_confirm">¿Eliminar «%1$s»? Esta página del plugin dejará de aparecer en los dispositivos Android conectados.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Atrás</string>
|
||||
<string name="git_state_staged">Preparados</string>
|
||||
<string name="git_state_modified">Modificados</string>
|
||||
<string name="git_state_untracked">Sin seguimiento</string>
|
||||
<string name="git_state_branches">Ramas</string>
|
||||
<string name="git_state_truncated">Resultados truncados: solo se muestran las primeras entradas.</string>
|
||||
<string name="git_state_no_changes">(sin cambios)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Los cambios de escritura requieren el permiso de cambios de plugin. Activa «Permitir cambios de plugins (plugin.api.write)» en la configuración de permisos de Plugins.</string>
|
||||
<string name="git_state_stage">Preparar</string>
|
||||
<string name="git_state_unstage">Quitar de preparados</string>
|
||||
<string name="git_state_discard">Descartar</string>
|
||||
<string name="git_state_commit">Confirmar</string>
|
||||
<string name="git_state_commit_title">Confirmar cambios preparados</string>
|
||||
<string name="git_state_commit_message_hint">Mensaje de confirmación</string>
|
||||
<string name="git_state_commit_confirm">Confirmar</string>
|
||||
<string name="git_state_cancel">Cancelar</string>
|
||||
<string name="git_state_fetch">Obtener</string>
|
||||
<string name="git_state_pull">Traer</string>
|
||||
<string name="git_state_push">Enviar</string>
|
||||
<string name="git_state_new_branch_hint">Nombre de la nueva rama</string>
|
||||
<string name="git_state_create_branch">Crear rama</string>
|
||||
<string name="git_state_track_remote">Seguir rama remota</string>
|
||||
<string name="git_state_switch">Cambiar</string>
|
||||
<string name="git_state_current">Actual</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s en curso…</string>
|
||||
<string name="git_state_mutation_success">%1$s completado. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s falló: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">¿Descartar cambios?</string>
|
||||
<string name="git_state_confirm_discard_text">Los cambios locales de los archivos seleccionados se descartarán y no podrán recuperarse.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Descartar</string>
|
||||
<string name="git_state_confirm_push_title">¿Enviar cambios?</string>
|
||||
<string name="git_state_confirm_push_text">Envía la rama actual a su remoto. Esto sube los commits locales al repositorio remoto.</string>
|
||||
<string name="git_state_confirm_push_confirm">Enviar</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">¿Cambiar de rama con cambios locales?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">El árbol de trabajo tiene cambios sin confirmar. Cambiar puede trasladarlos a la rama de destino.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Cambiar</string>
|
||||
<string name="git_state_generate_message">Generar mensaje de confirmación</string>
|
||||
<string name="git_state_generating_message">Generando mensaje de confirmación…</string>
|
||||
<string name="git_state_push_after_commit">Enviar después de confirmar</string>
|
||||
<string name="git_state_switch_stash">Cambiar (guardar en stash si hay cambios)</string>
|
||||
<string name="support_bundle_review">Revisar información de soporte</string>
|
||||
<string name="support_bundle_title">Información de soporte</string>
|
||||
<string name="support_bundle_privacy">Nada se sube automáticamente. Revisa la exportación local con hasta %1$d informes.</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">使用状況統計、TTFT、トークン、ヘルス</string>
|
||||
<string name="settings_diagnostics">診断</string>
|
||||
<string name="settings_diagnostics_desc">ステータス チェック、および最近の API、Relay、セッション、および音声アクティビティ</string>
|
||||
<string name="settings_advanced">詳細設定</string>
|
||||
<string name="settings_advanced_desc">監督モードとその他のオプション機能</string>
|
||||
<string name="settings_advanced_intro">メインの設定画面をシンプルに保つため、オプション機能と専門機能はここにまとめられています。</string>
|
||||
<string name="settings_supervised_mode">監督モード</string>
|
||||
<string name="settings_supervised_desc">プロファイルと許可するチャット機能を選択</string>
|
||||
<string name="settings_supervised_on">オン</string>
|
||||
<string name="settings_supervised_on_profile">オン · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">準備完了 · %1$s</string>
|
||||
<string name="settings_developer_options">開発者向けオプション</string>
|
||||
<string name="settings_developer_options_desc">機能フラグ、データ管理、実験的</string>
|
||||
<string name="settings_whats_new">新着情報</string>
|
||||
@@ -938,6 +946,10 @@
|
||||
<string name="drawer_search_sessions">セッションを検索</string>
|
||||
<string name="drawer_activity_working">処理中</string>
|
||||
<string name="drawer_activity_needs_input">入力が必要</string>
|
||||
<string name="drawer_activity_starting">開始中</string>
|
||||
<string name="drawer_activity_background_work">バックグラウンド処理</string>
|
||||
<string name="drawer_activity_checking">確認中</string>
|
||||
<string name="drawer_activity_unavailable">利用不可</string>
|
||||
<string name="drawer_new_thread">新しいスレッド</string>
|
||||
<string name="drawer_chats_not_named">この接続ではチャットの名前は自動的に付けられません。「⋮」→「名前の変更」を使用してください。</string>
|
||||
<string name="drawer_loading_sessions">セッションを読み込み中…</string>
|
||||
@@ -967,7 +979,7 @@
|
||||
<string name="drawer_filter_pinned">固定された</string>
|
||||
<string name="drawer_filter_archive">アーカイブ</string>
|
||||
<string name="drawer_filter_sessions">セッション</string>
|
||||
<string name="drawer_timestamp_active">アクティブ</string>
|
||||
<string name="drawer_timestamp_updated">更新</string>
|
||||
<string name="drawer_timestamp_started">開始しました</string>
|
||||
<string name="drawer_just_now">ちょうど今</string>
|
||||
|
||||
@@ -4005,6 +4017,48 @@
|
||||
<string name="plugins_keep">保持</string>
|
||||
<string name="plugins_remove">削除</string>
|
||||
<string name="plugins_remove_confirm">「%1$s」を削除しますか?接続された Android 端末にこのプラグインページは表示されなくなります。</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">戻る</string>
|
||||
<string name="git_state_staged">ステージ済み</string>
|
||||
<string name="git_state_modified">変更</string>
|
||||
<string name="git_state_untracked">未追跡</string>
|
||||
<string name="git_state_branches">ブランチ</string>
|
||||
<string name="git_state_truncated">結果は切り詰められています。最初の項目のみ表示されます。</string>
|
||||
<string name="git_state_no_changes">(変更なし)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">書き込み変更にはプラグイン変更権限が必要です。Plugins の権限設定で「プラグインの変更を許可する(plugin.api.write)」を有効にしてください。</string>
|
||||
<string name="git_state_stage">ステージ</string>
|
||||
<string name="git_state_unstage">ステージ解除</string>
|
||||
<string name="git_state_discard">破棄</string>
|
||||
<string name="git_state_commit">コミット</string>
|
||||
<string name="git_state_commit_title">ステージ済みの変更をコミット</string>
|
||||
<string name="git_state_commit_message_hint">コミットメッセージ</string>
|
||||
<string name="git_state_commit_confirm">コミット</string>
|
||||
<string name="git_state_cancel">キャンセル</string>
|
||||
<string name="git_state_fetch">フェッチ</string>
|
||||
<string name="git_state_pull">プル</string>
|
||||
<string name="git_state_push">プッシュ</string>
|
||||
<string name="git_state_new_branch_hint">新しいブランチ名</string>
|
||||
<string name="git_state_create_branch">ブランチを作成</string>
|
||||
<string name="git_state_track_remote">リモートブランチを追跡</string>
|
||||
<string name="git_state_switch">切り替え</string>
|
||||
<string name="git_state_current">現在</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s を実行中…</string>
|
||||
<string name="git_state_mutation_success">%1$s が完了しました。HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s に失敗しました: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">変更を破棄しますか?</string>
|
||||
<string name="git_state_confirm_discard_text">選択したファイルへのローカル変更は破棄され、元に戻せません。</string>
|
||||
<string name="git_state_confirm_discard_confirm">破棄</string>
|
||||
<string name="git_state_confirm_push_title">変更をプッシュしますか?</string>
|
||||
<string name="git_state_confirm_push_text">現在のブランチをリモートにプッシュします。ローカルのコミットがリモートリポジトリに送信されます。</string>
|
||||
<string name="git_state_confirm_push_confirm">プッシュ</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">ローカル変更のあるブランチに切り替えますか?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">作業ツリーに未コミットの変更があります。切り替えると、それらが対象ブランチへ引き継がれる可能性があります。</string>
|
||||
<string name="git_state_confirm_checkout_confirm">切り替え</string>
|
||||
<string name="git_state_generate_message">コミットメッセージを生成</string>
|
||||
<string name="git_state_generating_message">コミットメッセージを生成中…</string>
|
||||
<string name="git_state_push_after_commit">コミット後にプッシュ</string>
|
||||
<string name="git_state_switch_stash">切り替え(変更があればスタッシュ)</string>
|
||||
<string name="support_bundle_review">サポート情報を確認</string>
|
||||
<string name="support_bundle_title">サポート情報</string>
|
||||
<string name="support_bundle_privacy">自動アップロードはありません。最大 %1$d 件のレポートを含む端末内エクスポートを確認してください。</string>
|
||||
|
||||
@@ -668,6 +668,14 @@
|
||||
<string name="settings_analytics_desc">Статистика использования, TTFT, токены, состояние</string>
|
||||
<string name="settings_diagnostics">Диагностика</string>
|
||||
<string name="settings_diagnostics_desc">Проверка состояния, а также недавняя активность API, Relay, сессий и голосовых данных</string>
|
||||
<string name="settings_advanced">Дополнительно</string>
|
||||
<string name="settings_advanced_desc">Режим с контролем и другие дополнительные функции</string>
|
||||
<string name="settings_advanced_intro">Дополнительные и специальные функции собраны здесь, чтобы не перегружать главный экран настроек.</string>
|
||||
<string name="settings_supervised_mode">Режим с контролем</string>
|
||||
<string name="settings_supervised_desc">Выберите профиль и разрешённые функции чата</string>
|
||||
<string name="settings_supervised_on">Вкл.</string>
|
||||
<string name="settings_supervised_on_profile">Вкл. · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Готово · %1$s</string>
|
||||
<string name="settings_developer_options">Настройки разработчика</string>
|
||||
<string name="settings_developer_options_desc">Флаги функций, управление данными, экспериментальные</string>
|
||||
<string name="settings_whats_new">Что нового</string>
|
||||
@@ -948,6 +956,10 @@
|
||||
<string name="drawer_search_sessions">Поиск сессий</string>
|
||||
<string name="drawer_activity_working">Выполняется</string>
|
||||
<string name="drawer_activity_needs_input">Требуется ввод</string>
|
||||
<string name="drawer_activity_starting">Запуск</string>
|
||||
<string name="drawer_activity_background_work">Фоновая работа</string>
|
||||
<string name="drawer_activity_checking">Проверка</string>
|
||||
<string name="drawer_activity_unavailable">Недоступно</string>
|
||||
<string name="drawer_new_thread">Новая ветка</string>
|
||||
<string name="drawer_chats_not_named">Чаты не автоматически именуются на этом соединении — используйте ⋮ → Переименовать.</string>
|
||||
<string name="drawer_loading_sessions">Загрузка сессий…</string>
|
||||
@@ -977,7 +989,7 @@
|
||||
<string name="drawer_filter_pinned">Закрепленные</string>
|
||||
<string name="drawer_filter_archive">Архив</string>
|
||||
<string name="drawer_filter_sessions">Сессии</string>
|
||||
<string name="drawer_timestamp_active">Активен</string>
|
||||
<string name="drawer_timestamp_updated">Обновлено</string>
|
||||
<string name="drawer_timestamp_started">Начат</string>
|
||||
<string name="drawer_just_now">Только что</string>
|
||||
<string name="bridge_back">Назад</string>
|
||||
@@ -3727,6 +3739,48 @@
|
||||
<string name="plugins_keep">Оставить</string>
|
||||
<string name="plugins_remove">Удалить</string>
|
||||
<string name="plugins_remove_confirm">Удалить «%1$s»? Эта страница плагина больше не будет отображаться на подключённых устройствах Android.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Назад</string>
|
||||
<string name="git_state_staged">В индексе</string>
|
||||
<string name="git_state_modified">Изменённые</string>
|
||||
<string name="git_state_untracked">Неотслеживаемые</string>
|
||||
<string name="git_state_branches">Ветки</string>
|
||||
<string name="git_state_truncated">Результаты усечены — показаны только первые записи.</string>
|
||||
<string name="git_state_no_changes">(без изменений)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Для записи изменений требуется разрешение на изменение плагина. Включите «Разрешить изменения плагинов (plugin.api.write)» в настройках разрешений Plugins.</string>
|
||||
<string name="git_state_stage">Добавить в индекс</string>
|
||||
<string name="git_state_unstage">Убрать из индекса</string>
|
||||
<string name="git_state_discard">Отменить</string>
|
||||
<string name="git_state_commit">Коммит</string>
|
||||
<string name="git_state_commit_title">Зафиксировать индексированные изменения</string>
|
||||
<string name="git_state_commit_message_hint">Сообщение коммита</string>
|
||||
<string name="git_state_commit_confirm">Коммит</string>
|
||||
<string name="git_state_cancel">Отмена</string>
|
||||
<string name="git_state_fetch">Обновить</string>
|
||||
<string name="git_state_pull">Вытянуть</string>
|
||||
<string name="git_state_push">Отправить</string>
|
||||
<string name="git_state_new_branch_hint">Имя новой ветки</string>
|
||||
<string name="git_state_create_branch">Создать ветку</string>
|
||||
<string name="git_state_track_remote">Отслеживать удалённую ветку</string>
|
||||
<string name="git_state_switch">Переключить</string>
|
||||
<string name="git_state_current">Текущая</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s выполняется…</string>
|
||||
<string name="git_state_mutation_success">%1$s завершено. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s не удалось: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Отменить изменения?</string>
|
||||
<string name="git_state_confirm_discard_text">Локальные изменения выбранных файлов будут отменены и их нельзя будет восстановить.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Отменить</string>
|
||||
<string name="git_state_confirm_push_title">Отправить изменения?</string>
|
||||
<string name="git_state_confirm_push_text">Отправить текущую ветку в её удалённый репозиторий. Это передаст локальные коммиты в удалённый репозиторий.</string>
|
||||
<string name="git_state_confirm_push_confirm">Отправить</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Переключить ветку с локальными изменениями?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">В рабочем дереве есть незакоммиченные изменения. Переключение может перенести их в целевую ветку.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Переключить</string>
|
||||
<string name="git_state_generate_message">Создать сообщение коммита</string>
|
||||
<string name="git_state_generating_message">Создание сообщения коммита…</string>
|
||||
<string name="git_state_push_after_commit">Отправить после коммита</string>
|
||||
<string name="git_state_switch_stash">Переключить (сохранить изменения, если есть)</string>
|
||||
<string name="support_bundle_review">Проверить сведения для поддержки</string>
|
||||
<string name="support_bundle_title">Сведения для поддержки</string>
|
||||
<string name="support_bundle_privacy">Ничего не загружается автоматически. Проверьте локальный экспорт с не более чем %1$d отчётами.</string>
|
||||
|
||||
@@ -736,6 +736,14 @@
|
||||
<string name="settings_analytics_desc">Usage stats, TTFT, tokens, health</string>
|
||||
<string name="settings_diagnostics">Diagnostics</string>
|
||||
<string name="settings_diagnostics_desc">Status checks, plus recent API, relay, session, and voice activity</string>
|
||||
<string name="settings_advanced">Advanced</string>
|
||||
<string name="settings_advanced_desc">Supervised mode and other optional features</string>
|
||||
<string name="settings_advanced_intro">Optional and specialized features live here to keep the main Settings screen focused.</string>
|
||||
<string name="settings_supervised_mode">Supervised mode</string>
|
||||
<string name="settings_supervised_desc">Choose a profile and approved chat features</string>
|
||||
<string name="settings_supervised_on">On</string>
|
||||
<string name="settings_supervised_on_profile">On · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Ready · %1$s</string>
|
||||
<string name="settings_developer_options">Developer options</string>
|
||||
<string name="settings_developer_options_desc">Feature flags, data management, experimental</string>
|
||||
<string name="settings_whats_new">What\'s New</string>
|
||||
@@ -1040,6 +1048,10 @@
|
||||
<string name="drawer_search_sessions">Search sessions</string>
|
||||
<string name="drawer_activity_working">Working</string>
|
||||
<string name="drawer_activity_needs_input">Needs input</string>
|
||||
<string name="drawer_activity_starting">Starting</string>
|
||||
<string name="drawer_activity_background_work">Background work</string>
|
||||
<string name="drawer_activity_checking">Checking</string>
|
||||
<string name="drawer_activity_unavailable">Unavailable</string>
|
||||
<string name="drawer_new_thread">New Thread</string>
|
||||
<string name="drawer_chats_not_named">Chats aren\'t auto-named on this connection — use ⋮ → Rename.</string>
|
||||
<string name="drawer_loading_sessions">Loading sessions…</string>
|
||||
@@ -1069,7 +1081,7 @@
|
||||
<string name="drawer_filter_pinned">Pinned</string>
|
||||
<string name="drawer_filter_archive">Archive</string>
|
||||
<string name="drawer_filter_sessions">Sessions</string>
|
||||
<string name="drawer_timestamp_active">Active</string>
|
||||
<string name="drawer_timestamp_updated">Updated</string>
|
||||
<string name="drawer_timestamp_started">Started</string>
|
||||
<string name="drawer_just_now">Just now</string>
|
||||
|
||||
@@ -4202,6 +4214,51 @@
|
||||
<string name="plugins_keep">Keep</string>
|
||||
<string name="plugins_remove">Remove</string>
|
||||
<string name="plugins_remove_confirm">Remove “%1$s”? This plugin page will no longer appear on connected Android devices.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Back</string>
|
||||
<string name="git_state_staged">Staged</string>
|
||||
<string name="git_state_modified">Modified</string>
|
||||
<string name="git_state_untracked">Untracked</string>
|
||||
<string name="git_state_branches">Branches</string>
|
||||
<string name="git_state_truncated">Results truncated — showing the first entries only.</string>
|
||||
<string name="git_state_no_changes">(no changes)</string>
|
||||
|
||||
<!-- Git write surface (Phase 2). All mutations require the plugin.api.write grant. -->
|
||||
<string name="git_state_write_grant_required">Write changes require the plugin change permission. Enable “Allow plugin changes (plugin.api.write)” in the Plugins grant settings.</string>
|
||||
<string name="git_state_stage">Stage</string>
|
||||
<string name="git_state_unstage">Unstage</string>
|
||||
<string name="git_state_discard">Discard</string>
|
||||
<string name="git_state_commit">Commit</string>
|
||||
<string name="git_state_commit_title">Commit staged changes</string>
|
||||
<string name="git_state_commit_message_hint">Commit message</string>
|
||||
<string name="git_state_commit_confirm">Commit</string>
|
||||
<string name="git_state_cancel">Cancel</string>
|
||||
<string name="git_state_fetch">Fetch</string>
|
||||
<string name="git_state_pull">Pull</string>
|
||||
<string name="git_state_push">Push</string>
|
||||
<string name="git_state_new_branch_hint">New branch name</string>
|
||||
<string name="git_state_create_branch">Create branch</string>
|
||||
<string name="git_state_track_remote">Track remote branch</string>
|
||||
<string name="git_state_switch">Switch</string>
|
||||
<string name="git_state_current">Current</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s in progress…</string>
|
||||
<string name="git_state_mutation_success">%1$s completed. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s failed: %2$s</string>
|
||||
<!-- Destructive-op confirmations (token is sent only on explicit user confirmation). -->
|
||||
<string name="git_state_confirm_discard_title">Discard changes?</string>
|
||||
<string name="git_state_confirm_discard_text">Local changes to the selected file(s) will be discarded and cannot be recovered.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Discard</string>
|
||||
<string name="git_state_confirm_push_title">Push changes?</string>
|
||||
<string name="git_state_confirm_push_text">Push the current branch to its remote. This sends local commits to the remote repository.</string>
|
||||
<string name="git_state_confirm_push_confirm">Push</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Switch branch with local changes?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">The working tree has uncommitted changes. Switching may carry them onto the target branch.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Switch</string>
|
||||
<string name="git_state_generate_message">Generate commit message</string>
|
||||
<string name="git_state_generating_message">Generating commit message…</string>
|
||||
<string name="git_state_push_after_commit">Push after commit</string>
|
||||
<string name="git_state_switch_stash">Switch (stash if dirty)</string>
|
||||
|
||||
<string name="support_bundle_review">Review support information</string>
|
||||
<string name="support_bundle_title">Support information</string>
|
||||
<string name="support_bundle_privacy">Nothing is uploaded automatically. Review the exact local export below. It contains up to %1$d recent reports.</string>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.hermesandroid.relay.auth
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedModeAuthPayloadTest {
|
||||
@Test fun `active policy reports only public capability ids`() {
|
||||
val payload = relaySupervisedModePayload(
|
||||
SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(attachments = true, voice = true),
|
||||
),
|
||||
)
|
||||
assertTrue(payload.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals("willow", payload.getValue("profile_label").jsonPrimitive.content)
|
||||
val capabilities = payload.getValue("capabilities").jsonArray.map { it.jsonPrimitive.content }
|
||||
assertTrue("text_chat" in capabilities)
|
||||
assertTrue("attachments" in capabilities)
|
||||
assertTrue("voice" in capabilities)
|
||||
assertFalse(capabilities.any { it.contains("model") || it.contains("tool") })
|
||||
}
|
||||
|
||||
@Test fun `inactive update explicitly clears Relay tag`() {
|
||||
val payload = relaySupervisedModePayload(SupervisedModePolicy())
|
||||
assertFalse(payload.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals(setOf("active"), payload.keys)
|
||||
}
|
||||
|
||||
@Test fun `live update uses typed correlated system envelope`() {
|
||||
val envelope = relaySupervisedModeUpdateEnvelope(
|
||||
SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(voice = true),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("system", envelope.channel)
|
||||
assertEquals("supervised.update", envelope.type)
|
||||
assertTrue(envelope.id.isNotBlank())
|
||||
val mode = envelope.payload.getValue("supervised_mode")
|
||||
.jsonObject
|
||||
assertTrue(mode.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals("willow", mode.getValue("profile_label").jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionActivityRegistryTest {
|
||||
private val owner = owner("connection-a", "Default", "session-a")
|
||||
private val scope = SessionActivityScope.of("connection-a", "default")
|
||||
|
||||
@Test
|
||||
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(SessionActivityFreshness.Revalidating, checking.record(owner)?.freshness)
|
||||
assertNull(checking.record(owner)?.presentationState())
|
||||
|
||||
val unavailable = checking.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(scope, generation = 1, observedAtMillis = 2),
|
||||
)
|
||||
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())
|
||||
assertEquals(SessionActivityFreshness.Confirmed, confirmedIdle.record(owner)?.freshness)
|
||||
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()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(SessionActivityUpdate.ObserveOwner(owner, generation = 1, observedAtMillis = 20))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
|
||||
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `owner normalizes profile without collapsing connection ownership`() {
|
||||
assertEquals(owner, owner("connection-a", " DEFAULT ", "session-a"))
|
||||
val otherConnection = owner("connection-b", "default", "session-a")
|
||||
assertEquals(2, setOf(owner, otherConnection).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact pending input outranks live working and answer restores it`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase(nowMillis = 10))
|
||||
|
||||
state = state.reduce(closeInput(owner, "request-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expired pending input no longer overrides live state`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1, expiresAt = 50))
|
||||
.reduce(SessionActivityUpdate.Tick(nowMillis = 50))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase(nowMillis = 50))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checkpoint is revalidating until successful snapshot settles it`() {
|
||||
var state = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = 1,
|
||||
observedAtMillis = 1,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
|
||||
state = state.reduce(activeList(scope, generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful snapshot absence settles only the same profile`() {
|
||||
val otherProfile = owner("connection-a", "work", "session-a")
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(live(otherProfile, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
|
||||
state = state.reduce(activeList(scope, generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityPhase.Working, state.record(otherProfile)?.phase())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same ids cannot alias across profiles or connections`() {
|
||||
val profileB = owner("connection-a", "work", "session-a")
|
||||
val connectionB = owner("connection-b", "default", "session-a")
|
||||
var state = SessionActivityRegistry()
|
||||
listOf(owner, profileB, connectionB).forEach {
|
||||
state = state.reduce(live(it, "runtime-shared", SessionLiveStatus.Working, generation = 2))
|
||||
}
|
||||
|
||||
assertEquals(owner, state.ownerForRuntime(scope, "runtime-shared"))
|
||||
assertEquals(
|
||||
profileB,
|
||||
state.ownerForRuntime(SessionActivityScope.of("connection-a", "work"), "runtime-shared"),
|
||||
)
|
||||
assertEquals(
|
||||
connectionB,
|
||||
state.ownerForRuntime(SessionActivityScope.of("connection-b", "default"), "runtime-shared"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unscoped active row cannot mark duplicate stored ids as working`() {
|
||||
val profileB = owner("connection-a", "work", "session-a")
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "old-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(live(profileB, "old-b", SessionLiveStatus.Working, generation = 1))
|
||||
|
||||
state = state.reduce(
|
||||
activeList(
|
||||
scope,
|
||||
1,
|
||||
false,
|
||||
SessionLiveRuntime(
|
||||
owner = null,
|
||||
runtimeId = "ambiguous-runtime",
|
||||
status = SessionLiveStatus.Working,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityPhase.Working, state.record(profileB)?.phase())
|
||||
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `partial snapshot applies resolved row without settling absent owner`() {
|
||||
val absentOwner = owner("connection-a", "default", "session-b")
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(absentOwner, "runtime-b", SessionLiveStatus.Working, generation = 1))
|
||||
|
||||
state = state.reduce(
|
||||
activeList(
|
||||
scope,
|
||||
1,
|
||||
false,
|
||||
SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Working),
|
||||
SessionLiveRuntime(null, "ambiguous-runtime", SessionLiveStatus.Working),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityPhase.Working, state.record(absentOwner)?.phase())
|
||||
assertEquals(owner, state.ownerForRuntime(scope, "runtime-a"))
|
||||
assertNull(state.ownerForRuntime(scope, "ambiguous-runtime"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new generation rejects late terminal event and invalidates old alias`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-old", SessionLiveStatus.Working, generation = 3))
|
||||
.reduce(SessionActivityUpdate.BeginGeneration(scope, generation = 4, observedAtMillis = 20))
|
||||
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
assertNull(state.ownerForRuntime(scope, "runtime-old"))
|
||||
|
||||
state = state.reduce(live(owner, "runtime-old", SessionLiveStatus.Idle, generation = 3))
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed or unsupported status refresh preserves evidence but presents a neutral row`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(
|
||||
SessionActivityUpdate.LiveState(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
status = SessionLiveStatus.Working,
|
||||
source = SessionActivityEvidenceSource.ActiveList,
|
||||
generation = 1,
|
||||
observedAtMillis = 10,
|
||||
),
|
||||
)
|
||||
.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(
|
||||
scope = scope,
|
||||
generation = 1,
|
||||
observedAtMillis = 20,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Working, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Unavailable, state.record(owner)?.freshness)
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active-list failure does not override exact live session event`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(
|
||||
SessionActivityUpdate.StatusUnavailable(
|
||||
scope = scope,
|
||||
generation = 1,
|
||||
observedAtMillis = 20,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityFreshness.Confirmed, state.record(owner)?.freshness)
|
||||
assertEquals(SessionActivityState.Working, state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `presentation keeps starting and background distinct while revalidation stays neutral`() {
|
||||
val starting = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.LocalSend(owner, generation = 1, observedAtMillis = 1),
|
||||
)
|
||||
assertEquals(SessionActivityState.Starting, starting.record(owner)?.presentationState())
|
||||
|
||||
val background = starting
|
||||
.reduce(process(owner, "process-a", running = true, generation = 1))
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
|
||||
assertEquals(SessionActivityState.BackgroundWork, background.record(owner)?.presentationState())
|
||||
|
||||
val checking = starting.reduce(
|
||||
SessionActivityUpdate.BeginGeneration(scope, generation = 2, observedAtMillis = 2),
|
||||
)
|
||||
assertNull(checking.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal turn with running process projects background work separately`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(process(owner, "process-a", running = true, generation = 1))
|
||||
.reduce(terminal(owner, "runtime-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.BackgroundWork, state.record(owner)?.phase())
|
||||
|
||||
state = state.reduce(process(owner, "process-a", running = false, generation = 1))
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal settles pending input and removes its runtime alias`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1))
|
||||
|
||||
state = state.reduce(terminal(owner, "runtime-a", generation = 1))
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertNull(state.ownerForRuntime(scope, "runtime-a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authoritative live state is not overwritten by restored checkpoint`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Idle, generation = 1))
|
||||
.reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = 1,
|
||||
observedAtMillis = 30,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityEvidenceSource.SessionEvent, state.record(owner)?.evidence?.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restored needs-input checkpoint stays neutral until live confirmation`() {
|
||||
val state = SessionActivityRegistry().reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.NeedsInput,
|
||||
generation = 1,
|
||||
observedAtMillis = 10,
|
||||
),
|
||||
)
|
||||
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `synthetic checkpoint input does not confirm restored working state`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(
|
||||
SessionActivityUpdate.RestoreCheckpoint(
|
||||
owner = owner,
|
||||
runtimeId = "runtime-a",
|
||||
phase = SessionActivityPhase.Working,
|
||||
generation = 1,
|
||||
observedAtMillis = 10,
|
||||
),
|
||||
)
|
||||
.reduce(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:session-a",
|
||||
confirmed = false,
|
||||
generation = 1,
|
||||
observedAtMillis = 11,
|
||||
),
|
||||
)
|
||||
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
|
||||
state = state.reduce(
|
||||
SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = "checkpoint:session-a",
|
||||
confirmed = true,
|
||||
generation = 1,
|
||||
observedAtMillis = 12,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityState.NeedsInput, state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authoritative idle active-list row clears stale pending input`() {
|
||||
val state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
.reduce(openInput(owner, "request-a", generation = 1))
|
||||
.reduce(
|
||||
activeList(
|
||||
scope,
|
||||
generation = 1,
|
||||
runtimes = arrayOf(SessionLiveRuntime(owner, "runtime-a", SessionLiveStatus.Idle)),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityPhase.Idle, state.record(owner)?.phase())
|
||||
assertTrue(state.record(owner)?.pendingInputs.orEmpty().isEmpty())
|
||||
assertNull(state.record(owner)?.presentationState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runtime-only update requires alias in current scoped generation`() {
|
||||
var state = SessionActivityRegistry()
|
||||
.reduce(live(owner, "runtime-a", SessionLiveStatus.Working, generation = 1))
|
||||
state = state.reduce(
|
||||
SessionActivityUpdate.RuntimeState(
|
||||
scope = scope,
|
||||
runtimeId = "runtime-a",
|
||||
status = SessionLiveStatus.Waiting,
|
||||
generation = 1,
|
||||
observedAtMillis = 20,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
|
||||
|
||||
state = state.reduce(SessionActivityUpdate.BeginGeneration(scope, 2, 30))
|
||||
.reduce(
|
||||
SessionActivityUpdate.RuntimeState(
|
||||
scope = scope,
|
||||
runtimeId = "runtime-a",
|
||||
status = SessionLiveStatus.Idle,
|
||||
generation = 2,
|
||||
observedAtMillis = 40,
|
||||
),
|
||||
)
|
||||
assertEquals(SessionActivityPhase.NeedsInput, state.record(owner)?.phase())
|
||||
assertEquals(SessionActivityFreshness.Revalidating, state.record(owner)?.freshness)
|
||||
}
|
||||
|
||||
private fun owner(connection: String, profile: String, session: String) =
|
||||
SessionActivityOwner.of(connection, profile, session)
|
||||
|
||||
private fun live(
|
||||
owner: SessionActivityOwner,
|
||||
runtime: String,
|
||||
status: SessionLiveStatus,
|
||||
generation: Long,
|
||||
) = SessionActivityUpdate.LiveState(
|
||||
owner = owner,
|
||||
runtimeId = runtime,
|
||||
status = status,
|
||||
generation = generation,
|
||||
observedAtMillis = 10,
|
||||
)
|
||||
|
||||
private fun activeList(
|
||||
scope: SessionActivityScope,
|
||||
generation: Long,
|
||||
complete: Boolean = true,
|
||||
vararg runtimes: SessionLiveRuntime,
|
||||
) = SessionActivityUpdate.ActiveList(
|
||||
scope = scope,
|
||||
runtimes = runtimes.toList(),
|
||||
isCompleteForScope = complete,
|
||||
generation = generation,
|
||||
observedAtMillis = 20,
|
||||
)
|
||||
|
||||
private fun openInput(
|
||||
owner: SessionActivityOwner,
|
||||
request: String,
|
||||
generation: Long,
|
||||
expiresAt: Long? = null,
|
||||
) = SessionActivityUpdate.PendingInputOpened(
|
||||
owner = owner,
|
||||
requestId = request,
|
||||
expiresAtMillis = expiresAt,
|
||||
generation = generation,
|
||||
observedAtMillis = 10,
|
||||
)
|
||||
|
||||
private fun closeInput(owner: SessionActivityOwner, request: String, generation: Long) =
|
||||
SessionActivityUpdate.PendingInputClosed(
|
||||
owner = owner,
|
||||
requestId = request,
|
||||
generation = generation,
|
||||
observedAtMillis = 20,
|
||||
)
|
||||
|
||||
private fun process(owner: SessionActivityOwner, id: String, running: Boolean, generation: Long) =
|
||||
SessionActivityUpdate.ProcessState(
|
||||
owner = owner,
|
||||
processId = id,
|
||||
running = running,
|
||||
generation = generation,
|
||||
observedAtMillis = 10,
|
||||
)
|
||||
|
||||
private fun terminal(owner: SessionActivityOwner, runtime: String, generation: Long) =
|
||||
SessionActivityUpdate.Terminal(
|
||||
owner = owner,
|
||||
runtimeId = runtime,
|
||||
generation = generation,
|
||||
observedAtMillis = 20,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.mutablePreferencesOf
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedModeStoreTest {
|
||||
|
||||
@Test
|
||||
fun freshConnectionUsesRestrictiveDefaults() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
|
||||
assertFalse(policy.enabled)
|
||||
assertFalse(policy.isConfigured)
|
||||
assertFalse(policy.isActive)
|
||||
assertFalse(policy.capabilities.attachments)
|
||||
assertFalse(policy.capabilities.voice)
|
||||
assertFalse(policy.visibility.resolved().showModelName)
|
||||
assertFalse(policy.visibility.resolved().showTechnicalRoute)
|
||||
assertTrue(policy.parentAccess.requireDeviceAuthentication)
|
||||
assertEquals(5, policy.parentAccess.timeoutMinutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun policyRoundTripsWithCapabilitiesLimitsAndVisibility() = runTest {
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore()
|
||||
val store = SupervisedModeStore.forTesting(dataStore)
|
||||
val saved = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = " willow ",
|
||||
capabilities = SupervisedCapabilities(
|
||||
attachments = true,
|
||||
voice = true,
|
||||
attachmentMaxCount = 6,
|
||||
attachmentMaxFileMb = 20,
|
||||
attachmentCategories = setOf(
|
||||
SupervisedAttachmentCategory.Images,
|
||||
SupervisedAttachmentCategory.Documents,
|
||||
),
|
||||
sessionActions = SupervisedSessionActions(
|
||||
pin = true,
|
||||
rename = true,
|
||||
shareTranscript = true,
|
||||
),
|
||||
),
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "rose",
|
||||
themePreference = "dark",
|
||||
showPet = true,
|
||||
allowProfileIconChanges = true,
|
||||
allowBackgroundChanges = true,
|
||||
),
|
||||
visibility = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Custom,
|
||||
showAgentIdentity = true,
|
||||
showModelName = true,
|
||||
showToolNames = true,
|
||||
),
|
||||
)
|
||||
|
||||
store.setPolicy("connection-a", saved)
|
||||
val restored = SupervisedModeStore.forTesting(dataStore).policyFlow("connection-a").first()
|
||||
|
||||
assertTrue(restored.isActive)
|
||||
assertEquals("willow", restored.pinnedProfileName)
|
||||
assertEquals(6, restored.capabilities.attachmentMaxCount)
|
||||
assertEquals(20, restored.capabilities.attachmentMaxFileMb)
|
||||
assertEquals(saved.capabilities.attachmentCategories, restored.capabilities.attachmentCategories)
|
||||
assertEquals(saved.capabilities.sessionActions, restored.capabilities.sessionActions)
|
||||
assertEquals("rose", restored.appearance.appThemeId)
|
||||
assertEquals("dark", restored.appearance.themePreference)
|
||||
assertTrue(restored.appearance.showPet)
|
||||
assertTrue(restored.appearance.allowProfileIconChanges)
|
||||
assertTrue(restored.appearance.allowBackgroundChanges)
|
||||
assertEquals(SupervisedVisibilityPreset.Custom, restored.visibility.preset)
|
||||
assertTrue(restored.visibility.showModelName)
|
||||
assertTrue(restored.visibility.showToolNames)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionsAreIsolatedAndClearRemovesOnlyTarget() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
|
||||
store.setPolicy("connection-b", SupervisedModePolicy(true, "juniper"))
|
||||
|
||||
store.clear("connection-a")
|
||||
|
||||
assertFalse(store.policyFlow("connection-a").first().enabled)
|
||||
assertEquals("juniper", store.policyFlow("connection-b").first().pinnedProfileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateAndSetEnabledPreserveOtherPolicyFields() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy(
|
||||
"connection-a",
|
||||
SupervisedModePolicy(
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(voice = true),
|
||||
),
|
||||
)
|
||||
|
||||
store.setEnabled("connection-a", true)
|
||||
store.updatePolicy("connection-a") {
|
||||
it.copy(visibility = it.visibility.copy(preset = SupervisedVisibilityPreset.Transparent))
|
||||
}
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
assertTrue(policy.isActive)
|
||||
assertTrue(policy.capabilities.voice)
|
||||
assertEquals(SupervisedVisibilityPreset.Transparent, policy.visibility.preset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidLimitsAreNormalizedAndEmptyCategoriesFallBackToImages() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy(
|
||||
"connection-a",
|
||||
SupervisedModePolicy(
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
attachmentMaxCount = Int.MAX_VALUE,
|
||||
attachmentMaxFileMb = -1,
|
||||
attachmentCategories = emptySet(),
|
||||
),
|
||||
parentAccess = SupervisedParentAccess(
|
||||
requireDeviceAuthentication = false,
|
||||
timeoutMinutes = 0,
|
||||
),
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "missing-theme",
|
||||
themePreference = "sepia",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
assertEquals(SupervisedCapabilities.MAX_ATTACHMENT_COUNT, policy.capabilities.attachmentMaxCount)
|
||||
assertEquals(1, policy.capabilities.attachmentMaxFileMb)
|
||||
assertEquals(setOf(SupervisedAttachmentCategory.Images), policy.capabilities.attachmentCategories)
|
||||
assertTrue(policy.parentAccess.requireDeviceAuthentication)
|
||||
assertEquals(SupervisedParentAccess.MIN_TIMEOUT_MINUTES, policy.parentAccess.timeoutMinutes)
|
||||
assertEquals("hermes-relay", policy.appearance.appThemeId)
|
||||
assertEquals("auto", policy.appearance.themePreference)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun simplePresetResolvesToSafeValuesEvenIfStoredFlagsDiffer() {
|
||||
val visibility = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Simple,
|
||||
showModelName = true,
|
||||
showTechnicalRoute = true,
|
||||
showReasoning = true,
|
||||
).resolved()
|
||||
|
||||
assertFalse(visibility.showModelName)
|
||||
assertFalse(visibility.showTechnicalRoute)
|
||||
assertFalse(visibility.showReasoning)
|
||||
assertTrue(visibility.showAgentIdentity)
|
||||
assertTrue(visibility.showConnectionStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedPersistedPolicyFailsClosed() = runTest {
|
||||
val policyKey = androidx.datastore.preferences.core.stringPreferencesKey(
|
||||
"supervised_mode_policies_v1",
|
||||
)
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore(
|
||||
mutablePreferencesOf(policyKey to "{not-valid-json"),
|
||||
)
|
||||
|
||||
val policy = SupervisedModeStore.forTesting(dataStore)
|
||||
.policyFlow("connection-a")
|
||||
.first()
|
||||
|
||||
assertTrue(policy.enabled)
|
||||
assertFalse(policy.isConfigured)
|
||||
assertFalse(policy.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearAllDoesNotClearUnrelatedPreferences() = runTest {
|
||||
val unrelatedKey = androidx.datastore.preferences.core.stringPreferencesKey("unrelated")
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore(
|
||||
mutablePreferencesOf(unrelatedKey to "kept"),
|
||||
)
|
||||
val store = SupervisedModeStore.forTesting(dataStore)
|
||||
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
|
||||
|
||||
store.clearAll()
|
||||
|
||||
assertFalse(store.policyFlow("connection-a").first().enabled)
|
||||
assertEquals("kept", dataStore.data.first()[unrelatedKey])
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemorySupervisedPreferencesDataStore(
|
||||
initial: Preferences = emptyPreferences(),
|
||||
) : DataStore<Preferences> {
|
||||
private val state = MutableStateFlow(initial)
|
||||
override val data: Flow<Preferences> = state
|
||||
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): Preferences {
|
||||
val updated = transform(state.value)
|
||||
state.value = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedSessionPolicyTest {
|
||||
@Test fun `session action summary derives none mixed and all`() {
|
||||
val none = SupervisedSessionActions()
|
||||
val mixed = none.copy(rename = true, delete = true)
|
||||
val all = none.withAll(true)
|
||||
|
||||
assertTrue(none.noneEnabled)
|
||||
assertEquals(2, mixed.enabledCount)
|
||||
assertFalse(mixed.noneEnabled)
|
||||
assertFalse(mixed.allEnabled)
|
||||
assertTrue(all.allEnabled)
|
||||
assertEquals(SupervisedSessionActions.TOTAL, all.enabledCount)
|
||||
}
|
||||
|
||||
@Test fun `supervised history and granular flag are both required`() {
|
||||
val base = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
conversationHistory = true,
|
||||
sessionActions = SupervisedSessionActions(rename = true),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(base.allowsSessionAction(SupervisedSessionAction.Rename))
|
||||
assertFalse(base.allowsSessionAction(SupervisedSessionAction.Delete))
|
||||
assertFalse(
|
||||
base.copy(
|
||||
capabilities = base.capabilities.copy(conversationHistory = false),
|
||||
).allowsSessionAction(SupervisedSessionAction.Rename),
|
||||
)
|
||||
assertTrue(SupervisedModePolicy().allowsSessionAction(SupervisedSessionAction.Delete))
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChannelMultiplexerSupervisedUpdateTest {
|
||||
@Test fun `supervised update acknowledgement reaches system auth handler`() {
|
||||
val multiplexer = ChannelMultiplexer()
|
||||
val received = mutableListOf<Envelope>()
|
||||
multiplexer.registerHandler("system") { received += it }
|
||||
|
||||
val acknowledgement = Envelope(
|
||||
channel = "system",
|
||||
type = "supervised.updated",
|
||||
id = "update-1",
|
||||
)
|
||||
multiplexer.route(acknowledgement)
|
||||
|
||||
assertEquals(listOf(acknowledgement), received)
|
||||
}
|
||||
|
||||
@Test fun `correlated system error reaches system auth handler`() {
|
||||
val multiplexer = ChannelMultiplexer()
|
||||
val received = mutableListOf<Envelope>()
|
||||
multiplexer.registerHandler("system") { received += it }
|
||||
|
||||
val error = Envelope(channel = "system", type = "error", id = "update-2")
|
||||
multiplexer.route(error)
|
||||
|
||||
assertEquals(listOf(error), received)
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AutoVoiceAudioClientSupervisionTest {
|
||||
@Test
|
||||
fun `route override forces standard even when auto prefers ready relay`() = runTest {
|
||||
val standard = FakeVoiceClient(VoiceAudioRoute.Standard, "standard")
|
||||
val relay = FakeVoiceClient(VoiceAudioRoute.Relay, "relay")
|
||||
val router = AutoVoiceAudioClient(
|
||||
standardClient = standard,
|
||||
relayClient = relay,
|
||||
routeProvider = { VoiceAudioRoute.Auto },
|
||||
standardReadyProvider = { true },
|
||||
relayReadyProvider = { true },
|
||||
)
|
||||
|
||||
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
router.setRouteOverride(VoiceAudioRoute.Standard)
|
||||
assertEquals(VoiceAudioRoute.Standard, router.effectiveRoute)
|
||||
assertEquals("standard", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
router.setRouteOverride(null)
|
||||
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
}
|
||||
|
||||
private class FakeVoiceClient(
|
||||
override val route: VoiceAudioRoute,
|
||||
private val transcript: String,
|
||||
) : VoiceAudioClient {
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = Result.success(transcript)
|
||||
override suspend fun synthesize(text: String): Result<File> = Result.success(File("voice.mp3"))
|
||||
}
|
||||
}
|
||||
+116
@@ -195,6 +195,11 @@ class GatewayClientHarness(
|
||||
put("sessions", JsonArray(emptyList()))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var activeSessionListPayload: JsonObject = buildJsonObject {
|
||||
put("sessions", JsonArray(emptyList()))
|
||||
}
|
||||
|
||||
@Volatile
|
||||
var profileCreatePayload: JsonObject = buildJsonObject {
|
||||
put("ok", true)
|
||||
@@ -316,6 +321,7 @@ class GatewayClientHarness(
|
||||
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
|
||||
)
|
||||
"session.list" -> sessionListPayload
|
||||
"session.active_list" -> activeSessionListPayload
|
||||
"session.title" -> buildJsonObject { put("ok", true) }
|
||||
"prompt.submit" -> promptSubmitPayload
|
||||
"session.interrupt" -> buildJsonObject { put("ok", true) }
|
||||
@@ -1514,6 +1520,112 @@ class GatewayChatClientTest {
|
||||
assertTrue((refreshParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session list parses every authoritative upstream state`() = runBlocking {
|
||||
harness.activeSessionListPayload = buildJsonObject {
|
||||
put("sessions", buildJsonArray {
|
||||
listOf("idle", "starting", "working", "waiting").forEachIndexed { index, status ->
|
||||
add(buildJsonObject {
|
||||
put("id", "runtime-$index")
|
||||
put("session_key", "stored-$index")
|
||||
put("status", status)
|
||||
put("last_active", 1_774_000_000.25 + index)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val result = client.listActiveSessions()
|
||||
val rows = (result as GatewayActiveSessionsResult.Success).sessions
|
||||
|
||||
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewayActiveSessionStatus.Idle,
|
||||
GatewayActiveSessionStatus.Starting,
|
||||
GatewayActiveSessionStatus.Working,
|
||||
GatewayActiveSessionStatus.Waiting,
|
||||
),
|
||||
rows.map(GatewayActiveSession::status),
|
||||
)
|
||||
assertEquals("runtime-2", rows[2].runtimeSessionId)
|
||||
assertEquals("stored-2", rows[2].storedSessionId)
|
||||
assertEquals(1_774_000_002.25, rows[2].lastActiveEpochSeconds, 0.0)
|
||||
assertTrue(rows.all { it.profile == null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session list treats empty successful snapshot as authoritative`() = runBlocking {
|
||||
val result = client.listActiveSessions()
|
||||
|
||||
assertEquals(emptyList<GatewayActiveSession>(), (result as GatewayActiveSessionsResult.Success).sessions)
|
||||
assertEquals(GatewayActiveSessionCapability.Supported, client.activeSessionCapability.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session list stays process wide and never synthesizes fixed profile`() = runBlocking {
|
||||
val routeHarness = GatewayClientHarness()
|
||||
routeHarness.activeSessionListPayload = buildJsonObject {
|
||||
put("sessions", buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("id", "runtime-operator")
|
||||
put("session_key", "stored-shared")
|
||||
put("status", "working")
|
||||
put("last_active", 1_774_000_000.0)
|
||||
})
|
||||
})
|
||||
}
|
||||
val routeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
val routeClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = routeHarness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
fixedSessionProfile = "operator",
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
scope = routeScope,
|
||||
)
|
||||
try {
|
||||
val result = routeClient.listActiveSessions() as GatewayActiveSessionsResult.Success
|
||||
val params = routeHarness.awaitRpc("session.active_list")
|
||||
val requests = List(2) { routeHarness.server.takeRequest(5, TimeUnit.SECONDS) }
|
||||
|
||||
assertFalse(params.containsKey("profile"))
|
||||
assertNull(result.sessions.single().profile)
|
||||
assertTrue(requests.filterNotNull().any { it.path?.contains("profile=operator") == true })
|
||||
} finally {
|
||||
routeClient.shutdown()
|
||||
routeScope.cancel()
|
||||
routeHarness.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session method not found is explicit and sticky for current socket`() = runBlocking {
|
||||
harness.methodNotFound += "session.active_list"
|
||||
|
||||
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
|
||||
assertEquals(GatewayActiveSessionCapability.Unsupported, client.activeSessionCapability.value)
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
|
||||
|
||||
assertEquals(GatewayActiveSessionsResult.Unsupported, client.listActiveSessions())
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "session.active_list" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active session transient error stays distinct from unsupported`() = runBlocking {
|
||||
harness.rpcErrors["session.active_list"] = 5036 to "could not enumerate active sessions"
|
||||
|
||||
val failed = client.listActiveSessions()
|
||||
|
||||
assertTrue(failed is GatewayActiveSessionsResult.TransientFailure)
|
||||
assertEquals(GatewayActiveSessionCapability.Unknown, client.activeSessionCapability.value)
|
||||
harness.rpcErrors.remove("session.active_list")
|
||||
assertTrue(client.listActiveSessions() is GatewayActiveSessionsResult.Success)
|
||||
assertEquals(2, harness.rpcLog.count { it.first == "session.active_list" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process list uses live session id and parses typed snapshot`() = runBlocking {
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
@@ -4027,6 +4139,10 @@ class GatewayChatClientTest {
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
assertTrue(client.backgroundActiveTurn())
|
||||
assertEquals(
|
||||
GatewayKnownSessionOwner("20260612_120000_abc123", "coder"),
|
||||
client.knownSessionOwner("live-1"),
|
||||
)
|
||||
client.clearSession()
|
||||
client.sessionProfileProvider = { "writer" }
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ import com.hermesandroid.relay.data.AppearancePreferences
|
||||
import com.hermesandroid.relay.data.DashboardConnectionStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.PetBehaviorPreferences
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.relayDataStore
|
||||
import com.hermesandroid.relay.ui.components.ChatInputBar
|
||||
import com.hermesandroid.relay.ui.components.ChatInputPickerControl
|
||||
@@ -138,9 +139,14 @@ class StoreScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun capture(name: String, themeId: String = "hermes-relay", body: @Composable () -> Unit) {
|
||||
private fun capture(
|
||||
name: String,
|
||||
themeId: String = "hermes-relay",
|
||||
themePreference: String = "dark",
|
||||
body: @Composable () -> Unit,
|
||||
) {
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = themeId, themePreference = "dark") {
|
||||
HermesRelayTheme(appThemeId = themeId, themePreference = themePreference) {
|
||||
// Adaptive is the app's real default skin (resolve("auto") -> Adaptive);
|
||||
// it recolors to the active theme. The preview/test fallback is Classic,
|
||||
// which mismatches the app and reads poorly on light themes.
|
||||
@@ -251,6 +257,14 @@ class StoreScreenshotTest {
|
||||
}
|
||||
@Test fun s06_manage() = capture("06_manage", "hermes-relay") { ManageScene() }
|
||||
@Test fun s04_sessions() = capture("04_sessions", "hermes-relay") { SessionsScene() }
|
||||
@Test fun s12_session_activity_states() = capture("12_session_activity_states", "hermes-relay") {
|
||||
SessionActivityStatesScene()
|
||||
}
|
||||
@Test fun s12_session_activity_states_light() = capture(
|
||||
"12_session_activity_states_light",
|
||||
"nous-blue",
|
||||
themePreference = "light",
|
||||
) { SessionActivityStatesScene() }
|
||||
@Test fun s07_connections() = capture("07_connections", "hermes-relay") { ConnectionsScene() }
|
||||
// Real Appearance screen scrolled to the new Font picker — proves the
|
||||
// bundled Inter/Nunito faces load as visibly distinct previews (vs System).
|
||||
@@ -805,6 +819,41 @@ private fun SessionsScene() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionActivityStatesScene() {
|
||||
val states = SessionActivityState.entries
|
||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim)) {
|
||||
SessionDrawerContent(
|
||||
sessions = states.mapIndexed { index, state ->
|
||||
ChatSession(
|
||||
sessionId = "activity-$index",
|
||||
title = when (state) {
|
||||
SessionActivityState.Starting -> "Launching the agent"
|
||||
SessionActivityState.Working -> "Reviewing the release"
|
||||
SessionActivityState.NeedsInput -> "Approval required"
|
||||
SessionActivityState.BackgroundWork -> "Build still running"
|
||||
SessionActivityState.Checking -> "Reconnecting to Hermes"
|
||||
SessionActivityState.Unavailable -> "Offline session"
|
||||
},
|
||||
model = "gpt-5.6-sol",
|
||||
)
|
||||
},
|
||||
currentSessionId = null,
|
||||
activeProfileName = "default",
|
||||
scopeTitle = "Hermes",
|
||||
scopeSubtitle = "Live session status",
|
||||
activityStates = states.mapIndexed { index, state ->
|
||||
"default:activity-$index" to state
|
||||
}.toMap(),
|
||||
animationEnabled = false,
|
||||
onNewChat = {},
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionsScene() = ConnectionsSettingsScreen(
|
||||
connections = marketingConnections,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedAppearance
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedAppearancePolicyTest {
|
||||
private val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "rose",
|
||||
themePreference = "dark",
|
||||
showPet = false,
|
||||
),
|
||||
)
|
||||
|
||||
@Test fun `locked supervised root uses only its own theme`() {
|
||||
val resolved = resolveSupervisedTheme(policy, false, "midnight", "light")
|
||||
|
||||
assertEquals("rose", resolved.appThemeId)
|
||||
assertEquals("dark", resolved.themePreference)
|
||||
assertFalse(resolved.useGlobalCustomTheme)
|
||||
}
|
||||
|
||||
@Test fun `parent access restores ordinary app theme`() {
|
||||
val resolved = resolveSupervisedTheme(policy, true, "midnight", "light")
|
||||
|
||||
assertEquals("midnight", resolved.appThemeId)
|
||||
assertEquals("light", resolved.themePreference)
|
||||
assertTrue(resolved.useGlobalCustomTheme)
|
||||
}
|
||||
|
||||
@Test fun `pet visibility follows supervised policy only while locked`() {
|
||||
assertFalse(shouldShowPetInSupervisedMode(policy, false))
|
||||
assertTrue(shouldShowPetInSupervisedMode(policy, true))
|
||||
assertTrue(
|
||||
shouldShowPetInSupervisedMode(
|
||||
policy.copy(appearance = policy.appearance.copy(showPet = true)),
|
||||
false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `enabled recovery policy stays on restricted appearance defaults`() {
|
||||
val recovery = SupervisedModePolicy(enabled = true)
|
||||
val resolved = resolveSupervisedTheme(recovery, false, "rose", "dark")
|
||||
|
||||
assertEquals("hermes-relay", resolved.appThemeId)
|
||||
assertEquals("auto", resolved.themePreference)
|
||||
assertFalse(resolved.useGlobalCustomTheme)
|
||||
assertFalse(shouldShowPetInSupervisedMode(recovery, false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedNavigationPolicyTest {
|
||||
@Test fun `locked surface permits only approved destinations`() {
|
||||
assertTrue(isSupervisedRouteAllowed("chat?sessionId=owned", false))
|
||||
assertTrue(isSupervisedRouteAllowed("settings", false))
|
||||
assertTrue(isSupervisedRouteAllowed(Screen.SupervisedAppearanceSettings.route, false))
|
||||
// The full Appearance destination includes profile/avatar/pet controls.
|
||||
// The supervised Settings root owns its own allowlisted theme controls.
|
||||
assertFalse(isSupervisedRouteAllowed("settings/appearance", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/about", false))
|
||||
assertFalse(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, false))
|
||||
assertFalse(isSupervisedRouteAllowed("manage", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/developer", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/supervised", false))
|
||||
assertFalse(isSupervisedRouteAllowed(null, false))
|
||||
}
|
||||
|
||||
@Test fun `parent unlock permits full navigation`() {
|
||||
assertTrue(isSupervisedRouteAllowed("manage", true))
|
||||
assertTrue(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, true))
|
||||
}
|
||||
|
||||
@Test fun `supervised redirect waits until the navigation graph has a route`() {
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, false, null))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, false, null))
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, false, Screen.Chat.route))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, false, Screen.Chat.route))
|
||||
assertTrue(shouldRedirectSupervisedRoute(true, false, Screen.AdvancedSettings.route))
|
||||
assertFalse(isSupervisedRouteContentAllowed(true, false, Screen.AdvancedSettings.route))
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, true, Screen.AdvancedSettings.route))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, true, Screen.AdvancedSettings.route))
|
||||
}
|
||||
|
||||
@Test fun `navigation waits for connection store before trusting null active id`() {
|
||||
assertFalse(isRelayNavigationHydrated(false, null, false))
|
||||
assertTrue(isRelayNavigationHydrated(true, null, false))
|
||||
assertFalse(isRelayNavigationHydrated(true, "home", false))
|
||||
assertTrue(isRelayNavigationHydrated(true, "home", true))
|
||||
}
|
||||
|
||||
@Test fun `parent access relocks as soon as chat becomes current`() {
|
||||
assertTrue(shouldRelockParentAccess(true, true, "chat?sessionId=ignored"))
|
||||
assertFalse(shouldRelockParentAccess(true, true, "settings/supervised"))
|
||||
assertFalse(shouldRelockParentAccess(false, true, "chat"))
|
||||
assertFalse(shouldRelockParentAccess(true, false, "chat"))
|
||||
}
|
||||
|
||||
@Test fun `supervised route session requires history pinned profile and trusted ownership proof`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(conversationHistory = true),
|
||||
)
|
||||
|
||||
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "willow", false))
|
||||
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "parent", true))
|
||||
assertTrue(mayRestoreSupervisedSessionRoute(policy, "session-1", "WILLOW", true))
|
||||
assertFalse(
|
||||
mayRestoreSupervisedSessionRoute(
|
||||
policy.copy(capabilities = policy.capabilities.copy(conversationHistory = false)),
|
||||
"session-1",
|
||||
"willow",
|
||||
true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `supervised external route discards session profile and proactive targets`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(conversationHistory = true),
|
||||
)
|
||||
val external = SupervisedChatRouteArgs(
|
||||
sessionId = "parent-session",
|
||||
profile = "willow",
|
||||
proactiveChatId = "phone",
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(policy, external, false) ==
|
||||
SupervisedChatRouteArgs(),
|
||||
)
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(policy, external, true) ==
|
||||
external.copy(proactiveChatId = null),
|
||||
)
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(SupervisedModePolicy(), external, false) == external,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `first enable requires configured policy secure screen and successful device credential`() {
|
||||
val configured = SupervisedModePolicy(pinnedProfileName = "willow")
|
||||
|
||||
assertFalse(
|
||||
mayEnableSupervisedMode(
|
||||
configured,
|
||||
deviceSecure = false,
|
||||
deviceCredentialConfirmed = true,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
mayEnableSupervisedMode(
|
||||
configured,
|
||||
deviceSecure = true,
|
||||
deviceCredentialConfirmed = false,
|
||||
),
|
||||
)
|
||||
assertFalse(mayEnableSupervisedMode(SupervisedModePolicy(), true, true))
|
||||
assertTrue(mayEnableSupervisedMode(configured, true, true))
|
||||
assertFalse(mayEnableSupervisedMode(configured.copy(enabled = true), true, true))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.R
|
||||
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 {
|
||||
@@ -19,6 +22,138 @@ class SessionDrawerPolicyTest {
|
||||
|
||||
assertEquals("alpha:same", sessionRowKey(alpha))
|
||||
assertEquals("beta:same", sessionRowKey(beta))
|
||||
assertEquals("default:same", sessionRowKey(row("default", "same")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rest recent activity alone does not mark a session working`() {
|
||||
val recentlyActive = row("default", "recent", recentlyActive = true)
|
||||
|
||||
assertEquals(
|
||||
SessionDrawerStatus.Idle,
|
||||
sessionDrawerStatus(recentlyActive, activityStates = emptyMap()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate session ids cannot leak activity across profiles`() {
|
||||
val alpha = row("alpha", "same")
|
||||
val beta = row("beta", "same")
|
||||
val scoped = scopedSessionActivityStates(
|
||||
rows = listOf(alpha, beta),
|
||||
activityStates = mapOf(sessionRowKey(alpha) to SessionActivityState.Working),
|
||||
allowBareSessionIds = false,
|
||||
)
|
||||
|
||||
assertEquals(SessionDrawerStatus.Working, sessionDrawerStatus(alpha, scoped))
|
||||
assertEquals(SessionDrawerStatus.Idle, sessionDrawerStatus(beta, scoped))
|
||||
assertFalse(sessionRowKey(beta) in scoped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all profiles ignores ambiguous bare session activity`() {
|
||||
val alpha = row("alpha", "same")
|
||||
val beta = row("beta", "same")
|
||||
|
||||
val scoped = scopedSessionActivityStates(
|
||||
rows = listOf(alpha, beta),
|
||||
activityStates = mapOf("same" to SessionActivityState.Working),
|
||||
allowBareSessionIds = false,
|
||||
)
|
||||
|
||||
assertEquals(emptyMap<String, SessionActivityState>(), scoped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected profile may scope legacy bare session activity`() {
|
||||
val row = row("work", "session")
|
||||
|
||||
val scoped = scopedSessionActivityStates(
|
||||
rows = listOf(row),
|
||||
activityStates = mapOf("session" to SessionActivityState.NeedsInput),
|
||||
allowBareSessionIds = true,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
mapOf(sessionRowKey(row) to SessionActivityState.NeedsInput),
|
||||
scoped,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status filter and grouping use the same authoritative state`() {
|
||||
val restOnly = row("default", "rest-only", recentlyActive = true)
|
||||
val working = row("default", "working")
|
||||
val states = mapOf(sessionRowKey(working) to SessionActivityState.Working)
|
||||
|
||||
val filtered = filterAndSortSessionRows(
|
||||
rows = listOf(restOnly, working),
|
||||
options = SessionDrawerViewOptions(statuses = setOf(SessionDrawerStatus.Working)),
|
||||
activityStates = states,
|
||||
)
|
||||
val grouped = groupSessionRows(
|
||||
rows = listOf(restOnly, working),
|
||||
grouping = SessionDrawerGrouping.Status,
|
||||
activityStates = states,
|
||||
)
|
||||
|
||||
assertEquals(listOf("working"), filtered.map { it.session.sessionId })
|
||||
assertEquals(listOf("Idle", "Working"), grouped.mapNotNull { it.label })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expanded live phases retain distinct drawer statuses and labels`() {
|
||||
val phases = listOf(
|
||||
SessionActivityState.NeedsInput to (SessionDrawerStatus.NeedsInput to "Needs input"),
|
||||
SessionActivityState.Starting to (SessionDrawerStatus.Starting to "Starting"),
|
||||
SessionActivityState.Working to (SessionDrawerStatus.Working to "Working"),
|
||||
SessionActivityState.BackgroundWork to (SessionDrawerStatus.BackgroundWork to "Background work"),
|
||||
SessionActivityState.Checking to (SessionDrawerStatus.Checking to "Checking"),
|
||||
SessionActivityState.Unavailable to (SessionDrawerStatus.Unavailable to "Unavailable"),
|
||||
)
|
||||
val rows = phases.mapIndexed { index, _ -> row("default", "session-$index") }
|
||||
val states = rows.zip(phases).associate { (row, phase) -> sessionRowKey(row) to phase.first }
|
||||
|
||||
assertEquals(
|
||||
phases.map { it.second.first },
|
||||
rows.map { sessionDrawerStatus(it, states) },
|
||||
)
|
||||
assertEquals(
|
||||
phases.map { it.second.second },
|
||||
groupSessionRows(rows, SessionDrawerGrouping.Status, states).mapNotNull { it.label },
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
R.string.drawer_activity_needs_input,
|
||||
R.string.drawer_activity_starting,
|
||||
R.string.drawer_activity_working,
|
||||
R.string.drawer_activity_background_work,
|
||||
R.string.drawer_activity_checking,
|
||||
R.string.drawer_activity_unavailable,
|
||||
),
|
||||
phases.map { sessionActivityLabelResource(it.first) },
|
||||
)
|
||||
phases.forEachIndexed { index, phase ->
|
||||
assertEquals(
|
||||
listOf("session-$index"),
|
||||
filterAndSortSessionRows(
|
||||
rows = rows,
|
||||
options = SessionDrawerViewOptions(statuses = setOf(phase.second.first)),
|
||||
activityStates = states,
|
||||
).map { it.session.sessionId },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -113,6 +248,7 @@ class SessionDrawerPolicyTest {
|
||||
outputTokens: Int = 0,
|
||||
cost: Double? = null,
|
||||
updatedAt: Long = 0L,
|
||||
recentlyActive: Boolean = false,
|
||||
) = ProfileSessionRow(
|
||||
profile = profile,
|
||||
session = ChatSession(
|
||||
@@ -126,6 +262,7 @@ class SessionDrawerPolicyTest {
|
||||
outputTokens = outputTokens,
|
||||
actualCostUsd = cost,
|
||||
lastActivityAt = updatedAt,
|
||||
recentlyActive = recentlyActive,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -359,6 +360,66 @@ class SessionDrawerTest {
|
||||
compose.onNodeWithText("Filters").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawer renders every authoritative activity phase distinctly`() {
|
||||
val states = SessionActivityState.entries
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
SessionDrawerContent(
|
||||
sessions = states.mapIndexed { index, _ ->
|
||||
ChatSession("session-$index", "Session $index", null)
|
||||
},
|
||||
currentSessionId = null,
|
||||
activeProfileName = "default",
|
||||
activityStates = states.mapIndexed { index, state ->
|
||||
"default:session-$index" to state
|
||||
}.toMap(),
|
||||
animationEnabled = false,
|
||||
onNewChat = {},
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
listOf(
|
||||
"Starting",
|
||||
"Working",
|
||||
"Needs input",
|
||||
"Background work",
|
||||
"Checking",
|
||||
"Unavailable",
|
||||
).forEach { label ->
|
||||
compose.onNodeWithText(label).performScrollTo().assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rest recency alone renders no working badge`() {
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
SessionDrawerContent(
|
||||
sessions = listOf(
|
||||
ChatSession(
|
||||
"recent",
|
||||
"Recently updated",
|
||||
null,
|
||||
recentlyActive = true,
|
||||
),
|
||||
),
|
||||
currentSessionId = null,
|
||||
onNewChat = {},
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Working").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all profiles customization can override a profile identity color`() {
|
||||
var changed: Pair<String, String?>? = null
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import java.util.Locale
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SessionDrawerTimestampTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun `distinct activity is labeled updated`() {
|
||||
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
|
||||
|
||||
assertEquals(
|
||||
"Updated Just now",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relative timestamp changes when the drawer clock advances`() {
|
||||
val session = session(startedAt = 1_000L, lastActivityAt = 120_000L)
|
||||
|
||||
assertEquals(
|
||||
"Updated Just now",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
|
||||
)
|
||||
assertEquals(
|
||||
"Updated 2m ago",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 240_000L),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session without later activity keeps started label`() {
|
||||
val session = session(startedAt = 120_000L, lastActivityAt = 120_000L)
|
||||
|
||||
assertEquals(
|
||||
"Started Just now",
|
||||
sessionTimestampText(session, Locale.US, context, nowMillis = 150_000L),
|
||||
)
|
||||
}
|
||||
|
||||
private fun session(startedAt: Long, lastActivityAt: Long) = ChatSession(
|
||||
sessionId = "session",
|
||||
title = "Session",
|
||||
model = null,
|
||||
startedAt = startedAt,
|
||||
lastActivityAt = lastActivityAt,
|
||||
)
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedImagePresentationTest {
|
||||
@Test
|
||||
fun `disabled assistant images strip markdown without exposing a fetchable source`() {
|
||||
val content = "Here it is  and "
|
||||
|
||||
val (body, images) = assistantImageContent(content, showImages = false)
|
||||
|
||||
assertEquals("Here it is and", body)
|
||||
assertTrue(images.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled assistant images preserve all supported sources`() {
|
||||
val content = " "
|
||||
|
||||
val (_, images) = assistantImageContent(content, showImages = true)
|
||||
|
||||
assertEquals(listOf("https://example.com/a.png", "/tmp/b.png"), images.map { it.src })
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class SessionActivityStateTest {
|
||||
@Test
|
||||
fun `multiple background turns remain visible beside current working turn`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = mapOf(
|
||||
"session-a" to SessionActivityState.Working,
|
||||
"session-b" to SessionActivityState.NeedsInput,
|
||||
),
|
||||
currentSessionId = "session-c",
|
||||
isStreaming = true,
|
||||
needsInput = false,
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Working, resolved["session-a"])
|
||||
assertEquals(SessionActivityState.NeedsInput, resolved["session-b"])
|
||||
assertEquals(SessionActivityState.Working, resolved["session-c"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needs input takes precedence over current working state`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = emptyMap(),
|
||||
currentSessionId = "session-a",
|
||||
isStreaming = true,
|
||||
needsInput = true,
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.NeedsInput, resolved["session-a"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected idle session does not retain a stale background state`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = mapOf("session-a" to SessionActivityState.Working),
|
||||
currentSessionId = "session-a",
|
||||
isStreaming = false,
|
||||
needsInput = false,
|
||||
)
|
||||
|
||||
assertFalse(resolved.containsKey("session-a"))
|
||||
}
|
||||
}
|
||||
+100
@@ -14,6 +14,7 @@ import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.data.HermesCardDispatch
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
@@ -23,6 +24,7 @@ import com.hermesandroid.relay.network.upstream.GatewayClientHarness
|
||||
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -291,6 +293,83 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeListWorkingThenDisappearanceSettlesDespiteRestRecency() {
|
||||
bindActivityTestDirectory()
|
||||
handler.updateSessions(
|
||||
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
|
||||
)
|
||||
gatewayHarness.activeSessionListPayload = activeSessionPayload("working")
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
awaitCondition {
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
|
||||
SessionActivityState.Working
|
||||
}
|
||||
|
||||
gatewayHarness.activeSessionListPayload = buildJsonObject {
|
||||
put("sessions", buildJsonArray { })
|
||||
}
|
||||
viewModel.requestSessionActivityRefresh()
|
||||
gatewayHarness.awaitRpcCount("session.active_list", 2)
|
||||
awaitCondition {
|
||||
"default:$STORED_SESSION_ID" !in viewModel.backgroundSessionActivityStates.value
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeListWaitingProjectsNeedsInput() {
|
||||
bindActivityTestDirectory()
|
||||
gatewayHarness.activeSessionListPayload = activeSessionPayload("waiting")
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
|
||||
awaitCondition {
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] ==
|
||||
SessionActivityState.NeedsInput
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsupportedActiveListLeavesRowsNeutralAcrossDirectoryRefresh() {
|
||||
bindActivityTestDirectory()
|
||||
handler.updateSessions(
|
||||
listOf(SessionItem(id = STORED_SESSION_ID, title = "Recent", isActive = true)),
|
||||
)
|
||||
gatewayHarness.methodNotFound += "session.active_list"
|
||||
|
||||
viewModel.setChatVisible(true)
|
||||
gatewayHarness.awaitRpc("session.active_list")
|
||||
|
||||
awaitCondition {
|
||||
"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
|
||||
fun rejectedAdmissionCannotLeaveStartingStatusStale() {
|
||||
bindActivityTestDirectory()
|
||||
gatewayClient.clearSession()
|
||||
gatewayHarness.rpcErrors["session.resume"] = 4090 to "stored session is unavailable"
|
||||
|
||||
viewModel.sendMessage("This admission should fail")
|
||||
gatewayHarness.awaitRpc("session.resume")
|
||||
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
assertTrue(
|
||||
viewModel.backgroundSessionActivityStates.value["default:$STORED_SESSION_ID"] !=
|
||||
SessionActivityState.Starting,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
DiagnosticsLog.clear()
|
||||
@@ -2376,6 +2455,27 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertTrue(handler.messages.value.any { it.content == BACKGROUND_ANSWER })
|
||||
}
|
||||
|
||||
private fun bindActivityTestDirectory() {
|
||||
viewModel.switchProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", "default"),
|
||||
STORED_SESSION_ID,
|
||||
)
|
||||
viewModel.updateSessionActivityDirectory(
|
||||
rows = listOf("default" to STORED_SESSION_ID),
|
||||
)
|
||||
}
|
||||
|
||||
private fun activeSessionPayload(status: String) = buildJsonObject {
|
||||
put("sessions", buildJsonArray {
|
||||
add(buildJsonObject {
|
||||
put("id", "live-resumed")
|
||||
put("session_key", STORED_SESSION_ID)
|
||||
put("status", status)
|
||||
put("last_active", 1.0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private fun persistedAnswerHistory(
|
||||
answer: String = BACKGROUND_ANSWER,
|
||||
id: String = "persisted-background-answer",
|
||||
|
||||
@@ -318,10 +318,14 @@ class GatewayProcessControllerTest {
|
||||
controller.bind(source, "same-id", scopeKey = "profile-a")
|
||||
controller.sessionReady("same-id")
|
||||
runCurrent()
|
||||
assertTrue(controller.ownsSnapshot("same-id", "profile-a"))
|
||||
|
||||
controller.selectSession("same-id", scopeKey = "profile-b")
|
||||
assertFalse(controller.ownsSnapshot("same-id", "profile-a"))
|
||||
assertFalse(controller.ownsSnapshot("same-id", "profile-b"))
|
||||
controller.sessionReady("same-id")
|
||||
runCurrent()
|
||||
assertTrue(controller.ownsSnapshot("same-id", "profile-b"))
|
||||
oldResult.complete(Result.success(listOf(process(id = "old-profile"))))
|
||||
runCurrent()
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateExtrasViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(grant: Boolean = true): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
vm.setWriteGrant(ownerKey, grant)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
private fun selectAlpha(vm: GitStateViewModel) {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
|
||||
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
|
||||
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
|
||||
vm.selectRepo("alpha")
|
||||
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
|
||||
repeat(3) { server.takeRequest() }
|
||||
}
|
||||
|
||||
// ── AI commit message (magic-wand) ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `generate commit message pre-fills the suggestion via selected paths`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson("""{"message":"feat: add feature","notice":""}""")
|
||||
vm.generateCommitMessage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertEquals("feat: add feature", state.message)
|
||||
assertEquals("", state.notice)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/commit_message_selected"))
|
||||
assertTrue(req.body.readUtf8().contains("a.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate message without grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.generateCommitMessage(null)
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertTrue(state.notice.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty staged diff surfaces notice without error`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson("""{"message":"","notice":"nothing staged"}""")
|
||||
vm.generateCommitMessage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertEquals("", state.message)
|
||||
assertEquals("nothing staged", state.notice)
|
||||
}
|
||||
|
||||
// ── Push-after-commit toggle ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `push after commit defaults off and toggles`() {
|
||||
val vm = viewModel()
|
||||
assertTrue(!vm.isPushAfterCommitEnabled())
|
||||
vm.setPushAfterCommit(true)
|
||||
assertTrue(vm.isPushAfterCommitEnabled())
|
||||
vm.setPushAfterCommit(false)
|
||||
assertTrue(!vm.isPushAfterCommitEnabled())
|
||||
}
|
||||
|
||||
// ── Stash-checkout ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stash checkout surfaces the stash notice on success`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson(
|
||||
"""{"head":"abc","stashed":true,"stash_message":"git-state: feature","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
|
||||
)
|
||||
// refreshDetail fires two reads (status + branches).
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
vm.stashCheckout("feature")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val notice = withTimeout(5_000) { vm.stashNotice.first { it != null } }!!
|
||||
assertTrue(notice.contains("git-state: feature"))
|
||||
assertTrue(notice.contains("git stash pop"))
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/stash_checkout"))
|
||||
assertTrue(req.body.readUtf8().contains("feature"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stash checkout without grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.stashCheckout("feature")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clean stash checkout yields no stash notice`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson(
|
||||
"""{"head":"abc","stashed":false,"stash_message":"","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
|
||||
)
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
vm.stashCheckout("feature")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
// No stash notice for a clean checkout.
|
||||
assertEquals(null, vm.stashNotice.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRepos maps repo list and notice`() = runBlocking {
|
||||
enqueueJson(
|
||||
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":false}],"notice":null}""",
|
||||
)
|
||||
val vm = viewModel()
|
||||
val state = withTimeout(5_000) {
|
||||
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
|
||||
}
|
||||
assertEquals(1, state.repos.size)
|
||||
assertEquals("alpha", state.repos.single().name)
|
||||
assertNotNull(vm.repos.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRepos surfaces server error`() = runBlocking {
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository"}"""))
|
||||
val vm = viewModel()
|
||||
val state = withTimeout(5_000) {
|
||||
vm.repos.filterIsInstance<GitStateUiState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectRepo loads status and branches and preserves truncation flag`() = runBlocking {
|
||||
enqueueJson(
|
||||
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""",
|
||||
)
|
||||
// status
|
||||
enqueueJson(
|
||||
"""{"counts":{"staged":1,"modified":2,"untracked":3},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":true}""",
|
||||
)
|
||||
// branches
|
||||
enqueueJson(
|
||||
"""{"branches":[{"name":"main","upstream":"origin/main","ahead":1,"behind":0,"is_current":true}]}""",
|
||||
)
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
val ready = withTimeout(5_000) {
|
||||
vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first()
|
||||
}
|
||||
assertEquals(1, ready.status.counts.staged)
|
||||
assertEquals(2, ready.status.counts.modified)
|
||||
assertEquals(3, ready.status.counts.untracked)
|
||||
assertTrue(ready.status.truncated)
|
||||
assertEquals("main", ready.branches.single().name)
|
||||
assertTrue(ready.branches.single().isCurrent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadDiff surfaces truncated diff`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":1,"untracked":0},"staged":[],"modified":[{"path":"a.txt"}],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
enqueueJson("""{"path":"a.txt","kind":"unstaged","diff":"+change","truncated":true}""")
|
||||
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
|
||||
vm.loadDiff("a.txt", "unstaged")
|
||||
val content = withTimeout(5_000) {
|
||||
vm.content.filterIsInstance<GitContentViewState.Diff>().first()
|
||||
}
|
||||
assertEquals("a.txt", content.diff.path)
|
||||
assertTrue(content.diff.truncated)
|
||||
assertTrue(content.diff.diff.contains("change"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadFile surfaces content and truncation`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":1},"staged":[],"modified":[],"untracked":[{"path":"new.txt"}],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
enqueueJson("""{"path":"new.txt","content":"hello world","truncated":false}""")
|
||||
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
|
||||
vm.loadFile("new.txt")
|
||||
val content = withTimeout(5_000) {
|
||||
vm.content.filterIsInstance<GitContentViewState.File>().first()
|
||||
}
|
||||
assertEquals("hello world", content.file.content)
|
||||
assertFalse(content.file.truncated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectRepo surfaces status error for unknown repo`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository: bogus"}"""))
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("bogus")
|
||||
val error = withTimeout(5_000) {
|
||||
vm.detail.filterIsInstance<GitRepoDetailState.Error>().first()
|
||||
}
|
||||
assertTrue(error.message.contains("unknown repository"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateWriteViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(grant: Boolean = true): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
vm.setWriteGrant(ownerKey, grant)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads the repo list + selects ``alpha`` so a mutation has a target. */
|
||||
private fun selectAlpha(vm: GitStateViewModel) {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
|
||||
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
|
||||
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
|
||||
vm.selectRepo("alpha")
|
||||
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
|
||||
// Drain the three read requests (repos/status/branches) so the next
|
||||
// takeRequest() returns the mutation POST we actually assert on.
|
||||
repeat(3) { server.takeRequest() }
|
||||
}
|
||||
|
||||
private fun enqueuePostSuccess(head: String) {
|
||||
enqueueJson("""{"head":"$head","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}}""")
|
||||
// refreshDetail fires two more requests (status + branches).
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
}
|
||||
|
||||
// ── Grant gating (security first) ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stage without write grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.stage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
// No write POST was sent (only the 3 read requests for load/select).
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard without write grant is refused`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
// ── Happy paths ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stage sends POST and surfaces success + fresh status`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc123")
|
||||
vm.stage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
|
||||
}
|
||||
assertEquals("stage", state.label)
|
||||
assertEquals("abc123", state.head)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/stage"))
|
||||
assertTrue(req.body.readUtf8().contains("a.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit sends message and returns head`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("deadbeef")
|
||||
vm.commit("add feature")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
|
||||
}
|
||||
assertEquals("commit", state.label)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/commit"))
|
||||
assertTrue(req.body.readUtf8().contains("add feature"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit success callback fires only after successful response`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
var committedTarget: GitTarget? = null
|
||||
|
||||
vm.commit("add feature") { committedTarget = it }
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
|
||||
assertEquals("alpha", committedTarget?.repoId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit failure never invokes success callback`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"failed"}"""))
|
||||
var callbackCalled = false
|
||||
|
||||
vm.commit("add feature") { callbackCalled = true }
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Error>().first() }
|
||||
|
||||
assertFalse(callbackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connection change revokes grant and rejects prior target`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
val priorTarget = vm.currentTarget()!!
|
||||
enqueueJson("""{"repos":[]}""")
|
||||
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), "connection-b")
|
||||
vm.setWriteGrant(ownerKey, true)
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.push(GitConfirmationStrings.PUSH, expectedTarget = priorTarget)
|
||||
|
||||
assertFalse(vm.hasWriteGrant())
|
||||
assertEquals(null, vm.currentTarget())
|
||||
assertTrue(vm.mutation.value is GitMutationState.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard echoes the fixed confirmation token`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/discard"))
|
||||
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.DISCARD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `push echoes the confirmation token`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.push(GitConfirmationStrings.PUSH)
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/push"))
|
||||
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.PUSH))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch and pull send their endpoints`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.fetch()
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
assertTrue(server.takeRequest().path!!.contains("/git/fetch"))
|
||||
// Drain the two refreshDetail reads (status + branches) so the next
|
||||
// takeRequest() sees only the pull POST.
|
||||
repeat(2) { server.takeRequest() }
|
||||
|
||||
enqueuePostSuccess("xyz")
|
||||
vm.pull("origin", "main")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
assertTrue(server.takeRequest().path!!.contains("/git/pull"))
|
||||
}
|
||||
|
||||
// ── Error branches ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `commit surfaces server error as readable message`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(400).setBody("""{"detail":"commit message must not be empty"}"""),
|
||||
)
|
||||
vm.commit(" ")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("must not be empty"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard wrong confirmation surfaces server 403`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(403).setBody("""{"detail":"confirmation did not match"}"""),
|
||||
)
|
||||
vm.discard(listOf("a.txt"), "wrong")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("confirmation") || state.message.contains("403"))
|
||||
}
|
||||
|
||||
// ── Confirmation gating helpers ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `requiresConfirmation and confirmationFor match destructive ops`() {
|
||||
val vm = viewModel()
|
||||
assertTrue(vm.requiresConfirmation("discard"))
|
||||
assertTrue(vm.requiresConfirmation("push"))
|
||||
assertTrue(vm.requiresConfirmation("dirty-checkout"))
|
||||
assertEquals(GitConfirmationStrings.DISCARD, vm.confirmationFor("discard"))
|
||||
assertEquals(GitConfirmationStrings.PUSH, vm.confirmationFor("push"))
|
||||
assertEquals(GitConfirmationStrings.DIRTY_CHECKOUT, vm.confirmationFor("dirty-checkout"))
|
||||
assertEquals(null, vm.confirmationFor("commit"))
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SessionActivityOwner
|
||||
import com.hermesandroid.relay.data.SessionLiveStatus
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSession
|
||||
import com.hermesandroid.relay.network.upstream.GatewayActiveSessionStatus
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionActivityResolutionTest {
|
||||
private val alpha = SessionActivityOwner.of("connection", "alpha", "same")
|
||||
private val beta = SessionActivityOwner.of("connection", "beta", "same")
|
||||
|
||||
@Test
|
||||
fun `duplicate stored ids stay unresolved without exact runtime binding`() {
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(active("runtime-beta", GatewayActiveSessionStatus.Working)),
|
||||
directory = setOf(alpha, beta),
|
||||
currentOwner = alpha,
|
||||
)
|
||||
|
||||
assertNull(result.runtimes.single().owner)
|
||||
assertTrue(result.ambiguous)
|
||||
assertTrue(result.ambiguousForCurrent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact foreground runtime binding resolves duplicate stored id`() {
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(active("runtime-alpha", GatewayActiveSessionStatus.Waiting)),
|
||||
directory = setOf(alpha, beta),
|
||||
currentOwner = alpha,
|
||||
currentRuntimeId = "runtime-alpha",
|
||||
)
|
||||
|
||||
assertEquals(alpha, result.runtimes.single().owner)
|
||||
assertEquals(SessionLiveStatus.Waiting, result.runtimes.single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unscoped stored id stays unresolved even when bounded directory looks unique`() {
|
||||
val unique = SessionActivityOwner.of("connection", "beta", "unique")
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(
|
||||
GatewayActiveSession(
|
||||
runtimeSessionId = "runtime",
|
||||
storedSessionId = "unique",
|
||||
status = GatewayActiveSessionStatus.Starting,
|
||||
lastActiveEpochSeconds = 1.0,
|
||||
),
|
||||
),
|
||||
directory = setOf(alpha, unique),
|
||||
currentOwner = alpha,
|
||||
)
|
||||
|
||||
assertNull(result.runtimes.single().owner)
|
||||
assertTrue(result.ambiguous)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `client known detached runtime resolves exact profile owner`() {
|
||||
val unique = SessionActivityOwner.of("connection", "beta", "unique")
|
||||
val result = resolveGatewayActiveSessions(
|
||||
sessions = listOf(
|
||||
GatewayActiveSession(
|
||||
runtimeSessionId = "runtime",
|
||||
storedSessionId = "unique",
|
||||
status = GatewayActiveSessionStatus.Starting,
|
||||
lastActiveEpochSeconds = 1.0,
|
||||
),
|
||||
),
|
||||
directory = setOf(alpha, unique),
|
||||
currentOwner = alpha,
|
||||
knownOwnersByRuntime = mapOf("runtime" to unique),
|
||||
)
|
||||
|
||||
assertEquals(unique, result.runtimes.single().owner)
|
||||
assertEquals(SessionLiveStatus.Starting, result.runtimes.single().status)
|
||||
}
|
||||
|
||||
private fun active(runtimeId: String, status: GatewayActiveSessionStatus) =
|
||||
GatewayActiveSession(
|
||||
runtimeSessionId = runtimeId,
|
||||
storedSessionId = "same",
|
||||
status = status,
|
||||
lastActiveEpochSeconds = 1.0,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedChatPolicyTest {
|
||||
@Test
|
||||
fun `normal mode preserves slash commands`() {
|
||||
assertNull(supervisedMessageBlockReason(SupervisedModePolicy(), " /model"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled policy fails closed without pinned profile`() {
|
||||
assertEquals(
|
||||
"Supervised mode is unavailable until the parent selects a profile.",
|
||||
supervisedMessageBlockReason(SupervisedModePolicy(enabled = true), "hello"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active policy blocks slash commands after unicode whitespace`() {
|
||||
val policy = SupervisedModePolicy(enabled = true, pinnedProfileName = "willow")
|
||||
assertEquals(
|
||||
"Slash commands are unavailable in supervised mode.",
|
||||
supervisedMessageBlockReason(policy, "\u2003\t /model hidden"),
|
||||
)
|
||||
assertNull(supervisedMessageBlockReason(policy, "please explain /model"))
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.voice.VoiceCommandAction
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedVoiceCommandPolicyTest {
|
||||
@Test
|
||||
fun `normal mode preserves every voice command`() {
|
||||
VoiceCommandAction.entries.forEach { action ->
|
||||
assertTrue(isVoiceCommandAllowed(action, SupervisedModePolicy()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `supervised mode gates new chat and cancellation independently`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
voice = true,
|
||||
newChat = false,
|
||||
cancelResponse = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StartNewChat, policy))
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StopResponse, policy))
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.CancelBackgroundTask, policy))
|
||||
assertTrue(isVoiceCommandAllowed(VoiceCommandAction.EndVoiceChat, policy))
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"hermes-relay": "bin/hermes-relay.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"description": "Thin-client CLI for Hermes-Relay — talk to a remote Hermes agent over WSS with pairing auth, stream-renders tool calls and responses to plain stdout.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Regenerated from package.json by gen:version script. Do not edit by hand.
|
||||
export const VERSION = "0.4.0-beta.4" as const
|
||||
export const VERSION = "0.4.0-beta.5" as const
|
||||
|
||||
Generated
+1
-1
@@ -1232,7 +1232,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermes-relay-tray"
|
||||
version = "0.4.0-beta.4"
|
||||
version = "0.4.0-beta.5"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hermes-relay-tray"
|
||||
version = "0.4.0-beta.4"
|
||||
version = "0.4.0-beta.5"
|
||||
description = "Compact Windows management UI for Hermes-Relay CLI"
|
||||
authors = ["Axiom Labs"]
|
||||
edition = "2021"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"private": true,
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes-Relay CLI UI",
|
||||
"version": "0.4.0-beta.4",
|
||||
"version": "0.4.0-beta.5",
|
||||
"identifier": "com.axiomlabs.hermes-relay-tray",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -91,6 +91,8 @@ the `relay_plugin_draft` tool to create or replace a generated declarative page.
|
||||
tool accepts the same bounded schema as Android, stores JSON atomically below
|
||||
`HERMES_HOME/mobile-plugins`, and rejects every `action.request`. Generated previews
|
||||
therefore cannot reach Relay management APIs or acquire executable backend behavior.
|
||||
The contribution ID `git` is reserved for the Relay plugin's native Git workspace;
|
||||
generated drafts cannot shadow or duplicate that route.
|
||||
|
||||
The Relay mobile manifest exposes drafts as preview pages under the authenticated
|
||||
`hermes-relay` plugin namespace. Android polls the catalog every five seconds while
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user