mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 06:21:32 +08:00
Compare commits
1 Commits
main
...
sqlite-str
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b78260515d |
@@ -22,8 +22,6 @@ Use when:
|
||||
- Read dependency docs/source/types when the finding depends on external behavior.
|
||||
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- When an accepted finding shows a bug class or repeated pattern, inspect the current PR scope for sibling instances before fixing.
|
||||
- Fix the scoped bug class at once when practical; stop at touched surfaces, owner boundaries, and clear follow-up territory.
|
||||
- Keep going until structured review returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
|
||||
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Secret scanning alert handler for OpenClaw maintainers.
|
||||
* Usage: node secret-scanning.mjs <command> [options]
|
||||
*/
|
||||
// Secret scanning alert handler for OpenClaw maintainers.
|
||||
// Usage: node secret-scanning.mjs <command> [options]
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
@@ -59,7 +57,6 @@ function isBodyLocationType(locationType) {
|
||||
return locationType === "issue_body" || locationType === "pull_request_body";
|
||||
}
|
||||
|
||||
/** Decides whether redacting an issue/PR body requires notifying the reporter. */
|
||||
export function decideBodyRedaction(currentBody, redactedBody) {
|
||||
const bodyChanged = String(currentBody) !== String(redactedBody);
|
||||
return {
|
||||
@@ -68,7 +65,6 @@ export function decideBodyRedaction(currentBody, redactedBody) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Loads redaction-result metadata for issue/PR body secret locations. */
|
||||
export function loadBodyRedactionResult(locationType, resultFile) {
|
||||
if (!isBodyLocationType(locationType)) {
|
||||
return { notify_required: true };
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Heap snapshot diff utility for OpenClaw test memory leak investigations.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -16,10 +16,6 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
|
||||
- Watch one parent run plus compact child summaries. Avoid broad `gh run view` polling loops; REST quota is easy to burn.
|
||||
- Fetch logs only for failed or currently-blocking jobs. If quota is low, stop polling and wait for reset.
|
||||
- Treat live-provider flakes separately from code failures: prove key validity, provider HTTP status, retry evidence, and exact failing lane before editing code.
|
||||
- Full Release Validation parent monitors fail fast: once a required child job
|
||||
fails, the parent cancels the remaining child matrix and prints the failed
|
||||
job summary. Inspect that first red job instead of waiting for unrelated
|
||||
matrix tails.
|
||||
|
||||
## Preflight
|
||||
|
||||
@@ -77,9 +73,6 @@ gh workflow run full-release-validation.yml \
|
||||
```
|
||||
|
||||
Use `release_profile=stable` unless the operator explicitly asks for the broad advisory provider/media matrix. Use narrow `rerun_group` after focused fixes.
|
||||
Publish with `openclaw-release-publish.yml` using `release_profile=from-validation`
|
||||
unless a maintainer intentionally wants to cross-check a specific profile; the
|
||||
publish workflow reads the effective profile from the full-validation manifest.
|
||||
|
||||
## Watch
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Release CI summary helper that prints parent and child workflow status for a
|
||||
* full release run.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Release preflight helper that verifies required provider API keys can reach
|
||||
* their model-list endpoints without printing secret values.
|
||||
*/
|
||||
import process from "node:process";
|
||||
|
||||
const args = new Map();
|
||||
|
||||
@@ -49,21 +49,17 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
the next beta number until the matching npm package has actually published.
|
||||
If a published beta needs a fix, commit the fix on the release branch and
|
||||
increment to the next `-beta.N`.
|
||||
- For a beta release train, keep Full Release Validation as a pre-publish gate
|
||||
unless the operator explicitly waives it. Run the fast local preflight, npm
|
||||
preflight, full release validation, and performance in parallel where safe.
|
||||
If anything fails before npm publish, fix it on the release branch,
|
||||
forward-port the fix to `main`, move the unpublished beta tag/prerelease to
|
||||
the fixed commit, and rerun the affected pre-publish gates. If anything fails
|
||||
after npm publish, fix it, forward-port to `main`, increment beta number, and
|
||||
repeat. After each beta publish, run the published-package roster focused on
|
||||
install/update/Docker/Parallels/NPM Telegram. For later beta attempts, rerun
|
||||
only lanes whose evidence changed unless the fix touches broad release,
|
||||
install/update, plugin, Docker, Parallels, or live QA behavior. After each
|
||||
beta is live, scan current `main` once for critical fixes that landed after
|
||||
the release branch cut and backport only important low-risk fixes. Operators
|
||||
may authorize up to 4 autonomous beta attempts; after 4 failed beta attempts,
|
||||
stop and report.
|
||||
- For a beta release train, run the fast local preflight first, publish the
|
||||
beta to npm `beta`, then run the expensive published-package roster focused
|
||||
on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on
|
||||
the release branch, commit/push/pull, increment beta number, and repeat. Run
|
||||
the full expensive roster at least once before stable/latest promotion; for
|
||||
later beta attempts, rerun only lanes whose evidence changed unless the fix
|
||||
touches broad release, install/update, plugin, Docker, Parallels, or live QA
|
||||
behavior. After each beta is published, scan current `main` once for critical
|
||||
fixes that landed after the release branch cut and backport only important
|
||||
low-risk fixes. Operators may authorize up to 4 autonomous beta attempts;
|
||||
after 4 failed beta attempts, stop and report.
|
||||
- As soon as the release candidate SHA exists, dispatch `OpenClaw Performance`
|
||||
with `target_ref=<release-sha>` in parallel with the other release work. Do
|
||||
not wait for full release validation to start the performance signal.
|
||||
@@ -111,10 +107,9 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
|
||||
- “Bump version everywhere” means all version locations above except `appcast.xml`.
|
||||
- Release signing and notary credentials live outside the repo in the private maintainer docs.
|
||||
- Every stable OpenClaw release ships the npm package, macOS app, and signed
|
||||
Windows Hub installers together. Beta releases normally ship npm/package
|
||||
artifacts first and skip native app build/sign/notarize/promote unless the
|
||||
operator requests native beta validation.
|
||||
- Every stable OpenClaw release ships the npm package and macOS app together.
|
||||
Beta releases normally ship npm/package artifacts first and skip mac app
|
||||
build/sign/notarize unless the operator requests mac beta validation.
|
||||
- Do not let the slower macOS signing/notary path block npm publication once
|
||||
the npm preflight has passed. Keep mac validation/publish running in
|
||||
parallel, publish npm from the successful npm preflight, then start published
|
||||
@@ -144,17 +139,6 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
at `YYYY.M.D`, but the mac release must use a strictly higher numeric
|
||||
`APP_BUILD` / Sparkle build than the original release so existing installs
|
||||
see it as newer.
|
||||
- Stable Windows Hub release closeout requires the signed
|
||||
`OpenClawCompanion-Setup-x64.exe`, `OpenClawCompanion-Setup-arm64.exe`, and
|
||||
`OpenClawCompanion-SHA256SUMS.txt` assets on the canonical
|
||||
`openclaw/openclaw` GitHub Release. Use the public `Windows Node Release`
|
||||
workflow after the matching `openclaw/openclaw-windows-node` release exists;
|
||||
it verifies Authenticode signatures on Windows before uploading assets.
|
||||
- Website Windows Hub download links should target exact canonical
|
||||
`openclaw/openclaw/releases/download/vYYYY.M.D/...` assets for the current
|
||||
stable release, or `releases/latest/download/...` only after verifying the
|
||||
redirect resolves to that same tag, so the installable signed Windows artifact
|
||||
is visible from both the GitHub release page and openclaw.ai.
|
||||
|
||||
## Build changelog-backed release notes
|
||||
|
||||
@@ -190,13 +174,6 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
|
||||
or editing a release, extract from `## YYYY.M.D` through the line before the
|
||||
next level-2 heading and use that complete block as the release notes.
|
||||
- To update an existing GitHub Release body, resolve the numeric release id and
|
||||
patch that resource with the notes file as the `body` field:
|
||||
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.D --jq .id`, then
|
||||
`gh api -X PATCH repos/openclaw/openclaw/releases/<id> -F body=@/tmp/notes.md`.
|
||||
Do not trust `gh release edit --notes-file` or `--input` JSON if verification
|
||||
disagrees; verify with `gh api repos/openclaw/openclaw/releases/<id>` because
|
||||
the tag lookup and `gh release view` can lag or show stale body text.
|
||||
- When preparing release notes, scan `src/plugins/compat/registry.ts` and
|
||||
`src/commands/doctor/shared/deprecation-compat.ts` for compatibility records
|
||||
with `warningStarts` or `removeAfter` within 7 days after the release date.
|
||||
@@ -491,10 +468,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- The npm workflow and the private mac publish workflow accept
|
||||
`preflight_only=true` to run validation/build/package steps without uploading
|
||||
public release assets.
|
||||
- Real npm publish requires a prior successful npm preflight run id and the
|
||||
successful Full Release Validation run id for the same tag/SHA so the publish
|
||||
job promotes the prepared tarball instead of rebuilding it and attaches the
|
||||
correct release evidence.
|
||||
- Real npm publish requires a prior successful npm preflight run id so the
|
||||
publish job promotes the prepared tarball instead of rebuilding it.
|
||||
- Real private mac publish requires a prior successful private mac preflight
|
||||
run id so the publish job promotes the prepared artifacts instead of
|
||||
rebuilding or renotarizing them again.
|
||||
@@ -524,12 +499,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
instead of uploading public GitHub release assets.
|
||||
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
|
||||
workflow artifacts and intentionally skip stable `appcast.xml` generation.
|
||||
- For stable releases, npm preflight, Full Release Validation, public mac
|
||||
validation, private mac validation, and private mac preflight must all pass
|
||||
before any real publish run starts. For beta releases, npm preflight and Full
|
||||
Release Validation must pass before npm publish unless the operator explicitly
|
||||
waives the full gate; mac beta validation is still only required when
|
||||
requested.
|
||||
- For stable releases, npm preflight, public mac validation, private mac
|
||||
validation, and private mac preflight must all pass before any real publish
|
||||
run starts. For beta releases, npm preflight plus the selected Docker,
|
||||
install/update, Parallels, and release-check lanes are sufficient unless mac
|
||||
beta validation was explicitly requested.
|
||||
- Real publish runs may be dispatched from `main` or from a
|
||||
`release/YYYY.M.D` branch. For release-branch runs, the tag must be contained
|
||||
in that release branch, and the real publish must reuse a successful preflight
|
||||
|
||||
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -574,10 +574,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/openshell/**"
|
||||
"extensions: parallel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/parallel/**"
|
||||
"extensions: perplexity":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
156
.github/workflows/ci-check-arm-testbox.yml
vendored
156
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -1,156 +0,0 @@
|
||||
name: Blacksmith ARM Testbox
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
testbox_id:
|
||||
type: string
|
||||
description: "Testbox session ID"
|
||||
required: true
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
|
||||
|
||||
jobs:
|
||||
check-arm:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-arm"
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Verify ARM runner
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
runner_arch="$(uname -m)"
|
||||
echo "check-arm runner architecture: ${runner_arch}"
|
||||
case "$runner_arch" in
|
||||
aarch64 | arm64)
|
||||
;;
|
||||
*)
|
||||
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
if [[ -z "$CHECKOUT_TOKEN" ]]; then
|
||||
echo "checkout token is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
- name: Prepare Testbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
PNPM
|
||||
sudo chmod 0755 /usr/local/bin/pnpm
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
136
.github/workflows/ci-check-testbox.yml
vendored
136
.github/workflows/ci-check-testbox.yml
vendored
@@ -139,3 +139,139 @@ jobs:
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
check-arm:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-arm"
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Verify ARM runner
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
runner_arch="$(uname -m)"
|
||||
echo "check-arm runner architecture: ${runner_arch}"
|
||||
case "$runner_arch" in
|
||||
aarch64 | arm64)
|
||||
;;
|
||||
*)
|
||||
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
if [[ -z "$CHECKOUT_TOKEN" ]]; then
|
||||
echo "checkout token is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
- name: Prepare Testbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
PNPM
|
||||
sudo chmod 0755 /usr/local/bin/pnpm
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
41
.github/workflows/ci.yml
vendored
41
.github/workflows/ci.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=2 origin \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
@@ -146,12 +146,12 @@ jobs:
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
else
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD --merge-head-first-parent
|
||||
fi
|
||||
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
|
||||
- name: Build CI manifest
|
||||
id: manifest
|
||||
env:
|
||||
@@ -605,19 +605,7 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-all-v3-
|
||||
|
||||
- name: Restore dist build cache
|
||||
id: dist_build_cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: |
|
||||
dist/
|
||||
dist-runtime/
|
||||
extensions/*/src/host/**/.bundle.hash
|
||||
extensions/*/src/host/**/*.bundle.js
|
||||
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
|
||||
|
||||
- name: Build dist
|
||||
if: steps.dist_build_cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build:ci-artifacts
|
||||
@@ -626,6 +614,14 @@ jobs:
|
||||
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
|
||||
run: pnpm ui:i18n:check
|
||||
|
||||
- name: Cache dist build
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
dist/
|
||||
dist-runtime/
|
||||
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
|
||||
|
||||
- name: Pack built runtime artifacts
|
||||
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
|
||||
|
||||
@@ -755,18 +751,6 @@ jobs:
|
||||
done
|
||||
exit "$failures"
|
||||
|
||||
- name: Save dist build cache
|
||||
if: steps.dist_build_cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: |
|
||||
dist/
|
||||
dist-runtime/
|
||||
extensions/*/src/host/**/.bundle.hash
|
||||
extensions/*/src/host/**/*.bundle.js
|
||||
key: ${{ steps.dist_build_cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Upload gateway watch regression artifacts
|
||||
if: always() && needs.preflight.outputs.run_check_additional == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -1167,8 +1151,7 @@ jobs:
|
||||
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
|
||||
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
||||
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
|
||||
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
|
||||
OPENCLAW_VITEST_NO_OUTPUT_RETRY: "1"
|
||||
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "900000"
|
||||
OPENCLAW_TEST_PROJECTS_PARALLEL: "2"
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
java-version: "21"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: java-kotlin
|
||||
build-mode: manual
|
||||
@@ -46,6 +46,6 @@ jobs:
|
||||
run: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-security/android"
|
||||
|
||||
60
.github/workflows/codeql-critical-quality.yml
vendored
60
.github/workflows/codeql-critical-quality.yml
vendored
@@ -342,13 +342,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-core-auth-secrets-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/core-auth-secrets"
|
||||
|
||||
@@ -365,13 +365,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-config-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/config-boundary"
|
||||
|
||||
@@ -388,13 +388,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-gateway-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/gateway-runtime-boundary"
|
||||
|
||||
@@ -411,13 +411,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/channel-runtime-boundary"
|
||||
|
||||
@@ -460,7 +460,7 @@ jobs:
|
||||
|
||||
- name: Initialize CodeQL
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-network-runtime-boundary-critical-quality.yml
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
category: "/codeql-critical-quality/network-runtime-boundary"
|
||||
@@ -518,13 +518,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/agent-runtime-boundary"
|
||||
|
||||
@@ -541,13 +541,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-mcp-process-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/mcp-process-runtime-boundary"
|
||||
|
||||
@@ -564,13 +564,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/memory-runtime-boundary"
|
||||
|
||||
@@ -587,13 +587,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-session-diagnostics-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/session-diagnostics-boundary"
|
||||
|
||||
@@ -610,13 +610,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-sdk-reply-runtime-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-sdk-reply-runtime"
|
||||
|
||||
@@ -633,13 +633,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-provider-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/provider-runtime-boundary"
|
||||
|
||||
@@ -655,13 +655,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-ui-control-plane-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/ui-control-plane"
|
||||
|
||||
@@ -677,13 +677,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-web-media-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/web-media-runtime-boundary"
|
||||
|
||||
@@ -700,13 +700,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-boundary"
|
||||
|
||||
@@ -723,12 +723,12 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-sdk-package-contract-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-sdk-package-contract"
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: swift
|
||||
build-mode: manual
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
upload: failure-only
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload filtered SARIF
|
||||
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
sarif_file: sarif-results-filtered
|
||||
category: "/codeql-critical-security/macos"
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -101,12 +101,12 @@ jobs:
|
||||
.github/codeql
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ${{ matrix.config_file }}
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-security-high/${{ matrix.category }}"
|
||||
|
||||
53
.github/workflows/crabbox-hydrate.yml
vendored
53
.github/workflows/crabbox-hydrate.yml
vendored
@@ -32,11 +32,11 @@ permissions:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_CHILD_CONCURRENCY: "1"
|
||||
PNPM_CONFIG_MODULES_DIR: "/var/tmp/openclaw-pnpm/node_modules"
|
||||
PNPM_CONFIG_MODULES_DIR: "/tmp/openclaw-pnpm-node-modules"
|
||||
PNPM_CONFIG_NETWORK_CONCURRENCY: "1"
|
||||
PNPM_CONFIG_STORE_DIR: "/var/cache/crabbox/pnpm/store"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/var/tmp/openclaw-pnpm/virtual-store"
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/tmp/openclaw-pnpm-virtual-store"
|
||||
|
||||
jobs:
|
||||
hydrate:
|
||||
@@ -120,27 +120,6 @@ jobs:
|
||||
append_pnpm_option_arg PNPM_CONFIG_MODULES_DIR modules-dir
|
||||
append_pnpm_option_arg PNPM_CONFIG_NETWORK_CONCURRENCY network-concurrency
|
||||
append_pnpm_option_arg PNPM_CONFIG_VIRTUAL_STORE_DIR virtual-store-dir
|
||||
require_safe_writable_dir() {
|
||||
local dir="$1"
|
||||
if [ -L "$dir" ] || [ ! -d "$dir" ] || [ ! -w "$dir" ]; then
|
||||
echo "::error::Refusing unsafe pnpm directory: $dir"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
prepare_crabbox_pnpm_dirs() {
|
||||
local volatile_root="/var/tmp/openclaw-pnpm"
|
||||
case "${PNPM_CONFIG_MODULES_DIR:?}" in "$volatile_root"/*) ;; *) echo "::error::PNPM_CONFIG_MODULES_DIR must stay under $volatile_root"; exit 1 ;; esac
|
||||
case "${PNPM_CONFIG_VIRTUAL_STORE_DIR:?}" in "$volatile_root"/*) ;; *) echo "::error::PNPM_CONFIG_VIRTUAL_STORE_DIR must stay under $volatile_root"; exit 1 ;; esac
|
||||
rm -rf -- "$volatile_root"
|
||||
mkdir -p "$volatile_root" "$PNPM_CONFIG_STORE_DIR"
|
||||
require_safe_writable_dir "$volatile_root"
|
||||
require_safe_writable_dir "$PNPM_CONFIG_STORE_DIR"
|
||||
mkdir -p "$PNPM_CONFIG_MODULES_DIR" "$PNPM_CONFIG_VIRTUAL_STORE_DIR"
|
||||
}
|
||||
prepare_crabbox_pnpm_dirs
|
||||
if [ -L node_modules ] && [ "$(readlink node_modules)" = "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
rm -f node_modules
|
||||
fi
|
||||
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
mkdir -p "$PNPM_CONFIG_MODULES_DIR"
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
@@ -378,10 +357,9 @@ jobs:
|
||||
$env:XDG_CACHE_HOME = Join-Path $cacheRoot "cache"
|
||||
$env:COREPACK_HOME = Join-Path $env:XDG_CACHE_HOME "corepack"
|
||||
$env:PNPM_HOME = Join-Path $cacheRoot "pnpm-home"
|
||||
$pnpmCacheRoot = Join-Path $cacheRoot "openclaw-pnpm"
|
||||
$env:PNPM_CONFIG_STORE_DIR = Join-Path $pnpmCacheRoot "store"
|
||||
$env:PNPM_CONFIG_MODULES_DIR = Join-Path $pnpmCacheRoot "node_modules"
|
||||
$env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $pnpmCacheRoot "virtual-store"
|
||||
$env:PNPM_CONFIG_STORE_DIR = Join-Path $cacheRoot "openclaw-pnpm-store"
|
||||
$env:PNPM_CONFIG_MODULES_DIR = Join-Path $cacheRoot "openclaw-pnpm-node-modules"
|
||||
$env:PNPM_CONFIG_VIRTUAL_STORE_DIR = Join-Path $env:PNPM_CONFIG_MODULES_DIR ".pnpm"
|
||||
$env:PNPM_CONFIG_CHILD_CONCURRENCY = "4"
|
||||
$env:PNPM_CONFIG_NETWORK_CONCURRENCY = "8"
|
||||
$env:PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN = "false"
|
||||
@@ -453,25 +431,6 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
$workspaceNodeModules = Join-Path $workspace "node_modules"
|
||||
if (Test-Path $workspaceNodeModules) {
|
||||
$workspaceNodeModulesItem = Get-Item $workspaceNodeModules -Force
|
||||
if (($workspaceNodeModulesItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0) {
|
||||
$nodeModulesChildren = @(Get-ChildItem -LiteralPath $workspaceNodeModules -Force)
|
||||
$hasOnlyPnpmWorkspaceState = $nodeModulesChildren.Count -eq 1 -and $nodeModulesChildren[0].Name -eq ".pnpm-workspace-state-v1.json"
|
||||
if ($nodeModulesChildren.Count -ne 0 -and -not $hasOnlyPnpmWorkspaceState) {
|
||||
throw "workspace node_modules exists and is not a link: $workspaceNodeModules"
|
||||
}
|
||||
foreach ($nodeModulesChild in $nodeModulesChildren) {
|
||||
Remove-Item -LiteralPath $nodeModulesChild.FullName -Force
|
||||
}
|
||||
Remove-Item -LiteralPath $workspaceNodeModules -Force
|
||||
New-Item -ItemType Junction -Path $workspaceNodeModules -Target $env:PNPM_CONFIG_MODULES_DIR | Out-Null
|
||||
}
|
||||
} else {
|
||||
New-Item -ItemType Junction -Path $workspaceNodeModules -Target $env:PNPM_CONFIG_MODULES_DIR | Out-Null
|
||||
}
|
||||
|
||||
$corepackShimDir = Join-Path $nodeBin "node_modules\corepack\shims"
|
||||
if (Test-Path $corepackShimDir) {
|
||||
$env:PNPM_HOME = $corepackShimDir
|
||||
|
||||
21
.github/workflows/docker-release.yml
vendored
21
.github/workflows/docker-release.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "!v*-alpha.*"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
@@ -39,11 +38,7 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then
|
||||
echo "Docker alpha image publishing is disabled."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-(alpha|beta)\.[1-9][0-9]*)?$ ]]; then
|
||||
echo "Invalid release tag: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -89,7 +84,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
@@ -161,7 +156,7 @@ jobs:
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -179,7 +174,7 @@ jobs:
|
||||
id: build-browser
|
||||
if: steps.tags.outputs.browser != ''
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -280,7 +275,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
@@ -352,7 +347,7 @@ jobs:
|
||||
- name: Build and push arm64 image
|
||||
id: build
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -370,7 +365,7 @@ jobs:
|
||||
id: build-browser
|
||||
if: steps.tags.outputs.browser != ''
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -562,7 +557,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
|
||||
2
.github/workflows/docs-agent.yml
vendored
2
.github/workflows/docs-agent.yml
vendored
@@ -149,7 +149,7 @@ jobs:
|
||||
|
||||
- name: Run Codex docs agent
|
||||
if: steps.gate.outputs.run_agent == 'true'
|
||||
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
|
||||
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
|
||||
env:
|
||||
DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
|
||||
DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}
|
||||
|
||||
122
.github/workflows/full-release-validation.yml
vendored
122
.github/workflows/full-release-validation.yml
vendored
@@ -229,7 +229,7 @@ jobs:
|
||||
needs: [resolve_target]
|
||||
if: inputs.rerun_group == 'all'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -245,11 +245,54 @@ jobs:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
timeout --kill-after=30s 15m docker build \
|
||||
timeout --kill-after=30s 35m docker build \
|
||||
--target runtime-assets \
|
||||
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
|
||||
.
|
||||
|
||||
- name: Build and smoke test final Docker runtime image
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image_ref="openclaw-release-runtime-smoke:${TARGET_SHA}"
|
||||
timeout --kill-after=30s 35m docker build \
|
||||
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
|
||||
-t "${image_ref}" \
|
||||
.
|
||||
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
|
||||
set -eu
|
||||
test -f /app/src/agents/templates/HEARTBEAT.md
|
||||
temp_root="$(mktemp -d)"
|
||||
trap "rm -rf \"${temp_root}\"" EXIT
|
||||
mkdir -p "${temp_root}/home" "${temp_root}/cwd"
|
||||
cd "${temp_root}/cwd"
|
||||
set +e
|
||||
HOME="${temp_root}/home" \
|
||||
USERPROFILE="${temp_root}/home" \
|
||||
OPENCLAW_HOME="${temp_root}/home" \
|
||||
OPENCLAW_NO_ONBOARD=1 \
|
||||
OPENCLAW_SUPPRESS_NOTES=1 \
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \
|
||||
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 \
|
||||
AWS_EC2_METADATA_DISABLED=true \
|
||||
AWS_SHARED_CREDENTIALS_FILE="${temp_root}/home/.aws/credentials" \
|
||||
AWS_CONFIG_FILE="${temp_root}/home/.aws/config" \
|
||||
node /app/openclaw.mjs agent --message "workspace bootstrap smoke" --session-id "workspace-bootstrap-smoke" --local --timeout 1 --json \
|
||||
>"${temp_root}/out.log" 2>&1
|
||||
status="$?"
|
||||
set -e
|
||||
if grep -F "Missing workspace template:" "${temp_root}/out.log"; then
|
||||
cat "${temp_root}/out.log"
|
||||
exit 1
|
||||
fi
|
||||
test -f "${temp_root}/home/.openclaw/workspace/HEARTBEAT.md"
|
||||
if [ "${status}" -ne 0 ]; then
|
||||
cat "${temp_root}/out.log"
|
||||
fi
|
||||
'
|
||||
|
||||
normal_ci:
|
||||
name: Run normal full CI
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
@@ -337,21 +380,6 @@ jobs:
|
||||
gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]'
|
||||
}
|
||||
|
||||
fail_fast_failed_jobs() {
|
||||
local failed_jobs_json
|
||||
failed_jobs_json="$(
|
||||
fetch_child_jobs |
|
||||
jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
|
||||
)"
|
||||
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
|
||||
echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix."
|
||||
jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json"
|
||||
cancel_child
|
||||
trap - EXIT INT TERM
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cancel_child() {
|
||||
if [[ -n "${run_id:-}" ]]; then
|
||||
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
|
||||
@@ -367,9 +395,6 @@ jobs:
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 2 == 0 )); then
|
||||
fail_fast_failed_jobs
|
||||
fi
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
|
||||
@@ -485,21 +510,6 @@ jobs:
|
||||
gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]'
|
||||
}
|
||||
|
||||
fail_fast_failed_jobs() {
|
||||
local failed_jobs_json
|
||||
failed_jobs_json="$(
|
||||
fetch_child_jobs |
|
||||
jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
|
||||
)"
|
||||
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
|
||||
echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix."
|
||||
jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json"
|
||||
cancel_child
|
||||
trap - EXIT INT TERM
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cancel_child() {
|
||||
if [[ -n "${run_id:-}" ]]; then
|
||||
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
|
||||
@@ -515,9 +525,6 @@ jobs:
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 2 == 0 )); then
|
||||
fail_fast_failed_jobs
|
||||
fi
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
|
||||
@@ -683,24 +690,6 @@ jobs:
|
||||
[[ "$saw_advisory" == "1" && "$failed" == "0" ]]
|
||||
}
|
||||
|
||||
fail_fast_failed_jobs() {
|
||||
local failed_jobs_json
|
||||
if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
return 0
|
||||
fi
|
||||
failed_jobs_json="$(
|
||||
fetch_child_jobs |
|
||||
jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
|
||||
)"
|
||||
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
|
||||
echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix."
|
||||
jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json"
|
||||
cancel_child
|
||||
trap - EXIT INT TERM
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
cancel_child() {
|
||||
if [[ -n "${run_id:-}" ]]; then
|
||||
echo "Cancelling child workflow ${workflow}: ${run_id}" >&2
|
||||
@@ -716,9 +705,6 @@ jobs:
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 2 == 0 )); then
|
||||
fail_fast_failed_jobs
|
||||
fi
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true
|
||||
@@ -976,21 +962,6 @@ jobs:
|
||||
}
|
||||
trap cancel_child EXIT INT TERM
|
||||
|
||||
fail_fast_failed_jobs() {
|
||||
local failed_jobs_json
|
||||
failed_jobs_json="$(
|
||||
gh_with_retry run view "$run_id" --json jobs \
|
||||
--jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]'
|
||||
)"
|
||||
if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then
|
||||
echo "::error::npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run."
|
||||
jq '.[] | {name, conclusion, url}' <<< "$failed_jobs_json"
|
||||
cancel_child
|
||||
trap - EXIT INT TERM
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
poll_count=0
|
||||
while true; do
|
||||
status="$(gh_with_retry run view "$run_id" --json status --jq '.status')"
|
||||
@@ -998,9 +969,6 @@ jobs:
|
||||
break
|
||||
fi
|
||||
poll_count=$((poll_count + 1))
|
||||
if (( poll_count % 2 == 0 )); then
|
||||
fail_fast_failed_jobs
|
||||
fi
|
||||
if (( poll_count % 10 == 0 )); then
|
||||
echo "Still waiting on npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
|
||||
gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
4
.github/workflows/macos-release.yml
vendored
4
.github/workflows/macos-release.yml
vendored
@@ -13,7 +13,7 @@ on:
|
||||
default: true
|
||||
type: boolean
|
||||
public_release_branch:
|
||||
description: Public branch that contains the release tag commit, usually main or release/YYYY.M.PATCH
|
||||
description: Public branch that contains the release tag commit, usually main or release/YYYY.M.D
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${PUBLIC_RELEASE_BRANCH}" != "main" && ! "${PUBLIC_RELEASE_BRANCH}" =~ ^release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
|
||||
echo "public_release_branch must be main or release/YYYY.M.PATCH, got ${PUBLIC_RELEASE_BRANCH}." >&2
|
||||
echo "public_release_branch must be main or release/YYYY.M.D, got ${PUBLIC_RELEASE_BRANCH}." >&2
|
||||
exit 1
|
||||
fi
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
|
||||
@@ -445,7 +445,7 @@ jobs:
|
||||
sudo chown -R codex:codex "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Run Codex Mantis Telegram agent
|
||||
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
|
||||
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
|
||||
env:
|
||||
BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }}
|
||||
BASELINE_SHA: ${{ needs.validate_refs.outputs.baseline_revision }}
|
||||
|
||||
@@ -563,7 +563,7 @@ jobs:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e && inputs.live_suite_filter == ''
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
|
||||
env:
|
||||
OPENCLAW_VITEST_MAX_WORKERS: "2"
|
||||
@@ -595,7 +595,7 @@ jobs:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_repo_e2e && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'openshell-e2e')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
2
.github/workflows/openclaw-npm-release.yml
vendored
2
.github/workflows/openclaw-npm-release.yml
vendored
@@ -391,7 +391,7 @@ jobs:
|
||||
tideclaw_alpha_publish=true
|
||||
fi
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]]; then
|
||||
echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
|
||||
echo "Real publish runs must be dispatched from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
19
.github/workflows/openclaw-performance.yml
vendored
19
.github/workflows/openclaw-performance.yml
vendored
@@ -309,7 +309,24 @@ jobs:
|
||||
report_md="${report_json%.json}.md"
|
||||
effective_status="$status"
|
||||
if [[ "$FAIL_ON_REGRESSION" == "true" && "$status" != "0" ]]; then
|
||||
if node "$PERFORMANCE_HELPER_DIR/scripts/lib/kova-report-gate.mjs" "$report_json"
|
||||
if REPORT_JSON="$report_json" node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const report = JSON.parse(fs.readFileSync(process.env.REPORT_JSON, "utf8"));
|
||||
const statuses = report.summary?.statuses ?? {};
|
||||
const nonPassStatuses = Object.entries(statuses)
|
||||
.filter(([status, count]) => status !== "PASS" && Number(count) > 0);
|
||||
const baselineRegressionCount =
|
||||
Number(report.baseline?.comparison?.regressionCount ?? report.gate?.baseline?.regressionCount ?? 0);
|
||||
const gate = report.gate;
|
||||
const toleratedPartial =
|
||||
gate?.verdict === "PARTIAL" &&
|
||||
Number(gate.blockingCount ?? 0) === 0 &&
|
||||
baselineRegressionCount === 0 &&
|
||||
nonPassStatuses.length === 0;
|
||||
if (!toleratedPartial) {
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
then
|
||||
effective_status=0
|
||||
{
|
||||
|
||||
448
.github/workflows/openclaw-release-checks.yml
vendored
448
.github/workflows/openclaw-release-checks.yml
vendored
@@ -132,7 +132,7 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]] && [[ "${tideclaw_alpha_check}" != "true" ]]; then
|
||||
echo "Release checks must be dispatched from main, release/YYYY.M.PATCH, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
echo "Release checks must be dispatched from main, release/YYYY.M.D, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -798,10 +798,9 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run parity lane
|
||||
id: run_lane
|
||||
env:
|
||||
QA_PARITY_LANE: ${{ matrix.lane }}
|
||||
QA_PARITY_OUTPUT_DIR: ${{ matrix.output_dir }}
|
||||
@@ -832,7 +831,6 @@ jobs:
|
||||
--output-dir ".artifacts/qa-e2e/${QA_PARITY_OUTPUT_DIR}"
|
||||
|
||||
- name: Upload parity lane artifacts
|
||||
id: upload_parity_lane_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -841,52 +839,6 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_lab_parity_lane_release_checks
|
||||
RELEASE_CHECK_VARIANT: ${{ matrix.lane }}
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_parity_lane_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}-${RELEASE_CHECK_VARIANT}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'variant=%s\n' "$RELEASE_CHECK_VARIANT"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_lab_parity_lane_release_checks-${{ matrix.lane }}.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_lab_parity_report_release_checks:
|
||||
name: Run QA Lab parity report
|
||||
needs: [resolve_target, qa_lab_parity_lane_release_checks]
|
||||
@@ -924,10 +876,9 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Generate parity report
|
||||
id: generate_report
|
||||
run: |
|
||||
pnpm openclaw qa parity-report \
|
||||
--repo-root . \
|
||||
@@ -938,7 +889,6 @@ jobs:
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
- name: Upload parity artifacts
|
||||
id: upload_parity_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -947,50 +897,6 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_lab_parity_report_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.generate_report.outcome }} ${{ steps.upload_parity_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-parity-report-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_lab_parity_report_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_lab_runtime_parity_release_checks:
|
||||
name: Run QA Lab runtime parity lane
|
||||
needs: [resolve_target]
|
||||
@@ -1028,7 +934,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run runtime parity lane
|
||||
id: runtime_parity_lane
|
||||
@@ -1044,7 +950,6 @@ jobs:
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity"
|
||||
|
||||
- name: Run standard runtime parity tier
|
||||
id: runtime_parity_standard_lane
|
||||
if: ${{ always() && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1072,7 +977,6 @@ jobs:
|
||||
--output-dir ".artifacts/qa-e2e/runtime-parity-soak"
|
||||
|
||||
- name: Generate runtime parity report
|
||||
id: generate_runtime_parity_report
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1083,7 +987,6 @@ jobs:
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-report
|
||||
|
||||
- name: Generate standard runtime parity report
|
||||
id: generate_runtime_parity_standard_report
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1094,7 +997,6 @@ jobs:
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
|
||||
|
||||
- name: Generate soak runtime parity report
|
||||
id: generate_runtime_parity_soak_report
|
||||
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_soak_lane.outcome != 'skipped' && steps.runtime_parity_soak_lane.outcome != 'cancelled' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1110,7 +1012,6 @@ jobs:
|
||||
--output-dir .artifacts/qa-e2e/runtime-parity-soak-report
|
||||
|
||||
- name: Upload runtime parity artifacts
|
||||
id: upload_runtime_parity_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1119,50 +1020,6 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_lab_runtime_parity_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.runtime_parity_lane.outcome }} ${{ steps.runtime_parity_standard_lane.outcome }} ${{ steps.runtime_parity_soak_lane.outcome }} ${{ steps.generate_runtime_parity_report.outcome }} ${{ steps.generate_runtime_parity_standard_report.outcome }} ${{ steps.generate_runtime_parity_soak_report.outcome }} ${{ steps.upload_runtime_parity_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_lab_runtime_parity_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
runtime_tool_coverage_release_checks:
|
||||
name: Enforce QA Lab runtime tool coverage
|
||||
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
|
||||
@@ -1244,7 +1101,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane
|
||||
id: run_lane
|
||||
@@ -1284,7 +1141,6 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload Matrix QA artifacts
|
||||
id: upload_matrix_qa_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1293,50 +1149,6 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_live_matrix_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_matrix_qa_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_live_matrix_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_live_telegram_release_checks:
|
||||
name: Run QA Lab live Telegram lane
|
||||
needs: [resolve_target]
|
||||
@@ -1387,7 +1199,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Telegram live lane
|
||||
id: run_lane
|
||||
@@ -1425,7 +1237,6 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload Telegram QA artifacts
|
||||
id: upload_telegram_qa_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1434,50 +1245,6 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_live_telegram_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_telegram_qa_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_live_telegram_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_live_discord_release_checks:
|
||||
name: Run QA Lab live Discord lane
|
||||
needs: [resolve_target]
|
||||
@@ -1528,7 +1295,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Discord live lane
|
||||
id: run_lane
|
||||
@@ -1565,7 +1332,6 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload Discord QA artifacts
|
||||
id: upload_discord_qa_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1574,50 +1340,6 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_live_discord_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_discord_qa_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_live_discord_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_live_whatsapp_release_checks:
|
||||
name: Run QA Lab live WhatsApp lane
|
||||
needs: [resolve_target]
|
||||
@@ -1671,7 +1393,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run WhatsApp live lane
|
||||
id: run_lane
|
||||
@@ -1708,7 +1430,6 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload WhatsApp QA artifacts
|
||||
id: upload_whatsapp_qa_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1717,50 +1438,6 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_live_whatsapp_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_whatsapp_qa_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_live_whatsapp_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
qa_live_slack_release_checks:
|
||||
name: Run QA Lab live Slack lane
|
||||
needs: [resolve_target]
|
||||
@@ -1811,7 +1488,7 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Slack live lane
|
||||
id: run_lane
|
||||
@@ -1848,7 +1525,6 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload Slack QA artifacts
|
||||
id: upload_slack_qa_artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -1857,50 +1533,6 @@ jobs:
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_CHECK_JOB: qa_live_slack_release_checks
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_slack_qa_artifacts.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
status="success"
|
||||
mark_status() {
|
||||
case "$1" in
|
||||
failure) status="failure" ;;
|
||||
cancelled)
|
||||
if [[ "$status" != "failure" ]]; then
|
||||
status="cancelled"
|
||||
fi
|
||||
;;
|
||||
success|skipped|"") ;;
|
||||
*) status="failure" ;;
|
||||
esac
|
||||
}
|
||||
mark_status "${JOB_STATUS:-}"
|
||||
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
|
||||
mark_status "$outcome"
|
||||
done
|
||||
mkdir -p .artifacts/release-check-status
|
||||
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
|
||||
{
|
||||
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
|
||||
printf 'status=%s\n' "$status"
|
||||
printf 'job_status=%s\n' "${JOB_STATUS:-}"
|
||||
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
|
||||
} > "$status_path"
|
||||
|
||||
- name: Upload advisory status
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-check-status-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/release-check-status/qa_live_slack_release_checks.env
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
summary:
|
||||
name: Verify release checks
|
||||
needs:
|
||||
@@ -1921,19 +1553,9 @@ jobs:
|
||||
- qa_live_slack_release_checks
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
actions: read
|
||||
permissions: {}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Download advisory status artifacts
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: release-check-status-*
|
||||
path: .artifacts/release-check-status
|
||||
merge-multiple: true
|
||||
|
||||
- name: Verify release check results
|
||||
shell: bash
|
||||
env:
|
||||
@@ -1945,49 +1567,6 @@ jobs:
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
tideclaw_alpha=true
|
||||
fi
|
||||
release_check_result() {
|
||||
local name="$1"
|
||||
local fallback="$2"
|
||||
local status_dir=".artifacts/release-check-status"
|
||||
local saw=0
|
||||
local saw_failure=0
|
||||
local saw_cancelled=0
|
||||
if [[ -d "$status_dir" ]]; then
|
||||
while IFS= read -r -d '' file; do
|
||||
saw=1
|
||||
status="$(sed -n 's/^status=//p' "$file" | tail -n 1)"
|
||||
case "$status" in
|
||||
success|skipped) ;;
|
||||
cancelled) saw_cancelled=1 ;;
|
||||
failure|"") saw_failure=1 ;;
|
||||
*) saw_failure=1 ;;
|
||||
esac
|
||||
done < <(find "$status_dir" -type f -name "${name}*.env" -print0)
|
||||
fi
|
||||
if [[ "$saw_failure" == "1" ]]; then
|
||||
printf 'failure\n'
|
||||
elif [[ "$saw_cancelled" == "1" ]]; then
|
||||
printf 'cancelled\n'
|
||||
elif [[ "$fallback" != "success" && "$fallback" != "skipped" ]]; then
|
||||
printf '%s\n' "$fallback"
|
||||
elif [[ "$saw" == "1" ]]; then
|
||||
printf 'success\n'
|
||||
elif [[ "$fallback" == "success" ]]; then
|
||||
printf 'failure\n'
|
||||
else
|
||||
printf '%s\n' "$fallback"
|
||||
fi
|
||||
}
|
||||
advisory_status_override_allowed() {
|
||||
case "$1" in
|
||||
qa_lab_parity_lane_release_checks|qa_lab_parity_report_release_checks|qa_lab_runtime_parity_release_checks|qa_live_matrix_release_checks|qa_live_telegram_release_checks|qa_live_discord_release_checks|qa_live_whatsapp_release_checks|qa_live_slack_release_checks)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
for item in \
|
||||
"prepare_release_package=${{ needs.prepare_release_package.result }}" \
|
||||
"install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \
|
||||
@@ -2006,12 +1585,7 @@ jobs:
|
||||
"qa_live_slack_release_checks=${{ needs.qa_live_slack_release_checks.result }}"
|
||||
do
|
||||
name="${item%%=*}"
|
||||
raw_result="${item#*=}"
|
||||
if advisory_status_override_allowed "$name"; then
|
||||
result="$(release_check_result "$name" "$raw_result")"
|
||||
else
|
||||
result="$raw_result"
|
||||
fi
|
||||
result="${item#*=}"
|
||||
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
|
||||
if [[ "$tideclaw_alpha" == "true" ]]; then
|
||||
case "$name" in
|
||||
|
||||
27
.github/workflows/openclaw-release-publish.yml
vendored
27
.github/workflows/openclaw-release-publish.yml
vendored
@@ -46,12 +46,11 @@ on:
|
||||
default: true
|
||||
type: boolean
|
||||
release_profile:
|
||||
description: Release coverage profile used for release evidence summaries; default reads it from the validation manifest
|
||||
description: Release coverage profile used for release evidence summaries
|
||||
required: false
|
||||
default: from-validation
|
||||
default: beta
|
||||
type: choice
|
||||
options:
|
||||
- from-validation
|
||||
- beta
|
||||
- stable
|
||||
- full
|
||||
@@ -120,7 +119,7 @@ jobs:
|
||||
tideclaw_alpha_publish=true
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then
|
||||
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PLUGIN_PUBLISH_SCOPE}" != "all-publishable" ]]; then
|
||||
@@ -136,9 +135,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
case "$RELEASE_PROFILE" in
|
||||
from-validation|beta|stable|full) ;;
|
||||
beta|stable|full) ;;
|
||||
*)
|
||||
echo "release_profile must be one of: from-validation, beta, stable, full" >&2
|
||||
echo "release_profile must be one of: beta, stable, full" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -260,7 +259,6 @@ jobs:
|
||||
echo "sha=$release_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate full release validation manifest
|
||||
id: full_manifest
|
||||
if: ${{ inputs.publish_openclaw_npm }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -291,7 +289,7 @@ jobs:
|
||||
echo "Full release validation target SHA mismatch: expected $EXPECTED_SHA, got $target_sha" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$EXPECTED_RELEASE_PROFILE" != "from-validation" && "$release_profile" != "$EXPECTED_RELEASE_PROFILE" ]]; then
|
||||
if [[ "$release_profile" != "$EXPECTED_RELEASE_PROFILE" ]]; then
|
||||
echo "Full release validation profile mismatch: expected $EXPECTED_RELEASE_PROFILE, got $release_profile" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -299,7 +297,6 @@ jobs:
|
||||
echo "Full release validation must run rerun_group=all before npm publish; got $rerun_group" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "release_profile=$release_profile" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate release tag is reachable from a trusted release branch
|
||||
env:
|
||||
@@ -335,7 +332,7 @@ jobs:
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
TARGET_SHA: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
|
||||
RELEASE_PROFILE: ${{ steps.full_manifest.outputs.release_profile || inputs.release_profile }}
|
||||
RELEASE_PROFILE: ${{ inputs.release_profile }}
|
||||
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
|
||||
run: |
|
||||
{
|
||||
@@ -504,7 +501,7 @@ jobs:
|
||||
wait_for_run() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
local status conclusion url updated_at created_at duration_seconds duration_label last_state failed_json
|
||||
local status conclusion url updated_at created_at duration_seconds duration_label last_state
|
||||
|
||||
last_state=""
|
||||
while true; do
|
||||
@@ -513,14 +510,6 @@ jobs:
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
break
|
||||
fi
|
||||
failed_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs \
|
||||
--jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' || true)"
|
||||
if [[ -n "${failed_json}" ]] && jq -e 'length > 0' <<< "$failed_json" >/dev/null; then
|
||||
echo "${workflow} has failed jobs before the workflow completed: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" >&2
|
||||
jq '.[] | {name, conclusion, url}' <<< "$failed_json" >&2 || true
|
||||
print_failed_run_summary "${run_id}"
|
||||
return 1
|
||||
fi
|
||||
url="$(printf '%s' "$run_json" | jq -r '.url')"
|
||||
updated_at="$(printf '%s' "$run_json" | jq -r '.updatedAt')"
|
||||
state="${status}:${updated_at}"
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4.36.1
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
5
.github/workflows/opengrep-precise.yml
vendored
5
.github/workflows/opengrep-precise.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 2
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
@@ -74,7 +74,6 @@ jobs:
|
||||
- name: Run opengrep on PR diff
|
||||
env:
|
||||
OPENCLAW_OPENGREP_BASE_REF: ${{ github.event.pull_request.base.sha }}...HEAD
|
||||
OPENCLAW_OPENGREP_MERGE_HEAD_FIRST_PARENT: "1"
|
||||
# Findings from precise rules block this workflow. Pull requests scan
|
||||
# changed first-party source paths only so findings stay attributable to
|
||||
# the PR diff. Test/fixture/QA path exclusions live in `.semgrepignore`
|
||||
@@ -84,7 +83,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --changed --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4.36.1
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
@@ -818,7 +818,6 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_SLACK_CAPTURE_CONTENT: "1"
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.slack_scenario || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
2
.github/workflows/sandbox-common-smoke.yml
vendored
2
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Build minimal sandbox base (USER sandbox)
|
||||
shell: bash
|
||||
|
||||
2
.github/workflows/test-performance-agent.yml
vendored
2
.github/workflows/test-performance-agent.yml
vendored
@@ -129,7 +129,7 @@ jobs:
|
||||
|
||||
- name: Run Codex test performance agent
|
||||
if: steps.gate.outputs.run_agent == 'true'
|
||||
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
|
||||
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
|
||||
with:
|
||||
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
prompt-file: .github/codex/prompts/test-performance-agent.md
|
||||
|
||||
6
.github/workflows/tui-pty.yml
vendored
6
.github/workflows/tui-pty.yml
vendored
@@ -27,9 +27,7 @@ env:
|
||||
jobs:
|
||||
tui-pty:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: "1"
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -40,4 +38,4 @@ jobs:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Run TUI PTY tests
|
||||
run: timeout --kill-after=30s 240s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
|
||||
run: timeout --kill-after=30s 120s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
|
||||
|
||||
126
.github/workflows/windows-node-release.yml
vendored
126
.github/workflows/windows-node-release.yml
vendored
@@ -1,126 +0,0 @@
|
||||
name: Windows Node Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing OpenClaw release tag to receive Windows Hub installers, for example v2026.6.1
|
||||
required: true
|
||||
type: string
|
||||
windows_node_tag:
|
||||
description: openclaw-windows-node release tag to promote, or latest
|
||||
required: true
|
||||
default: latest
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: windows-node-release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
promote_signed_windows_installers:
|
||||
name: Promote signed Windows installers
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
shell: pwsh
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if ($env:RELEASE_TAG -notmatch '^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?$') {
|
||||
throw "Invalid OpenClaw release tag: $env:RELEASE_TAG"
|
||||
}
|
||||
if ($env:WINDOWS_NODE_TAG -ne "latest" -and $env:WINDOWS_NODE_TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$') {
|
||||
throw "Invalid openclaw-windows-node release tag: $env:WINDOWS_NODE_TAG"
|
||||
}
|
||||
gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY | Out-Null
|
||||
|
||||
- name: Download Windows Hub release installers
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
$tagArgs = @()
|
||||
if ($env:WINDOWS_NODE_TAG -ne "latest") {
|
||||
$tagArgs += $env:WINDOWS_NODE_TAG
|
||||
}
|
||||
gh release download @tagArgs `
|
||||
--repo openclaw/openclaw-windows-node `
|
||||
--pattern "OpenClawCompanion-Setup-*.exe" `
|
||||
--dir dist
|
||||
|
||||
$expected = @(
|
||||
"dist/OpenClawCompanion-Setup-x64.exe",
|
||||
"dist/OpenClawCompanion-Setup-arm64.exe"
|
||||
)
|
||||
foreach ($file in $expected) {
|
||||
if (-not (Test-Path -LiteralPath $file)) {
|
||||
throw "Missing expected Windows installer: $file"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Verify Authenticode signatures
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" | ForEach-Object {
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $_.FullName
|
||||
if ($signature.Status -ne "Valid") {
|
||||
throw "$($_.Name) Authenticode signature was $($signature.Status)."
|
||||
}
|
||||
if (-not $signature.SignerCertificate) {
|
||||
throw "$($_.Name) has no signer certificate."
|
||||
}
|
||||
[pscustomobject]@{
|
||||
File = $_.Name
|
||||
Signer = $signature.SignerCertificate.Subject
|
||||
Thumbprint = $signature.SignerCertificate.Thumbprint
|
||||
} | Format-List
|
||||
}
|
||||
|
||||
- name: Write SHA-256 manifest
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-ChildItem -LiteralPath dist -Filter "OpenClawCompanion-Setup-*.exe" |
|
||||
Sort-Object Name |
|
||||
ForEach-Object {
|
||||
$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName
|
||||
"$($hash.Hash.ToLowerInvariant()) $($_.Name)"
|
||||
} | Set-Content -Encoding utf8NoBOM -Path dist/OpenClawCompanion-SHA256SUMS.txt
|
||||
|
||||
- name: Upload to OpenClaw release
|
||||
shell: pwsh
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload $env:RELEASE_TAG `
|
||||
dist/OpenClawCompanion-Setup-x64.exe `
|
||||
dist/OpenClawCompanion-Setup-arm64.exe `
|
||||
dist/OpenClawCompanion-SHA256SUMS.txt `
|
||||
--repo $env:GITHUB_REPOSITORY `
|
||||
--clobber
|
||||
|
||||
- name: Summary
|
||||
shell: pwsh
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
|
||||
run: |
|
||||
@"
|
||||
## Windows Hub installers promoted
|
||||
|
||||
OpenClaw release: $env:RELEASE_TAG
|
||||
Source release: openclaw/openclaw-windows-node@$env:WINDOWS_NODE_TAG
|
||||
|
||||
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-x64.exe
|
||||
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-Setup-arm64.exe
|
||||
- https://github.com/openclaw/openclaw/releases/download/$env:RELEASE_TAG/OpenClawCompanion-SHA256SUMS.txt
|
||||
"@ >> $env:GITHUB_STEP_SUMMARY
|
||||
166
CHANGELOG.md
166
CHANGELOG.md
@@ -2,105 +2,25 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.5
|
||||
## 2026.5.31
|
||||
|
||||
### Highlights
|
||||
|
||||
- QQBot now strips model reasoning/thinking scaffolding before native delivery, preventing raw `<thinking>` content from leaking into channel replies. (#89913, #90132) Thanks @openperf.
|
||||
- MCP tool results now coerce `resource_link`, `resource`, `audio`, malformed image, and future non-text/image blocks at the materialize boundary, preventing Anthropic 400s and poisoned session history after a tool returns richer MCP content. (#90710, #90728) Thanks @RanSHammer and @849261680.
|
||||
- Anthropic extended-thinking sessions recover after prompt-cache expiry or Gateway restart because stream start events wait for `message_start`, letting pre-generation signature errors trigger the existing recovery retry. (#90667, #90697) Thanks @openperf.
|
||||
- Parallel is now a bundled `web_search` provider with `PARALLEL_API_KEY` discovery, guarded endpoint handling, cache-safe session ids, onboarding picker support, and docs. (#85158) Thanks @NormallyGaussian.
|
||||
- Google Vertex ADC users get static catalog rows and runtime model resolution again, while single-provider cooldown recovery and memory adapter status checks are more reliable. (#90506, #90609, #90717, #90816) Thanks @849261680.
|
||||
- Matrix can preflight voice notes before mention gating, preserve thread reads/replies through Matrix relations pagination, and carry QA coverage for voice and thread flows. (#78016, #90415)
|
||||
- Auth and plugin install state is more durable: auth profiles now live in SQLite, official npm plugin install records keep their trusted pins, and prerelease fallback integrity checks avoid carrying stale integrity forward. (#89102, #88585)
|
||||
- macOS node mode no longer silently self-reconnects away from a healthy direct Gateway session, reducing unexpected companion app session churn. (#90668, #90815) Thanks @vrurg.
|
||||
- Upgrade and service paths are safer: cron legacy JSON stores migrate during doctor preflight, service env placeholders no longer mask state-dir secrets, WhatsApp startup waits are bounded, and disabled WhatsApp accounts tear down on config reload. (#90072, #90208, #90277, #90488, #90486, #87951, #87965) Thanks @MonkeyLeeT, @sallyom, @mcaxtr, and @MukundaKatta.
|
||||
- Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)
|
||||
- Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)
|
||||
- Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.
|
||||
- Skills, session metadata, gateway runtime state, plugin metadata, and store writes do less repeated work on hot paths while keeping config and dispatch behavior stable.
|
||||
- Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.
|
||||
- Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)
|
||||
- Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.
|
||||
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, and expose calmer composer controls. (#88772, #88825)
|
||||
- Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)
|
||||
- iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)
|
||||
- Release, CI, Docker, E2E, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, and status polling so failures report bounded proof instead of stalling.
|
||||
|
||||
### Changes
|
||||
|
||||
- Search/providers: add the Parallel bundled web-search plugin, live provider tests, registration contracts, onboarding/docs wiring, and guarded `api.parallel.ai/v1/search` support. (#85158) Thanks @NormallyGaussian.
|
||||
- Matrix/channels: add voice-message preflight and thread-aware read/reply behavior, including Matrix QA scenario wiring and docs for voice-message behavior. (#78016, #90415)
|
||||
- Skills/ClawHub: install ClawHub skills backed by GitHub repositories through the resolved install API, download the pinned GitHub commit, keep install-policy checks, and report install telemetry after success. (#90478) Thanks @Patrick-Erichsen.
|
||||
- Google Chat/channels: add native approval card actions and click handling so Google Chat approvals use platform-native cards instead of generic message flow.
|
||||
- Mobile: Android provider/model screens now surface expiring, unavailable, unresolved, and attention states more clearly, while iOS settings and Talk tabs keep diagnostics, gateway rows, attachment labels, and unavailable Talk controls reachable.
|
||||
- Memory: QMD search can use the new rerank toggle, and memory adapter status uses the resolved default model identity when checking plain status. (#61834)
|
||||
- Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward `web_fetch`, clarify legacy `openai-codex` auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.
|
||||
- Release/process: switch release trains to `YYYY.M.PATCH` monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at `2026.6.5` after the published beta.
|
||||
- Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.
|
||||
- Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.
|
||||
- Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until `message_start`, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.
|
||||
- Provider/model resolution: preserve Google Vertex ADC auth markers in generated catalogs, re-probe a single-provider primary after cooldown, share Codex model visibility, fail closed for unknown model auth, preserve Codex alias availability, keep unresolved profile refs unknown, and avoid resolving auth while listing models. (#90506, #90609, #90717, #90702) Thanks @849261680.
|
||||
- Gateway/macOS/mobile: avoid duplicate Gateway probe warnings by identity, rate-limit node pairing requests while preserving paired-node reconnects, keep macOS node mode on a healthy direct Gateway session, keep iOS diagnostics and gateway rows reachable, and avoid Linux ARM Gradle resource tasks during Android builds. (#85791, #90147, #90668, #90815) Thanks @giodl73-repo and @vrurg.
|
||||
- TUI/chat/Workboard/auto-reply: optimistic user messages stay stable across stale history reloads, runId reassignment, and abort windows instead of disappearing, jumping, or lingering as ghost rows; Workboard stale lifecycle bulk updates no longer overwrite newer status/provenance; message-tool sends now count as delivery. (#86205, #89600, #88592, #90123) Thanks @RomneyDa.
|
||||
- Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, service env planning skips unresolved placeholders that would mask state-dir `.env` values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.
|
||||
- Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)
|
||||
- Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on `globalThis`; Feishu streaming cards preserve full merged content; voice-call tracks Twilio streams after connect; ClickClack reply tools respect `toolsAllow`. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, and @sahibzada-allahyar.
|
||||
- Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.
|
||||
- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)
|
||||
|
||||
## 2026.6.2
|
||||
|
||||
### Highlights
|
||||
|
||||
- Plugin and skill installs now use an operator install policy instead of the old dangerous-code scanner path, with clearer doctor, CLI, ClawHub, and troubleshooting surfaces for package, archive, source, upload, and marketplace installs. (#89516) Thanks @joshavant.
|
||||
- Telegram, Feishu, Discord, WhatsApp, and outbound delivery paths got safer around duplicate transcript mirrors, Telegram admin writeback, streamed-final previews, approval allowlists, setup runtime state, poll modifiers, Discord voice errors, and internal progress traces. (#88973, #89626, #89812, #89035, #89814, #89813, #89601) Thanks @pgondhi987, @Petru2224, @zhangguiping-xydt, @codezz, and @takhoffman.
|
||||
- Chat, Control UI, Skill Workshop, Workboard, Android companion shell, and WebChat flows now preserve visible streaming text, reconcile completed sends, expose ACK timing, add Workboard keyboard movement, harden dialog accessibility, lazy-load usage views, keep current chat toggles working, and improve Android companion-first shell navigation. (#89801, #89777, #89802) Thanks @vincentkoc.
|
||||
- Security, policy, and config recovery now reject corrupt shell snapshots, unsupported policy keys, unsafe exec approval precheck environments, malformed script limits, and suspicious gateway startup configs while adding data-handling conformance checks. (#89701, #87074, #81488, #87056, #89480) Thanks @RomneyDa, @giodl73-repo, and @mmaps.
|
||||
- Gateway, agent, Codex, provider, model, and memory paths now recover session write-lock release failures, abandoned Codex app-server startups, stream-to-parent ACP spawns, custom-provider runtime fanout, bundled provider aliases, prompt-cache boundaries, Gemini stop sequences, Kimi cache markers, and watcher pressure warnings. (#89811, #89244) Thanks @RomneyDa and @takhoffman.
|
||||
- Release, CI, Docker, Crabbox/Testbox, package, and E2E validation lanes now bound more network calls, malformed numeric limits, process groups, cleanup leaks, package hydration paths, Windows installer publishing, release asset verification, and log drains so failures produce bounded proof instead of hanging.
|
||||
|
||||
### Changes
|
||||
|
||||
- Plugins/security: replace dangerous-code scanner enforcement with operator install policy, install-policy context, doctor checks, install/update CLI wiring, ClawHub metadata paths, and package/archive/source/upload lifecycle coverage. (#89516) Thanks @joshavant.
|
||||
- Policy: add data-handling conformance checks and reject unsupported policy keys. (#87056, #87074) Thanks @giodl73-repo.
|
||||
- Telegram/channels: show commentary and reasoning in progress drafts, share progress draft compositors across channel plugins, and keep Telegram polling stop/reset boundaries cheaper and more reliable.
|
||||
- UI/mobile: add Workboard keyboard movement controls, tighten Workboard card operations, improve Android companion-first shell UX, and document chat ACK timing metadata. (#89802) Thanks @vincentkoc.
|
||||
- Release metadata: align the root package, publishable plugin manifests, generated shrinkwraps, appcast, iOS, Android, macOS, Matrix plugin changelog, and docs/generated baselines with the 2026.6.2 beta train.
|
||||
- Release/packaging: promote Windows node installer publishing, require verified Windows release asset links, and document GitHub release-note edits.
|
||||
- Docs: refresh Windows Hub setup guidance and document Gateway, CLI, and plugin SDK helper contracts.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Channels/outbound: keep channel sends durable when transcript mirroring fails, stop schema-padded poll modifiers from blocking normal sends, preserve WebChat `sessions_send` handoffs, preserve Discord channel-label suppression while hiding internal agent failure traces, match Discord libopus error shapes, and sanitize Discord tool progress scaffolding. (#89626, #89812, #89601) Thanks @Petru2224, @codezz, and @takhoffman.
|
||||
- Telegram/Feishu: require admin rights for Telegram target writeback, keep Telegram DM exec approval allowlists working with `ask:off`, prevent Telegram preview duplication across streaming modes, isolate verbose status after streamed finals, cancel clean restart stop timers, slow polling restart storms, and wire Feishu setup runtime setters. (#88973, #89035, #89813, #89814) Thanks @pgondhi987, @zhangguiping-xydt, and @takhoffman.
|
||||
- Feishu: preserve full streaming card content by sending the merged text on each update instead of only the latest delta, so card readers see complete output when intermediate frames are missed. (#90181) Thanks @mushuiyu886.
|
||||
- Chat/UI/Gateway: preserve visible chat stream text, clear stale stream buffers before terminal commits, reconcile completed sends, scroll pending sends into view, harden Workboard dialog accessibility, stabilize WebChat prompt-cache affinity, overlap chat catalog startup, render chat history incrementally, lazy-load usage dashboard, and report gateway health auth diagnostics. (#89337) Thanks @RomneyDa.
|
||||
- Agents/Codex/providers/models: release session write locks when prompt-release fence reads fail, retire abandoned Codex app-server startups, keep stream-to-parent ACP spawns registered, close Codex startup clients on timeout, recover bundled provider aliases, avoid custom-provider runtime fanout, preserve provider prompt-cache boundaries, forward Gemini stop sequences, and strip Kimi-incompatible Anthropic cache markers. (#89811) Thanks @takhoffman.
|
||||
- Memory/build/update: warn after startup watcher pressure checks, externalize optional Baileys image backends, restore and pin Canvas A2UI compatibility assets, keep plugin repair fetch failures nonblocking, restore Skill Workshop view switching, and keep the current chat toggle active after awaited session switches. (#89244) Thanks @RomneyDa.
|
||||
- Plugins/auth: keep Hermes migration reports pointed at SQLite auth-profile stores and keep plugin auth-profile reuse tests on the current store path.
|
||||
- Plugins/CLI: avoid importing the runtime plugin loader only to clear in-process caches after short-lived plugin install, enable, disable, update, and uninstall commands refresh registry metadata.
|
||||
- Security/config/tooling: reject corrupt shell snapshots, suspicious gateway startup configs, malformed release/test/tooling/Docker/perf numeric limits, oversized audit responses, unsafe exec precheck env, and invalid pending-agent SQLite scaffold denials. (#89701, #89705, #89480, #81488) Thanks @RomneyDa and @mmaps.
|
||||
- Release/CI/E2E: restore package changelog extraction after the post-2026.6.1 version bump, keep hydrated pnpm modules under `node_modules` for ARM/Linux package lifecycle scripts, keep OpenAI live-cache prerequisites advisory while Anthropic prerequisites stay blocking, retry Windows Parallels background log appends on transient file-lock errors, bound candidate GitHub and cross-OS Discord fetches, harden ARM smoke/browser checks, show Docker build heartbeats, reset Crabbox pnpm hydrate state, and isolate Testbox/Docker/release journey artifacts.
|
||||
- Release/CI/E2E: keep Crabbox hydrate pnpm stores on the persistent cache volume while still resetting volatile modules, reducing cold installs and runner memory churn.
|
||||
- Release/CI/E2E: fail secret-provider proof startup immediately when the gateway exits by signal instead of waiting for the readiness timeout.
|
||||
- Release/CI/E2E: report plugin gateway gauntlet command-log write failures as failed rows instead of crashing the harness from child-process callbacks.
|
||||
- Release/CI/E2E: abort stalled Kitchen Sink RPC readiness probes as soon as the gateway exits so proof failures return promptly.
|
||||
- Release/CI/E2E: keep Parallels JSON-mode progress on stderr so macOS, Linux, Windows, and aggregate update smoke summaries stay parseable on stdout.
|
||||
- Release/CI/E2E: fail Crabbox sparse-sync runs clearly when their temporary full checkout disappears while the child process is running, instead of pretending the child's deleted cwd can be repaired.
|
||||
- Release/CI/E2E: fail PTY-backed E2E commands when transcript logs cannot be written instead of letting missing proof capture crash around a live child process.
|
||||
- Release/CI/E2E: fail mock OpenAI request-log write errors with clear HTTP responses instead of leaving provider proof clients waiting on a broken socket.
|
||||
- Release/CI/E2E: fail Parallels host-command log write errors through the command result path instead of leaving streaming smoke phases unresolved.
|
||||
|
||||
## 2026.6.1
|
||||
|
||||
### Highlights
|
||||
|
||||
- Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, auth-profile failover, reasoning-tag cleanup, and media delivery retries. (#85798, #87484, #88129, #88136, #88141, #88162, #88182, #88924, #89220) Thanks @RomneyDa, @neeravmakwana, and @omarshahine.
|
||||
- Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, QQBot, and iOS realtime Talk. (#88096, #88105, #88183, #88749, #88866, #88948, #88984, #89015, #88231) Thanks @omarshahine, @Jensenwgd, and @sliverp.
|
||||
- Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, generated-content polling, provider-catalog failures, reasoning output, and model catalog paths before they can hang a run. (#88480, #88512, #88767, #88781, #88851, #88860, #89343, #89379, #89400) Thanks @vincentkoc, @charles-openclaw, @zz327455573, @849261680, and @XuZehan-iCenter.
|
||||
- Skills, Skill Workshop, and plugin loading now handle proposal review, stale disabled snapshots, support-file approvals, locale/routing fixes, and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173, #88734) Thanks @zeus1959 and @shakkernerd.
|
||||
- Workboard, SecretRef plugin manifests, hosted iOS push relay, typed presentation command actions, and external Copilot/Tokenjuice packaging add broader orchestration, integration, SDK, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117, #88721, #89336) Thanks @RomneyDa.
|
||||
- Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, clear the composer after sends, trace first-output latency, cache transcript renders, prioritize first connect, and expose calmer composer controls and notification settings. (#74715, #88772, #88825, #88952, #88960, #88998, #89030, #89106) Thanks @VladyslavLevchuk, @vincentkoc, and @sallyom.
|
||||
- iMessage monitor state, inbound queues, Discord thread bindings, plugin install ledgers, session metadata, gateway runtime state, plugin metadata, memory watchers, and store writes moved toward SQLite-backed or cached state so restarts and hot paths do less repeated work. (#88794, #88797, #88866, #89075, #89185, #89188, #85351) Thanks @RomneyDa and @NianJiuZst.
|
||||
- Release, CI, Docker, E2E, plugin install, update, doctor, diagnostics, and security lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, child workflow waits, docker package cleanup, quiet test stalls, downgrade repair, and health probes so failures report bounded proof instead of stalling. (#84988, #87914, #87952, #88966, #89169, #89701, #89731) Thanks @LibraHo, @Niriakot, @MukundaKatta, and @RomneyDa.
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery; refresh ClawHub cards; and add ClawHub CLI, iMessage SSH-wrapper TCC, Android helper, diff-language, and host-local media-send guidance. (#79658, #88734, #88758, #88865, #89297) Thanks @simplyclever914, @shakkernerd, @vyctorbrzezowski, @TurboTheTurtle, @RomneyDa, and @Wang-Yeah623.
|
||||
- Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery. Thanks @shakkernerd.
|
||||
- Skills: let the `skill_workshop` agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.
|
||||
- Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.
|
||||
- Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.
|
||||
@@ -110,83 +30,56 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins: externalize the GitHub Copilot agent runtime as the official `@openclaw/copilot` plugin with npm and ClawHub publish metadata.
|
||||
- iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)
|
||||
- iOS: support native iPad display layouts.
|
||||
- Android: add installed-app inspection commands, notification picker helpers, and updated-system-app classification.
|
||||
- Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)
|
||||
- Workboard: wire task-backed board runs and show task comments in the edit modal.
|
||||
- Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)
|
||||
- Code mode: add MCP API files and docs for code-mode integrations.
|
||||
- Gateway: support Tailscale Serve service names for local service routing.
|
||||
- Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.
|
||||
- Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.
|
||||
- Control UI: add calmer chat composer controls for active chat entry. (#88772)
|
||||
- Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)
|
||||
- Plugin SDK: add typed presentation command actions and the bounded `resolve_exec_env` hook for plugin-provided exec environment contributions. (#88721)
|
||||
- Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)
|
||||
- Providers: add MiniMax M3 model support. (#88860)
|
||||
- Tools/media: allow validated host-local text document media sends while keeping unsafe plain-text media sends blocked. (#79658) Thanks @simplyclever914.
|
||||
- Doctor: add disk space health checks and stabilize post-upgrade JSON probes.
|
||||
- Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)
|
||||
- Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.
|
||||
- Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.
|
||||
- Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.
|
||||
- Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.
|
||||
- Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.
|
||||
- Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.
|
||||
- Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.
|
||||
- Release/CI/E2E: normalize inherited Linux `C.UTF-8` locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.
|
||||
- Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.
|
||||
- Update: keep core updates nonblocking when missing external plugin repair downloads or soft plugin repair warnings would otherwise stall, pin post-core plugin compatibility to the downgraded core version, and still block installed active plugin payload smoke failures. (#84431, #87914, #87952) Thanks @TurboTheTurtle, @Niriakot, and @MukundaKatta.
|
||||
- Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as `null` or arrays.
|
||||
- Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.
|
||||
- Talk: preserve explicit `null` payloads on controller-created turn and output-audio lifecycle events.
|
||||
- Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.
|
||||
- Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.
|
||||
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, avoid duplicate generated-media fallbacks, and let mixed requests continue with summaries or other work while media renders in the background. (#89220) Thanks @omarshahine.
|
||||
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.
|
||||
- Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.
|
||||
- Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex `lastGood` auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.
|
||||
- Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.
|
||||
- Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when `skill_workshop` is available. Thanks @shakkernerd.
|
||||
- Skill Workshop: restore and localize the Control UI board/today view switcher so review workflows keep their intended layout toggle across locales. Thanks @shakkernerd.
|
||||
- Agents/auth: write auth profiles atomically, dispatch auth failures by type, add force re-login and exhausted-failover recovery, clear legacy auto fallback pins, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state. (#85798, #87484, #89181) Thanks @RomneyDa and @neeravmakwana.
|
||||
- Agents/auth: write auth profiles atomically, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state.
|
||||
- Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill `apiKey` SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.
|
||||
- Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.
|
||||
- CLI: avoid live catalog validation during `openclaw agents add`, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.
|
||||
- CLI: harden CLI and plugin edge cases, and keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph. (#88896)
|
||||
- CLI/desktop: bridge WSL clipboard operations through the shell, recognize manual-update launchd jobs, and keep machine-readable startup output parseable during progress setup. (#88764, #88689) Thanks @alexzhu0.
|
||||
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
|
||||
- CLI/desktop: bridge WSL clipboard operations through the shell and recognize manual-update launchd jobs. (#88764)
|
||||
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
|
||||
- Plugins: clarify plugin loader failure guidance and treat soft plugin repair warnings as nonfatal so missing or incompatible plugin packages point operators at the right repair path without blocking unrelated work. (#84431) Thanks @TurboTheTurtle.
|
||||
- Plugins: preserve npm plugin roots after blocked installs, skip plugin-local `openclaw` peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, isolate provider catalog projections and web-provider factory failures, and keep private LLM-core declarations bundled so one bad plugin does not poison sibling runtime paths. (#77237, #88767, #88807, #89336) Thanks @vincentkoc and @RomneyDa.
|
||||
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, single-job run-history names, startup cron retries, and legacy one-shot delete-after-run behavior. (#88285, #88294, #89075) Thanks @kip-claw.
|
||||
- Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.
|
||||
- Plugins: preserve npm plugin roots after blocked installs, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)
|
||||
- Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)
|
||||
- Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.
|
||||
- Auto-reply: guard dispatcher failure-count probes so missing optional counters do not break SDK-typed recovery paths. (#89318) Thanks @Alix-007 and @takhoffman.
|
||||
- Memory: serialize QMD update/embed writes per store, reduce Linux watcher fan-out, avoid noisy gateway watcher warnings, retry transient FileProvider-backed reads, preserve phase signals on read errors, harden envelope metadata sanitization, reattach Linux native watchers when directories are recreated, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931, #89185, #89188, #89246, #85351) Thanks @openperf, @amittell, @RomneyDa, and @NianJiuZst.
|
||||
- Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.
|
||||
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
|
||||
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows and `gemini-3.1-flash-lite`, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, forward Gemini stop sequences, switch direct Gemini reasoning to native mode, strip provider self-prefixes and Kimi-incompatible Anthropic cache markers, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512, #88781, #89343, #89379, #89400, #76612) Thanks @coder999999999, @BryanTegomoh, @vliuyt, @charles-openclaw, @zz327455573, @849261680, and @XuZehan-iCenter.
|
||||
- Providers: resolve Google defaults to `google-generative-ai`, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512)
|
||||
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
|
||||
- Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.
|
||||
- Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)
|
||||
- Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.
|
||||
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; keep iMessage typing active during tool work; allow RFC2544 benchmark ranges for QQBot token fetches; and retry WhatsApp QR login 408 timeouts. (#88183, #88948, #88984, #89015) Thanks @omarshahine, @Jensenwgd, and @sliverp.
|
||||
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, corrupt shell snapshots, untrusted workspace setup-only channel loads, remote media reference overreads, trajectory export leaks, hooks-token auth reuse, and gateway WebSocket calls after close. (#86953, #87376, #88974, #89354, #89701) Thanks @hxy91819, @coygeek, @pgondhi987, and @RomneyDa.
|
||||
- Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)
|
||||
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
|
||||
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, dependency guard admin approvals, child workflow failure detection, quiet Node test shard stalls, dist cache restores, Docker base-image/package cleanup, and mainline test flakes. (#84988, #88127, #88137, #88155, #88160, #88966, #89169) Thanks @LibraHo and @RomneyDa.
|
||||
- Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.
|
||||
- Backup: accept root-relative hardlink targets during backup verification. (#89328) Thanks @abnershang.
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
|
||||
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
|
||||
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
|
||||
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
|
||||
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
|
||||
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
|
||||
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, cache chat transcript renders, record pending-send paint timing, show the Communication Notifications tab, honor Chromium executable overrides, and detect system Chromium for E2E. (#74715, #88952, #88960, #88998) Thanks @VladyslavLevchuk and @vincentkoc.
|
||||
- Channels: stop schema-padded poll modifiers from turning normal `send` actions into invalid poll sends. (#89601) Thanks @codezz and @takhoffman.
|
||||
- Channels: preserve long Feishu streaming replies, recover failed progress draft starts, send visible fallbacks when accepted Feishu turns produce no final reply, preserve external `sessions_send` routes, persist Discord thread bindings in SQLite, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896, #88749, #88803, #88866) Thanks @MonkeyLeeT.
|
||||
- Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, surface disabled Codex plugin routes in doctor lint, respect explicit PI runtime policy, report runtime tool-schema and gateway health credential errors, clear recovered embedded-run activity, migrate voice-call call logs through doctor, and keep post-upgrade JSON stable. (#88731, #88761, #88820, #88288, #89731) Thanks @brokemac79, @openperf, and @RomneyDa.
|
||||
- Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from `sessions.list`, make task persistence failures explicit, support Tailscale Serve service names, guard Browser/Chrome pending attach aborts, and carry session UUIDs on interactive dispatch events. (#88305) Thanks @rohitjavvadi.
|
||||
- Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.
|
||||
- Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, honor Chromium executable overrides, and detect system Chromium for E2E.
|
||||
- Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, decode Nostr `npub` allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)
|
||||
- Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)
|
||||
- Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from `sessions.list`, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.
|
||||
- OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)
|
||||
- CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.
|
||||
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
|
||||
@@ -761,7 +654,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
|
||||
- Agents/embedded runner: classify HTML auth provider responses as `auth_html` and return a re-authentication hint instead of the CDN-blocked copy that `upstream_html` returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.
|
||||
- TUI/streaming watchdog: dismiss the `This response is taking longer than expected` notice as soon as a chat event for the same run arrives, so the message no longer sits next to the recovered response when the run was only briefly silent. Refs #67052, #69081 (closed), prior attempt #69026. Thanks @jpruit20 and @romneyda.
|
||||
- Agents/auth profiles: replace the bare `No available auth profile for <provider> (all in cooldown or unavailable)` TUI error with plain-language copy that explains what happened in user terms (sign-in expired, provider asking us to slow down, billing issue on the account, etc.) and suggests the matching `openclaw models auth login --provider <provider>` recovery command for sign-in and billing causes, while falling back to the underlying provider error for cases without a clear recovery path. Thanks @romneyda.
|
||||
- Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.
|
||||
|
||||
## 2026.5.20
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -9,18 +9,18 @@
|
||||
# Build stages use full bookworm; the runtime image is always bookworm-slim.
|
||||
ARG OPENCLAW_EXTENSIONS=""
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions
|
||||
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="docker.io/library/node:24-bookworm@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="docker.io/library/node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:8530f76a96d88820d288761f022e318970dda93d01536919fbc16076b7983e63"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf"
|
||||
# Keep in sync with .github/actions/setup-node-env/action.yml bun-version.
|
||||
# To update: docker buildx imagetools inspect docker.io/oven/bun:<version> and use the manifest-list digest.
|
||||
ARG OPENCLAW_BUN_IMAGE="docker.io/oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e"
|
||||
# To update: docker buildx imagetools inspect oven/bun:<version> and use the manifest-list digest.
|
||||
ARG OPENCLAW_BUN_IMAGE="oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e"
|
||||
|
||||
# Base images are pinned to SHA256 digests for reproducible builds.
|
||||
# Dependabot refreshes these blessed digests; release builds consume the
|
||||
# reviewed base snapshot instead of mutating distro state on every build.
|
||||
# To update, run: docker buildx imagetools inspect docker.io/library/node:24-bookworm and
|
||||
# docker.io/library/node:24-bookworm-slim (or podman) and replace the digests below with the
|
||||
# To update, run: docker buildx imagetools inspect node:24-bookworm and
|
||||
# node:24-bookworm-slim (or podman) and replace the digests below with the
|
||||
# current multi-arch manifest list entries.
|
||||
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS workspace-deps
|
||||
|
||||
@@ -30,8 +30,7 @@ Supported channels include: WhatsApp, Telegram, Slack, Discord, Google Chat, Sig
|
||||
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
Preferred setup: run `openclaw onboard` in your terminal.
|
||||
OpenClaw Onboard guides you step by step through setting up the gateway, workspace, channels, and skills. It is the recommended CLI setup path and works on **macOS, Linux, and Windows**.
|
||||
Windows desktop users can start with the native [Windows Hub](https://docs.openclaw.ai/platforms/windows) companion app for setup, tray status, chat, node mode, and local MCP mode.
|
||||
OpenClaw Onboard guides you step by step through setting up the gateway, workspace, channels, and skills. It is the recommended CLI setup path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
|
||||
## Sponsors
|
||||
@@ -165,7 +164,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — wake words on macOS/iOS and continuous voice on Android (ElevenLabs + system TTS fallback).
|
||||
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||
- **[Companion apps](https://docs.openclaw.ai/platforms)** — Windows Hub, macOS menu bar app, and iOS/Android [nodes](https://docs.openclaw.ai/nodes).
|
||||
- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
|
||||
- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — onboarding-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Security model (important)
|
||||
@@ -186,7 +185,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
- New here: [Getting started](https://docs.openclaw.ai/start/getting-started), [Onboarding](https://docs.openclaw.ai/start/wizard), [Updating](https://docs.openclaw.ai/install/updating)
|
||||
- Channel setup: [Channels index](https://docs.openclaw.ai/channels), [WhatsApp](https://docs.openclaw.ai/channels/whatsapp), [Telegram](https://docs.openclaw.ai/channels/telegram), [Discord](https://docs.openclaw.ai/channels/discord), [Slack](https://docs.openclaw.ai/channels/slack)
|
||||
- Apps + nodes: [Windows Hub](https://docs.openclaw.ai/platforms/windows), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android), [Nodes](https://docs.openclaw.ai/nodes)
|
||||
- Apps + nodes: [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android), [Nodes](https://docs.openclaw.ai/nodes)
|
||||
- Config + security: [Configuration](https://docs.openclaw.ai/gateway/configuration), [Security](https://docs.openclaw.ai/gateway/security), [Exposure runbook](https://docs.openclaw.ai/gateway/security/exposure-runbook), [Sandboxing](https://docs.openclaw.ai/gateway/sandboxing)
|
||||
- Remote + web: [Gateway](https://docs.openclaw.ai/gateway), [Remote access](https://docs.openclaw.ai/gateway/remote), [Tailscale](https://docs.openclaw.ai/gateway/tailscale), [Web surfaces](https://docs.openclaw.ai/web)
|
||||
- Tools + automation: [Tools](https://docs.openclaw.ai/tools), [Skills](https://docs.openclaw.ai/tools/skills), [Cron jobs](https://docs.openclaw.ai/automation/cron-jobs), [Webhooks](https://docs.openclaw.ai/automation/webhook), [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
|
||||
336
appcast.xml
336
appcast.xml
@@ -2,133 +2,6 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.6.1</title>
|
||||
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026060190</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.1</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)</li>
|
||||
<li>Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)</li>
|
||||
<li>Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.</li>
|
||||
<li>Skills, session metadata, gateway runtime state, plugin metadata, memory watchers, and store writes do less repeated work on hot paths while keeping config, dispatch, and Linux file-watch behavior stable. (#89185, #89188, #85351) Thanks @RomneyDa and @NianJiuZst.</li>
|
||||
<li>Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.</li>
|
||||
<li>Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)</li>
|
||||
<li>Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.</li>
|
||||
<li>Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, clear the composer after sends, trace first-output latency, prioritize first connect, and expose calmer composer controls. (#88772, #88825, #88998, #89030, #89106) Thanks @vincentkoc and @sallyom.</li>
|
||||
<li>Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)</li>
|
||||
<li>iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)</li>
|
||||
<li>Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, child workflow waits, docker package cleanup, quiet test stalls, and rollback snapshots so failures report bounded proof instead of stalling. (#88966) Thanks @RomneyDa.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery, and refresh the ClawHub showcase cards. (#88734) Thanks @shakkernerd and @vyctorbrzezowski.</li>
|
||||
<li>Skills: let the <code>skill_workshop</code> agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.</li>
|
||||
<li>Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.</li>
|
||||
<li>Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.</li>
|
||||
<li>Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the <code>skill_workshop</code> agent tool. Thanks @shakkernerd.</li>
|
||||
<li>Skill Workshop: add the Control UI navigation, styled dashboard, proposal today view, revision dialog, file preview modal, searchable preview files, reusable session handoff, and localized strings.</li>
|
||||
<li>Plugins: externalize Tokenjuice as the official <code>@openclaw/tokenjuice</code> plugin with npm and ClawHub publish metadata.</li>
|
||||
<li>Plugins: externalize the GitHub Copilot agent runtime as the official <code>@openclaw/copilot</code> plugin with npm and ClawHub publish metadata.</li>
|
||||
<li>iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)</li>
|
||||
<li>iOS: support native iPad display layouts.</li>
|
||||
<li>Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)</li>
|
||||
<li>Workboard: wire task-backed board runs and show task comments in the edit modal.</li>
|
||||
<li>Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)</li>
|
||||
<li>Code mode: add MCP API files and docs for code-mode integrations.</li>
|
||||
<li>Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.</li>
|
||||
<li>Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.</li>
|
||||
<li>Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)</li>
|
||||
<li>Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)</li>
|
||||
<li>Providers: add MiniMax M3 model support. (#88860)</li>
|
||||
<li>Doctor: add disk space health checks and stabilize post-upgrade JSON probes.</li>
|
||||
<li>Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)</li>
|
||||
<li>Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.</li>
|
||||
<li>Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.</li>
|
||||
<li>Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.</li>
|
||||
<li>Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.</li>
|
||||
<li>Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.</li>
|
||||
<li>Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.</li>
|
||||
<li>Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.</li>
|
||||
<li>Release/CI/E2E: normalize inherited Linux <code>C.UTF-8</code> locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.</li>
|
||||
<li>Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.</li>
|
||||
<li>Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.</li>
|
||||
<li>Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as <code>null</code> or arrays.</li>
|
||||
<li>Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.</li>
|
||||
<li>Talk: preserve explicit <code>null</code> payloads on controller-created turn and output-audio lifecycle events.</li>
|
||||
<li>Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.</li>
|
||||
<li>Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.</li>
|
||||
<li>Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.</li>
|
||||
<li>Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.</li>
|
||||
<li>Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex <code>lastGood</code> auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.</li>
|
||||
<li>Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.</li>
|
||||
<li>Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when <code>skill_workshop</code> is available. Thanks @shakkernerd.</li>
|
||||
<li>Skill Workshop: restore and localize the Control UI board/today view switcher so review workflows keep their intended layout toggle across locales. Thanks @shakkernerd.</li>
|
||||
<li>Agents/auth: write auth profiles atomically, dispatch auth failures by type, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state. (#89181) Thanks @RomneyDa.</li>
|
||||
<li>Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill <code>apiKey</code> SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.</li>
|
||||
<li>Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.</li>
|
||||
<li>CLI: avoid live catalog validation during <code>openclaw agents add</code>, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.</li>
|
||||
<li>CLI: keep <code>plugins list --json</code> on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.</li>
|
||||
<li>CLI/desktop: bridge WSL clipboard operations through the shell, recognize manual-update launchd jobs, and keep machine-readable startup output parseable during progress setup. (#88764, #88689) Thanks @alexzhu0.</li>
|
||||
<li>Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.</li>
|
||||
<li>Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.</li>
|
||||
<li>Plugins: preserve npm plugin roots after blocked installs, skip plugin-local <code>openclaw</code> peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)</li>
|
||||
<li>Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)</li>
|
||||
<li>Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.</li>
|
||||
<li>Memory: serialize QMD update/embed writes per store, reduce Linux watcher fan-out, retry transient FileProvider-backed reads, preserve phase signals on read errors, harden envelope metadata sanitization, reattach Linux native watchers when directories are recreated, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931, #89185, #89188, #85351) Thanks @openperf, @amittell, @RomneyDa, and @NianJiuZst.</li>
|
||||
<li>Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.</li>
|
||||
<li>Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.</li>
|
||||
<li>Providers: resolve Google defaults to <code>google-generative-ai</code>, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, forward Gemini stop sequences, strip Kimi-incompatible Anthropic cache markers, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512, #76612) Thanks @coder999999999, @BryanTegomoh, and @vliuyt.</li>
|
||||
<li>Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.</li>
|
||||
<li>Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.</li>
|
||||
<li>Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)</li>
|
||||
<li>Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.</li>
|
||||
<li>Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)</li>
|
||||
<li>Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.</li>
|
||||
<li>Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.</li>
|
||||
<li>Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, dependency guard admin approvals, child workflow failure detection, quiet Node test shard stalls, docker package cleanup, and mainline test flakes. (#88127, #88137, #88155, #88160, #88966) Thanks @RomneyDa.</li>
|
||||
<li>Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.</li>
|
||||
<li>Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.</li>
|
||||
<li>Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.</li>
|
||||
<li>Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.</li>
|
||||
<li>Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.</li>
|
||||
<li>Agents: accept hidden <code>sessions_send</code> body aliases before validation while keeping the model-facing <code>message</code> schema canonical. (#88229) Thanks @zhangguiping-xydt.</li>
|
||||
<li>Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.</li>
|
||||
<li>Channels: stop schema-padded poll modifiers from turning normal <code>send</code> actions into invalid poll sends. (#89601) Thanks @codezz.</li>
|
||||
<li>Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr <code>npub</code> allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)</li>
|
||||
<li>Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)</li>
|
||||
<li>Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from <code>sessions.list</code>, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.</li>
|
||||
<li>Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.</li>
|
||||
<li>OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)</li>
|
||||
<li>CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.</li>
|
||||
<li>CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.</li>
|
||||
<li>CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.</li>
|
||||
<li>CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.</li>
|
||||
<li>CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.</li>
|
||||
<li>CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.</li>
|
||||
<li>CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.</li>
|
||||
<li>CI/tooling: route script edits through conventional owner tests when matching <code>test/scripts</code> or <code>src/scripts</code> coverage already exists.</li>
|
||||
<li>CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.</li>
|
||||
<li>Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.</li>
|
||||
<li>Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.</li>
|
||||
<li>Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.</li>
|
||||
<li>Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.</li>
|
||||
<li>Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.</li>
|
||||
<li>Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.</li>
|
||||
<li>Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.1/OpenClaw-2026.6.1.zip" length="55062100" type="application/octet-stream" sparkle:edSignature="PVp8E2HBCvikB/0LCr36lFEyHPAzoFA2ScT6LW27FlzvP+m4r1AEuVN2UrtgWlpkGSsn4Eav0kPJe32u4ObNBw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.28</title>
|
||||
<pubDate>Sat, 30 May 2026 21:21:09 +0000</pubDate>
|
||||
@@ -240,5 +113,214 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.27/OpenClaw-2026.5.27.zip" length="54488811" type="application/octet-stream" sparkle:edSignature="c5w2T1UO6vpPs70hyYH93cIyWEOd5sl5z2NkhU53E+XQBSd+jAr+xd0qf3KzWbeX2mfXYMQmnx+VMls3L22EDg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.26</title>
|
||||
<pubDate>Wed, 27 May 2026 12:24:26 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052690</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.26</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.26</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Faster Gateway and replies: startup avoids repeated plugin, channel, session, usage-cost, warning, scheduled-service, and filesystem scans; visible replies separate user-facing sends from slower follow-up work; Gateway runtime/session caches churn less under load.</li>
|
||||
<li>Transcripts are core: transcript-backed meeting summaries, source-provider chunks, cleaned user turns, media provenance, Codex mirrors, WebChat replies, and CLI/TUI replay now use one more reliable transcript path.</li>
|
||||
<li>More channels are production-ready: Telegram keeps typing/progress context and forum topics, iMessage handles attachment roots, remote media staging, and duplicate local Messages sources, WhatsApp restores group/media behavior, Discord improves voice playback and model picking, and Signal/iMessage/WhatsApp get reaction approvals.</li>
|
||||
<li>Better voice and Talk: realtime Talk runs can be inspected, steered, cancelled, or followed up from Web UI and Discord voice; wake-name handling is more tolerant without letting ambient speech trigger agents.</li>
|
||||
<li>Safer content boundaries: Browser snapshot reads honor SSRF policy, system-event text cannot spoof nested prompt markers, fetched file text is wrapped as external content, ClickClack inbound sender allowlists run before agent dispatch, stale device tokens are rejected, and serialized tool-call text is scrubbed from replies.</li>
|
||||
<li>Providers, Codex, and local models are steadier: named auth profiles, OpenAI sampling params, Codex app-server resume/timeout/usage-limit recovery, dynamic tool-schema guards, xAI usage-limit surfacing, Ollama top-p normalization, and local approval resolution reduce provider-specific dead ends.</li>
|
||||
<li>More reliable install/update/release paths: Alpine installs, trusted runtime fallback roots, stable update channels, Docker/package timeouts, Windows Scheduled Tasks, Windows/macOS proof lanes, Testbox/Crabbox delegation, plugin publish checks, and macOS runner bootstraps all got hardened.</li>
|
||||
<li>Better observability: Activity tab, gateway secret-prep traces, tool/model stream progress, explicit fast-mode status, systemd Gateway hygiene, OpenTelemetry LLM spans, release performance evidence, and richer telemetry signals make failures easier to inspect.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Transcripts: add core transcript capture and source-provider support for transcript-backed meeting summaries, including the renamed Transcripts docs, CLI surface, source-provider chunks, and cleaned user-turn persistence.</li>
|
||||
<li>Auth: add named model login profiles and supported credential migration for Hermes, OpenCode, and Codex auth profiles, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.</li>
|
||||
<li>Diagnostics: trace gateway secret preparation, classify skill/tool usage, surface model stream progress, add OpenTelemetry LLM content spans, and expose alertable telemetry for blocked tools, failover, stale sessions, liveness, oversized payloads, and webhook ingress. (#83019, #80370, #86191)</li>
|
||||
<li>Channels: add Signal reaction approvals, iMessage thumb approval reactions, and WhatsApp thumb approval reaction support so mobile approval flows work without textual <code>/approve</code> commands. (#85894, #85952, #85477)</li>
|
||||
<li>Agents/API: forward OpenAI sampling params through the Gateway and expose estimated context-budget status for active agent runs. (#84094)</li>
|
||||
<li>TUI/status: queue prompts submitted while an agent is busy and show explicit fast-mode state plus richer systemd Gateway hygiene in status output. (#86722, #87115, #86976)</li>
|
||||
<li>Exec approvals: hide durable approval actions that are unavailable for the current prompt and keep approval runtime tokens local-only so stale prompts cannot offer misleading controls. (#86270, #86359)</li>
|
||||
<li>Plugin SDK: add reaction approval helpers and keep diagnostic event root exports discoverable across function-name and alias-bound module graphs. (#86735, #87084)</li>
|
||||
<li>Android/iOS: add the Android pair-new-gateway action and improve mobile Talk mode surfaces, including iOS realtime Talk mode and Android offline voice/gateway recovery. (#86798, #86355) Thanks @ngutman.</li>
|
||||
<li>Performance: cache plugin metadata snapshots, package realpaths, stable gateway metadata, model cost indexes, channel resolution, usage-cost indexes, and session/auth hot-path facts so common Gateway and reply paths do less rediscovery. (#84649, #85843, #86517, #86678)</li>
|
||||
<li>Voice: expose shared realtime turn-context tracking through the realtime voice SDK and reuse it for Discord speaker attribution and wake-name context recovery.</li>
|
||||
<li>Voice: reuse shared realtime output activity tracking in Google Meet command and node audio bridges, including recent-output checks for local barge-in detection.</li>
|
||||
<li>Voice: expose shared realtime output activity tracking through the realtime voice SDK and reuse it for Discord playback activity and barge-in decisions.</li>
|
||||
<li>Voice: expose shared realtime consult question matching, speakable-result extraction, and alias-aware forced-consult coordination through the realtime voice SDK, then reuse it in Gateway Talk, Voice Call, and Discord voice paths.</li>
|
||||
<li>Voice: share activation-name matching and consult-transcript screening through the realtime voice SDK so Discord, browser voice, and meeting surfaces can reuse one implementation.</li>
|
||||
<li>Cron: default <code>cron.maxConcurrentRuns</code> to 8 so scheduled automations and their isolated agent turns can make progress in parallel without explicit configuration.</li>
|
||||
<li>QA-Lab: add <code>qa coverage --match <query></code> so focused proof selection can discover matching scenarios from existing metadata before running live or remote lanes.</li>
|
||||
<li>Discord/model picker: surface an alpha-bucket select (e.g. <code>A–G (12) · H–N (18) · O–Z (5)</code>) when the provider list or a provider's model list exceeds 25 items, so configs with <code>provider/*</code> wildcards stay one click from the right page instead of paginating through prev/next; falls back to numeric chunks when every item shares the same first letter.</li>
|
||||
<li>Control UI: add an ephemeral Activity tab for sanitized live tool activity summaries without persisting raw telemetry. Fixes #12831. Thanks @BunsDev.</li>
|
||||
<li>Build: include <code>ui:build</code> in the <code>full</code> and <code>ciArtifacts</code> profiles of <code>scripts/build-all.mjs</code> so <code>pnpm build</code> always rebuilds <code>dist/control-ui</code> after <code>tsdown</code> cleans <code>dist</code>, removing the second-command requirement and the missing-asset failure mode for source/runtime installs and CI artifact uploads. (#85206)</li>
|
||||
<li>iOS: improve Talk mode with direct realtime voice sessions, compact toolbar status, and responsive voice waveform feedback. (#86355) Thanks @ngutman.</li>
|
||||
<li>Media: replace the Sharp image backend with Rastermill for metadata, resizing, EXIF orientation, and PNG alpha-preserving optimization so OpenClaw no longer installs Sharp or the WhatsApp Jimp fallback for image processing. (#86437)</li>
|
||||
<li>Codex: update the bundled Codex CLI to 0.134.0 and keep native compaction disabled for budget-triggered app-server turns so OpenClaw owns the recovery boundary. (#86772)</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Memory/security: reject prompt-like text submitted through the explicit <code>memory_store</code> tool before embedding or storage, matching the existing auto-capture prompt-injection filter. (#87142)</li>
|
||||
<li>Gateway/security: enable the default auth rate limiter for remote non-browser and HTTP gateway auth failures when <code>gateway.auth.rateLimit</code> is unset, while preserving the loopback exemption. (#87148)</li>
|
||||
<li>Security/content boundaries: validate Browser snapshot tab URLs against SSRF policy before ChromeMCP or direct CDP reads, sanitize queued system-event text so untrusted plugin/channel labels cannot spoof nested prompt markers, wrap fetched file text and metadata as external content, apply ClickClack <code>allowFrom</code> sender allowlists before agent dispatch, reject RPCs from invalidated device-token clients during rotation, require staged sandbox media refs, and scrub serialized tool-call text from replies. (#78526, #87094, #87062, #83741, #70707, #86924) Thanks @zsxsoft, @ttzero25, and @mmaps.</li>
|
||||
<li>Transcripts/user turns: persist CLI, WebChat, media, follow-up, hook, and Codex-mirror user turns to the admitted session target; keep cleaned transcript text, inline image routing, provenance metadata, replay hooks, and fallback paths idempotent when runtimes fail or restart.</li>
|
||||
<li>TUI/status/onboarding/UI: queue busy TUI prompts instead of dropping them, preserve the configured default model during onboarding, show failed tool results as errors, show config-open failures in Control UI, keep status JSON plugin scans healthy, preserve xAI usage-limit errors locally, and expose explicit fast-mode/systemd state. (#86722, #87000, #85786, #87108, #87001, #86614, #87115, #86976)</li>
|
||||
<li>Plugin commands/SDK: preserve plugin LLM command auth, bind native plugin command dispatch to the host agent's LLM auth, keep <code>onDiagnosticEvent</code> exports discoverable through <code>Function.name</code>, stabilize diagnostic event root aliases, correlate pathless read diagnostics, suppress transient runner failures in channel command paths, and repair local approval resolution. (#85936, #87084, #86977, #87069, #86771)</li>
|
||||
<li>Codex/providers: keep WebChat delivery hints out of user prompts, avoid false queued-terminal idle timeouts, share the native hook relay registry, quarantine unsupported dynamic tool schemas, preserve Claude resumed-session system prompts, normalize greedy Ollama <code>top_p</code>, preserve per-agent thinking defaults for ingress runs, and avoid native compaction takeover on budget-triggered Codex turns. (#87096, #73950, #87049, #86689, #86772)</li>
|
||||
<li>Gateway/perf/release: reuse startup-warning metadata and prepared auth stores, avoid cloning live-switch and lifecycle session caches on read paths, defer warning and scheduled-service fallback imports, trim Gateway session/startup/runtime CPU churn, skip duplicate turn session touches, stop chat timeout fallback cascades, drop stale subagent announce history, bound benchmark/watch/kitchen-sink teardown waits, bound macOS/package/onboarding/plugin smoke commands, bound install finalization probes, resolve Parallels npm-update commands from guest <code>PATH</code>, and bootstrap raw AWS macOS Node/pnpm commands through <code>/usr/bin/env</code>. (#86997)</li>
|
||||
<li>Reply/perf: reduce visible reply delivery latency by preserving Telegram typing/progress context, lazy-loading slash-command startup metadata, avoiding hot-path model hydration, flag-gating Codex profiler timing, deferring context compaction maintenance, and tracking delivery timing. (#86989, #86990, #86991, #86992, #86993, #86994) Thanks @keshavbotagent.</li>
|
||||
<li>Reply/source delivery: keep TUI, Control UI, media, TTS, transcript, and Codex source-reply finals live without duplicate terminal events or stale replay artifacts.</li>
|
||||
<li>Agents/replay: repair legacy tool results before replay, preserve <code>sessions_spawn</code> transcript payloads, restore current guard checks, stage sandboxed workspace media, and keep duplicate transcripts tool display metadata from reappearing. (#82203, #86934, #87025) Thanks @martingarramon, @vincentkoc, and @joshavant.</li>
|
||||
<li>Agents/sessions: handle active-fallback failures in <code>sessions_send</code> so fallback routing reports the real failure and does not leave callers with an ambiguous dropped send. (#86638)</li>
|
||||
<li>Agents/hooks/subagents: enforce default hook agent allowlists, recover failed subagent lifecycle completions, and keep node task lifecycle cleanup from closing the Gateway listener. (#86101)</li>
|
||||
<li>Codex: project newer OpenClaw chat history into resumed app-server threads and keep Codex turn timeouts inside the Codex runtime boundary so timeouts do not poison shared app-server clients or fall through to unrelated provider fallback. (#86677, #86476) Thanks @TurboTheTurtle and @pashpashpash.</li>
|
||||
<li>Config/doctor/update: narrow profiled tool-section doctor repair, keep runtime-injected legacy web-search provider config out of user-authored config validation, and keep prerelease tags excluded from stable updater resolution. (#87030, #86818, #86559) Thanks @joshavant, @luoyanglang, and @stevenepalmer.</li>
|
||||
<li>CLI/Windows: add a Windows-only stack-size respawn for stack-heavy startup paths, default CLI logs to local timestamps, and validate timeout/banner TTY state more strictly. (#87031, #85387) Thanks @giodl73-repo and @vincentkoc.</li>
|
||||
<li>Locking/security: require owner identity proof before stale plugin lock removal, memoize session lock owner arguments, and avoid writing default exec approval stores unless policy state actually changed. (#86814, #86964) Thanks @Alix-007 and @vincentkoc.</li>
|
||||
<li>Install/release: bound Docker package build, inventory, pack, and tarball preparation with process-group timeouts; pin shrinkwrap patch drift to the pnpm lock; harden macOS restart and dSYM packaging; and run release Docker/live timeout wrappers in the foreground so child processes cannot wedge gates.</li>
|
||||
<li>Telegram/network: treat <code>ENETDOWN</code> as a transient pre-connect network failure so Telegram sends, gateway unhandled-rejection handling, and cron network retries follow the same recovery path as sibling network outages. (#86762) Thanks @TurboTheTurtle.</li>
|
||||
<li>Telegram: preserve inbound text entities, overlapping DM replies, account topic cache sidecars, outbound reply context, targeted bot-command mentions, durable group retry targets, forum topic names, and native progress callbacks. (#83873, #85361, #85555, #85656, #85709, #86299, #86553) Thanks @SebTardif, @luoyanglang, and @neeravmakwana.</li>
|
||||
<li>iMessage: read image attachments from local Messages attachment roots, dedupe duplicate local Messages-source accounts, seed direct DM history, fix image/group media attachment commands, advance catchup cursors after live handling, and keep slash-command acknowledgements in the source conversation. (#82642, #85475, #86569, #86705, #86706, #86770) Thanks @homer-byte, @TurboTheTurtle, @swang430, and @OmarShahine.</li>
|
||||
<li>WhatsApp/QQ/Twitch/IRC/Slack: restore WhatsApp ack identity and group-drop warnings, make QQ Bot media respect <code>OPENCLAW_HOME</code>, serialize Twitch auth disconnects, store IRC channel routes canonically, and keep Slack downloaded files out of reply media. (#83833, #85309, #85777, #85794, #85906, #86318, #86697) Thanks @sliverp, @neeravmakwana, and @Kailigithub.</li>
|
||||
<li>Discord/voice: improve voice playback and wake replies, bucket large model picker menus, merge media captions into one message, route metadata through configured proxies, restore numeric channel sends, suppress self-reply echoes, and tighten wake matching without breaking fuzzy wake phrases. (#80227, #86238, #86487, #86571, #86595, #86601)</li>
|
||||
<li>Codex: preserve native web-search metadata, keep oversized native thread reuse, bridge CLI API-key auth into the app server, preserve sandbox bootstrap path style, recover context-window prompt errors, honor yolo approval policy, disable native thread personality, and route compaction through Codex auth. (#85378, #85542, #85891, #85909, #86408)</li>
|
||||
<li>Agents/runtime: enforce session lock max-hold reclaim, release embedded-attempt locks on all exits, treat aborted subagent runs as terminal, avoid runtime model hydration on hot paths, disclose scoped session list counts, derive overflow budgets from provider errors, and keep fallback errors scoped to the active model candidate. (#70473, #85764, #86014, #86134, #86427, #86944) Thanks @openperf, @fuller-stack-dev, @zhangguiping-xydt, and @ferminquant.</li>
|
||||
<li>Config/update/doctor: retry config recovery after failed backup restore, skip shell env fallback on Windows, exclude prerelease tags from the stable git channel, support deep config edits, warn instead of aborting on unreadable cron stores, prune stale bundled plugin paths, and avoid duplicate restart prompts when the Gateway is already healthy. (#85739, #85787, #86060, #86260, #86384, #86533) Thanks @liaoyl830.</li>
|
||||
<li>Install/release: support Alpine CLI installs and runtime floors, prefer trusted startup argv runtime fallback roots, reject stale CLI node runtimes, avoid npm <code>min-release-age</code> installer failures, bound npm/package/Docker install phases, restore config parent ownership in Docker, seed Docker lockfile package tarballs before prune, make release/plugin prerelease checks fail closed instead of hanging or false-greening, and use host-visible Crabbox local work roots for Docker-backed proof. (#85491)</li>
|
||||
<li>Windows daemon: keep Scheduled Task gateway launches running on battery power and avoid workgroup-machine prompts for a domain user during task installation. (#59299)</li>
|
||||
<li>Security: avoid printing Gateway tokens in Docker, validate plugin model-pattern regexes safely, escape transcript metadata field names, harden session allowlist glob matching, audit Claude permission overrides under YOLO, and require explicit allow for ACP auto approvals. (#85849, #85934, #86046, #86557)</li>
|
||||
<li>Media/images: replace Sharp with Rastermill, keep EXIF normalization best-effort, normalize HEIC/HEIF before image descriptions, route Codex image API keys through OpenAI, preserve image compression metadata, and auto-scale live tool result caps. (#85776, #86037, #86437, #86857, #86923)</li>
|
||||
<li>Memory: prevent semantic vector indexes from silently degrading when embeddings are unavailable, stop doctor OOMs on large session stores, preserve sidecar hooks/artifacts, write fallback dream diaries, use CJK-aware dreaming dedupe, and avoid per-file watcher FD fan-out. (#80613, #82928, #85060, #85704, #85967, #86701) Thanks @brokemac79, @openperf, and @yaaboo-gif.</li>
|
||||
<li>Agents/sessions: include visibility metadata on restricted <code>sessions_list</code> results so scoped counts are clearly reported without widening access or exposing hidden-session counts. (#86944) Thanks @ferminquant.</li>
|
||||
<li>Gateway/DNS: validate wide-area discovery domains before deriving zone paths or writing zone files, so invalid <code>discovery.wideArea.domain</code> and <code>dns setup --domain</code> values fail with a DNS-name diagnostic instead of falling through to unrelated configuration errors. Thanks @mmaps.</li>
|
||||
<li>Agents/BTW: route fallback side-question streams through the embedded stream resolver so Anthropic-compatible MiniMax requests use the same capped transport as normal chat. (#86312) Thanks @neeravmakwana.</li>
|
||||
<li>Telegram: treat <code>/command@TargetBot</code> bot-command entities as explicit mentions for the addressed bot so <code>requireMention</code> groups no longer drop targeted commands or captions. Fixes #84462. (#86553) Thanks @luoyanglang.</li>
|
||||
<li>CI: bound Docker/Bash E2E tarball npm installs with <code>OPENCLAW_E2E_NPM_INSTALL_TIMEOUT</code> so package, onboarding, plugin, and upgrade lanes fail instead of hanging on a stuck npm install.</li>
|
||||
<li>CI: fail Parallels npm-update smoke jobs after the guest command timeout and cleanup backstop instead of only logging a timeout line.</li>
|
||||
<li>CI: bound kitchen-sink RPC HTTP probes so stalled gateway readiness or response bodies fail and retry instead of wedging the walker.</li>
|
||||
<li>CI: keep <code>OPENCLAW_TESTBOX=1 pnpm check:changed</code> delegating to Blacksmith Testbox through Crabbox without forwarding local Testbox or worker env into the remote command.</li>
|
||||
<li>CI: send KILL after the TERM grace period for manual checkout fetch timeouts so stuck Testbox and workflow checkout retries cannot hang behind a wedged <code>git fetch</code>.</li>
|
||||
<li>CI: send KILL after the TERM grace period for Bun global install smoke command timeouts so trapped <code>openclaw</code> child processes cannot wedge the scheduled install smoke.</li>
|
||||
<li>iMessage: thread current channel/account inbound attachment roots into the image tool so iMessage-saved attachments under <code>~/Library/Messages/Attachments</code> (including the wildcard <code>/Users/*/Library/Messages/Attachments</code> root) are read through the existing inbound path policy instead of being rejected as <code>path-not-allowed</code>. Literal <code>localRoots</code> stays workspace-scoped. Fixes #30170. (#86569)</li>
|
||||
<li>QQ Bot: respect <code>OPENCLAW_HOME</code> for outbound media path resolution so <code><qqmedia></code> sends no longer silently fail when <code>HOME</code> and <code>OPENCLAW_HOME</code> differ (Docker / multi-user hosts). Persisted QQ Bot data (sessions, known users, refs) stays anchored on the OS home for upgrade compatibility. Fixes #83562. Thanks @sliverp.</li>
|
||||
<li>Update: report the primary malformed <code>openclaw.extensions</code> payload error without adding a duplicate missing-main diagnostic. (#86596) Thanks @ferminquant.</li>
|
||||
<li>Control UI: keep host-local Markdown file paths inert while preserving app-relative links. (#86620) Thanks @BryanTegomoh.</li>
|
||||
<li>Gateway: dampen repeated unauthenticated device-required probes per URL while preserving explicit-auth and paired recovery paths. (#86575) Thanks @ferminquant.</li>
|
||||
<li>IRC: store inbound channel routes with the canonical <code>channel:#name</code> target and join transient channel sends before writing. (#85906) Thanks @Kailigithub.</li>
|
||||
<li>Usage: surface unknown all-zero model pricing as missing cost entries instead of a confident <code>$0</code> total. (#85882) Thanks @MichaelZelbel.</li>
|
||||
<li>Agents/Codex: honor yolo app-server approval policy only for the full <code>never</code> plus <code>danger-full-access</code> case. (#85909) Thanks @earlvanze.</li>
|
||||
<li>Gateway/Gmail: clear Gmail watcher renewal intervals on re-entry so hot reloads do not leak lifecycle timers. (#82947) Thanks @SebTardif.</li>
|
||||
<li>Logging: exit cleanly on broken stdout/stderr pipes without masking existing failure exit codes. (#80059) Thanks @pavelzak.</li>
|
||||
<li>Gateway/security: escape transcript metadata field names while extracting oversized session line prefixes. (#85934) Thanks @SebTardif.</li>
|
||||
<li>Plugins/security: validate manifest model pattern regexes with the safe-regex compiler so unsafe patterns are ignored before matching. (#86046) Thanks @SebTardif.</li>
|
||||
<li>Discord: route gateway metadata REST lookups through the configured Discord proxy so proxied accounts do not fall back to direct <code>discord.com</code> connections before opening the WebSocket. Fixes #80227. Thanks @Clivilwalker.</li>
|
||||
<li>Agents/media: hydrate current-turn image attachments from filename-derived MIME types so active vision can see generated or forwarded images whose source omitted an image content type. (#84812) Thanks @marchpure.</li>
|
||||
<li>Agents/fs: point workspace-only scratch-path guidance at in-workspace temp directories while keeping host-root writes rejected by the tool guard. (#86501) Thanks @tianxiaochannel-oss88.</li>
|
||||
<li>Agents/media: keep async cron media completions scoped to their run session while preserving direct delivery for stale generated-media success and failure notifications. (#86529) Thanks @ai-hpc.</li>
|
||||
<li>Gateway: emit plugin <code>session_end</code>/<code>session_start</code> hooks when <code>agent.send</code> rotates or replaces a session id, keeping hook lifecycle state aligned with <code>sessions.changed</code> notifications. Fixes #83507. (#85875) Thanks @brokemac79.</li>
|
||||
<li>OpenShell/SSH: reject malformed generated exec commands before sandbox/session setup so unresolved workflow placeholders fail fast instead of reaching the remote shell. Fixes #72373. Thanks @brokemac79.</li>
|
||||
<li>Google: stop normalizing <code>gemini-3.1-flash-lite</code> to the retired preview endpoint and update Flash Lite alias guidance to the GA model id. Fixes #86151. (#86240) Thanks @SebTardif.</li>
|
||||
<li>Installer: make Alpine apk installs cover Git, verify the Node runtime floor, try <code>nodejs-current</code>, and report Alpine version guidance when repositories only provide older Node packages.</li>
|
||||
<li>Agents/status: prefer the active Claude CLI OAuth auth label over an unused Anthropic env API-key label for equivalent runtime aliases. Fixes #80184. (#86570) Thanks @brokemac79.</li>
|
||||
<li>Agents/media: send direct fallback for generated media still missing after an active requester wake fails. (#85489) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents: derive overflow compaction budgets from provider-reported and synthetic over-budget token counts so confirmed context overflows compact before retrying. (#70473) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents/Codex: recover Codex context-window prompt errors through overflow compaction and surface reset guidance when recovery is exhausted. (#85542) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents/Codex: allow Codex app-server runs to bootstrap from <code>CODEX_API_KEY</code> or <code>OPENAI_API_KEY</code> when no Codex auth profile is configured.</li>
|
||||
<li>Agents/Codex: keep selected Codex runtime routing on OpenAI-Codex while preserving direct OpenAI API-key compaction fallback. (#86408) Thanks @funmerlin and @VACInc.</li>
|
||||
<li>Agent transcript: include OpenClaw agent session logs when finding local transcript candidates.</li>
|
||||
<li>Crabbox: bootstrap raw AWS macOS shell commands wrapped in absolute <code>time</code> paths so RSS probes can run Node and pnpm on fresh macOS runners.</li>
|
||||
<li>Crabbox: bootstrap raw AWS macOS shell commands even when setup statements precede Node or pnpm usage.</li>
|
||||
<li>TUI/local: skip unnecessary secret resolution, gateway model catalog loading, bootstrap, and skill scans in explicit local-model runs so startup reaches the model request faster.</li>
|
||||
<li>Sessions/doctor: load large session stores without clone amplification during read-only doctor checks and reclaim stale <code>sessions.json.*.tmp</code> sidecars. Fixes #56827. Thanks @openperf.</li>
|
||||
<li>Tests: clean successful plugin gateway gauntlet isolated temp roots while keeping an explicit preservation switch for failed/debug runs.</li>
|
||||
<li>Plugins/perf: reuse derived plugin metadata snapshots for the lifetime of the process so reply-time skill setup no longer rescans plugin metadata on every turn.</li>
|
||||
<li>Discord/OpenAI voice: keep wake-name master consults using the current speaker context after ignored ambient transcripts and shorten the default capture silence grace.</li>
|
||||
<li>Doctor: skip redundant Gateway restart prompts when a recent supervisor restart leaves the Gateway healthy. Fixes #86518. (#86533) Thanks @liaoyl830.</li>
|
||||
<li>Cron: restore suspended cron lanes to the configured/default concurrency instead of falling back to one after quota or circuit-breaker auto-resume.</li>
|
||||
<li>Gateway: keep session-only Control UI tool-start mirrors flowing during diagnostic queue pressure instead of silently dropping non-terminal tool updates.</li>
|
||||
<li>Agents/memory: return optional not-found context for missing date-only daily memory reads instead of logging benign first-run <code>ENOENT</code> failures. Fixes #82928. Thanks @galiniliev.</li>
|
||||
<li>Discord: merge streamed text captions into following media block replies so captions and attachments send as one message. (#86487) Thanks @neeravmakwana.</li>
|
||||
<li>Gateway: avoid sending duplicate tool-event frames to Control UI connections that are subscribed by both run and session.</li>
|
||||
<li>Discord/OpenAI voice: accept broader edge-position fuzzy wake-name transcripts while keeping ambient speech gated.</li>
|
||||
<li>Discord/OpenAI voice: accept longer leading wake-name mistranscripts such as "Open Club" for OpenClaw.</li>
|
||||
<li>Agents/OpenAI-compatible: stop ModelStudio-compatible chat requests before sending system/tool-only payloads that have no usable user or assistant turn. (#86177) Thanks @TurboTheTurtle.</li>
|
||||
<li>Gateway/plugins: reuse plugin package realpath checks while building installed plugin indexes so startup avoids repeated filesystem resolution work.</li>
|
||||
<li>Kilo Gateway: send string <code>stop</code> sequences as arrays so Kilo accepts OpenAI-compatible chat completions. (#86461) Thanks @SebTardif.</li>
|
||||
<li>Discord/OpenAI voice: accept leading fuzzy wake-name transcripts such as "Monty" or "Moti" for a Molty agent while keeping ambient speech gated.</li>
|
||||
<li>Media understanding: convert HEIC and HEIF images to JPEG before image description providers run so iPhone photos work in direct and configured image-description flows. (#86037)</li>
|
||||
<li>Agents: release embedded-attempt session locks from outer teardown so post-prompt exceptions cannot wedge later requests behind <code>SessionWriteLockTimeoutError</code>. Fixes #86014. Thanks @openperf.</li>
|
||||
<li>Discord/OpenAI voice: rotate Realtime sessions at provider max duration without logging the expected session-expiry event as an error.</li>
|
||||
<li>Sessions: skip metadata-only entries during QMD-slugified session lookup so one incomplete row does not block transcript hit resolution. (#86327) Thanks @abnershang.</li>
|
||||
<li>Agents/media: derive bundled plugin local-media trust from plugin tool metadata instead of importing the full plugin registry on subscription paths. (#84409) Thanks @samzong.</li>
|
||||
<li>Image tool: keep config-backed custom-provider API keys usable for auto-discovered vision models, including deferred image-tool execution without env keys or auth profiles. (#85733)</li>
|
||||
<li>Memory/local embeddings: run local GGUF embeddings in an isolated worker sidecar and degrade to configured fallback or keyword search on worker failure so native embedding crashes do not take down the Gateway. (#85348) Thanks @osolmaz.</li>
|
||||
<li>Gateway: clear the runtime config snapshot before <code>SIGUSR1</code> in-process restarts so config changes survive the next gateway loop. (#86388) Thanks @XuZehan-iCenter.</li>
|
||||
<li>Models: show OAuth delegation markers as configured <code>models.json</code> auth while keeping runtime route usability checks strict. (#86378) Thanks @rohitjavvadi.</li>
|
||||
<li>Cron: seed active scheduled and manual cron task rows with a progress summary so status surfaces do not look blank while jobs run. (#86313) Thanks @ferminquant.</li>
|
||||
<li>Cron: preserve unsupported persisted cron payload rows during routine store writes while keeping those rows non-runnable. Fixes #84922. (#86415) Thanks @IWhatsskill.</li>
|
||||
<li>Updater: exclude prerelease git tags from stable channel resolution so source updates do not check out newer alpha/rc/preview/canary tags. (#86260) Thanks @stevenepalmer.</li>
|
||||
<li>Security/Audit: flag webhook <code>hooks.token</code> reuse of active Gateway password auth in <code>openclaw security audit</code> while keeping password-mode startup compatibility. (#84338) Thanks @coygeek.</li>
|
||||
<li>QQBot: derive the outbound reply watchdog from configured agent and provider timeouts so slow local model replies are not cut off at five minutes. Fixes #85267. (#85271) Thanks @SymbolStar.</li>
|
||||
<li>Agents/heartbeat: stop heartbeat turns after the first valid <code>heartbeat_respond</code> so repeated response loops do not burn tokens. (#86357) Thanks @udaymanish6.</li>
|
||||
<li>Tasks: keep retained lost tasks out of default status health counts, explain their cleanup window during maintenance, and prune lost task records after 24 hours instead of the general 7-day terminal retention.</li>
|
||||
<li>Memory-core: keep REM dreaming focused on live light-staged memories and mark staged entries as considered so old recall history no longer dominates fresh candidates. (#86302) Thanks @SebTardif.</li>
|
||||
<li>Memory: abort sync instead of downgrading an existing semantic vector index to FTS-only when the configured embedding provider is temporarily unavailable. (#85704) Thanks @yaaboo-gif.</li>
|
||||
<li>Telegram: propagate forum topic names through the account-scoped topic cache for native command context and topic create/edit actions. (#86299) Thanks @SebTardif.</li>
|
||||
<li>Slack: keep downloaded read-only files out of reply media so Slack file reads do not echo files back to the conversation. (#86318) Thanks @neeravmakwana.</li>
|
||||
<li>Cron: accept leading-plus relative durations such as <code>+5m</code> for one-shot <code>--at</code> schedules. (#86341) Thanks @mushuiyu886.</li>
|
||||
<li>Agents/media: preserve async-started media tool metadata so background generation starts no longer surface generic incomplete-turn warnings while replay stays unsafe. (#85933) Thanks @fuller-stack-dev.</li>
|
||||
<li>Docker E2E: dedupe scheduler lane resources so npm/service package lanes are not over-counted and serialized unnecessarily.</li>
|
||||
<li>QA/diagnostics: add a collector-backed OpenTelemetry smoke lane, make the OTLP payload leak check scenario-aware, and keep source QA builds from failing on optional dependency imports resolved through pnpm's temp module path.</li>
|
||||
<li>Crabbox: bootstrap Git metadata for sparse remote changed gates so raw synced workspaces can run <code>pnpm check:changed</code> from the intended diff.</li>
|
||||
<li>xAI/LM Studio: avoid buffering ordinary bracketed or <code>final</code> prose until stream completion while watching for plain-text tool-call fallbacks.</li>
|
||||
<li>Doctor: warn and continue when the cron job store exists but cannot be read so later health checks still run. Fixes #86102. (#86384) Thanks @1052326311.</li>
|
||||
<li>Discord: suppress a bot's previous reply body and referenced media from prompt context when a user replies to that bot message, while keeping reply metadata for routing. (#86238) Thanks @fuller-stack-dev.</li>
|
||||
<li>Discord: restore bare numeric channel IDs for outbound message-tool sends while keeping explicit DM targets unambiguous. (#86571) Thanks @joshavant.</li>
|
||||
<li>Docker E2E: avoid rebuilding the Control UI twice while preparing the shared OpenClaw package tarball for package-backed scenario runs.</li>
|
||||
<li>Tests: avoid rebuilding the Control UI twice during the installer Docker smoke now that <code>pnpm build</code> includes <code>ui:build</code>.</li>
|
||||
<li>Tests: give QA config mutation RPCs enough native Windows budget to finish gateway config writes and restart settle after hot scenario runs.</li>
|
||||
<li>Tests: keep the gateway restart-inflight QA scenario focused on restart recovery on native Windows by allowing expected embedded prompt handoff errors and using the Windows-safe timeout budget.</li>
|
||||
<li>QA-Lab: make the synthetic OpenAI provider honor generic <code>reply exactly:</code> directives after required kickoff reads so restart-recovery scenarios do not fall through to generic repo-summary prose.</li>
|
||||
<li>Gateway: abort active <code>agent</code> RPC runs during forced restart shutdown so stale in-process turns cannot keep writing a session after the Gateway lifecycle restarts.</li>
|
||||
<li>Crabbox: sync clean sparse worktrees through a temporary full checkout even when reusing an existing lease so tracked build-time files are not omitted.</li>
|
||||
<li>Build: route <code>scripts/ui.js</code> through the shared pnpm runner and keep Control UI chunking helpers in sparse-included source so native Windows Corepack builds can produce <code>dist/control-ui</code>.</li>
|
||||
<li>Tests: give the memory fallback QA scenario enough turn budget to exercise native Windows gateway runs instead of failing on the client timeout while the mock agent is still dispatching.</li>
|
||||
<li>Tests: collect QA gateway CPU/RSS metrics on native Windows and give the channel baseline enough turn budget to report slow gateway runs instead of timing out before proof.</li>
|
||||
<li>Install/update: bypass npm <code>min-release-age</code> policies with <code>--min-release-age=0</code> instead of <code>--before</code> so hosted installers keep working on npm versions that reject the combined config. (#84749) Thanks @TeodoroRodrigo.</li>
|
||||
<li>Diagnostics: reclaim wedged session lanes when stale active-run bookkeeping blocks queued work despite no forward progress. Fixes #85639. Thanks @openperf.</li>
|
||||
<li>WebChat: keep message-tool replies visible in the chat while still summarizing internal tool results for the model. Fixes #86347. Thanks @shakkernerd.</li>
|
||||
<li>Gateway/perf: fail startup benchmark samples when the Gateway process exits before benchmark teardown, including signal deaths after readiness probes.</li>
|
||||
<li>Gateway/perf: fail restart benchmark samples when the Gateway exits before benchmark teardown, including clean exits and signal deaths after successful restart probes.</li>
|
||||
<li>Agents/tests: keep model catalog visibility on static selection helpers so catalog visibility checks avoid the broad model-selection barrel import.</li>
|
||||
<li>Agents/commitments: serialize commitment store load-modify-save writes so concurrent heartbeat and CLI updates no longer lose dismissal, sent, or attempt state. (#81153) Thanks @ai-hpc.</li>
|
||||
<li>xAI/LM Studio: promote plain-text tool-call fallbacks into structured tool calls and strip leaked internal tool syntax before user-facing delivery. (#86222) Thanks @fuller-stack-dev.</li>
|
||||
<li>CLI: suppress benign self-update version-skew warnings during package post-update finalization.</li>
|
||||
<li>Gateway/perf: tighten restart and startup benchmark failure handling so long profiling runs, failed probes, and fresh Linux runners no longer produce false passing or <code>n/a</code> results.</li>
|
||||
<li>Checks: keep intentional Knip unused-file findings optional so full CI and sparse proof workspaces stay aligned.</li>
|
||||
<li>Docker: restore writable <code>~/.config</code> in runtime images. Fixes #85968. Thanks @hkoessler and @Bartok9.</li>
|
||||
<li>Plugin SDK: keep legacy root diagnostic subscriptions connected when built plugin SDK aliases resolve diagnostic helpers through a separate module graph.</li>
|
||||
<li>Diagnostics: export alertable OTel and Prometheus signals for blocked tools, model failover, stale sessions, liveness warnings, oversized payloads, and webhook ingress while fixing shared OTLP endpoints with query strings.</li>
|
||||
<li>Tests: normalize macOS canonical temp paths in exec allowlists, fs-safe trash assertions, installed plugin matching, Telegram topic-name stores, and built ACPX MCP server expectations so native macOS proof runners cover the intended behavior.</li>
|
||||
<li>Codex/app-server: preserve message-tool-only source reply delivery mode on active runs so sub-agent completion wakeups can steer the active Codex turn instead of being rejected. (#86287) Thanks @ferminquant.</li>
|
||||
<li>Tests: sample the Windows kitchen-sink RPC gateway directly and serialize RSS probes so native runs keep the memory guard active.</li>
|
||||
<li>Tests: normalize bundled plugin lifecycle probe paths and state-root lookup so native Windows release sweeps accept valid packaged plugin installs.</li>
|
||||
<li>Agents/Claude CLI: route live native Bash permission requests through OpenClaw exec policy so Claude turns no longer stall on <code>control_request</code>, and document that OpenClaw exec policy is authoritative. Fixes #80819. (#86330, from #81971) Thanks @guthirry and @sallyom.</li>
|
||||
<li>Security audit: warn when YOLO OpenClaw exec policy overrides a restrictive raw Claude <code>--permission-mode</code> for managed live sessions. (#86557) Thanks @sallyom.</li>
|
||||
<li>Config: keep benign legacy metadata write anomalies out of default doctor and config command output while preserving explicit anomaly logging for diagnostics.</li>
|
||||
<li>Codex: log when implicit app-server <code>never</code> approvals are promoted for OpenClaw tool policy, including whether the trigger was a <code>before_tool_call</code> hook or trusted tool policy.</li>
|
||||
<li>Codex harness: make subscription usage-limit errors without reset times explain that OpenClaw cannot determine the reset and point users to wait until Codex is available, use another Codex account, or switch to another configured model/provider. Thanks @amknight.</li>
|
||||
<li>Google Vertex: support production ADC modes such as Workload Identity Federation, service-account credentials, and metadata-server ADC for the native Vertex transport. (#83971) Thanks @damianFelixPago.</li>
|
||||
<li>Telegram: route normal <code>[telegram][diag]</code> polling diagnostics through <code>runtime.log</code> while keeping non-diag warnings and persistence failures on <code>runtime.error</code>, so healthy polling startup no longer looks like an error. Fixes #82957. (#82958) Thanks @galiniliev.</li>
|
||||
<li>Providers/Ollama: strip inline Kimi cloud reasoning prefixes from streamed and final visible replies while keeping ordinary Kimi answers append-only. (#86286) Thanks @jason-allen-oneal.</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>Gateway: require Talk secret authority before setup-code handoff can include Talk secrets. (#85690) Thanks @ngutman.</li>
|
||||
<li>Agents: keep fallback error reporting scoped to the active model candidate so stale prior-provider quota/auth text is not reported for later fallback attempts. (#86134) Thanks @zhangguiping-xydt.</li>
|
||||
<li>iMessage: dedupe watcher startup when <code>channels.imessage.accounts</code> lists both <code>default</code> and a named account that point at the same local Messages source, so the gateway no longer spawns two <code>imsg rpc</code> processes or doubles inbound replies; the dedupe is scoped to watcher startup, leaving duplicate accounts addressable for outbound sends, status, and capability listings, and <code>openclaw doctor</code> flags the redundant account with a rebinding hint. Fixes #65141. (#86705) Thanks @swang430.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.26/OpenClaw-2026.5.26.zip" length="54484748" type="application/octet-stream" sparkle:edSignature="y4WXG7JT8ktJ+K7YDgllY7u5Z9BSKR/SwGiwEh0gikOJ/SWqwcQd+z2tWa2zgwvCJKWsAUFwJs1ATor880SUBg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -218,7 +218,6 @@ Current OpenClaw Android implication:
|
||||
- Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception.
|
||||
- The repo now ships this split as Android product flavors:
|
||||
- `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, and `READ_EXTERNAL_STORAGE`; hides SMS, Call Log, and Photos surfaces in onboarding, settings, and advertised node capabilities.
|
||||
- Installed-app listing is user controlled. `device.apps` is advertised only after the user enables **Settings > Phone Capabilities > Installed Apps**. The command defaults to launcher-visible apps and does not require `QUERY_ALL_PACKAGES`.
|
||||
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality.
|
||||
|
||||
Policy links:
|
||||
@@ -253,9 +252,9 @@ Pre-req checklist:
|
||||
4) Open the app **Screen** tab and keep it active during the run (canvas/A2UI commands require the canvas WebView attached there).
|
||||
5) Grant runtime permissions for capabilities you expect to pass (camera/mic/location/notification listener/location, etc.).
|
||||
6) No interactive system dialogs should be pending before test start.
|
||||
7) Canvas host is enabled and reachable from the device for remote Canvas checks (do not run gateway with `OPENCLAW_SKIP_CANVAS_HOST=1`; startup logs should include `canvas host mounted at .../__openclaw__/`).
|
||||
7) Canvas host is enabled and reachable from the device (do not run gateway with `OPENCLAW_SKIP_CANVAS_HOST=1`; startup logs should include `canvas host mounted at .../__openclaw__/`).
|
||||
8) Local operator test client pairing is approved. If first run fails with `pairing required`, preview the latest pending request, approve the printed request ID, then rerun:
|
||||
9) For A2UI checks, keep the app on **Screen** tab; the node uses its bundled app-owned A2UI page for message application.
|
||||
9) For A2UI checks, keep the app on **Screen** tab; the node now auto-refreshes canvas capability once on first A2UI reachability failure (TTL-safe retry).
|
||||
|
||||
```bash
|
||||
openclaw devices list
|
||||
@@ -287,8 +286,8 @@ Common failure quick-fixes:
|
||||
|
||||
- `pairing required` before tests start:
|
||||
- list pending requests (`openclaw devices list`), then approve with the exact ID (`openclaw devices approve <requestId>`) and rerun.
|
||||
- `A2UI host not reachable` / `A2UI_HOST_UNAVAILABLE`:
|
||||
- keep the app foregrounded on the **Screen** tab and rerun. A2UI commands use the bundled app-owned A2UI page; the Gateway Canvas host is still needed for remote Canvas checks, but not for A2UI message application.
|
||||
- `A2UI host not reachable` / `A2UI_HOST_NOT_CONFIGURED`:
|
||||
- ensure the Canvas plugin host is running and reachable, keep the app on the **Screen** tab. The app refreshes the Canvas plugin surface URL once before failing; if it still fails, reconnect app and rerun.
|
||||
- `NODE_BACKGROUND_UNAVAILABLE: canvas unavailable`:
|
||||
- app is not effectively ready for canvas commands; keep app foregrounded and **Screen** tab active.
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026060201
|
||||
versionName = "2026.6.2"
|
||||
versionCode = 2026053101
|
||||
versionName = "2026.5.31"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -2,35 +2,18 @@ package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.ui.OpenClawTheme
|
||||
import ai.openclaw.app.ui.RootScreen
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Main Android activity that owns Compose UI attachment and runtime UI wiring.
|
||||
@@ -38,81 +21,18 @@ import kotlinx.coroutines.withContext
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val viewModel: MainViewModel by viewModels()
|
||||
private lateinit var permissionRequester: PermissionRequester
|
||||
private var initializedViewModel: MainViewModel? = null
|
||||
private var didAttachRuntimeUi = false
|
||||
private var didStartNodeService = false
|
||||
private var didStartViewModelCollectors = false
|
||||
private var foreground = false
|
||||
private var pendingIntent: Intent? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
pendingIntent = intent
|
||||
handleAssistantIntent(intent)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
permissionRequester = PermissionRequester(this)
|
||||
|
||||
setContent {
|
||||
var activeViewModel by remember { mutableStateOf<MainViewModel?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withFrameNanos { }
|
||||
withContext(Dispatchers.Default) {
|
||||
(application as NodeApp).prefs
|
||||
}
|
||||
val readyViewModel = viewModel
|
||||
activateViewModel(readyViewModel)
|
||||
activeViewModel = readyViewModel
|
||||
}
|
||||
|
||||
OpenClawTheme {
|
||||
activeViewModel?.let { RootScreen(viewModel = it) } ?: StartupSurface()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
foreground = true
|
||||
initializedViewModel?.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
foreground = false
|
||||
initializedViewModel?.setForeground(false)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
pendingIntent = intent
|
||||
initializedViewModel?.let { handleAssistantIntent(viewModel = it, intent = intent) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires MainViewModel only after Activity first draw and background prefs warm-up.
|
||||
*/
|
||||
private fun activateViewModel(readyViewModel: MainViewModel) {
|
||||
if (initializedViewModel != null) return
|
||||
initializedViewModel = readyViewModel
|
||||
readyViewModel.setForeground(foreground)
|
||||
startViewModelCollectors(readyViewModel)
|
||||
pendingIntent?.let { initialIntent ->
|
||||
handleAssistantIntent(viewModel = readyViewModel, intent = initialIntent)
|
||||
pendingIntent = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts lifecycle collectors after ViewModel construction so they cannot force early startup.
|
||||
*/
|
||||
private fun startViewModelCollectors(readyViewModel: MainViewModel) {
|
||||
if (didStartViewModelCollectors) return
|
||||
didStartViewModelCollectors = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
readyViewModel.preventSleep.collect { enabled ->
|
||||
viewModel.preventSleep.collect { enabled ->
|
||||
if (enabled) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
@@ -124,10 +44,10 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
readyViewModel.runtimeInitialized.collect { ready ->
|
||||
viewModel.runtimeInitialized.collect { ready ->
|
||||
if (!ready || didAttachRuntimeUi) return@collect
|
||||
// Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready.
|
||||
readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
|
||||
viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
|
||||
didAttachRuntimeUi = true
|
||||
if (!didStartNodeService) {
|
||||
NodeForegroundService.start(this@MainActivity)
|
||||
@@ -136,15 +56,36 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
OpenClawTheme {
|
||||
Surface(modifier = Modifier) {
|
||||
RootScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
viewModel.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
viewModel.setForeground(false)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleAssistantIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes assistant/app-action intents into ViewModel state without recreating the activity.
|
||||
*/
|
||||
private fun handleAssistantIntent(
|
||||
viewModel: MainViewModel,
|
||||
intent: Intent?,
|
||||
) {
|
||||
private fun handleAssistantIntent(intent: android.content.Intent?) {
|
||||
parseHomeDestinationIntent(intent)?.let { destination ->
|
||||
viewModel.requestHomeDestination(destination)
|
||||
return
|
||||
@@ -153,23 +94,3 @@ class MainActivity : ComponentActivity() {
|
||||
viewModel.handleAssistantLaunch(request)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StartupSurface() {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = Color.Black,
|
||||
contentColor = Color.White,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "OPENCLAW",
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.gateway.DeviceAuthStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
|
||||
import ai.openclaw.app.node.CameraCaptureManager
|
||||
@@ -16,7 +14,6 @@ import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -24,7 +21,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows.
|
||||
@@ -36,11 +32,7 @@ class MainViewModel(
|
||||
private val nodeApp = app as NodeApp
|
||||
private val prefs = nodeApp.prefs
|
||||
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
|
||||
|
||||
@Volatile private var foreground = false
|
||||
|
||||
@Volatile private var runtimeStartupQueued = false
|
||||
|
||||
private var foreground = true
|
||||
private val _requestedHomeDestination = MutableStateFlow<HomeDestination?>(null)
|
||||
val requestedHomeDestination: StateFlow<HomeDestination?> = _requestedHomeDestination
|
||||
private val _startOnboardingAtGatewaySetup = MutableStateFlow(false)
|
||||
@@ -61,19 +53,6 @@ class MainViewModel(
|
||||
return runtime
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the node runtime off the main thread so fresh installs can render
|
||||
* the shell before encrypted prefs, device identity, and gateway setup warm up.
|
||||
*/
|
||||
private fun queueRuntimeStartup() {
|
||||
if (runtimeRef.value != null || runtimeStartupQueued) return
|
||||
runtimeStartupQueued = true
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
runCatching { ensureRuntime() }
|
||||
runtimeStartupQueued = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a runtime StateFlow to a stable ViewModel StateFlow before runtime startup.
|
||||
*/
|
||||
@@ -112,7 +91,6 @@ class MainViewModel(
|
||||
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
|
||||
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
|
||||
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
|
||||
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
|
||||
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
|
||||
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
|
||||
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
|
||||
@@ -170,7 +148,6 @@ class MainViewModel(
|
||||
val gatewayBootstrapToken: StateFlow<String> = prefs.gatewayBootstrapToken
|
||||
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
|
||||
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
|
||||
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
|
||||
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
|
||||
@@ -202,6 +179,12 @@ class MainViewModel(
|
||||
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
|
||||
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
|
||||
|
||||
init {
|
||||
if (prefs.onboardingCompleted.value) {
|
||||
ensureRuntime()
|
||||
}
|
||||
}
|
||||
|
||||
val canvas: CanvasController
|
||||
get() = ensureRuntime().canvas
|
||||
|
||||
@@ -229,10 +212,13 @@ class MainViewModel(
|
||||
*/
|
||||
fun setForeground(value: Boolean) {
|
||||
foreground = value
|
||||
if (value && prefs.onboardingCompleted.value) {
|
||||
queueRuntimeStartup()
|
||||
}
|
||||
runtimeRef.value?.setForeground(value)
|
||||
val runtime =
|
||||
if (value && prefs.onboardingCompleted.value) {
|
||||
ensureRuntime()
|
||||
} else {
|
||||
runtimeRef.value
|
||||
}
|
||||
runtime?.setForeground(value)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
@@ -283,51 +269,9 @@ class MainViewModel(
|
||||
prefs.setGatewayPassword(value)
|
||||
}
|
||||
|
||||
/** Clears setup credentials without starting the runtime just to discard first-run pairing auth. */
|
||||
private fun resetGatewaySetupAuth() {
|
||||
runtimeRef.value?.resetGatewaySetupAuth() ?: resetGatewaySetupAuthWithoutRuntime()
|
||||
}
|
||||
|
||||
private fun resetGatewaySetupAuthWithoutRuntime() {
|
||||
prefs.clearGatewaySetupAuth()
|
||||
val deviceId = DeviceIdentityStore(nodeApp).loadOrCreate().deviceId
|
||||
val deviceAuthStore = DeviceAuthStore(prefs)
|
||||
deviceAuthStore.clearToken(deviceId, "node")
|
||||
deviceAuthStore.clearToken(deviceId, "operator")
|
||||
}
|
||||
|
||||
fun saveGatewayConfigAndConnect(
|
||||
host: String,
|
||||
port: Int,
|
||||
tls: Boolean,
|
||||
token: String,
|
||||
bootstrapToken: String,
|
||||
password: String,
|
||||
resetSetupAuth: Boolean,
|
||||
) {
|
||||
// Gateway pairing touches encrypted prefs, identity files, and sockets; keep
|
||||
// the whole sequence off the Compose thread so retries cannot trigger ANRs.
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
if (resetSetupAuth) {
|
||||
resetGatewaySetupAuth()
|
||||
}
|
||||
prefs.setManualEnabled(true)
|
||||
prefs.setManualHost(host)
|
||||
prefs.setManualPort(port)
|
||||
prefs.setManualTls(tls)
|
||||
prefs.setGatewayBootstrapToken(bootstrapToken)
|
||||
prefs.setGatewayToken(token)
|
||||
prefs.setGatewayPassword(password)
|
||||
ensureRuntime()
|
||||
.connect(
|
||||
GatewayEndpoint.manual(host = host, port = port),
|
||||
NodeRuntime.GatewayConnectAuth(
|
||||
token = token.ifEmpty { null },
|
||||
bootstrapToken = bootstrapToken.ifEmpty { null },
|
||||
password = password.ifEmpty { null },
|
||||
),
|
||||
)
|
||||
}
|
||||
/** Clears setup credentials through the runtime so active gateway sessions drop stale auth state. */
|
||||
fun resetGatewaySetupAuth() {
|
||||
ensureRuntime().resetGatewaySetupAuth()
|
||||
}
|
||||
|
||||
/** Marks onboarding complete and starts the runtime before UI observes connected-state flows. */
|
||||
@@ -340,12 +284,10 @@ class MainViewModel(
|
||||
|
||||
/** Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */
|
||||
fun pairNewGateway() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
runtimeRef.value?.disconnect()
|
||||
resetGatewaySetupAuth()
|
||||
prefs.setOnboardingCompleted(false)
|
||||
_startOnboardingAtGatewaySetup.value = true
|
||||
}
|
||||
runtimeRef.value?.disconnect()
|
||||
resetGatewaySetupAuth()
|
||||
_startOnboardingAtGatewaySetup.value = true
|
||||
prefs.setOnboardingCompleted(false)
|
||||
}
|
||||
|
||||
/** Acknowledges the one-shot request that opens onboarding at the gateway setup step. */
|
||||
@@ -357,10 +299,6 @@ class MainViewModel(
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setInstalledAppsSharingEnabled(value: Boolean) {
|
||||
ensureRuntime().setInstalledAppsSharingEnabled(value)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingEnabled(value: Boolean) {
|
||||
ensureRuntime().setNotificationForwardingEnabled(value)
|
||||
}
|
||||
@@ -441,25 +379,13 @@ class MainViewModel(
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
ensureRuntime().refreshGatewayConnection()
|
||||
}
|
||||
}
|
||||
|
||||
fun startGatewayDiscovery() {
|
||||
queueRuntimeStartup()
|
||||
ensureRuntime().refreshGatewayConnection()
|
||||
}
|
||||
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
ensureRuntime().connect(endpoint)
|
||||
}
|
||||
|
||||
fun connectInBackground(endpoint: GatewayEndpoint) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
ensureRuntime().connect(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(
|
||||
endpoint: GatewayEndpoint,
|
||||
token: String?,
|
||||
|
||||
@@ -78,25 +78,6 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
/**
|
||||
* Process runtime that owns gateway sessions, node command handlers, capture managers, and UI-facing state.
|
||||
*/
|
||||
data class GatewayConnectionProblem(
|
||||
val code: String?,
|
||||
val message: String,
|
||||
val reason: String?,
|
||||
val requestId: String?,
|
||||
val recommendedNextStep: String?,
|
||||
val pauseReconnect: Boolean,
|
||||
val retryable: Boolean,
|
||||
) {
|
||||
val isPairingRequired: Boolean = code == "PAIRING_REQUIRED"
|
||||
val canAutoRetry: Boolean =
|
||||
isPairingRequired &&
|
||||
(
|
||||
retryable ||
|
||||
!pauseReconnect ||
|
||||
recommendedNextStep == "wait_then_retry"
|
||||
)
|
||||
}
|
||||
|
||||
class NodeRuntime(
|
||||
context: Context,
|
||||
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
|
||||
@@ -208,6 +189,8 @@ class NodeRuntime(
|
||||
A2UIHandler(
|
||||
canvas = canvas,
|
||||
json = json,
|
||||
getNodeCanvasHostUrl = { nodeSession.currentCanvasHostUrl() },
|
||||
getOperatorCanvasHostUrl = { operatorSession.currentCanvasHostUrl() },
|
||||
)
|
||||
|
||||
private val connectionManager: ConnectionManager =
|
||||
@@ -224,7 +207,6 @@ class NodeRuntime(
|
||||
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
|
||||
photosAvailable = { SensitiveFeatureConfig.photosEnabled },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
installedAppsSharingEnabled = { installedAppsSharingEnabled.value },
|
||||
manualTls = { manualTls.value },
|
||||
)
|
||||
|
||||
@@ -263,7 +245,6 @@ class NodeRuntime(
|
||||
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
|
||||
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
|
||||
photosAvailable = { SensitiveFeatureConfig.photosEnabled },
|
||||
installedAppsSharingEnabled = { installedAppsSharingEnabled.value },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
onCanvasA2uiPush = {
|
||||
_canvasA2uiHydrated.value = true
|
||||
@@ -271,6 +252,7 @@ class NodeRuntime(
|
||||
_canvasRehydrateErrorText.value = null
|
||||
},
|
||||
onCanvasA2uiReset = { _canvasA2uiHydrated.value = false },
|
||||
refreshCanvasHostUrl = { nodeSession.refreshCanvasHostUrl() },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
)
|
||||
@@ -304,8 +286,6 @@ class NodeRuntime(
|
||||
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
private val _gatewayConnectionProblem = MutableStateFlow<GatewayConnectionProblem?>(null)
|
||||
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = _gatewayConnectionProblem.asStateFlow()
|
||||
|
||||
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
|
||||
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
||||
@@ -431,7 +411,6 @@ class NodeRuntime(
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { hello ->
|
||||
_gatewayConnectionProblem.value = null
|
||||
operatorConnected = true
|
||||
operatorStatusText = "Connected"
|
||||
_serverName.value = hello.serverName
|
||||
@@ -479,7 +458,6 @@ class NodeRuntime(
|
||||
updateStatus()
|
||||
micCapture.onGatewayConnectionChanged(false)
|
||||
},
|
||||
onConnectFailure = ::handleGatewayConnectFailure,
|
||||
onEvent = { event, payloadJson ->
|
||||
handleGatewayEvent(event, payloadJson)
|
||||
},
|
||||
@@ -491,7 +469,6 @@ class NodeRuntime(
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = {
|
||||
_gatewayConnectionProblem.value = null
|
||||
_nodeConnected.value = true
|
||||
nodeStatusText = "Connected"
|
||||
didAutoRequestCanvasRehydrate = false
|
||||
@@ -517,7 +494,6 @@ class NodeRuntime(
|
||||
updateStatus()
|
||||
showLocalCanvasOnDisconnect()
|
||||
},
|
||||
onConnectFailure = ::handleGatewayConnectFailure,
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
invokeDispatcher.handleInvoke(req.command, req.paramsJson)
|
||||
@@ -712,23 +688,6 @@ class NodeRuntime(
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
private fun handleGatewayConnectFailure(
|
||||
error: GatewaySession.ErrorShape,
|
||||
pauseReconnect: Boolean,
|
||||
) {
|
||||
val details = error.details
|
||||
_gatewayConnectionProblem.value =
|
||||
GatewayConnectionProblem(
|
||||
code = details?.code ?: error.code,
|
||||
message = error.message,
|
||||
reason = details?.reason,
|
||||
requestId = details?.requestId,
|
||||
recommendedNextStep = details?.recommendedNextStep,
|
||||
pauseReconnect = pauseReconnect || details?.pauseReconnect == true,
|
||||
retryable = details?.retryable == true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveMainSessionKey(): String {
|
||||
val trimmed = _mainSessionKey.value.trim()
|
||||
return if (trimmed.isEmpty()) "main" else trimmed
|
||||
@@ -907,7 +866,6 @@ class NodeRuntime(
|
||||
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
|
||||
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
|
||||
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
|
||||
prefs.notificationForwardingMode
|
||||
@@ -1119,12 +1077,6 @@ class NodeRuntime(
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setInstalledAppsSharingEnabled(value: Boolean) {
|
||||
if (prefs.installedAppsSharingEnabled.value == value) return
|
||||
prefs.setInstalledAppsSharingEnabled(value)
|
||||
refreshNodeSurfaceAfterSharingChange()
|
||||
}
|
||||
|
||||
fun setNotificationForwardingEnabled(value: Boolean) {
|
||||
prefs.setNotificationForwardingEnabled(value)
|
||||
}
|
||||
@@ -1452,24 +1404,16 @@ class NodeRuntime(
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
val endpoint = connectedEndpoint
|
||||
if (endpoint == null) {
|
||||
resolvePreferredGatewayEndpoint()?.let(::connect)
|
||||
?: run {
|
||||
_statusText.value = "Failed: no saved gateway endpoint"
|
||||
}
|
||||
return
|
||||
}
|
||||
val endpoint =
|
||||
connectedEndpoint ?: run {
|
||||
_statusText.value = "Failed: no cached gateway endpoint"
|
||||
return
|
||||
}
|
||||
operatorStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
|
||||
}
|
||||
|
||||
private fun refreshNodeSurfaceAfterSharingChange() {
|
||||
val endpoint = connectedEndpoint ?: return
|
||||
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
|
||||
}
|
||||
|
||||
private fun connectWithAuth(
|
||||
endpoint: GatewayEndpoint,
|
||||
auth: GatewayConnectAuth,
|
||||
@@ -1569,7 +1513,6 @@ class NodeRuntime(
|
||||
connectAttemptId: Long,
|
||||
) {
|
||||
if (!isCurrentConnectAttempt(connectAttemptId)) return
|
||||
_gatewayConnectionProblem.value = null
|
||||
connectedEndpoint = endpoint
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
@@ -1666,7 +1609,6 @@ class NodeRuntime(
|
||||
stopActiveVoiceSession()
|
||||
connectedEndpoint = null
|
||||
activeGatewayAuth = null
|
||||
_gatewayConnectionProblem.value = null
|
||||
_pendingGatewayTrust.value = null
|
||||
operatorSession.disconnect()
|
||||
nodeSession.disconnect()
|
||||
@@ -1905,7 +1847,7 @@ class NodeRuntime(
|
||||
return
|
||||
}
|
||||
try {
|
||||
val modelsRes = operatorSession.request("models.list", "{}")
|
||||
val modelsRes = operatorSession.request("models.list", """{"view":"all"}""")
|
||||
val modelsRoot = json.parseToJsonElement(modelsRes).asObjectOrNull()
|
||||
_modelCatalog.value = parseGatewayModels(modelsRoot?.get("models") as? JsonArray)
|
||||
|
||||
@@ -2132,7 +2074,6 @@ class NodeRuntime(
|
||||
id = id,
|
||||
name = obj["name"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: id,
|
||||
provider = provider,
|
||||
available = obj.optionalBoolean("available"),
|
||||
supportsVision = "image" in inputTypes,
|
||||
supportsAudio = "audio" in inputTypes,
|
||||
supportsDocuments = "document" in inputTypes,
|
||||
@@ -2749,7 +2690,6 @@ data class GatewayModelSummary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val provider: String,
|
||||
val available: Boolean?,
|
||||
val supportsVision: Boolean,
|
||||
val supportsAudio: Boolean,
|
||||
val supportsDocuments: Boolean,
|
||||
@@ -2932,15 +2872,6 @@ private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonP
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean = (this?.get(key) as? JsonPrimitive)?.content?.trim() == "true"
|
||||
|
||||
private fun JsonObject?.optionalBoolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.content?.trim()?.lowercase()?.let { value ->
|
||||
when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun cronJobLastRunStatus(state: JsonObject?): String? =
|
||||
state
|
||||
.cronStatus("lastStatus")
|
||||
|
||||
@@ -53,7 +53,6 @@ class PermissionRequester internal constructor(
|
||||
private val mutex = Mutex()
|
||||
private val requestSlotsLock = Any()
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
// ActivityResult launchers cannot be registered after start; pre-register a small pool for nested UI flows.
|
||||
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
|
||||
|
||||
|
||||
@@ -40,13 +40,11 @@ class SecurePrefs(
|
||||
private const val notificationsForwardingMaxEventsPerMinuteKey =
|
||||
"notifications.forwarding.maxEventsPerMinute"
|
||||
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
|
||||
private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled"
|
||||
private const val voiceMicEnabledKey = "voice.micEnabled"
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// Non-secret UI/runtime preferences stay readable for migration and backup behavior.
|
||||
private val plainPrefs: SharedPreferences =
|
||||
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
|
||||
@@ -116,10 +114,6 @@ class SecurePrefs(
|
||||
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _installedAppsSharingEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean(installedAppsSharingEnabledKey, false))
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = _installedAppsSharingEnabled
|
||||
|
||||
private val _notificationForwardingEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
|
||||
val notificationForwardingEnabled: StateFlow<Boolean> = _notificationForwardingEnabled
|
||||
@@ -258,11 +252,6 @@ class SecurePrefs(
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun setInstalledAppsSharingEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean(installedAppsSharingEnabledKey, value) }
|
||||
_installedAppsSharingEnabled.value = value
|
||||
}
|
||||
|
||||
internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy {
|
||||
val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null)
|
||||
val mode = NotificationPackageFilterMode.fromRawValue(modeRaw)
|
||||
|
||||
@@ -61,11 +61,9 @@ class ChatController(
|
||||
|
||||
private val pendingRuns = mutableSetOf<String>()
|
||||
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
// Preserve sent messages locally until chat.history includes the gateway-confirmed copy.
|
||||
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
|
||||
private val pendingRunTimeoutMs = 120_000L
|
||||
|
||||
// Drops stale history responses after session switches or refresh races.
|
||||
private val historyLoadGeneration = AtomicLong(0)
|
||||
|
||||
@@ -227,7 +225,6 @@ class ChatController(
|
||||
role = "user",
|
||||
content = userContent,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
idempotencyKey = "$runId:user",
|
||||
)
|
||||
optimisticMessagesByRunId[runId] = optimisticMessage
|
||||
_messages.value = _messages.value + optimisticMessage
|
||||
@@ -353,7 +350,6 @@ class ChatController(
|
||||
)
|
||||
if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
|
||||
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
_historyLoading.value = false
|
||||
@@ -426,8 +422,10 @@ class ChatController(
|
||||
}
|
||||
if (runId != null) {
|
||||
clearPendingRun(runId)
|
||||
optimisticMessagesByRunId.remove(runId)
|
||||
} else {
|
||||
clearPendingRuns(clearOptimisticMessages = false)
|
||||
clearPendingRuns()
|
||||
optimisticMessagesByRunId.clear()
|
||||
}
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
@@ -457,7 +455,6 @@ class ChatController(
|
||||
sessionKey = currentSessionKey,
|
||||
previousMessages = _messages.value,
|
||||
)
|
||||
prunePersistedOptimisticMessages(history.messages)
|
||||
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
|
||||
_sessionId.value = history.sessionId
|
||||
history.thinkingLevel
|
||||
@@ -564,14 +561,12 @@ class ChatController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPendingRuns(clearOptimisticMessages: Boolean = true) {
|
||||
private fun clearPendingRuns() {
|
||||
for ((_, job) in pendingRunTimeoutJobs) {
|
||||
job.cancel()
|
||||
}
|
||||
pendingRunTimeoutJobs.clear()
|
||||
if (clearOptimisticMessages) {
|
||||
optimisticMessagesByRunId.clear()
|
||||
}
|
||||
optimisticMessagesByRunId.clear()
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.clear()
|
||||
_pendingRunCount.value = 0
|
||||
@@ -583,15 +578,6 @@ class ChatController(
|
||||
_messages.value = _messages.value.filterNot { it.id == message.id }
|
||||
}
|
||||
|
||||
private fun prunePersistedOptimisticMessages(incoming: List<ChatMessage>) {
|
||||
val retained =
|
||||
retainUnmatchedOptimisticMessages(
|
||||
incoming = incoming,
|
||||
optimistic = optimisticMessagesByRunId.values,
|
||||
).toSet()
|
||||
optimisticMessagesByRunId.entries.removeAll { entry -> entry.value !in retained }
|
||||
}
|
||||
|
||||
private fun parseHistory(
|
||||
historyJson: String,
|
||||
sessionKey: String,
|
||||
@@ -606,14 +592,13 @@ class ChatController(
|
||||
array.mapNotNull { item ->
|
||||
val obj = item.asObjectOrNull() ?: return@mapNotNull null
|
||||
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
|
||||
val content = parseChatMessageContents(obj)
|
||||
val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseChatMessageContent) ?: emptyList()
|
||||
val ts = obj["timestamp"].asLongOrNull()
|
||||
ChatMessage(
|
||||
id = UUID.randomUUID().toString(),
|
||||
role = role,
|
||||
content = content,
|
||||
timestampMs = ts,
|
||||
idempotencyKey = obj["idempotencyKey"].asStringOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -689,19 +674,6 @@ internal fun parseChatMessageContent(el: JsonElement): ChatMessageContent? {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseChatMessageContents(obj: JsonObject): List<ChatMessageContent> {
|
||||
obj["content"].asArrayOrNull()?.let { content ->
|
||||
return content.mapNotNull(::parseChatMessageContent)
|
||||
}
|
||||
obj["content"].asStringOrNull()?.let { text ->
|
||||
return listOf(ChatMessageContent(type = "text", text = text))
|
||||
}
|
||||
obj["text"].asStringOrNull()?.let { text ->
|
||||
return listOf(ChatMessageContent(type = "text", text = text))
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
internal data class MainSessionState(
|
||||
val currentSessionKey: String,
|
||||
val appliedMainSessionKey: String,
|
||||
@@ -760,41 +732,29 @@ internal fun mergeOptimisticMessages(
|
||||
): List<ChatMessage> {
|
||||
if (optimistic.isEmpty()) return incoming
|
||||
|
||||
val missingOptimistic = retainUnmatchedOptimisticMessages(incoming = incoming, optimistic = optimistic)
|
||||
val unmatchedIncoming = incoming.toMutableList()
|
||||
val missingOptimistic =
|
||||
optimistic.filter { message ->
|
||||
val matchIndex =
|
||||
unmatchedIncoming.indexOfFirst { incomingMessage ->
|
||||
incomingMessageConsumesOptimistic(incomingMessage, message)
|
||||
}
|
||||
if (matchIndex >= 0) {
|
||||
unmatchedIncoming.removeAt(matchIndex)
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
if (missingOptimistic.isEmpty()) return incoming
|
||||
|
||||
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
|
||||
}
|
||||
|
||||
internal fun retainUnmatchedOptimisticMessages(
|
||||
incoming: List<ChatMessage>,
|
||||
optimistic: Collection<ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
if (optimistic.isEmpty()) return emptyList()
|
||||
|
||||
val unmatchedIncoming = incoming.toMutableList()
|
||||
return optimistic.filter { message ->
|
||||
val matchIndex =
|
||||
unmatchedIncoming.indexOfFirst { incomingMessage ->
|
||||
incomingMessageConsumesOptimistic(incomingMessage, message)
|
||||
}
|
||||
if (matchIndex >= 0) {
|
||||
unmatchedIncoming.removeAt(matchIndex)
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Message identity used only for refresh reconciliation; it avoids exposing gateway ids as UI keys.
|
||||
*/
|
||||
internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
val idempotencyKey = message.idempotencyKey?.trim().orEmpty()
|
||||
if (idempotencyKey.isNotEmpty()) {
|
||||
return listOf(message.role.trim().lowercase(), idempotencyKey).joinToString(separator = "|")
|
||||
}
|
||||
val contentKey = messageContentIdentityKey(message) ?: return null
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
|
||||
@@ -807,10 +767,6 @@ private fun incomingMessageConsumesOptimistic(
|
||||
incoming: ChatMessage,
|
||||
optimistic: ChatMessage,
|
||||
): Boolean {
|
||||
val optimisticIdempotencyKey = optimistic.idempotencyKey?.trim().orEmpty()
|
||||
if (optimisticIdempotencyKey.isNotEmpty()) {
|
||||
return incoming.idempotencyKey?.trim() == optimisticIdempotencyKey
|
||||
}
|
||||
if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false
|
||||
val incomingTimestamp = incoming.timestampMs ?: return false
|
||||
val optimisticTimestamp = optimistic.timestampMs ?: return true
|
||||
|
||||
@@ -8,7 +8,6 @@ data class ChatMessage(
|
||||
val role: String,
|
||||
val content: List<ChatMessageContent>,
|
||||
val timestampMs: Long?,
|
||||
val idempotencyKey: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,12 +66,10 @@ class GatewayDiscovery(
|
||||
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
|
||||
|
||||
/** Current discovered gateway list, merged from local DNS-SD and optional wide-area DNS-SD. */
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Searching…")
|
||||
|
||||
/** Short diagnostic text shown by connect UI while discovery is running. */
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
|
||||
@@ -77,8 +77,6 @@ data class GatewayConnectErrorDetails(
|
||||
val recommendedNextStep: String?,
|
||||
val pauseReconnect: Boolean? = null,
|
||||
val reason: String? = null,
|
||||
val requestId: String? = null,
|
||||
val retryable: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -122,7 +120,6 @@ class GatewaySession(
|
||||
private val deviceAuthStore: DeviceAuthTokenStore,
|
||||
private val onConnected: (GatewayHelloSummary) -> Unit,
|
||||
private val onDisconnected: (message: String) -> Unit,
|
||||
private val onConnectFailure: (error: ErrorShape, pauseReconnect: Boolean) -> Unit = { _, _ -> },
|
||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
|
||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
||||
@@ -130,7 +127,6 @@ class GatewaySession(
|
||||
private companion object {
|
||||
// Keep connect timeout above observed gateway unauthorized close on lower-end devices.
|
||||
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
|
||||
private val PAIRING_REQUEST_ID_PATTERN = Regex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -927,8 +923,6 @@ class GatewaySession(
|
||||
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
|
||||
pauseReconnect = it["pauseReconnect"].asBooleanOrNull(),
|
||||
reason = it["reason"].asStringOrNull(),
|
||||
requestId = normalizePairingRequestId(it["requestId"].asStringOrNull()),
|
||||
retryable = it["retryable"].asBooleanOrNull() == true,
|
||||
)
|
||||
}
|
||||
ErrorShape(code, msg, details)
|
||||
@@ -954,11 +948,6 @@ class GatewaySession(
|
||||
onEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private fun normalizePairingRequestId(requestId: String?): String? {
|
||||
val trimmed = requestId?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
return trimmed.takeIf { PAIRING_REQUEST_ID_PATTERN.matches(it) }
|
||||
}
|
||||
|
||||
private suspend fun awaitConnectNonce(): String =
|
||||
try {
|
||||
withTimeout(2_000) { connectNonceDeferred.await() }
|
||||
@@ -1072,14 +1061,10 @@ class GatewaySession(
|
||||
} catch (err: Throwable) {
|
||||
attempt += 1
|
||||
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
|
||||
val gatewayConnectFailure = err as? GatewayConnectFailure
|
||||
val pauseForAuthFailure =
|
||||
gatewayConnectFailure
|
||||
?.let { shouldPauseReconnectAfterAuthFailure(it.gatewayError) } == true
|
||||
if (gatewayConnectFailure != null) {
|
||||
onConnectFailure(gatewayConnectFailure.gatewayError, pauseForAuthFailure)
|
||||
}
|
||||
if (pauseForAuthFailure) {
|
||||
if (
|
||||
err is GatewayConnectFailure &&
|
||||
shouldPauseReconnectAfterAuthFailure(err.gatewayError)
|
||||
) {
|
||||
reconnectPausedForAuthFailure = true
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -12,30 +12,47 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
class A2UIHandler(
|
||||
private val canvas: CanvasController,
|
||||
private val json: Json,
|
||||
private val getNodeCanvasHostUrl: () -> String?,
|
||||
private val getOperatorCanvasHostUrl: () -> String?,
|
||||
) {
|
||||
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean = CanvasActionTrust.isTrustedCanvasActionUrl(rawUrl)
|
||||
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean =
|
||||
CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = rawUrl,
|
||||
trustedA2uiUrls = listOfNotNull(resolveA2uiHostUrl()),
|
||||
)
|
||||
|
||||
suspend fun ensureA2uiReady(): Boolean {
|
||||
if (canvas.currentUrl()?.trim() == CanvasActionTrust.localA2uiAssetUrl && isA2uiReady()) {
|
||||
return true
|
||||
fun resolveA2uiHostUrl(): String? {
|
||||
val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty()
|
||||
val operatorRaw = getOperatorCanvasHostUrl()?.trim().orEmpty()
|
||||
// Prefer node-advertised canvas host; operator URL is a fallback for older hello payloads.
|
||||
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
|
||||
if (raw.isBlank()) return null
|
||||
val base = raw.trimEnd('/')
|
||||
return "$base/__openclaw__/a2ui/?platform=android"
|
||||
}
|
||||
|
||||
suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||
try {
|
||||
val already = canvas.eval(a2uiReadyCheckJS)
|
||||
if (already == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
canvas.showLocalA2ui()
|
||||
// The bundled A2UI host bootstraps asynchronously after navigation; poll briefly before failing the command.
|
||||
canvas.navigate(a2uiUrl)
|
||||
// A2UI host bootstraps asynchronously after navigation; poll briefly before failing the command.
|
||||
repeat(50) {
|
||||
if (isA2uiReady()) return true
|
||||
try {
|
||||
val ready = canvas.eval(a2uiReadyCheckJS)
|
||||
if (ready == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
delay(120)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private suspend fun isA2uiReady(): Boolean =
|
||||
try {
|
||||
canvas.eval(a2uiReadyCheckJS) == "true"
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
|
||||
fun decodeA2uiMessages(
|
||||
command: String,
|
||||
paramsJson: String?,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Trust helper for WebView-originated canvas/A2UI actions.
|
||||
*/
|
||||
@@ -7,15 +9,62 @@ object CanvasActionTrust {
|
||||
/** Local canvas scaffold is the only trusted file URL. */
|
||||
const val scaffoldAssetUrl: String = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||
|
||||
/** Local bundled A2UI is the only action-capable A2UI host. */
|
||||
const val localA2uiAssetUrl: String = "file:///android_asset/CanvasA2UI/index.html"
|
||||
|
||||
/** Accepts only app-owned bundled pages. Remote WebView content is render-only. */
|
||||
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean {
|
||||
/** Accepts local scaffold or exact remote A2UI URLs advertised by the gateway. */
|
||||
fun isTrustedCanvasActionUrl(
|
||||
rawUrl: String?,
|
||||
trustedA2uiUrls: List<String>,
|
||||
): Boolean {
|
||||
val candidate = rawUrl?.trim().orEmpty()
|
||||
if (candidate.isEmpty()) return false
|
||||
if (candidate == scaffoldAssetUrl) return true
|
||||
if (candidate == localA2uiAssetUrl) return true
|
||||
return false
|
||||
|
||||
val candidateUri = parseUri(candidate) ?: return false
|
||||
if (candidateUri.scheme.equals("file", ignoreCase = true)) {
|
||||
return false
|
||||
}
|
||||
val normalizedCandidate = normalizeTrustedRemoteA2uiUri(candidateUri) ?: return false
|
||||
|
||||
return trustedA2uiUrls.any { trusted ->
|
||||
matchesTrustedRemoteA2uiUrlExact(normalizedCandidate, trusted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesTrustedRemoteA2uiUrlExact(
|
||||
candidateUri: URI,
|
||||
trustedUrl: String,
|
||||
): Boolean {
|
||||
// Gateway-advertised URLs are capabilities. Treat malformed entries as
|
||||
// absent instead of broadening trust to same-origin or prefix matches.
|
||||
val trustedUri = parseUri(trustedUrl) ?: return false
|
||||
val normalizedTrusted = normalizeTrustedRemoteA2uiUri(trustedUri) ?: return false
|
||||
return candidateUri == normalizedTrusted
|
||||
}
|
||||
|
||||
/** Normalizes only the URL parts allowed to vary across trusted remote A2UI URLs. */
|
||||
private fun normalizeTrustedRemoteA2uiUri(uri: URI): URI? {
|
||||
// Keep Android trust normalization aligned with iOS ScreenController:
|
||||
// exact remote URL match, scheme/host normalized, fragment ignored.
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
if (scheme != "http" && scheme != "https") return null
|
||||
|
||||
val host =
|
||||
uri.host
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.lowercase() ?: return null
|
||||
|
||||
return try {
|
||||
URI(scheme, uri.userInfo, host, uri.port, uri.rawPath, uri.rawQuery, null)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses untrusted WebView/gateway URL text without throwing into UI event handlers. */
|
||||
private fun parseUri(raw: String): URI? =
|
||||
try {
|
||||
URI(raw)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,7 @@ class CanvasController {
|
||||
private val _currentUrl = MutableStateFlow<String?>(null)
|
||||
val currentUrl: StateFlow<String?> = _currentUrl.asStateFlow()
|
||||
|
||||
private val scaffoldAssetUrl = CanvasActionTrust.scaffoldAssetUrl
|
||||
private val localA2uiAssetUrl = CanvasActionTrust.localA2uiAssetUrl
|
||||
private val scaffoldAssetUrl = "file:///android_asset/CanvasScaffold/scaffold.html"
|
||||
|
||||
private fun clampJpegQuality(quality: Double?): Int {
|
||||
val q = (quality ?: 0.82).coerceIn(0.1, 1.0)
|
||||
@@ -88,13 +87,6 @@ class CanvasController {
|
||||
reload()
|
||||
}
|
||||
|
||||
/** Shows the app-owned A2UI renderer that is allowed to dispatch native actions. */
|
||||
fun showLocalA2ui() {
|
||||
this.url = localA2uiAssetUrl
|
||||
_currentUrl.value = localA2uiAssetUrl
|
||||
reload()
|
||||
}
|
||||
|
||||
fun currentUrl(): String? = url
|
||||
|
||||
fun isDefaultCanvas(): Boolean = url == null
|
||||
|
||||
@@ -28,7 +28,6 @@ class ConnectionManager(
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val installedAppsSharingEnabled: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
companion object {
|
||||
@@ -116,7 +115,6 @@ class ConnectionManager(
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
installedAppsSharingEnabled = installedAppsSharingEnabled(),
|
||||
debugBuild = BuildConfig.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
@@ -25,120 +24,16 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import java.util.Locale
|
||||
|
||||
private const val DEFAULT_DEVICE_APPS_LIMIT = 100
|
||||
private const val MAX_DEVICE_APPS_LIMIT = 200
|
||||
private const val DEVICE_APPS_SYSTEM_FLAGS =
|
||||
ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||
|
||||
internal fun isSystemDeviceApp(appInfo: ApplicationInfo): Boolean = (appInfo.flags and DEVICE_APPS_SYSTEM_FLAGS) != 0
|
||||
|
||||
internal data class DeviceAppEntry(
|
||||
val label: String,
|
||||
val packageName: String,
|
||||
val system: Boolean,
|
||||
val enabled: Boolean,
|
||||
val launchable: Boolean,
|
||||
)
|
||||
|
||||
internal interface DeviceAppSource {
|
||||
fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry>
|
||||
}
|
||||
|
||||
private class AndroidDeviceAppSource(
|
||||
private val appContext: Context,
|
||||
) : DeviceAppSource {
|
||||
override fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry> {
|
||||
val packageManager = appContext.packageManager
|
||||
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
|
||||
val launchablePackages =
|
||||
packageManager
|
||||
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
|
||||
.asSequence()
|
||||
.mapNotNull {
|
||||
it.activityInfo
|
||||
?.packageName
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
}.toSet()
|
||||
|
||||
val appInfos =
|
||||
if (includeNonLaunchable) {
|
||||
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
|
||||
} else {
|
||||
launchablePackages.mapNotNull { packageName ->
|
||||
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
return appInfos
|
||||
.asSequence()
|
||||
.mapNotNull { appInfo ->
|
||||
appInfo.packageName
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?.let { packageName ->
|
||||
val label = packageManager.getApplicationLabel(appInfo).toString().trim()
|
||||
DeviceAppEntry(
|
||||
label = label.ifEmpty { packageName },
|
||||
packageName = packageName,
|
||||
system = isSystemDeviceApp(appInfo),
|
||||
enabled = appInfo.enabled,
|
||||
launchable = packageName in launchablePackages,
|
||||
)
|
||||
}
|
||||
}.distinctBy { it.packageName }
|
||||
.sortedWith(compareBy<DeviceAppEntry> { it.label.lowercase() }.thenBy { it.packageName })
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private data class DeviceAppsRequest(
|
||||
val includeSystem: Boolean,
|
||||
val includeDisabled: Boolean,
|
||||
val includeNonLaunchable: Boolean,
|
||||
val query: String?,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Gateway device command adapter for Android status, info, permission, and health snapshots.
|
||||
*/
|
||||
class DeviceHandler private constructor(
|
||||
class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
private val appSource: DeviceAppSource = AndroidDeviceAppSource(appContext),
|
||||
) {
|
||||
constructor(
|
||||
appContext: Context,
|
||||
smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
) : this(
|
||||
appContext = appContext,
|
||||
smsEnabled = smsEnabled,
|
||||
callLogEnabled = callLogEnabled,
|
||||
photosEnabled = photosEnabled,
|
||||
appSource = AndroidDeviceAppSource(appContext),
|
||||
)
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
appSource: DeviceAppSource,
|
||||
smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
): DeviceHandler =
|
||||
DeviceHandler(
|
||||
appContext = appContext,
|
||||
smsEnabled = smsEnabled,
|
||||
callLogEnabled = callLogEnabled,
|
||||
photosEnabled = photosEnabled,
|
||||
appSource = appSource,
|
||||
)
|
||||
|
||||
/**
|
||||
* SMS is available only when the feature flag, telephony hardware, and at least one SMS permission align.
|
||||
*/
|
||||
@@ -179,48 +74,6 @@ class DeviceHandler private constructor(
|
||||
/** Returns coarse device health for memory, power, thermal, battery, and security patch state. */
|
||||
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(healthPayloadJson())
|
||||
|
||||
fun handleDeviceApps(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val request = parseDeviceAppsRequest(paramsJson)
|
||||
val matchingApps =
|
||||
appSource
|
||||
.listApps(includeNonLaunchable = request.includeNonLaunchable)
|
||||
.asSequence()
|
||||
.filter { request.includeSystem || !it.system }
|
||||
.filter { request.includeDisabled || it.enabled }
|
||||
.filter { app ->
|
||||
val query = request.query ?: return@filter true
|
||||
app.label.contains(query, ignoreCase = true) || app.packageName.contains(query, ignoreCase = true)
|
||||
}.toList()
|
||||
val limitedApps = matchingApps.take(request.limit)
|
||||
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("count", JsonPrimitive(limitedApps.size))
|
||||
put("totalMatched", JsonPrimitive(matchingApps.size))
|
||||
put("truncated", JsonPrimitive(matchingApps.size > limitedApps.size))
|
||||
put("visibility", JsonPrimitive(if (request.includeNonLaunchable) "android-visible" else "launcher"))
|
||||
put("includeSystem", JsonPrimitive(request.includeSystem))
|
||||
put("includeDisabled", JsonPrimitive(request.includeDisabled))
|
||||
put(
|
||||
"apps",
|
||||
buildJsonArray {
|
||||
for (app in limitedApps) {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("label", JsonPrimitive(app.label))
|
||||
put("packageName", JsonPrimitive(app.packageName))
|
||||
put("system", JsonPrimitive(app.system))
|
||||
put("enabled", JsonPrimitive(app.enabled))
|
||||
put("launchable", JsonPrimitive(app.launchable))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun statusPayloadJson(): String {
|
||||
val battery = readBatterySnapshot()
|
||||
val powerManager = appContext.getSystemService(PowerManager::class.java)
|
||||
@@ -512,24 +365,6 @@ class DeviceHandler private constructor(
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun parseDeviceAppsRequest(paramsJson: String?): DeviceAppsRequest {
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val includeSystem = parseJsonBooleanFlag(params, "includeSystem") ?: false
|
||||
val includeDisabled = parseJsonBooleanFlag(params, "includeDisabled") ?: false
|
||||
val includeNonLaunchable = parseJsonBooleanFlag(params, "includeNonLaunchable") ?: false
|
||||
val query = parseJsonString(params, "query")?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val limit =
|
||||
(parseJsonInt(params, "limit") ?: DEFAULT_DEVICE_APPS_LIMIT)
|
||||
.coerceIn(1, MAX_DEVICE_APPS_LIMIT)
|
||||
return DeviceAppsRequest(
|
||||
includeSystem = includeSystem,
|
||||
includeDisabled = includeDisabled,
|
||||
includeNonLaunchable = includeNonLaunchable,
|
||||
query = query,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun readBatterySnapshot(): BatterySnapshot {
|
||||
// ACTION_BATTERY_CHANGED is sticky; registerReceiver(null, ...) reads the last system snapshot.
|
||||
val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
|
||||
@@ -28,7 +28,6 @@ data class NodeRuntimeFlags(
|
||||
val voiceWakeEnabled: Boolean,
|
||||
val motionActivityAvailable: Boolean,
|
||||
val motionPedometerAvailable: Boolean,
|
||||
val installedAppsSharingEnabled: Boolean,
|
||||
val debugBuild: Boolean,
|
||||
)
|
||||
|
||||
@@ -44,7 +43,6 @@ enum class InvokeCommandAvailability {
|
||||
PhotosAvailable,
|
||||
MotionActivityAvailable,
|
||||
MotionPedometerAvailable,
|
||||
InstalledAppsSharingEnabled,
|
||||
DebugBuild,
|
||||
}
|
||||
|
||||
@@ -195,10 +193,6 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Health.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Apps.rawValue,
|
||||
availability = InvokeCommandAvailability.InstalledAppsSharingEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.List.rawValue,
|
||||
),
|
||||
@@ -287,7 +281,6 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandAvailability.PhotosAvailable -> flags.photosAvailable
|
||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||
InvokeCommandAvailability.InstalledAppsSharingEnabled -> flags.installedAppsSharingEnabled
|
||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||
}
|
||||
}.map { it.name }
|
||||
|
||||
@@ -85,10 +85,10 @@ class InvokeDispatcher(
|
||||
private val smsTelephonyAvailable: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val installedAppsSharingEnabled: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
private val refreshCanvasHostUrl: suspend () -> String?,
|
||||
private val motionActivityAvailable: () -> Boolean,
|
||||
private val motionPedometerAvailable: () -> Boolean,
|
||||
) {
|
||||
@@ -193,7 +193,6 @@ class InvokeDispatcher(
|
||||
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
|
||||
OpenClawDeviceCommand.Permissions.rawValue -> deviceHandler.handleDevicePermissions(paramsJson)
|
||||
OpenClawDeviceCommand.Health.rawValue -> deviceHandler.handleDeviceHealth(paramsJson)
|
||||
OpenClawDeviceCommand.Apps.rawValue -> deviceHandler.handleDeviceApps(paramsJson)
|
||||
|
||||
// Notifications command
|
||||
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
|
||||
@@ -241,11 +240,24 @@ class InvokeDispatcher(
|
||||
}
|
||||
|
||||
private suspend fun withReadyA2ui(block: suspend () -> GatewaySession.InvokeResult): GatewaySession.InvokeResult {
|
||||
if (!a2uiHandler.ensureA2uiReady()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI_HOST_UNAVAILABLE: bundled A2UI host not reachable",
|
||||
)
|
||||
var a2uiUrl =
|
||||
a2uiHandler.resolveA2uiHostUrl()
|
||||
?: refreshCanvasHostUrl().let { a2uiHandler.resolveA2uiHostUrl() }
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val readyOnFirstCheck = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!readyOnFirstCheck) {
|
||||
// Gateway canvas host metadata can lag reconnects; refresh once before failing the command.
|
||||
refreshCanvasHostUrl()
|
||||
a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: a2uiUrl
|
||||
if (!a2uiHandler.ensureA2uiReady(a2uiUrl)) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI_HOST_UNAVAILABLE: A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
}
|
||||
return block()
|
||||
}
|
||||
@@ -336,15 +348,6 @@ class InvokeDispatcher(
|
||||
message = "PHOTOS_UNAVAILABLE: photos not available on this build",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.InstalledAppsSharingEnabled ->
|
||||
if (installedAppsSharingEnabled()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INSTALLED_APPS_SHARING_DISABLED",
|
||||
message = "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
|
||||
@@ -112,7 +112,6 @@ enum class OpenClawDeviceCommand(
|
||||
Info("device.info"),
|
||||
Permissions("device.permissions"),
|
||||
Health("device.health"),
|
||||
Apps("device.apps"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -152,8 +152,9 @@ fun CanvasScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// The listener accepts any WebView origin at registration time; native
|
||||
// dispatch still requires the live URL to be an app-owned bundled page.
|
||||
// The listener accepts any WebView origin at registration time because
|
||||
// gateway A2UI URLs are dynamic; CanvasActionTrust validates the live URL
|
||||
// before forwarding each message.
|
||||
val bridge =
|
||||
CanvasA2UIActionBridge(
|
||||
isTrustedPage = { viewModel.isTrustedCanvasActionUrl(currentPageUrlRef.get()) },
|
||||
|
||||
@@ -297,15 +297,17 @@ private fun CommandSectionLabel(title: String) {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun providerCommandSubtitle(
|
||||
/** Builds provider quick-action metadata from current gateway/catalog state. */
|
||||
private fun providerCommandSubtitle(
|
||||
isConnected: Boolean,
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): String {
|
||||
if (!isConnected) return "Connect Gateway to view providers"
|
||||
val readyProviderCount = providerRows(providers = providers, models = models).count { it.ready }
|
||||
if (!isConnected) return "Connect Gateway to load models"
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
if (readyProviderCount > 0) return "$readyProviderCount providers ready"
|
||||
return "No ready providers"
|
||||
if (models.isNotEmpty()) return "${models.size} models available"
|
||||
return "Configure model access"
|
||||
}
|
||||
|
||||
/** Falls back to the canonical main-session label when gateway display names are blank. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -66,7 +66,6 @@ private enum class ConnectInputMode {
|
||||
fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
@@ -148,10 +147,13 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
val showDiagnostics = !isConnected && (gatewayConnectionProblem != null || gatewayStatusHasDiagnostics(statusText))
|
||||
val pairingRequired = !isConnected && (gatewayConnectionProblem?.isPairingRequired == true || gatewayStatusLooksLikePairing(statusText))
|
||||
val pairingInstruction = gatewayPairingInstruction(gatewayConnectionProblem)
|
||||
val statusLabel = gatewayStatusForDisplay(gatewayConnectionProblem?.message ?: statusText)
|
||||
val showDiagnostics = !isConnected && gatewayStatusHasDiagnostics(statusText)
|
||||
val pairingRequired = !isConnected && gatewayStatusLooksLikePairing(statusText)
|
||||
val statusLabel = gatewayStatusForDisplay(statusText)
|
||||
|
||||
PairingAutoRetryEffect(enabled = pairingRequired) {
|
||||
viewModel.refreshGatewayConnection()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
@@ -289,14 +291,27 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
|
||||
validationText = null
|
||||
viewModel.saveGatewayConfigAndConnect(
|
||||
host = config.host,
|
||||
port = config.port,
|
||||
tls = config.tls,
|
||||
token = config.token,
|
||||
bootstrapToken = config.bootstrapToken,
|
||||
password = config.password,
|
||||
resetSetupAuth = inputMode == ConnectInputMode.SetupCode,
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
// Setup-code auth should replace old bootstrap/shared credentials;
|
||||
// manual reconnects keep existing typed credentials.
|
||||
viewModel.resetGatewaySetupAuth()
|
||||
}
|
||||
viewModel.setManualEnabled(true)
|
||||
viewModel.setManualHost(config.host)
|
||||
viewModel.setManualPort(config.port)
|
||||
viewModel.setManualTls(config.tls)
|
||||
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
|
||||
if (config.token.isNotBlank()) {
|
||||
viewModel.setGatewayToken(config.token)
|
||||
} else if (config.bootstrapToken.isNotBlank()) {
|
||||
viewModel.setGatewayToken("")
|
||||
}
|
||||
viewModel.setGatewayPassword(config.password)
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = config.host, port = config.port),
|
||||
token = config.token.ifEmpty { null },
|
||||
bootstrapToken = config.bootstrapToken.ifEmpty { null },
|
||||
password = config.password.ifEmpty { null },
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
@@ -326,7 +341,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
Text(statusLabel, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
|
||||
if (pairingRequired) {
|
||||
Text(
|
||||
pairingInstruction,
|
||||
"Approve this phone on the gateway. OpenClaw retries automatically while this screen stays open.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
@@ -575,13 +590,6 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun gatewayPairingInstruction(problem: GatewayConnectionProblem?): String =
|
||||
if (problem?.canAutoRetry == true) {
|
||||
"Approve this phone on the gateway. OpenClaw will reconnect automatically."
|
||||
} else {
|
||||
"Approve this phone on the gateway, then retry the connection."
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MethodChip(
|
||||
label: String,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal const val PAIRING_INITIAL_AUTO_RETRY_MS = 1_500L
|
||||
internal const val PAIRING_AUTO_RETRY_MS = 4_000L
|
||||
|
||||
/** Retries pairing-only gateway refreshes while the screen is visible and started. */
|
||||
@Composable
|
||||
internal fun PairingAutoRetryEffect(
|
||||
enabled: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var lifecycleStarted by
|
||||
remember(lifecycleOwner) {
|
||||
mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED))
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, _ ->
|
||||
lifecycleStarted = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(enabled, lifecycleStarted) {
|
||||
if (!enabled || !lifecycleStarted) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
// Give the gateway a short settling window before the first retry so an
|
||||
// approval response is not immediately chased by a redundant reconnect.
|
||||
delay(PAIRING_INITIAL_AUTO_RETRY_MS)
|
||||
while (true) {
|
||||
onRetry()
|
||||
delay(PAIRING_AUTO_RETRY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
/** App entry shown in the notification-forwarding package picker. */
|
||||
data class InstalledApp(
|
||||
val label: String,
|
||||
val packageName: String,
|
||||
val isSystemApp: Boolean,
|
||||
)
|
||||
|
||||
/** Reads launcher, recent-notification, and configured packages for the picker. */
|
||||
internal fun queryInstalledApps(
|
||||
context: Context,
|
||||
configuredPackages: Set<String>,
|
||||
): List<InstalledApp> {
|
||||
val packageManager = context.packageManager
|
||||
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
|
||||
|
||||
val launcherPackages =
|
||||
packageManager
|
||||
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
|
||||
.asSequence()
|
||||
.mapNotNull {
|
||||
it.activityInfo
|
||||
?.packageName
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
}.toMutableSet()
|
||||
|
||||
val recentNotificationPackages =
|
||||
DeviceNotificationListenerService
|
||||
.recentPackages(context)
|
||||
.asSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toList()
|
||||
|
||||
val candidatePackages =
|
||||
resolveNotificationCandidatePackages(
|
||||
launcherPackages = launcherPackages,
|
||||
recentPackages = recentNotificationPackages,
|
||||
configuredPackages = configuredPackages,
|
||||
appPackageName = context.packageName,
|
||||
)
|
||||
|
||||
return candidatePackages
|
||||
.asSequence()
|
||||
.mapNotNull { packageName ->
|
||||
runCatching {
|
||||
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||
val label = packageManager.getApplicationLabel(appInfo).toString().trim()
|
||||
InstalledApp(
|
||||
label = if (label.isEmpty()) packageName else label,
|
||||
packageName = packageName,
|
||||
isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0,
|
||||
)
|
||||
}.getOrNull()
|
||||
}.sortedWith(compareBy<InstalledApp> { it.label.lowercase() }.thenBy { it.packageName })
|
||||
.toList()
|
||||
}
|
||||
|
||||
/** Merges package sources while excluding OpenClaw from its own forwarding filter. */
|
||||
internal fun resolveNotificationCandidatePackages(
|
||||
launcherPackages: Set<String>,
|
||||
recentPackages: List<String>,
|
||||
configuredPackages: Set<String>,
|
||||
appPackageName: String,
|
||||
): Set<String> {
|
||||
val blockedPackage = appPackageName.trim()
|
||||
return sequenceOf(
|
||||
configuredPackages.asSequence(),
|
||||
launcherPackages.asSequence(),
|
||||
recentPackages.asSequence(),
|
||||
).flatten()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && it != blockedPackage }
|
||||
.toSet()
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.SensitiveFeatureConfig
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import ai.openclaw.app.ui.design.ClawDesignTheme
|
||||
import ai.openclaw.app.ui.design.ClawErrorState
|
||||
import ai.openclaw.app.ui.design.ClawListItem
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
@@ -32,30 +30,24 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
@@ -95,13 +87,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -133,14 +122,11 @@ fun OnboardingFlow(
|
||||
ClawDesignTheme {
|
||||
val context = LocalContext.current
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val gateways by viewModel.gateways.collectAsState()
|
||||
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
|
||||
val savedToken by viewModel.gatewayToken.collectAsState()
|
||||
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
|
||||
val startAtGatewaySetup by viewModel.startOnboardingAtGatewaySetup.collectAsState()
|
||||
@@ -155,7 +141,6 @@ fun OnboardingFlow(
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var setupError by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var attemptedConnect by rememberSaveable { mutableStateOf(false) }
|
||||
var attemptedGatewayName by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var connectAttemptStartedAtMs by rememberSaveable { mutableLongStateOf(0L) }
|
||||
var recoveryNowMs by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) }
|
||||
|
||||
@@ -177,12 +162,6 @@ fun OnboardingFlow(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(step) {
|
||||
if (step == OnboardingStep.Gateway) {
|
||||
viewModel.startGatewayDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(ready, attemptedConnect) {
|
||||
if (attemptedConnect && ready) {
|
||||
step = OnboardingStep.Permissions
|
||||
@@ -237,8 +216,6 @@ fun OnboardingFlow(
|
||||
token = token,
|
||||
password = password,
|
||||
nearbyGatewayName = gateways.firstOrNull()?.name,
|
||||
discoveryStatusText = discoveryStatusText,
|
||||
discoveryStarted = runtimeInitialized,
|
||||
error = setupError,
|
||||
onBack = { step = OnboardingStep.Welcome },
|
||||
onScan = {
|
||||
@@ -275,10 +252,8 @@ fun OnboardingFlow(
|
||||
onPasswordChange = { password = it },
|
||||
onUseNearby = {
|
||||
val endpoint = gateways.firstOrNull() ?: return@GatewaySetupScreen
|
||||
attemptedGatewayName = endpoint.name
|
||||
attemptedConnect = true
|
||||
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
|
||||
viewModel.connectInBackground(endpoint)
|
||||
viewModel.connect(endpoint)
|
||||
step = OnboardingStep.Recovery
|
||||
},
|
||||
onPair = {
|
||||
@@ -297,17 +272,23 @@ fun OnboardingFlow(
|
||||
}
|
||||
|
||||
setupError = null
|
||||
attemptedGatewayName = null
|
||||
attemptedConnect = true
|
||||
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
|
||||
viewModel.saveGatewayConfigAndConnect(
|
||||
host = config.host,
|
||||
port = config.port,
|
||||
tls = config.tls,
|
||||
token = config.token,
|
||||
bootstrapToken = config.bootstrapToken,
|
||||
password = config.password,
|
||||
resetSetupAuth = true,
|
||||
// Setup-code pairing replaces any stale shared credentials before
|
||||
// the bootstrap token is stored for the first authenticated connect.
|
||||
viewModel.resetGatewaySetupAuth()
|
||||
viewModel.setManualEnabled(true)
|
||||
viewModel.setManualHost(config.host)
|
||||
viewModel.setManualPort(config.port)
|
||||
viewModel.setManualTls(config.tls)
|
||||
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
|
||||
viewModel.setGatewayToken(config.token)
|
||||
viewModel.setGatewayPassword(config.password)
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = config.host, port = config.port),
|
||||
token = config.token.ifEmpty { null },
|
||||
bootstrapToken = config.bootstrapToken.ifEmpty { null },
|
||||
password = config.password.ifEmpty { null },
|
||||
)
|
||||
step = OnboardingStep.Recovery
|
||||
},
|
||||
@@ -317,11 +298,11 @@ fun OnboardingFlow(
|
||||
modifier = modifier,
|
||||
statusText = statusText,
|
||||
serverName = serverName,
|
||||
attemptedGatewayName = attemptedGatewayName,
|
||||
remoteAddress = remoteAddress,
|
||||
ready = ready,
|
||||
gatewayConnectionProblem = gatewayConnectionProblem,
|
||||
attemptedConnect = attemptedConnect,
|
||||
connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS,
|
||||
onAutoRetry = viewModel::refreshGatewayConnection,
|
||||
onBack = { step = OnboardingStep.Gateway },
|
||||
onRetry = {
|
||||
attemptedConnect = true
|
||||
@@ -335,14 +316,11 @@ fun OnboardingFlow(
|
||||
token = token,
|
||||
password = password,
|
||||
) ?: return@GatewayRecoveryScreen
|
||||
viewModel.saveGatewayConfigAndConnect(
|
||||
host = config.host,
|
||||
port = config.port,
|
||||
tls = config.tls,
|
||||
token = config.token,
|
||||
bootstrapToken = config.bootstrapToken,
|
||||
password = config.password,
|
||||
resetSetupAuth = false,
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = config.host, port = config.port),
|
||||
token = config.token.ifEmpty { null },
|
||||
bootstrapToken = config.bootstrapToken.ifEmpty { null },
|
||||
password = config.password.ifEmpty { null },
|
||||
)
|
||||
},
|
||||
onEdit = { step = OnboardingStep.Gateway },
|
||||
@@ -367,39 +345,20 @@ private fun WelcomeScreen(
|
||||
onConnect: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val welcomeBackground =
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
Color(0xFFFF4D4D),
|
||||
Color(0xFFD73332),
|
||||
Color(0xFF991B1B),
|
||||
Color(0xFF260707),
|
||||
),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(welcomeBackground)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 24.dp, vertical = 18.dp),
|
||||
) {
|
||||
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 18.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(96.dp))
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
WelcomeLogo()
|
||||
Text(
|
||||
text = "OPENCLAW",
|
||||
style = ClawTheme.type.display.copy(fontSize = 34.sp, lineHeight = 38.sp, fontWeight = FontWeight.Black),
|
||||
color = ClawTheme.colors.text,
|
||||
)
|
||||
Text(
|
||||
text = "Your personal AI assistant.\nExfoliate! Exfoliate!",
|
||||
text = "Your AI command center.\nPrivate. Local. Under your control.",
|
||||
style = ClawTheme.type.section,
|
||||
color = ClawTheme.colors.text,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -410,26 +369,19 @@ private fun WelcomeScreen(
|
||||
Spacer(modifier = Modifier.height(30.dp))
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
HeroPrimaryAction(title = "Connect Gateway", onClick = onConnect)
|
||||
OutlinedAction(title = "Enter setup code", icon = Icons.AutoMirrored.Filled.KeyboardArrowRight, onClick = onConnect)
|
||||
Surface(onClick = onConnect, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
Text(text = "Already have a setup? ", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Text(text = "Sign in", style = ClawTheme.type.body.copy(fontWeight = FontWeight.SemiBold), color = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(104.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeLogo() {
|
||||
Surface(
|
||||
modifier = Modifier.size(82.dp),
|
||||
shape = CircleShape,
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
contentColor = Color.Unspecified,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) {
|
||||
Image(painter = painterResource(id = R.drawable.openclaw_logo), contentDescription = "OpenClaw logo", modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WelcomeHorizon() {
|
||||
Canvas(modifier = Modifier.fillMaxWidth().height(120.dp)) {
|
||||
@@ -475,8 +427,6 @@ private fun GatewaySetupScreen(
|
||||
token: String,
|
||||
password: String,
|
||||
nearbyGatewayName: String?,
|
||||
discoveryStatusText: String,
|
||||
discoveryStarted: Boolean,
|
||||
error: String?,
|
||||
onBack: () -> Unit,
|
||||
onScan: () -> Unit,
|
||||
@@ -491,29 +441,6 @@ private fun GatewaySetupScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var advancedOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var nearbySearchTimedOut by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(nearbyGatewayName, discoveryStatusText, discoveryStarted) {
|
||||
if (!nearbyGatewayName.isNullOrBlank()) {
|
||||
nearbySearchTimedOut = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (!discoveryStarted) {
|
||||
nearbySearchTimedOut = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
nearbySearchTimedOut = false
|
||||
delay(5_000)
|
||||
nearbySearchTimedOut = true
|
||||
}
|
||||
|
||||
val nearbyGateway =
|
||||
nearbyGatewayUiState(
|
||||
nearbyGatewayName = nearbyGatewayName,
|
||||
discoveryStatusText = discoveryStatusText,
|
||||
discoveryStarted = discoveryStarted,
|
||||
searchTimedOut = nearbySearchTimedOut,
|
||||
)
|
||||
|
||||
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
|
||||
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
@@ -533,9 +460,9 @@ private fun GatewaySetupScreen(
|
||||
GatewayOption(
|
||||
icon = Icons.Default.WifiTethering,
|
||||
title = "Nearby gateway",
|
||||
subtitle = nearbyGateway.subtitle,
|
||||
status = nearbyGateway.status,
|
||||
onClick = onUseNearby.takeIf { nearbyGateway.canConnect },
|
||||
subtitle = nearbyGatewayName ?: "Discovery ready",
|
||||
status = nearbyGatewayName?.let { "Found" },
|
||||
onClick = onUseNearby,
|
||||
)
|
||||
}
|
||||
item {
|
||||
@@ -546,14 +473,6 @@ private fun GatewaySetupScreen(
|
||||
onClick = { advancedOpen = true },
|
||||
)
|
||||
}
|
||||
error?.let { message ->
|
||||
item {
|
||||
ClawErrorState(
|
||||
title = "Setup code issue",
|
||||
body = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Surface(
|
||||
@@ -586,6 +505,9 @@ private fun GatewaySetupScreen(
|
||||
}
|
||||
ClawTextField(value = token, onValueChange = onTokenChange, placeholder = "Token optional")
|
||||
ClawTextField(value = password, onValueChange = onPasswordChange, placeholder = "Password optional")
|
||||
error?.let {
|
||||
Text(text = it, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -599,19 +521,20 @@ private fun GatewaySetupScreen(
|
||||
private fun GatewayRecoveryScreen(
|
||||
statusText: String,
|
||||
serverName: String?,
|
||||
attemptedGatewayName: String?,
|
||||
remoteAddress: String?,
|
||||
ready: Boolean,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem?,
|
||||
attemptedConnect: Boolean,
|
||||
connectSettling: Boolean,
|
||||
onAutoRetry: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onContinue: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling, gatewayConnectionProblem = gatewayConnectionProblem)
|
||||
val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling)
|
||||
val context = LocalContext.current
|
||||
PairingAutoRetryEffect(enabled = recoveryState.canAutoRetry && attemptedConnect, onRetry = onAutoRetry)
|
||||
|
||||
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
|
||||
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
@@ -622,7 +545,6 @@ private fun GatewayRecoveryScreen(
|
||||
imageVector =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle
|
||||
GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering
|
||||
GatewayRecoveryUiState.Failed -> Icons.Default.ErrorOutline
|
||||
@@ -632,7 +554,6 @@ private fun GatewayRecoveryScreen(
|
||||
tint =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> ClawTheme.colors.success
|
||||
GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning
|
||||
GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text
|
||||
GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text
|
||||
GatewayRecoveryUiState.Failed -> ClawTheme.colors.warning
|
||||
@@ -650,16 +571,12 @@ private fun GatewayRecoveryScreen(
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "Last gateway", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
|
||||
Text(text = recoveryGatewayName(serverName = serverName, attemptedGatewayName = attemptedGatewayName), style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText, gatewayConnectionProblem = gatewayConnectionProblem), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
recoveryGatewayApprovalCommand(gatewayConnectionProblem)?.let { command ->
|
||||
ApprovalCommandBlock(command = command, onCopy = { copyApprovalCommand(context, command) })
|
||||
}
|
||||
Text(text = serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
ClawStatusPill(
|
||||
text =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> "Healthy"
|
||||
GatewayRecoveryUiState.ApprovalRequired -> "Needs approval"
|
||||
GatewayRecoveryUiState.Pairing -> "Pairing"
|
||||
GatewayRecoveryUiState.Finishing -> "Connecting"
|
||||
GatewayRecoveryUiState.Failed -> "Needs attention"
|
||||
@@ -667,7 +584,6 @@ private fun GatewayRecoveryScreen(
|
||||
status =
|
||||
when (recoveryState) {
|
||||
GatewayRecoveryUiState.Connected -> ClawStatus.Success
|
||||
GatewayRecoveryUiState.ApprovalRequired -> ClawStatus.Warning
|
||||
GatewayRecoveryUiState.Pairing -> ClawStatus.Neutral
|
||||
GatewayRecoveryUiState.Finishing -> ClawStatus.Neutral
|
||||
GatewayRecoveryUiState.Failed -> ClawStatus.Warning
|
||||
@@ -684,42 +600,7 @@ private fun GatewayRecoveryScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedAction(title = "Edit connection", icon = Icons.Default.Edit, onClick = onEdit)
|
||||
OutlinedAction(title = "Copy diagnostic", icon = Icons.Default.ContentCopy, onClick = { copyGatewayDiagnostic(context, statusText, serverName, remoteAddress, ready, gatewayConnectionProblem) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ApprovalCommandBlock(
|
||||
command: String,
|
||||
onCopy: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 6.dp, top = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SelectionContainer(modifier = Modifier.weight(1f)) {
|
||||
Text(text = command, style = ClawTheme.type.body.copy(fontFamily = FontFamily.Monospace), color = ClawTheme.colors.text)
|
||||
}
|
||||
Surface(
|
||||
onClick = onCopy,
|
||||
modifier = Modifier.size(36.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = ClawTheme.colors.surfaceRaised,
|
||||
contentColor = ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Default.ContentCopy, contentDescription = "Copy approval command", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
OutlinedAction(title = "Copy diagnostic", icon = Icons.Default.ContentCopy, onClick = { copyGatewayDiagnostic(context, statusText, serverName, remoteAddress, ready) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -816,7 +697,7 @@ private fun GatewayOption(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: (() -> Unit)?,
|
||||
onClick: () -> Unit,
|
||||
status: String? = null,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
@@ -825,12 +706,9 @@ private fun GatewayOption(
|
||||
subtitle = subtitle,
|
||||
metadata = status,
|
||||
leading = { Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(22.dp), tint = ClawTheme.colors.text) },
|
||||
trailing =
|
||||
onClick?.let {
|
||||
{
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open $title", modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
},
|
||||
trailing = {
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open $title", modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
@@ -1006,80 +884,38 @@ private fun PermissionContinueButton(onClick: () -> Unit) {
|
||||
internal enum class GatewayRecoveryUiState(
|
||||
val title: String,
|
||||
val message: String,
|
||||
val canAutoRetry: Boolean,
|
||||
) {
|
||||
Connected(
|
||||
title = "Connected",
|
||||
message = "Your Gateway is ready.",
|
||||
),
|
||||
ApprovalRequired(
|
||||
title = "Pairing Gateway",
|
||||
message = "Approve this phone on the gateway.\nThen retry the connection.",
|
||||
canAutoRetry = false,
|
||||
),
|
||||
Pairing(
|
||||
title = "Pairing Gateway",
|
||||
message = "Approval is in progress.\nOpenClaw will reconnect automatically.",
|
||||
canAutoRetry = true,
|
||||
),
|
||||
Finishing(
|
||||
title = "Connecting Gateway",
|
||||
message = "OpenClaw is checking gateway and node access.",
|
||||
title = "Finishing Setup",
|
||||
message = "Gateway approved this phone.\nOpenClaw is bringing the node online.",
|
||||
canAutoRetry = true,
|
||||
),
|
||||
Failed(
|
||||
title = "Connection issue",
|
||||
message = "We could not reach your Gateway.\nLet's fix this.",
|
||||
canAutoRetry = false,
|
||||
),
|
||||
}
|
||||
|
||||
internal data class NearbyGatewayUiState(
|
||||
val subtitle: String,
|
||||
val status: String?,
|
||||
val canConnect: Boolean,
|
||||
)
|
||||
|
||||
/** Maps best-effort discovery into row copy and clickability for onboarding. */
|
||||
internal fun nearbyGatewayUiState(
|
||||
nearbyGatewayName: String?,
|
||||
discoveryStatusText: String,
|
||||
discoveryStarted: Boolean = true,
|
||||
searchTimedOut: Boolean = false,
|
||||
): NearbyGatewayUiState {
|
||||
val name = nearbyGatewayName?.trim().takeUnless { it.isNullOrEmpty() }
|
||||
if (name != null) {
|
||||
return NearbyGatewayUiState(subtitle = name, status = "Found", canConnect = true)
|
||||
}
|
||||
if (!discoveryStarted) {
|
||||
return NearbyGatewayUiState(subtitle = "Starting discovery...", status = "Starting", canConnect = false)
|
||||
}
|
||||
|
||||
val status = discoveryStatusText.trim()
|
||||
val searching =
|
||||
status.isEmpty() ||
|
||||
status.equals("Searching…", ignoreCase = true) ||
|
||||
status.contains("Searching", ignoreCase = true) ||
|
||||
status.endsWith("?", ignoreCase = true)
|
||||
return if (searching) {
|
||||
if (searchTimedOut) {
|
||||
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false)
|
||||
} else {
|
||||
NearbyGatewayUiState(subtitle = "Searching for gateways...", status = "Searching", canConnect = false)
|
||||
}
|
||||
} else {
|
||||
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Derives recovery screen state from gateway/node readiness and transient status text. */
|
||||
internal fun gatewayRecoveryUiState(
|
||||
ready: Boolean,
|
||||
statusText: String,
|
||||
connectSettling: Boolean,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem? = null,
|
||||
): GatewayRecoveryUiState =
|
||||
when {
|
||||
ready -> GatewayRecoveryUiState.Connected
|
||||
gatewayConnectionProblem?.isPairingRequired == true &&
|
||||
!gatewayConnectionProblem.canAutoRetry -> GatewayRecoveryUiState.ApprovalRequired
|
||||
gatewayConnectionProblem?.isPairingRequired == true -> GatewayRecoveryUiState.Pairing
|
||||
gatewayConnectionProblem?.pauseReconnect == true -> GatewayRecoveryUiState.Failed
|
||||
connectSettling -> GatewayRecoveryUiState.Finishing
|
||||
gatewayStatusLooksLikePairing(statusText) -> GatewayRecoveryUiState.Pairing
|
||||
gatewayStatusLooksLikePartialConnect(statusText) -> GatewayRecoveryUiState.Finishing
|
||||
@@ -1092,18 +928,6 @@ internal fun gatewayStatusLooksLikePartialConnect(statusText: String): Boolean {
|
||||
return lower.contains("operator offline") || lower.contains("node offline")
|
||||
}
|
||||
|
||||
internal fun recoveryGatewayName(
|
||||
serverName: String?,
|
||||
attemptedGatewayName: String?,
|
||||
): String =
|
||||
serverName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: attemptedGatewayName
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: "Home Gateway"
|
||||
|
||||
private data class GatewayConfig(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
@@ -1163,16 +987,11 @@ private fun recoveryGatewayDetail(
|
||||
ready: Boolean,
|
||||
remoteAddress: String?,
|
||||
statusText: String,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem?,
|
||||
): String =
|
||||
remoteAddress
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: if (ready) {
|
||||
"Ready for chat and voice"
|
||||
} else if (gatewayConnectionProblem?.isPairingRequired == true && !gatewayConnectionProblem.canAutoRetry) {
|
||||
recoveryGatewayApprovalCommand(gatewayConnectionProblem)
|
||||
?.let { "Gateway approval is pending. Run this on the gateway host:" }
|
||||
?: "Gateway approval is pending. Run openclaw devices list on the gateway host, approve this phone, then retry."
|
||||
} else if (statusText.contains("operator offline", ignoreCase = true)) {
|
||||
"Gateway paired. Waiting for operator access."
|
||||
} else if (gatewayStatusLooksLikePairing(statusText)) {
|
||||
@@ -1181,25 +1000,6 @@ private fun recoveryGatewayDetail(
|
||||
"Gateway unreachable"
|
||||
}
|
||||
|
||||
private fun recoveryGatewayApprovalCommand(gatewayConnectionProblem: GatewayConnectionProblem?): String? {
|
||||
if (gatewayConnectionProblem?.isPairingRequired != true || gatewayConnectionProblem.canAutoRetry) return null
|
||||
val requestId = gatewayConnectionProblem.requestId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
return if (requestId != null) {
|
||||
"openclaw devices approve $requestId"
|
||||
} else {
|
||||
"openclaw devices list"
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyApprovalCommand(
|
||||
context: Context,
|
||||
command: String,
|
||||
) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw pairing approval command", command))
|
||||
Toast.makeText(context, "Approval command copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
/** Copies the onboarding recovery snapshot for support without including credentials. */
|
||||
private fun copyGatewayDiagnostic(
|
||||
context: Context,
|
||||
@@ -1207,16 +1007,11 @@ private fun copyGatewayDiagnostic(
|
||||
serverName: String?,
|
||||
remoteAddress: String?,
|
||||
ready: Boolean,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem?,
|
||||
) {
|
||||
val approvalCommand = recoveryGatewayApprovalCommand(gatewayConnectionProblem)
|
||||
val diagnostic =
|
||||
listOfNotNull(
|
||||
listOf(
|
||||
"OpenClaw Android gateway diagnostic",
|
||||
"Status: $statusText",
|
||||
gatewayConnectionProblem?.message?.let { "Gateway problem: $it" },
|
||||
gatewayConnectionProblem?.requestId?.let { "Pairing request: $it" },
|
||||
approvalCommand?.let { "Approval command: $it" },
|
||||
"Gateway: ${serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway"}",
|
||||
"Address: ${remoteAddress?.takeIf { it.isNotBlank() } ?: "Not available"}",
|
||||
"Ready: ${if (ready) "yes" else "no"}",
|
||||
|
||||
@@ -6,6 +6,7 @@ import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.providerDisplayName
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
@@ -16,20 +17,23 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -38,20 +42,25 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/** Android provider readiness screen backed by the configured gateway model view. */
|
||||
/** Android providers/models browser backed by the gateway catalog. */
|
||||
@Composable
|
||||
internal fun ProvidersModelsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onAddProvider: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
@@ -59,6 +68,9 @@ internal fun ProvidersModelsScreen(
|
||||
val refreshing by viewModel.modelCatalogRefreshing.collectAsState()
|
||||
val errorText by viewModel.modelCatalogErrorText.collectAsState()
|
||||
val providerRows = providerRows(providers = providers, models = models)
|
||||
val modelGroups = sortedModelGroups(models)
|
||||
val setupRows = providerSetupRows(providerRows)
|
||||
var expandedModelProviders by rememberSaveable { mutableStateOf(emptyList<String>()) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
@@ -66,16 +78,9 @@ internal fun ProvidersModelsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 13.dp)) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
contentPadding = PaddingValues(bottom = 4.dp),
|
||||
) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp), contentPadding = PaddingValues(bottom = 112.dp)) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
@@ -84,11 +89,12 @@ internal fun ProvidersModelsScreen(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
ProviderHeaderIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
|
||||
ProviderHeaderIconButton(icon = Icons.Default.Add, contentDescription = "Add provider", outlined = true, onClick = onAddProvider)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "Providers & Models", style = ClawTheme.type.display.copy(fontSize = 14.8.sp, lineHeight = 18.sp), color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(
|
||||
text = "Review provider readiness\nand configured models.",
|
||||
text = "Connect and manage AI providers\nBrowse models and their capabilities.",
|
||||
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
@@ -102,17 +108,26 @@ internal fun ProvidersModelsScreen(
|
||||
providerRows = providerRows,
|
||||
modelCount = models.size,
|
||||
onRefresh = viewModel::refreshModelCatalog,
|
||||
onSetup = onAddProvider,
|
||||
refreshing = refreshing,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Provider setup")
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSetupList(rows = setupRows, onSetup = onAddProvider)
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Connected providers")
|
||||
}
|
||||
|
||||
item {
|
||||
if (!isConnected && providerRows.isEmpty()) {
|
||||
ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness.")
|
||||
ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness and model catalog.")
|
||||
} else {
|
||||
ProviderList(rows = providerRows, refreshing = refreshing)
|
||||
}
|
||||
@@ -125,12 +140,50 @@ internal fun ProvidersModelsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ProviderSectionLabel(title = "Model catalog")
|
||||
}
|
||||
|
||||
if (modelGroups.isEmpty()) {
|
||||
item {
|
||||
ModelCatalogEmpty(
|
||||
title = if (refreshing) "Loading models" else "No models loaded",
|
||||
body = if (isConnected) "Refresh after configuring a provider on the Gateway." else "Connect the Gateway to browse models.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(modelGroups, key = { it.first }) { entry ->
|
||||
val expanded = expandedModelProviders.contains(entry.first)
|
||||
ModelGroup(
|
||||
provider = entry.first,
|
||||
models = entry.second,
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
expandedModelProviders =
|
||||
if (expanded) {
|
||||
expandedModelProviders - entry.first
|
||||
} else {
|
||||
expandedModelProviders + entry.first
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ProviderAddButton(onClick = onAddProvider, modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ProviderRow(
|
||||
private data class ProviderSetupRow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val subtitle: String,
|
||||
val ready: Boolean,
|
||||
)
|
||||
|
||||
private data class ProviderRow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String,
|
||||
@@ -138,28 +191,28 @@ internal data class ProviderRow(
|
||||
val modelCount: Int,
|
||||
)
|
||||
|
||||
/** Combines gateway auth-provider readiness with configured model providers. */
|
||||
internal fun providerRows(
|
||||
/** Combines auth-provider readiness rows with catalog-only providers. */
|
||||
private fun providerRows(
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): List<ProviderRow> {
|
||||
val modelCounts = models.groupingBy { it.provider }.eachCount()
|
||||
val authRows =
|
||||
providers
|
||||
.map { provider ->
|
||||
val ready = modelProviderReady(provider.status)
|
||||
ProviderRow(
|
||||
id = provider.id,
|
||||
name = provider.displayName,
|
||||
status = if (ready) "Ready" else "Needs attention",
|
||||
ready = ready,
|
||||
modelCount = modelCounts[provider.id] ?: 0,
|
||||
)
|
||||
}
|
||||
val authProviderIds = authRows.mapTo(mutableSetOf()) { it.id.trim().lowercase() }
|
||||
val configuredModelRows =
|
||||
providers.map { provider ->
|
||||
val ready = modelProviderReady(provider.status)
|
||||
ProviderRow(
|
||||
id = provider.id,
|
||||
name = provider.displayName,
|
||||
status = if (ready) "Ready" else "Needs setup",
|
||||
ready = ready,
|
||||
modelCount = modelCounts[provider.id] ?: 0,
|
||||
)
|
||||
}
|
||||
// Static/catalog-only providers may expose models without a matching auth
|
||||
// provider row; keep them visible as ready providers.
|
||||
val missingAuthRows =
|
||||
modelCounts.keys
|
||||
.filter { provider -> provider.trim().lowercase() !in authProviderIds }
|
||||
.filter { provider -> authRows.none { it.id == provider } }
|
||||
.map { provider ->
|
||||
ProviderRow(
|
||||
id = provider,
|
||||
@@ -169,9 +222,33 @@ internal fun providerRows(
|
||||
modelCount = modelCounts[provider] ?: 0,
|
||||
)
|
||||
}
|
||||
return (authRows + configuredModelRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() }))
|
||||
return (authRows + missingAuthRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() }))
|
||||
}
|
||||
|
||||
private fun providerSetupRows(providerRows: List<ProviderRow>): List<ProviderSetupRow> {
|
||||
val byId = providerRows.associateBy { it.id.trim().lowercase() }
|
||||
return listOf("openai", "anthropic", "google", "openrouter", "ollama").map { id ->
|
||||
val row = byId[id] ?: byId["ollama-local"].takeIf { id == "ollama" }
|
||||
ProviderSetupRow(
|
||||
id = id,
|
||||
name = providerDisplayName(id),
|
||||
subtitle = providerSetupSubtitle(id, row),
|
||||
ready = row?.ready == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun providerSetupSubtitle(
|
||||
id: String,
|
||||
row: ProviderRow?,
|
||||
): String =
|
||||
when {
|
||||
row?.ready == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Ready"
|
||||
row != null -> "Finish setup to use ${row.name}"
|
||||
id == "ollama" -> "Use models running on your network"
|
||||
else -> "Add provider credentials on your Gateway"
|
||||
}
|
||||
|
||||
/** Normalizes gateway provider status strings into a ready/not-ready boolean. */
|
||||
internal fun modelProviderReady(status: String): Boolean {
|
||||
val normalized = status.trim().lowercase()
|
||||
@@ -182,6 +259,14 @@ internal fun modelProviderReady(status: String): Boolean {
|
||||
normalized == "static"
|
||||
}
|
||||
|
||||
/** Groups models by provider using the same display priority as provider rows. */
|
||||
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
|
||||
models
|
||||
.groupBy { it.provider }
|
||||
.entries
|
||||
.sortedWith(compareBy({ providerPriority(it.key) }, { providerDisplayName(it.key).lowercase() }))
|
||||
.map { it.key to it.value }
|
||||
|
||||
private fun providerPriority(row: ProviderRow): Int = providerPriority(row.id)
|
||||
|
||||
private fun providerPriority(provider: String): Int =
|
||||
@@ -203,15 +288,7 @@ private fun ProviderList(
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
if (rows.isEmpty()) {
|
||||
ProviderListRow(
|
||||
ProviderRow(
|
||||
id = "loading",
|
||||
name = "Provider catalog",
|
||||
status = if (refreshing) "Loading" else "No providers",
|
||||
ready = false,
|
||||
modelCount = 0,
|
||||
),
|
||||
)
|
||||
ProviderListRow(ProviderRow(id = "loading", name = "Provider catalog", status = if (refreshing) "Loading" else "No providers", ready = false, modelCount = 0))
|
||||
} else {
|
||||
val visibleRows = rows.take(5)
|
||||
visibleRows.forEachIndexed { index, row ->
|
||||
@@ -232,6 +309,7 @@ private fun ProviderOverviewPanel(
|
||||
modelCount: Int,
|
||||
refreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
val readyCount = providerRows.count { it.ready }
|
||||
val needsSetupCount = providerRows.count { !it.ready }
|
||||
@@ -240,14 +318,17 @@ private fun ProviderOverviewPanel(
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ProviderMetricTile(label = "Ready", value = readyCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Models", value = modelCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Needs", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Setup", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
|
||||
}
|
||||
Text(
|
||||
text = if (isConnected) "Refresh to recheck provider readiness from your Gateway." else "Connect your Gateway to view provider readiness.",
|
||||
text = if (isConnected) "Choose a provider below, then finish credentials on your Gateway." else "Connect your Gateway before adding model providers.",
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
ClawSecondaryButton(text = if (refreshing) "Refreshing" else "Refresh", onClick = onRefresh, enabled = isConnected && !refreshing, modifier = Modifier.fillMaxWidth())
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(text = if (refreshing) "Refreshing" else "Refresh", onClick = onRefresh, enabled = isConnected && !refreshing, modifier = Modifier.weight(1f))
|
||||
ClawPrimaryButton(text = "Setup Provider", onClick = onSetup, enabled = isConnected, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,13 +353,55 @@ private fun ProviderMetricTile(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSetupList(
|
||||
rows: List<ProviderSetupRow>,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
rows.forEachIndexed { index, row ->
|
||||
ProviderSetupListRow(row = row, onClick = onSetup)
|
||||
if (index != rows.lastIndex) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSetupListRow(
|
||||
row: ProviderSetupRow,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ProviderBadge(text = row.name)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
Text(text = if (row.ready) "Ready" else "Setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open ${row.name}", modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderListRow(row: ProviderRow) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ProviderBadge(text = row.name)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "No configured models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "Provider setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
@@ -305,6 +428,78 @@ private fun providerInitials(value: String): String =
|
||||
.joinToString("")
|
||||
.ifBlank { "AI" }
|
||||
|
||||
@Composable
|
||||
private fun ModelCatalogEmpty(
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 11.dp, vertical = 10.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = body, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelGroup(
|
||||
provider: String,
|
||||
models: List<GatewayModelSummary>,
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ProviderBadge(text = providerDisplayName(provider))
|
||||
Text(text = providerDisplayName(provider), style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
|
||||
ProviderMiniTag(text = "${models.size} models")
|
||||
Icon(imageVector = if (expanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = if (expanded) "Collapse ${providerDisplayName(provider)} models" else "Expand ${providerDisplayName(provider)} models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
val visibleModels = if (expanded) models else models.take(3)
|
||||
visibleModels.forEachIndexed { index, model ->
|
||||
ModelRow(model)
|
||||
if (index != visibleModels.lastIndex || models.size > visibleModels.size) {
|
||||
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
if (models.size > visibleModels.size) {
|
||||
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "View all models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, modifier = Modifier.weight(1f))
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "View all models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModelRow(model: GatewayModelSummary) {
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = model.name, style = ClawTheme.type.mono, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
modelCapabilityLabels(model).take(3).forEach { label ->
|
||||
ProviderMiniTag(text = label)
|
||||
}
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
}
|
||||
}
|
||||
|
||||
/** Derives compact capability chips for model catalog rows. */
|
||||
private fun modelCapabilityLabels(model: GatewayModelSummary): List<String> =
|
||||
buildList {
|
||||
if (model.supportsReasoning) add("Reasoning")
|
||||
if (model.supportsVision) add("Vision")
|
||||
if (model.supportsAudio) add("Voice")
|
||||
if (model.supportsDocuments) add("Docs")
|
||||
if ((model.contextTokens ?: 0L) >= 100_000L) add("Long context")
|
||||
if (isEmpty()) add("Fast")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderSectionLabel(title: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
@@ -332,3 +527,39 @@ private fun ProviderHeaderIconButton(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderAddButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth().height(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Add, contentDescription = null, modifier = Modifier.size(17.dp))
|
||||
Spacer(modifier = Modifier.width(7.dp))
|
||||
Text(text = "Open Gateway Setup", style = ClawTheme.type.label, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProviderMiniTag(text: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(5.dp),
|
||||
color = Color.Transparent,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Text(text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 0.5.dp), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,11 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -91,15 +88,8 @@ internal fun SessionsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
contentPadding = PaddingValues(bottom = 4.dp),
|
||||
) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -143,16 +133,11 @@ internal fun SessionsScreen(
|
||||
|
||||
if (visibleSessions.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxHeight(0.56f).fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ClawEmptyState(
|
||||
title = emptySessionTitle(filter),
|
||||
body = emptySessionBody(filter),
|
||||
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
|
||||
)
|
||||
}
|
||||
ClawEmptyState(
|
||||
title = emptySessionTitle(filter),
|
||||
body = emptySessionBody(filter),
|
||||
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(visibleSessions, key = { it.key }) { session ->
|
||||
@@ -170,6 +155,10 @@ internal fun SessionsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.NotificationPackageFilterMode
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawIconBadge
|
||||
@@ -43,15 +44,11 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -496,8 +493,6 @@ private fun playVoiceSetupTone() {
|
||||
Handler(Looper.getMainLooper()).postDelayed({ tone.release() }, 300L)
|
||||
}
|
||||
|
||||
private const val NOTIFICATION_PICKER_RESULT_LIMIT = 40
|
||||
|
||||
@Composable
|
||||
private fun NotificationSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
@@ -512,19 +507,6 @@ private fun NotificationSettingsScreen(
|
||||
val quietEnd by viewModel.notificationForwardingQuietEnd.collectAsState()
|
||||
val maxEventsPerMinute by viewModel.notificationForwardingMaxEventsPerMinute.collectAsState()
|
||||
val modeLabel = if (mode == NotificationPackageFilterMode.Blocklist) "Blocklist" else "Allowlist"
|
||||
val installedApps = remember(context, packages) { queryInstalledApps(context, packages) }
|
||||
var notificationPickerExpanded by remember { mutableStateOf(false) }
|
||||
var notificationAppSearch by remember { mutableStateOf("") }
|
||||
var notificationShowSystemApps by remember { mutableStateOf(false) }
|
||||
val filteredApps =
|
||||
remember(installedApps, packages, notificationAppSearch, notificationShowSystemApps) {
|
||||
filterNotificationAppsForPicker(
|
||||
apps = installedApps,
|
||||
selectedPackages = packages,
|
||||
query = notificationAppSearch,
|
||||
showSystemApps = notificationShowSystemApps,
|
||||
)
|
||||
}
|
||||
var listenerEnabled by remember { mutableStateOf(DeviceNotificationListenerService.isAccessEnabled(context)) }
|
||||
val notificationPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
@@ -585,124 +567,6 @@ private fun NotificationSettingsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
NotificationPackagePickerPanel(
|
||||
mode = mode,
|
||||
selectedPackages = packages,
|
||||
apps = filteredApps,
|
||||
search = notificationAppSearch,
|
||||
showSystemApps = notificationShowSystemApps,
|
||||
expanded = notificationPickerExpanded,
|
||||
onSearchChange = { notificationAppSearch = it },
|
||||
onShowSystemAppsChange = { notificationShowSystemApps = it },
|
||||
onExpandedChange = { notificationPickerExpanded = it },
|
||||
onPackageSelectionChange = { packageName, selected ->
|
||||
val next = packages.toMutableSet()
|
||||
if (selected) {
|
||||
next.add(packageName)
|
||||
} else {
|
||||
next.remove(packageName)
|
||||
}
|
||||
viewModel.setNotificationForwardingPackagesCsv(next.sorted().joinToString(","))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationPackagePickerPanel(
|
||||
mode: NotificationPackageFilterMode,
|
||||
selectedPackages: Set<String>,
|
||||
apps: List<InstalledApp>,
|
||||
search: String,
|
||||
showSystemApps: Boolean,
|
||||
expanded: Boolean,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onShowSystemAppsChange: (Boolean) -> Unit,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onPackageSelectionChange: (String, Boolean) -> Unit,
|
||||
) {
|
||||
val visibleApps = apps.take(NOTIFICATION_PICKER_RESULT_LIMIT)
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "App Filter", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = notificationPackageSelectionSummary(mode = mode, selectedCount = selectedPackages.size),
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
ClawSecondaryButton(
|
||||
text = if (expanded) "Close App Picker" else "Open App Picker",
|
||||
onClick = { onExpandedChange(!expanded) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (expanded) {
|
||||
ClawTextField(value = search, onValueChange = onSearchChange, placeholder = "Search apps")
|
||||
SettingsToggleListRow(
|
||||
SettingsToggleRow(
|
||||
title = "Show System Apps",
|
||||
subtitle = "Include Android and background packages.",
|
||||
icon = Icons.Default.Storage,
|
||||
checked = showSystemApps,
|
||||
onCheckedChange = onShowSystemAppsChange,
|
||||
),
|
||||
)
|
||||
if (visibleApps.isEmpty()) {
|
||||
Text(text = "No matching apps.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
} else {
|
||||
ClawSeparatedColumn(items = visibleApps) { app ->
|
||||
NotificationPackageAppRow(
|
||||
app = app,
|
||||
selected = selectedPackages.contains(app.packageName),
|
||||
onSelectedChange = { selected -> onPackageSelectionChange(app.packageName, selected) },
|
||||
)
|
||||
}
|
||||
if (apps.size > visibleApps.size) {
|
||||
Text(
|
||||
text = "Showing ${visibleApps.size} of ${apps.size}. Refine search for more.",
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationPackageAppRow(
|
||||
app: InstalledApp,
|
||||
selected: Boolean,
|
||||
onSelectedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 58.dp)
|
||||
.clickable { onSelectedChange(!selected) }
|
||||
.padding(vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
ClawTextBadge(text = notificationAppBadge(app.label))
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(
|
||||
text = app.label,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = app.packageName,
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Switch(checked = selected, onCheckedChange = onSelectedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,7 +581,6 @@ private fun PhoneCapabilitiesScreen(
|
||||
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
|
||||
val preventSleep by viewModel.preventSleep.collectAsState()
|
||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
||||
val installedAppsSharingEnabled by viewModel.installedAppsSharingEnabled.collectAsState()
|
||||
val cameraPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
viewModel.setCameraEnabled(granted)
|
||||
@@ -772,13 +635,6 @@ private fun PhoneCapabilitiesScreen(
|
||||
listOf(
|
||||
SettingsToggleRow("Camera", "Allow camera tools when requested.", Icons.Default.CameraAlt, cameraEnabled, ::setCameraAccess),
|
||||
SettingsToggleRow("Precise Location", "Share precise location while location is enabled.", Icons.Default.LocationOn, locationPreciseEnabled, ::setPreciseLocation),
|
||||
SettingsToggleRow(
|
||||
"Installed Apps",
|
||||
if (installedAppsSharingEnabled) "OpenClaw can list launcher-visible apps." else "App list stays on this phone.",
|
||||
Icons.Default.Storage,
|
||||
installedAppsSharingEnabled,
|
||||
viewModel::setInstalledAppsSharingEnabled,
|
||||
),
|
||||
SettingsToggleRow("Keep Awake", "Keep the node available during active work.", Icons.Default.Bolt, preventSleep, viewModel::setPreventSleep),
|
||||
SettingsToggleRow("Canvas Status", "Show screen-sharing debug state.", Icons.AutoMirrored.Filled.ScreenShare, canvasDebugStatusEnabled, viewModel::setCanvasDebugStatusEnabled),
|
||||
),
|
||||
@@ -896,14 +752,18 @@ private fun GatewaySettingsScreen(
|
||||
.orEmpty()
|
||||
.ifEmpty { passwordInput.trim() }
|
||||
validationText = null
|
||||
viewModel.saveGatewayConfigAndConnect(
|
||||
host = endpointConfig.host,
|
||||
port = endpointConfig.port,
|
||||
tls = endpointConfig.tls,
|
||||
token = token,
|
||||
bootstrapToken = bootstrapToken,
|
||||
password = password,
|
||||
resetSetupAuth = setup != null,
|
||||
viewModel.setManualEnabled(true)
|
||||
viewModel.setManualHost(endpointConfig.host)
|
||||
viewModel.setManualPort(endpointConfig.port)
|
||||
viewModel.setManualTls(endpointConfig.tls)
|
||||
viewModel.setGatewayBootstrapToken(bootstrapToken)
|
||||
viewModel.setGatewayToken(token)
|
||||
viewModel.setGatewayPassword(password)
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = endpointConfig.host, port = endpointConfig.port),
|
||||
token = token.ifEmpty { null },
|
||||
bootstrapToken = bootstrapToken.ifEmpty { null },
|
||||
password = password.ifEmpty { null },
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -966,7 +826,7 @@ private fun AboutSettingsScreen(
|
||||
listOf(
|
||||
SettingsMetric("Android App", BuildConfig.VERSION_NAME),
|
||||
SettingsMetric("Build", BuildConfig.VERSION_CODE.toString()),
|
||||
SettingsMetric("Channel", androidDistributionChannel()),
|
||||
SettingsMetric("Channel", "Play"),
|
||||
SettingsMetric("Gateway", currentGatewayVersion ?: "Not connected"),
|
||||
),
|
||||
)
|
||||
@@ -989,14 +849,6 @@ private fun AboutSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun androidDistributionChannel(flavor: String = BuildConfig.FLAVOR): String =
|
||||
when (flavor.trim()) {
|
||||
"play" -> "Play"
|
||||
"thirdParty" -> "Third-party"
|
||||
"" -> "Unknown"
|
||||
else -> flavor.trim()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutStatusRow(
|
||||
title: String,
|
||||
@@ -1035,11 +887,8 @@ internal fun SettingsDetailFrame(
|
||||
onBack: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 6.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
SettingsBackButton(onClick = onBack)
|
||||
@@ -1055,6 +904,9 @@ internal fun SettingsDetailFrame(
|
||||
content()
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1260,58 +1112,6 @@ private fun cronJobStatus(job: GatewayCronJobSummary): ClawStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies query/system visibility rules while always preserving selected packages. */
|
||||
internal fun filterNotificationAppsForPicker(
|
||||
apps: List<InstalledApp>,
|
||||
selectedPackages: Set<String>,
|
||||
query: String,
|
||||
showSystemApps: Boolean,
|
||||
): List<InstalledApp> {
|
||||
val normalizedQuery = query.trim().lowercase()
|
||||
return apps.filter { app ->
|
||||
val selected = app.packageName in selectedPackages
|
||||
val visibleByType = showSystemApps || !app.isSystemApp || selected
|
||||
val visibleBySearch =
|
||||
normalizedQuery.isEmpty() ||
|
||||
app.label.lowercase().contains(normalizedQuery) ||
|
||||
app.packageName.lowercase().contains(normalizedQuery)
|
||||
visibleByType && visibleBySearch
|
||||
}
|
||||
}
|
||||
|
||||
/** Summarizes allowlist/blocklist mode with an empty-state warning when needed. */
|
||||
private fun notificationPackageSelectionSummary(
|
||||
mode: NotificationPackageFilterMode,
|
||||
selectedCount: Int,
|
||||
): String =
|
||||
when (mode) {
|
||||
NotificationPackageFilterMode.Allowlist ->
|
||||
if (selectedCount == 0) {
|
||||
"No apps selected. Nothing forwards until you add apps."
|
||||
} else {
|
||||
"$selectedCount ${if (selectedCount == 1) "app" else "apps"} allowed to forward."
|
||||
}
|
||||
NotificationPackageFilterMode.Blocklist ->
|
||||
if (selectedCount == 0) {
|
||||
"No apps blocked. Apps can forward unless you add blocks."
|
||||
} else {
|
||||
"$selectedCount ${if (selectedCount == 1) "app" else "apps"} blocked from forwarding."
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds compact two-letter app badges from package-picker labels. */
|
||||
private fun notificationAppBadge(label: String): String {
|
||||
val initials =
|
||||
label
|
||||
.split(' ', '-', '_', '.')
|
||||
.asSequence()
|
||||
.filter { it.isNotBlank() }
|
||||
.take(2)
|
||||
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
|
||||
.joinToString("")
|
||||
return initials.ifBlank { "A" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts cron wake times into short relative labels for scheduled-work rows.
|
||||
*/
|
||||
|
||||
@@ -1222,6 +1222,82 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
/** App entry shown in the notification-forwarding package picker. */
|
||||
data class InstalledApp(
|
||||
val label: String,
|
||||
val packageName: String,
|
||||
val isSystemApp: Boolean,
|
||||
)
|
||||
|
||||
/** Reads launcher, recent-notification, and configured packages for the picker. */
|
||||
private fun queryInstalledApps(
|
||||
context: Context,
|
||||
configuredPackages: Set<String>,
|
||||
): List<InstalledApp> {
|
||||
val packageManager = context.packageManager
|
||||
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
|
||||
|
||||
val launcherPackages =
|
||||
packageManager
|
||||
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
|
||||
.asSequence()
|
||||
.mapNotNull {
|
||||
it.activityInfo
|
||||
?.packageName
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
}.toMutableSet()
|
||||
|
||||
val recentNotificationPackages =
|
||||
DeviceNotificationListenerService
|
||||
.recentPackages(context)
|
||||
.asSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toList()
|
||||
|
||||
val candidatePackages =
|
||||
resolveNotificationCandidatePackages(
|
||||
launcherPackages = launcherPackages,
|
||||
recentPackages = recentNotificationPackages,
|
||||
configuredPackages = configuredPackages,
|
||||
appPackageName = context.packageName,
|
||||
)
|
||||
|
||||
return candidatePackages
|
||||
.asSequence()
|
||||
.mapNotNull { packageName ->
|
||||
runCatching {
|
||||
val appInfo = packageManager.getApplicationInfo(packageName, 0)
|
||||
val label = packageManager.getApplicationLabel(appInfo).toString().trim()
|
||||
InstalledApp(
|
||||
label = if (label.isEmpty()) packageName else label,
|
||||
packageName = packageName,
|
||||
isSystemApp = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0,
|
||||
)
|
||||
}.getOrNull()
|
||||
}.sortedWith(compareBy<InstalledApp> { it.label.lowercase() }.thenBy { it.packageName })
|
||||
.toList()
|
||||
}
|
||||
|
||||
/** Merges package sources while excluding OpenClaw from its own forwarding filter. */
|
||||
internal fun resolveNotificationCandidatePackages(
|
||||
launcherPackages: Set<String>,
|
||||
recentPackages: List<String>,
|
||||
configuredPackages: Set<String>,
|
||||
appPackageName: String,
|
||||
): Set<String> {
|
||||
val blockedPackage = appPackageName.trim()
|
||||
return sequenceOf(
|
||||
configuredPackages.asSequence(),
|
||||
launcherPackages.asSequence(),
|
||||
recentPackages.asSequence(),
|
||||
).flatten()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && it != blockedPackage }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/** Shared Material text-field colors for the legacy mobile settings sheet. */
|
||||
@Composable
|
||||
private fun settingsTextFieldColors() =
|
||||
|
||||
@@ -9,14 +9,11 @@ import ai.openclaw.app.HomeDestination
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.NodeRuntime
|
||||
import ai.openclaw.app.ui.chat.ChatScreen
|
||||
import ai.openclaw.app.ui.design.ClawBottomNav
|
||||
import ai.openclaw.app.ui.design.ClawDesignTheme
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawNavItem
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -27,26 +24,20 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.ScreenShare
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
@@ -63,7 +54,6 @@ import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -79,35 +69,23 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
internal enum class Tab(
|
||||
private enum class Tab(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
Overview(key = "overview", label = "Home", icon = Icons.Default.Home),
|
||||
Chat(key = "chat", label = "Chat", icon = Icons.Outlined.ChatBubbleOutline),
|
||||
Voice(key = "voice", label = "Voice", icon = Icons.Outlined.MicNone),
|
||||
Sessions(key = "sessions", label = "Sessions", icon = Icons.Outlined.AccessTime),
|
||||
Settings(key = "settings", label = "Settings", icon = Icons.Outlined.Settings),
|
||||
ProvidersModels(key = "providers-models", label = "Providers", icon = Icons.Outlined.Inventory2),
|
||||
Overview(key = "overview", label = "Home"),
|
||||
Chat(key = "chat", label = "Chat"),
|
||||
Voice(key = "voice", label = "Voice"),
|
||||
Sessions(key = "sessions", label = "Sessions"),
|
||||
Settings(key = "settings", label = "Settings"),
|
||||
ProvidersModels(key = "providers-models", label = "Providers"),
|
||||
}
|
||||
|
||||
private val shellNavTabs = listOf(Tab.Overview, Tab.Chat, Tab.Voice, Tab.Settings)
|
||||
|
||||
private val shellContentInsets: WindowInsets
|
||||
@Composable get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
|
||||
internal fun shellBottomNavVisible(
|
||||
keyboardVisible: Boolean,
|
||||
commandOpen: Boolean,
|
||||
): Boolean = !keyboardVisible && !commandOpen
|
||||
|
||||
/** Main post-onboarding shell that owns top-level Android navigation state. */
|
||||
@Composable
|
||||
fun ShellScreen(
|
||||
@@ -119,10 +97,8 @@ fun ShellScreen(
|
||||
var settingsRoute by rememberSaveable { mutableStateOf(SettingsRoute.Home) }
|
||||
var returnToOverviewFromSettings by rememberSaveable { mutableStateOf(false) }
|
||||
var commandOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var voiceScreenWasActive by rememberSaveable { mutableStateOf(false) }
|
||||
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
|
||||
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
|
||||
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
|
||||
|
||||
LaunchedEffect(requestedHomeDestination) {
|
||||
val destination = requestedHomeDestination ?: return@LaunchedEffect
|
||||
@@ -143,12 +119,8 @@ fun ShellScreen(
|
||||
viewModel.clearRequestedHomeDestination()
|
||||
}
|
||||
|
||||
LaunchedEffect(activeTab, runtimeInitialized) {
|
||||
val voiceScreenActive = activeTab == Tab.Voice
|
||||
if (voiceScreenActive || voiceScreenWasActive || runtimeInitialized) {
|
||||
viewModel.setVoiceScreenActive(voiceScreenActive)
|
||||
}
|
||||
voiceScreenWasActive = voiceScreenActive
|
||||
LaunchedEffect(activeTab) {
|
||||
viewModel.setVoiceScreenActive(activeTab == Tab.Voice)
|
||||
}
|
||||
|
||||
BackHandler(enabled = activeTab != Tab.Overview) {
|
||||
@@ -159,139 +131,117 @@ fun ShellScreen(
|
||||
commandOpen = false
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val keyboardVisible = WindowInsets.ime.getBottom(density) > 0
|
||||
val showBottomNav = shellBottomNavVisible(keyboardVisible = keyboardVisible, commandOpen = commandOpen)
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = ClawTheme.colors.canvas,
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
bottomBar = {
|
||||
if (showBottomNav) {
|
||||
ClawBottomNav(
|
||||
items = shellNavTabs.map { ClawNavItem(key = it.key, label = it.label, icon = it.icon) },
|
||||
selectedKey = if (activeTab in shellNavTabs) activeTab.key else Tab.Overview.key,
|
||||
onSelect = { key ->
|
||||
val next = shellNavTabs.firstOrNull { it.key == key } ?: Tab.Overview
|
||||
if (next == Tab.Settings) {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
returnToOverviewFromSettings = false
|
||||
}
|
||||
activeTab = next
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { shellPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(shellPadding)) {
|
||||
when (activeTab) {
|
||||
Tab.Overview ->
|
||||
OverviewScreen(
|
||||
viewModel = viewModel,
|
||||
onSelectTab = { activeTab = it },
|
||||
onOpenSettingsRoute = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = true
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
Tab.Chat ->
|
||||
ChatShellScreen(
|
||||
viewModel = viewModel,
|
||||
onVoice = { activeTab = Tab.Voice },
|
||||
onOpenSessions = { activeTab = Tab.Sessions },
|
||||
)
|
||||
Tab.Voice ->
|
||||
VoiceShellScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenGatewaySettings = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
onOpenVoiceSettings = {
|
||||
settingsRoute = SettingsRoute.Voice
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
Tab.ProvidersModels ->
|
||||
ProvidersModelsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { activeTab = Tab.Overview },
|
||||
)
|
||||
Tab.Sessions ->
|
||||
SessionsScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenChat = { activeTab = Tab.Chat },
|
||||
)
|
||||
Tab.Settings ->
|
||||
SettingsShellScreen(
|
||||
viewModel = viewModel,
|
||||
route = settingsRoute,
|
||||
onRouteChange = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = false
|
||||
},
|
||||
onRouteBack = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
if (returnToOverviewFromSettings) {
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Overview
|
||||
}
|
||||
},
|
||||
onBackHome = { activeTab = Tab.Overview },
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (commandOpen) {
|
||||
CommandPalette(
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (activeTab) {
|
||||
Tab.Overview ->
|
||||
OverviewScreen(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { commandOpen = false },
|
||||
onOpenChat = {
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
onSelectTab = { activeTab = it },
|
||||
onOpenSettingsRoute = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = true
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
onOpenVoice = {
|
||||
activeTab = Tab.Voice
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSessions = {
|
||||
activeTab = Tab.Sessions
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenProviders = {
|
||||
activeTab = Tab.ProvidersModels
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSettings = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
Tab.Chat ->
|
||||
ChatShellScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { activeTab = Tab.Overview },
|
||||
onVoice = { activeTab = Tab.Voice },
|
||||
)
|
||||
Tab.Voice ->
|
||||
VoiceShellScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenGatewaySettings = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSession = { sessionKey ->
|
||||
viewModel.switchChatSession(sessionKey)
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
onOpenVoiceSettings = {
|
||||
settingsRoute = SettingsRoute.Voice
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
}
|
||||
Tab.ProvidersModels ->
|
||||
ProvidersModelsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { activeTab = Tab.Overview },
|
||||
onAddProvider = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
Tab.Sessions ->
|
||||
SessionsScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenChat = { activeTab = Tab.Chat },
|
||||
)
|
||||
Tab.Settings ->
|
||||
SettingsShellScreen(
|
||||
viewModel = viewModel,
|
||||
route = settingsRoute,
|
||||
onRouteChange = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = false
|
||||
},
|
||||
onRouteBack = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
if (returnToOverviewFromSettings) {
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Overview
|
||||
}
|
||||
},
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
}
|
||||
|
||||
pendingTrust?.let { prompt ->
|
||||
// Gateway certificate trust is modal across the shell so navigation
|
||||
// cannot hide a changed TLS identity prompt.
|
||||
GatewayTrustDialog(
|
||||
prompt = prompt,
|
||||
onAccept = viewModel::acceptGatewayTrustPrompt,
|
||||
onDecline = viewModel::declineGatewayTrustPrompt,
|
||||
)
|
||||
}
|
||||
if (commandOpen) {
|
||||
CommandPalette(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { commandOpen = false },
|
||||
onOpenChat = {
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenVoice = {
|
||||
activeTab = Tab.Voice
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSessions = {
|
||||
activeTab = Tab.Sessions
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenProviders = {
|
||||
activeTab = Tab.ProvidersModels
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSettings = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSession = { sessionKey ->
|
||||
viewModel.switchChatSession(sessionKey)
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingTrust?.let { prompt ->
|
||||
// Gateway certificate trust is modal across the shell so navigation
|
||||
// cannot hide a changed TLS identity prompt.
|
||||
GatewayTrustDialog(
|
||||
prompt = prompt,
|
||||
onAccept = viewModel::acceptGatewayTrustPrompt,
|
||||
onDecline = viewModel::declineGatewayTrustPrompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,39 +289,33 @@ private fun OverviewScreen(
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
val providers by viewModel.modelAuthProviders.collectAsState()
|
||||
val agents by viewModel.gatewayAgents.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val cronStatus by viewModel.cronStatus.collectAsState()
|
||||
val usageSummary by viewModel.usageSummary.collectAsState()
|
||||
val skillsSummary by viewModel.skillsSummary.collectAsState()
|
||||
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val channelsSummary by viewModel.channelsSummary.collectAsState()
|
||||
val readyProviderCount = providerRows(providers = providers, models = models).count { it.ready }
|
||||
val attentionRows =
|
||||
homeAttentionRows(
|
||||
isConnected = isConnected,
|
||||
pendingApprovals = pendingToolCalls.size,
|
||||
channelsSummary = channelsSummary,
|
||||
nodesDevicesSummary = nodesDevicesSummary,
|
||||
readyProviderCount = readyProviderCount,
|
||||
)
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshChatSessions(limit = 20)
|
||||
viewModel.refreshModelCatalog()
|
||||
viewModel.refreshAgents()
|
||||
viewModel.refreshCronJobs()
|
||||
viewModel.refreshUsage()
|
||||
viewModel.refreshSkills()
|
||||
viewModel.refreshNodesDevices()
|
||||
viewModel.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 104.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -390,20 +334,41 @@ private fun OverviewScreen(
|
||||
}
|
||||
|
||||
item {
|
||||
CompanionHeroPanel(
|
||||
statusText = gatewaySummary(statusText, isConnected),
|
||||
isConnected = isConnected,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onOpenChat = { onSelectTab(Tab.Chat) },
|
||||
onOpenVoice = { onSelectTab(Tab.Voice) },
|
||||
onOpenGateway = { onOpenSettingsRoute(SettingsRoute.Gateway) },
|
||||
)
|
||||
SectionLabel(title = "MODULES")
|
||||
}
|
||||
|
||||
if (attentionRows.isNotEmpty()) {
|
||||
item {
|
||||
HomeAttentionPanel(rows = attentionRows, onSelectTab = onSelectTab, onOpenSettingsRoute = onOpenSettingsRoute)
|
||||
}
|
||||
item {
|
||||
ModuleList(
|
||||
rows =
|
||||
listOf(
|
||||
ModuleRow("Chat", null, null, Icons.Outlined.ChatBubbleOutline, Tab.Chat),
|
||||
ModuleRow("Sessions", null, if (sessions.isEmpty()) "Empty" else "${sessions.size} recent", Icons.Outlined.AccessTime, Tab.Sessions),
|
||||
ModuleRow("Voice", null, if (isConnected) "Ready" else "Offline", Icons.Outlined.MicNone, Tab.Voice),
|
||||
ModuleRow(
|
||||
title = "Providers & Models",
|
||||
subtitle = null,
|
||||
metadata =
|
||||
when {
|
||||
!isConnected -> "Offline"
|
||||
readyProviderCount > 0 -> "$readyProviderCount ready"
|
||||
models.isNotEmpty() -> "${models.size} models"
|
||||
else -> "Setup"
|
||||
},
|
||||
icon = Icons.Outlined.Inventory2,
|
||||
tab = Tab.ProvidersModels,
|
||||
),
|
||||
ModuleRow("Channels", null, channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels),
|
||||
ModuleRow("Agents", null, if (agents.isEmpty()) "Load" else "${agents.size} ready", Icons.Default.Person, Tab.Settings, SettingsRoute.Agents),
|
||||
ModuleRow("Approvals", null, approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals),
|
||||
ModuleRow("Cron Jobs", null, cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, Tab.Settings, SettingsRoute.CronJobs),
|
||||
ModuleRow("Skills", null, skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, Tab.Settings, SettingsRoute.Skills),
|
||||
ModuleRow("Nodes & Devices", null, nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices),
|
||||
ModuleRow("Usage", null, usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, Tab.Settings, SettingsRoute.Usage),
|
||||
ModuleRow("Settings", null, null, Icons.Outlined.Settings, Tab.Settings, SettingsRoute.Home),
|
||||
),
|
||||
onSelectTab = onSelectTab,
|
||||
onOpenSettingsRoute = onOpenSettingsRoute,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -432,7 +397,7 @@ private fun OverviewScreen(
|
||||
item {
|
||||
RecentSessionList(
|
||||
rows =
|
||||
sessions.take(5).map { session ->
|
||||
sessions.take(7).map { session ->
|
||||
RecentSessionListItem(
|
||||
key = session.key,
|
||||
title = displaySessionTitle(session.displayName),
|
||||
@@ -447,38 +412,8 @@ private fun OverviewScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionLabel(title = "Control center")
|
||||
}
|
||||
|
||||
item {
|
||||
ModuleList(
|
||||
rows =
|
||||
listOf(
|
||||
ModuleRow("Sessions", "Conversation history", if (sessions.isEmpty()) "Empty" else "${sessions.size} recent", Icons.Outlined.AccessTime, Tab.Sessions),
|
||||
ModuleRow(
|
||||
title = "Providers & Models",
|
||||
subtitle = "Provider readiness",
|
||||
metadata =
|
||||
when {
|
||||
!isConnected -> "Offline"
|
||||
readyProviderCount > 0 -> "$readyProviderCount ready"
|
||||
else -> "No ready"
|
||||
},
|
||||
icon = Icons.Outlined.Inventory2,
|
||||
tab = Tab.ProvidersModels,
|
||||
),
|
||||
ModuleRow("Channels", "Connected messengers", channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels),
|
||||
ModuleRow("Nodes & Devices", "Phone and node health", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices),
|
||||
ModuleRow("Approvals", "Tool decisions", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals),
|
||||
ModuleRow("Settings", "More runtime controls", null, Icons.Outlined.Settings, Tab.Settings, SettingsRoute.Home),
|
||||
),
|
||||
onSelectTab = onSelectTab,
|
||||
onOpenSettingsRoute = onOpenSettingsRoute,
|
||||
)
|
||||
}
|
||||
}
|
||||
OverviewChatButton(onClick = { onSelectTab(Tab.Chat) }, modifier = Modifier.align(Alignment.BottomEnd).padding(bottom = 20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,110 +427,26 @@ private data class ModuleRow(
|
||||
val settingsRoute: SettingsRoute? = null,
|
||||
)
|
||||
|
||||
/** Floating overview shortcut that keeps chat one tap away from module lists. */
|
||||
@Composable
|
||||
private fun CompanionHeroPanel(
|
||||
statusText: String,
|
||||
isConnected: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onOpenChat: () -> Unit,
|
||||
onOpenVoice: () -> Unit,
|
||||
onOpenGateway: () -> Unit,
|
||||
private fun OverviewChatButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(16.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.size(38.dp),
|
||||
shape = CircleShape,
|
||||
color = if (isConnected) ClawTheme.colors.successSoft else ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, if (isConnected) ClawTheme.colors.success else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(19.dp), tint = if (isConnected) ClawTheme.colors.success else ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = if (pendingRunCount > 0) "OpenClaw is working" else "Ready when you are", style = ClawTheme.type.title.copy(fontSize = 20.sp, lineHeight = 24.sp), color = ClawTheme.colors.text)
|
||||
Text(text = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
ClawPrimaryButton(text = "Start chat", icon = Icons.Outlined.ChatBubbleOutline, onClick = onOpenChat, modifier = Modifier.weight(1f))
|
||||
ClawSecondaryButton(text = "Voice", icon = Icons.Outlined.MicNone, onClick = onOpenVoice, modifier = Modifier.weight(1f))
|
||||
}
|
||||
if (!isConnected) {
|
||||
ClawSecondaryButton(text = "Reconnect gateway", icon = Icons.Default.Cloud, onClick = onOpenGateway, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class HomeAttentionRow(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val icon: ImageVector,
|
||||
val tab: Tab,
|
||||
val settingsRoute: SettingsRoute? = null,
|
||||
)
|
||||
|
||||
internal fun homeAttentionRows(
|
||||
isConnected: Boolean,
|
||||
pendingApprovals: Int,
|
||||
channelsSummary: GatewayChannelsSummary,
|
||||
nodesDevicesSummary: GatewayNodesDevicesSummary,
|
||||
readyProviderCount: Int,
|
||||
expiringProviderCount: Int = 0,
|
||||
): List<HomeAttentionRow> =
|
||||
listOfNotNull(
|
||||
if (!isConnected) {
|
||||
HomeAttentionRow("Gateway", "Connect before chat, voice, and live status.", Icons.Default.Cloud, Tab.Settings, SettingsRoute.Gateway)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (pendingApprovals > 0) {
|
||||
HomeAttentionRow("Approvals", approvalsSummary(pendingApprovals), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (channelsSummary.channels.any { it.error != null }) {
|
||||
HomeAttentionRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (nodesDevicesSummary.pendingDevices.isNotEmpty()) {
|
||||
HomeAttentionRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (isConnected && readyProviderCount == 0) {
|
||||
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.Settings, SettingsRoute.Gateway)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun HomeAttentionPanel(
|
||||
rows: List<HomeAttentionRow>,
|
||||
onSelectTab: (Tab) -> Unit,
|
||||
onOpenSettingsRoute: (SettingsRoute) -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = "Needs attention", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.warning)
|
||||
rows.forEach { row ->
|
||||
ModuleListRow(
|
||||
row = ModuleRow(row.title, row.subtitle, null, row.icon, row.tab, row.settingsRoute),
|
||||
onClick = {
|
||||
val route = row.settingsRoute
|
||||
if (route == null) {
|
||||
onSelectTab(row.tab)
|
||||
} else {
|
||||
onOpenSettingsRoute(route)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.button),
|
||||
color = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(text = "Chat", style = ClawTheme.type.label.copy(fontSize = 16.sp, lineHeight = 20.sp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,18 +527,14 @@ private fun ModuleListRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(
|
||||
text = row.title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
row.subtitle?.let {
|
||||
Text(text = it, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = row.title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
row.metadata?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(statusDotColor(it)))
|
||||
@@ -791,18 +638,11 @@ private fun RecentSessionRowContent(
|
||||
@Composable
|
||||
private fun ChatShellScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
ChatScreen(
|
||||
viewModel = viewModel,
|
||||
onVoice = onVoice,
|
||||
onOpenSessions = onOpenSessions,
|
||||
)
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 8.dp)) {
|
||||
ChatScreen(viewModel = viewModel, onBack = onBack, onVoice = onVoice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,10 +653,7 @@ private fun VoiceShellScreen(
|
||||
onOpenGatewaySettings: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 8.dp)) {
|
||||
VoiceScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = onOpenCommand,
|
||||
@@ -832,7 +669,6 @@ private fun SettingsShellScreen(
|
||||
route: SettingsRoute,
|
||||
onRouteChange: (SettingsRoute) -> Unit,
|
||||
onRouteBack: () -> Unit,
|
||||
onBackHome: () -> Unit,
|
||||
onOpenCommand: () -> Unit,
|
||||
) {
|
||||
val displayName by viewModel.displayName.collectAsState()
|
||||
@@ -871,18 +707,14 @@ private fun SettingsShellScreen(
|
||||
return
|
||||
}
|
||||
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(13.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(13.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
PlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to home", onClick = onBackHome)
|
||||
Text(text = "Settings", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
|
||||
SettingsSearchButton(onClick = onOpenCommand)
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@ fun VoiceScreen(
|
||||
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
|
||||
val talkModeListening by viewModel.talkModeListening.collectAsState()
|
||||
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
|
||||
val talkModeStatusText by viewModel.talkModeStatusText.collectAsState()
|
||||
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
|
||||
|
||||
var pendingAction by remember { mutableStateOf<VoiceAction?>(null) }
|
||||
@@ -120,16 +119,6 @@ fun VoiceScreen(
|
||||
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
|
||||
val voiceActive = micEnabled || micIsSending || talkModeEnabled
|
||||
val gatewayReady = gatewayStatus.isVoiceGatewayReady()
|
||||
val voiceAttentionStatus =
|
||||
voiceAttentionStatus(
|
||||
talkModeStatusText = talkModeStatusText,
|
||||
voiceCaptureMode = voiceCaptureMode,
|
||||
micEnabled = micEnabled,
|
||||
micIsSending = micIsSending,
|
||||
talkModeEnabled = talkModeEnabled,
|
||||
talkModeListening = talkModeListening,
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
)
|
||||
val activeStatus =
|
||||
voiceStatusLabel(
|
||||
gatewayStatus = gatewayStatus,
|
||||
@@ -139,7 +128,6 @@ fun VoiceScreen(
|
||||
micIsSending = micIsSending,
|
||||
talkModeListening = talkModeListening,
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
voiceAttentionStatus = voiceAttentionStatus,
|
||||
)
|
||||
|
||||
if (talkModeEnabled) {
|
||||
@@ -181,7 +169,7 @@ fun VoiceScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
VoiceHeader(
|
||||
statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
|
||||
statusText = if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
|
||||
speakerEnabled = speakerEnabled,
|
||||
onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
onOpenCommand = onOpenCommand,
|
||||
@@ -196,7 +184,6 @@ fun VoiceScreen(
|
||||
talkModeSpeaking = talkModeSpeaking,
|
||||
micLiveTranscript = micLiveTranscript,
|
||||
gatewayReady = gatewayReady,
|
||||
voiceAttentionStatus = voiceAttentionStatus,
|
||||
onStartTalk = {
|
||||
runVoiceAction(
|
||||
action = VoiceAction.Talk,
|
||||
@@ -255,9 +242,7 @@ private fun DictationScreen(
|
||||
) {
|
||||
val lastUserText = conversation.lastOrNull { it.role == VoiceConversationRole.User }?.text
|
||||
val draftText = liveTranscript?.takeIf { it.isNotBlank() } ?: lastUserText.orEmpty()
|
||||
val providerAttentionStatus = voiceRuntimeAttentionStatus(statusText)
|
||||
val displayStatusText = providerAttentionStatus ?: statusText
|
||||
val speechProviderReady = providerAttentionStatus == null && gatewayStatus.isVoiceGatewayReady()
|
||||
val speechProviderReady = gatewayStatus.isVoiceGatewayReady()
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -293,7 +278,7 @@ private fun DictationScreen(
|
||||
DictationWaveform(active = listening || sending)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(7.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(imageVector = Icons.Default.Mic, contentDescription = null, modifier = Modifier.size(15.dp), tint = if (listening) ClawTheme.colors.success else ClawTheme.colors.textMuted)
|
||||
Text(text = displayStatusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Text(text = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,20 +298,13 @@ private fun DictationScreen(
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Speech provider", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = providerAttentionStatus ?: gatewayStatus.voiceGatewayLabel(),
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
sending -> "Sending"
|
||||
providerAttentionStatus != null -> "Attention"
|
||||
speechProviderReady -> "Ready"
|
||||
else -> "Offline"
|
||||
},
|
||||
@@ -334,7 +312,6 @@ private fun DictationScreen(
|
||||
color =
|
||||
when {
|
||||
sending -> ClawTheme.colors.warning
|
||||
providerAttentionStatus != null -> ClawTheme.colors.warning
|
||||
speechProviderReady -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textMuted
|
||||
},
|
||||
@@ -347,7 +324,6 @@ private fun DictationScreen(
|
||||
.background(
|
||||
when {
|
||||
sending -> ClawTheme.colors.warning
|
||||
providerAttentionStatus != null -> ClawTheme.colors.warning
|
||||
speechProviderReady -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textSubtle
|
||||
},
|
||||
@@ -618,7 +594,6 @@ private fun VoiceHero(
|
||||
talkModeSpeaking: Boolean,
|
||||
micLiveTranscript: String?,
|
||||
gatewayReady: Boolean,
|
||||
voiceAttentionStatus: String?,
|
||||
onStartTalk: () -> Unit,
|
||||
onStartDictation: () -> Unit,
|
||||
onConnectGateway: () -> Unit,
|
||||
@@ -641,7 +616,6 @@ private fun VoiceHero(
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
voiceAttentionStatus != null -> voiceAttentionStatus
|
||||
talkModeSpeaking -> "OpenClaw is replying"
|
||||
talkModeListening -> "Listening"
|
||||
talkModeEnabled -> "Talk is live"
|
||||
@@ -698,7 +672,7 @@ private fun VoiceHero(
|
||||
)
|
||||
}
|
||||
|
||||
VoiceProviderCard(gatewayStatus = gatewayStatus, voiceAttentionStatus = voiceAttentionStatus)
|
||||
VoiceProviderCard(gatewayStatus = gatewayStatus)
|
||||
|
||||
VoicePrimaryAction(
|
||||
text =
|
||||
@@ -760,11 +734,8 @@ private fun VoiceModeRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceProviderCard(
|
||||
gatewayStatus: String,
|
||||
voiceAttentionStatus: String?,
|
||||
) {
|
||||
val ready = voiceAttentionStatus == null && gatewayStatus.isVoiceGatewayReady()
|
||||
private fun VoiceProviderCard(gatewayStatus: String) {
|
||||
val ready = gatewayStatus.isVoiceGatewayReady()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.panel),
|
||||
@@ -790,13 +761,7 @@ private fun VoiceProviderCard(
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(text = "Provider", style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
|
||||
Text(
|
||||
text = voiceAttentionStatus ?: gatewayStatus.voiceGatewayLabel(),
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Box(
|
||||
@@ -804,25 +769,9 @@ private fun VoiceProviderCard(
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
when {
|
||||
ready -> ClawTheme.colors.success
|
||||
voiceAttentionStatus != null -> ClawTheme.colors.warning
|
||||
else -> ClawTheme.colors.textSubtle
|
||||
},
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
when {
|
||||
ready -> "Ready"
|
||||
voiceAttentionStatus != null -> "Attention"
|
||||
else -> "Offline"
|
||||
},
|
||||
style = ClawTheme.type.caption,
|
||||
color = ClawTheme.colors.textMuted,
|
||||
maxLines = 1,
|
||||
.background(if (ready) ClawTheme.colors.success else ClawTheme.colors.textSubtle),
|
||||
)
|
||||
Text(text = if (ready) "Ready" else "Offline", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1019,7 +968,7 @@ private fun runVoiceAction(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun voiceStatusLabel(
|
||||
private fun voiceStatusLabel(
|
||||
gatewayStatus: String,
|
||||
voiceCaptureMode: VoiceCaptureMode,
|
||||
micStatusText: String,
|
||||
@@ -1027,10 +976,8 @@ internal fun voiceStatusLabel(
|
||||
micIsSending: Boolean,
|
||||
talkModeListening: Boolean,
|
||||
talkModeSpeaking: Boolean,
|
||||
voiceAttentionStatus: String?,
|
||||
): String =
|
||||
when {
|
||||
voiceAttentionStatus != null -> voiceAttentionStatus
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "OpenClaw is speaking"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Listening"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk is live"
|
||||
@@ -1041,69 +988,6 @@ internal fun voiceStatusLabel(
|
||||
else -> "Ready to talk"
|
||||
}
|
||||
|
||||
internal fun voiceAttentionStatus(
|
||||
talkModeStatusText: String,
|
||||
voiceCaptureMode: VoiceCaptureMode,
|
||||
micEnabled: Boolean,
|
||||
micIsSending: Boolean,
|
||||
talkModeEnabled: Boolean,
|
||||
talkModeListening: Boolean,
|
||||
talkModeSpeaking: Boolean,
|
||||
): String? {
|
||||
if (voiceCaptureMode != VoiceCaptureMode.Off || micEnabled || micIsSending) return null
|
||||
if (talkModeEnabled || talkModeListening || talkModeSpeaking) return null
|
||||
val status = talkModeStatusText.trim()
|
||||
if (status.isBlank()) return null
|
||||
val lower = status.lowercase()
|
||||
if (lower == "off" || lower == "ready" || lower == "listening" || lower == "connecting…") return null
|
||||
return status
|
||||
.takeIf {
|
||||
lower.contains("failed") ||
|
||||
lower.contains("unavailable") ||
|
||||
lower.contains("permission required") ||
|
||||
lower.contains("not connected") ||
|
||||
lower.contains("error")
|
||||
}?.let(::userFacingVoiceAttentionStatus)
|
||||
}
|
||||
|
||||
internal fun voiceRuntimeAttentionStatus(statusText: String): String? {
|
||||
val status = statusText.trim()
|
||||
if (status.isBlank()) return null
|
||||
val lower = status.lowercase()
|
||||
return status
|
||||
.takeIf {
|
||||
lower.contains("transcription unavailable") ||
|
||||
lower.contains("provider unavailable") ||
|
||||
(lower.contains("provider") && lower.contains("not configured")) ||
|
||||
lower.contains("no realtime transcription provider") ||
|
||||
lower.contains("failed")
|
||||
}?.let(::userFacingVoiceAttentionStatus)
|
||||
}
|
||||
|
||||
private fun userFacingVoiceAttentionStatus(status: String): String {
|
||||
val normalized =
|
||||
status
|
||||
.removePrefix("Start failed:")
|
||||
.trim()
|
||||
.removePrefix("Transcription unavailable:")
|
||||
.trim()
|
||||
.removePrefix("UNAVAILABLE:")
|
||||
.trim()
|
||||
.removePrefix("Error:")
|
||||
.trim()
|
||||
val lower = normalized.lowercase()
|
||||
if (lower.contains("realtime voice provider") && lower.contains("not configured")) {
|
||||
return "Realtime voice provider is not configured."
|
||||
}
|
||||
if (lower.contains("no realtime transcription provider")) {
|
||||
return "Realtime transcription provider is not configured."
|
||||
}
|
||||
if (lower.contains("microphone permission required")) {
|
||||
return "Microphone permission is required."
|
||||
}
|
||||
return if (normalized.length <= 90) normalized else "${normalized.take(87)}..."
|
||||
}
|
||||
|
||||
private fun String.isVoiceGatewayReady(): Boolean {
|
||||
val status = lowercase()
|
||||
return !status.contains("offline") && !status.contains("not connected") && !status.contains("failed") && !status.contains("error")
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -40,19 +40,17 @@ fun ChatMessageListCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val timeline =
|
||||
remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText) {
|
||||
buildChatTimeline(
|
||||
messages = messages,
|
||||
pendingRunCount = pendingRunCount,
|
||||
pendingToolCalls = pendingToolCalls,
|
||||
streamingAssistantText = streamingAssistantText,
|
||||
)
|
||||
}
|
||||
val displayMessages = remember(messages) { messages.asReversed() }
|
||||
val stream = streamingAssistantText?.trim()
|
||||
|
||||
LaunchedEffect(timeline.scrollTargetIndex, timeline.items.size, pendingRunCount, pendingToolCalls.size) {
|
||||
timeline.scrollTargetIndex?.let { index ->
|
||||
listState.animateScrollToItem(index = index)
|
||||
// New list items/tool rows should animate into view, but token streaming should not restart
|
||||
// that animation on every delta.
|
||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
|
||||
listState.animateScrollToItem(index = 0)
|
||||
}
|
||||
LaunchedEffect(stream) {
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
listState.scrollToItem(index = 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,17 +64,32 @@ fun ChatMessageListCard(
|
||||
androidx.compose.foundation.layout
|
||||
.PaddingValues(bottom = 8.dp),
|
||||
) {
|
||||
itemsIndexed(items = timeline.items, key = { _, item -> chatTimelineItemKey(item) }) { _, item ->
|
||||
when (item) {
|
||||
is ChatTimelineItem.Message -> ChatMessageBubble(message = item.message)
|
||||
is ChatTimelineItem.PendingTools -> ChatPendingToolsBubble(toolCalls = item.toolCalls)
|
||||
is ChatTimelineItem.StreamingAssistant -> ChatStreamingAssistantBubble(text = item.text)
|
||||
ChatTimelineItem.Thinking -> ChatTypingIndicatorBubble()
|
||||
// With reverseLayout = true, index 0 renders at the BOTTOM.
|
||||
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
item(key = "stream") {
|
||||
ChatStreamingAssistantBubble(text = stream)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingToolCalls.isNotEmpty()) {
|
||||
item(key = "tools") {
|
||||
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
item(key = "typing") {
|
||||
ChatTypingIndicatorBubble()
|
||||
}
|
||||
}
|
||||
|
||||
items(items = displayMessages, key = { it.id }) { message ->
|
||||
ChatMessageBubble(message = message)
|
||||
}
|
||||
}
|
||||
|
||||
if (timeline.items.isEmpty()) {
|
||||
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
|
||||
if (historyLoading) {
|
||||
LoadingChatHint(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,6 @@ import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.ui.design.ClawListItem
|
||||
import ai.openclaw.app.ui.design.ClawLoadingState
|
||||
@@ -31,18 +30,18 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -79,8 +78,8 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
) {
|
||||
val messages by viewModel.chatMessages.collectAsState()
|
||||
val historyLoading by viewModel.chatHistoryLoading.collectAsState()
|
||||
@@ -159,23 +158,13 @@ fun ChatScreen(
|
||||
thinkingLevel = thinkingLevel,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onBack = onBack,
|
||||
onMore = {
|
||||
viewModel.refreshChat()
|
||||
viewModel.refreshChatSessions(limit = 100)
|
||||
},
|
||||
)
|
||||
|
||||
ChatSessionSwitcher(
|
||||
sessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
onSelectSession = { key ->
|
||||
viewModel.switchChatSession(key)
|
||||
viewModel.refreshChatSessions(limit = 100)
|
||||
},
|
||||
onOpenSessions = onOpenSessions,
|
||||
)
|
||||
|
||||
errorText?.takeIf { it.isNotBlank() }?.let { error ->
|
||||
ChatNotice(title = "Chat needs attention", body = userFacingChatError(error))
|
||||
}
|
||||
@@ -225,88 +214,13 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatSessionSwitcher(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
) {
|
||||
val choices =
|
||||
remember(sessionKey, sessions, mainSessionKey) {
|
||||
resolveCompactSessionChoices(
|
||||
currentSessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
)
|
||||
}
|
||||
if (choices.size <= 1 && sessions.size <= 1) return
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
choices.forEach { entry ->
|
||||
ChatSessionChip(
|
||||
text = chatSessionChipText(entry = entry, mainSessionKey = mainSessionKey),
|
||||
active = isActiveSessionChoice(entry.key, sessionKey, mainSessionKey),
|
||||
onClick = { onSelectSession(entry.key) },
|
||||
)
|
||||
}
|
||||
if (sessions.size > choices.size) {
|
||||
Surface(
|
||||
onClick = onOpenSessions,
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.MoreHoriz, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(text = "All", style = ClawTheme.type.caption, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatSessionChip(
|
||||
text: String,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = if (active) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
|
||||
contentColor = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||
style = ClawTheme.type.caption,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatHeader(
|
||||
sessionTitle: String,
|
||||
thinkingLevel: String,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onBack: () -> Unit,
|
||||
onMore: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@@ -314,7 +228,7 @@ private fun ChatHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.size(ClawTheme.spacing.touchTarget))
|
||||
HeaderIcon(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -406,19 +320,15 @@ private fun ChatMessageList(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val timeline =
|
||||
remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText) {
|
||||
buildChatTimeline(
|
||||
messages = messages,
|
||||
pendingRunCount = pendingRunCount,
|
||||
pendingToolCalls = pendingToolCalls,
|
||||
streamingAssistantText = streamingAssistantText,
|
||||
)
|
||||
}
|
||||
val displayMessages = remember(messages) { messages.asReversed() }
|
||||
val stream = streamingAssistantText?.trim()
|
||||
|
||||
LaunchedEffect(timeline.scrollTargetIndex, timeline.items.size, pendingRunCount, pendingToolCalls.size) {
|
||||
timeline.scrollTargetIndex?.let { index ->
|
||||
listState.animateScrollToItem(index = index)
|
||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
|
||||
listState.animateScrollToItem(index = 0)
|
||||
}
|
||||
LaunchedEffect(stream) {
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
listState.scrollToItem(index = 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,29 +340,30 @@ private fun ChatMessageList(
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
contentPadding = PaddingValues(top = 6.dp, bottom = 3.dp),
|
||||
) {
|
||||
itemsIndexed(items = timeline.items, key = { _, item -> chatTimelineItemKey(item) }) { _, item ->
|
||||
when (item) {
|
||||
is ChatTimelineItem.Message ->
|
||||
ChatBubble(
|
||||
role = item.message.role,
|
||||
live = false,
|
||||
content = item.message.content,
|
||||
timestampMs = item.message.timestampMs,
|
||||
)
|
||||
is ChatTimelineItem.PendingTools -> ToolBubble(toolCalls = item.toolCalls)
|
||||
is ChatTimelineItem.StreamingAssistant ->
|
||||
ChatBubble(
|
||||
role = "assistant",
|
||||
live = true,
|
||||
content = listOf(ChatMessageContent(text = item.text)),
|
||||
timestampMs = null,
|
||||
)
|
||||
ChatTimelineItem.Thinking -> ChatThinkingBubble()
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
item(key = "stream") {
|
||||
ChatBubble(role = "assistant", live = true, content = listOf(ChatMessageContent(text = stream)), timestampMs = null)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingToolCalls.isNotEmpty()) {
|
||||
item(key = "tools") {
|
||||
ToolBubble(toolCalls = pendingToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
item(key = "thinking") {
|
||||
ChatThinkingBubble()
|
||||
}
|
||||
}
|
||||
|
||||
items(items = displayMessages, key = { it.id }) { message ->
|
||||
ChatBubble(role = message.role, live = false, content = message.content, timestampMs = message.timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
if (timeline.items.isEmpty()) {
|
||||
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && stream.isNullOrBlank()) {
|
||||
if (historyLoading) {
|
||||
ClawLoadingState(title = "Loading session", modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
@@ -875,33 +786,13 @@ private fun AttachmentChip(
|
||||
|
||||
private fun currentSessionTitle(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
sessions: List<ai.openclaw.app.chat.ChatSessionEntry>,
|
||||
): String {
|
||||
val entry = sessions.firstOrNull { it.key == sessionKey }
|
||||
val name = entry?.displayName?.takeIf { it.isNotBlank() } ?: return "New chat"
|
||||
return friendlySessionName(name)
|
||||
}
|
||||
|
||||
private fun chatSessionChipText(
|
||||
entry: ChatSessionEntry,
|
||||
mainSessionKey: String,
|
||||
): String {
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
if (entry.key == mainKey || (entry.key == "main" && mainKey == "main")) return "Main"
|
||||
val name = entry.displayName?.takeIf { it.isNotBlank() } ?: entry.key.takeIf { entry.updatedAtMs != null } ?: "Current"
|
||||
return friendlySessionName(name)
|
||||
}
|
||||
|
||||
private fun isActiveSessionChoice(
|
||||
choiceKey: String,
|
||||
sessionKey: String,
|
||||
mainSessionKey: String,
|
||||
): Boolean {
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
val current = sessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
||||
return choiceKey == current
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendButton(
|
||||
enabled: Boolean,
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
|
||||
internal sealed class ChatTimelineItem {
|
||||
data class Message(
|
||||
val message: ChatMessage,
|
||||
) : ChatTimelineItem()
|
||||
|
||||
data class StreamingAssistant(
|
||||
val text: String,
|
||||
) : ChatTimelineItem()
|
||||
|
||||
data class PendingTools(
|
||||
val toolCalls: List<ChatPendingToolCall>,
|
||||
) : ChatTimelineItem()
|
||||
|
||||
object Thinking : ChatTimelineItem()
|
||||
}
|
||||
|
||||
internal data class ChatTimeline(
|
||||
val items: List<ChatTimelineItem>,
|
||||
val scrollTargetIndex: Int?,
|
||||
)
|
||||
|
||||
internal fun buildChatTimeline(
|
||||
messages: List<ChatMessage>,
|
||||
pendingRunCount: Int,
|
||||
pendingToolCalls: List<ChatPendingToolCall>,
|
||||
streamingAssistantText: String?,
|
||||
): ChatTimeline {
|
||||
val stream = streamingAssistantText?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val hasActiveRun = pendingRunCount > 0 || pendingToolCalls.isNotEmpty() || stream != null
|
||||
val items =
|
||||
buildList {
|
||||
if (stream != null) add(ChatTimelineItem.StreamingAssistant(stream))
|
||||
if (pendingToolCalls.isNotEmpty()) add(ChatTimelineItem.PendingTools(pendingToolCalls))
|
||||
if (pendingRunCount > 0) add(ChatTimelineItem.Thinking)
|
||||
messages.asReversed().forEach { message -> add(ChatTimelineItem.Message(message)) }
|
||||
}
|
||||
if (items.isEmpty()) return ChatTimeline(items = items, scrollTargetIndex = null)
|
||||
|
||||
// In reverseLayout, index 0 is bottom-most. During an active run, keep the prompt
|
||||
// anchored so streaming/tool rows do not immediately push the just-sent message away.
|
||||
val activePromptIndex =
|
||||
if (hasActiveRun) {
|
||||
items.indexOfFirst { item ->
|
||||
item is ChatTimelineItem.Message &&
|
||||
item.message.role
|
||||
.trim()
|
||||
.equals("user", ignoreCase = true)
|
||||
}
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
return ChatTimeline(
|
||||
items = items,
|
||||
scrollTargetIndex = activePromptIndex.takeIf { it >= 0 } ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun chatTimelineItemKey(item: ChatTimelineItem): String =
|
||||
when (item) {
|
||||
is ChatTimelineItem.Message -> "message:${item.message.id}"
|
||||
is ChatTimelineItem.PendingTools -> "tools"
|
||||
is ChatTimelineItem.StreamingAssistant -> "stream"
|
||||
ChatTimelineItem.Thinking -> "thinking"
|
||||
}
|
||||
@@ -4,9 +4,22 @@ import ai.openclaw.app.chat.ChatSessionEntry
|
||||
|
||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Derive a human-friendly label from a raw session key.
|
||||
* Examples:
|
||||
* "telegram:g-agent-main-main" -> "Main"
|
||||
* "agent:main:main" -> "Main"
|
||||
* "discord:g-server-channel" -> "Server Channel"
|
||||
* "my-custom-session" -> "My Custom Session"
|
||||
*/
|
||||
fun friendlySessionName(key: String): String {
|
||||
// Strip common prefixes like "telegram:", "agent:", "discord:" etc.
|
||||
val stripped = key.substringAfterLast(":")
|
||||
|
||||
// Remove leading "g-" prefix (gateway artifact)
|
||||
val cleaned = if (stripped.startsWith("g-")) stripped.removePrefix("g-") else stripped
|
||||
|
||||
// Split on hyphens/underscores, title-case each word, collapse "main main" -> "Main"
|
||||
val words =
|
||||
cleaned
|
||||
.split('-', '_')
|
||||
@@ -65,34 +78,3 @@ fun resolveSessionChoices(
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun resolveCompactSessionChoices(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
maxOptions: Int = 5,
|
||||
): List<ChatSessionEntry> {
|
||||
val allChoices =
|
||||
resolveSessionChoices(
|
||||
currentSessionKey = currentSessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
nowMs = nowMs,
|
||||
)
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
||||
val pinnedRank =
|
||||
listOf(mainKey, current)
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.withIndex()
|
||||
.associate { it.value to it.index }
|
||||
val unpinnedRank = pinnedRank.size
|
||||
|
||||
return allChoices
|
||||
.withIndex()
|
||||
.sortedWith(compareBy({ pinnedRank[it.value.key] ?: unpinnedRank }, { it.index }))
|
||||
.take(maxOptions)
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ internal enum class ClawStatus {
|
||||
internal fun ClawScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = ClawTheme.spacing.lg, vertical = ClawTheme.spacing.lg),
|
||||
contentWindowInsets: WindowInsets = WindowInsets.safeDrawing,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
@@ -69,7 +68,7 @@ internal fun ClawScaffold(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(ClawTheme.colors.canvas)
|
||||
.windowInsetsPadding(contentWindowInsets)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(contentPadding),
|
||||
) {
|
||||
content()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -92,29 +91,27 @@ internal fun ClawBottomNav(
|
||||
) {
|
||||
val safeInsets = WindowInsets.navigationBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Bottom)
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(safeInsets)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(safeInsets)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
ClawBottomNavItem(
|
||||
item = item,
|
||||
selected = item.key == selectedKey,
|
||||
onClick = { onSelect(item.key) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
items.forEach { item ->
|
||||
ClawBottomNavItem(
|
||||
item = item,
|
||||
selected = item.key == selectedKey,
|
||||
onClick = { onSelect(item.key) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +129,7 @@ private fun ClawBottomNavItem(
|
||||
modifier = modifier.heightIn(min = 48.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.control),
|
||||
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textSubtle,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
|
||||
|
||||
@@ -104,7 +104,6 @@ class MicCaptureManager(
|
||||
private val messageQueue = ArrayDeque<String>()
|
||||
private val messageQueueLock = Any()
|
||||
private var flushedPartialTranscript: String? = null
|
||||
|
||||
// Correlates chat events with the idempotency key generated before sendChat returns.
|
||||
private var pendingRunId: String? = null
|
||||
private var pendingAssistantEntryId: String? = null
|
||||
|
||||
@@ -168,7 +168,6 @@ class TalkModeManager internal constructor(
|
||||
@Volatile private var realtimeSessionId: String? = null
|
||||
private var realtimeCaptureJob: Job? = null
|
||||
private var realtimeAppendJob: Job? = null
|
||||
|
||||
// Realtime tool calls can complete before their chat final arrives; cache by call/run id until both sides meet.
|
||||
private val realtimeToolRuns = LinkedHashMap<String, RealtimeToolRun>()
|
||||
private val pendingRealtimeToolCalls = LinkedHashSet<String>()
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="120dp"
|
||||
android:height="120dp"
|
||||
android:viewportWidth="120"
|
||||
android:viewportHeight="120">
|
||||
<path android:pathData="M60,10 C30,10 15,35 15,55 C15,75 30,95 45,100 L45,110 L55,110 L55,100 C55,100 60,102 65,100 L65,110 L75,110 L75,100 C90,95 105,75 105,55 C105,35 90,10 60,10Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="120"
|
||||
android:endY="120"
|
||||
android:startColor="#ff4d4d"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:type="linear"
|
||||
android:endColor="#991b1b" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path android:pathData="M20,45 C5,40 0,50 5,60 C10,70 20,65 25,55 C28,48 25,45 20,45Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="120"
|
||||
android:endY="120"
|
||||
android:startColor="#ff4d4d"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:type="linear"
|
||||
android:endColor="#991b1b" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path android:pathData="M100,45 C115,40 120,50 115,60 C110,70 100,65 95,55 C92,48 95,45 100,45Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="120"
|
||||
android:endY="120"
|
||||
android:startColor="#ff4d4d"
|
||||
android:startX="0"
|
||||
android:startY="0"
|
||||
android:type="linear"
|
||||
android:endColor="#991b1b" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:pathData="M45,15 Q35,5 30,8"
|
||||
android:strokeColor="#ff4d4d"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="3" />
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:pathData="M75,15 Q85,5 90,8"
|
||||
android:strokeColor="#ff4d4d"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="3" />
|
||||
<path
|
||||
android:fillColor="#050810"
|
||||
android:pathData="M45,35 m-6,0 a6,6 0,1 0,12 0 a6,6 0,1 0,-12 0" />
|
||||
<path
|
||||
android:fillColor="#050810"
|
||||
android:pathData="M75,35 m-6,0 a6,6 0,1 0,12 0 a6,6 0,1 0,-12 0" />
|
||||
<path
|
||||
android:fillColor="#00e5cc"
|
||||
android:pathData="M46,34 m-2.5,0 a2.5,2.5 0,1 0,5 0 a2.5,2.5 0,1 0,-5 0" />
|
||||
<path
|
||||
android:fillColor="#00e5cc"
|
||||
android:pathData="M76,34 m-2.5,0 a2.5,2.5 0,1 0,5 0 a2.5,2.5 0,1 0,-5 0" />
|
||||
</vector>
|
||||
@@ -294,38 +294,6 @@ class GatewayBootstrapAuthTest {
|
||||
assertEquals("aaaaaaaa", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshGatewayConnection_reconnectsSavedManualEndpointAfterDisconnect() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
android.content.Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
prefs.setManualEnabled(true)
|
||||
prefs.setManualHost("127.0.0.1")
|
||||
prefs.setManualPort(18789)
|
||||
prefs.setManualTls(false)
|
||||
prefs.setGatewayToken("shared-token")
|
||||
val runtime = NodeRuntime(app, prefs)
|
||||
|
||||
runtime.connect(
|
||||
GatewayEndpoint.manual(host = "127.0.0.1", port = 18789),
|
||||
NodeRuntime.GatewayConnectAuth(token = "initial-token", bootstrapToken = null, password = null),
|
||||
)
|
||||
runtime.disconnect()
|
||||
assertNull(desiredConnection(runtime, "nodeSession"))
|
||||
|
||||
runtime.refreshGatewayConnection()
|
||||
|
||||
val desired = desiredConnection(runtime, "nodeSession") ?: error("Expected desired node connection")
|
||||
val endpoint = readField<GatewayEndpoint>(desired, "endpoint")
|
||||
assertEquals("127.0.0.1", endpoint.host)
|
||||
assertEquals(18789, endpoint.port)
|
||||
assertEquals("shared-token", readField<String?>(desired, "token"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
|
||||
@@ -62,21 +62,6 @@ class SecurePrefsTest {
|
||||
assertFalse(plainPrefs.getBoolean("talk.enabled", false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun installedAppsSharing_defaultsOffAndPersistsOptIn() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
|
||||
plainPrefs.edit().clear().commit()
|
||||
val prefs = SecurePrefs(context)
|
||||
|
||||
assertFalse(prefs.installedAppsSharingEnabled.value)
|
||||
|
||||
prefs.setInstalledAppsSharingEnabled(true)
|
||||
|
||||
assertTrue(prefs.installedAppsSharingEnabled.value)
|
||||
assertTrue(plainPrefs.getBoolean("device.apps.sharing.enabled", false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
|
||||
@@ -1,44 +1,10 @@
|
||||
package ai.openclaw.app.chat
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChatControllerMessageIdentityTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun parseChatMessageContentsReadsGatewayStringContent() {
|
||||
val obj =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
"""
|
||||
{"role":"user","content":"Hello","idempotencyKey":"run-1:user"}
|
||||
""".trimIndent(),
|
||||
).jsonObject
|
||||
|
||||
val content = parseChatMessageContents(obj)
|
||||
|
||||
assertEquals(listOf(ChatMessageContent(type = "text", text = "Hello")), content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseChatMessageContentsFallsBackToTopLevelText() {
|
||||
val obj =
|
||||
json
|
||||
.parseToJsonElement(
|
||||
"""
|
||||
{"role":"assistant","text":"Hi there"}
|
||||
""".trimIndent(),
|
||||
).jsonObject
|
||||
|
||||
val content = parseChatMessageContents(obj)
|
||||
|
||||
assertEquals(listOf(ChatMessageContent(type = "text", text = "Hi there")), content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() {
|
||||
val previous =
|
||||
@@ -135,62 +101,6 @@ class ChatControllerMessageIdentityTest {
|
||||
assertEquals(listOf("local-user", "remote-assistant"), merged.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retainUnmatchedOptimisticMessagesKeepsOutgoingUserTurnWhenHistoryOmitsIt() {
|
||||
val optimistic =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "Testing testing 1 2 3")),
|
||||
timestampMs = 1000L,
|
||||
)
|
||||
val assistant =
|
||||
ChatMessage(
|
||||
id = "remote-assistant",
|
||||
role = "assistant",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "Received.")),
|
||||
timestampMs = 2000L,
|
||||
)
|
||||
|
||||
val retained = retainUnmatchedOptimisticMessages(incoming = listOf(assistant), optimistic = listOf(optimistic))
|
||||
|
||||
assertEquals(listOf("local-user"), retained.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retainUnmatchedOptimisticMessagesDropsGatewayPersistedUserTurn() {
|
||||
val optimistic =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
idempotencyKey = "run-1:user",
|
||||
)
|
||||
val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 500L)
|
||||
|
||||
val retained = retainUnmatchedOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic))
|
||||
|
||||
assertEquals(emptyList<String>(), retained.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retainUnmatchedOptimisticMessagesKeepsDistinctIdempotencyKey() {
|
||||
val optimistic =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
idempotencyKey = "run-2:user",
|
||||
)
|
||||
val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 2000L, idempotencyKey = "run-1:user")
|
||||
|
||||
val retained = retainUnmatchedOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic))
|
||||
|
||||
assertEquals(listOf("local-user"), retained.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeOptimisticMessagesDoesNotDuplicateHistoryTurns() {
|
||||
val user =
|
||||
|
||||
@@ -20,7 +20,6 @@ import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -234,75 +233,7 @@ class GatewaySessionReconnectTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingRequiredFailureNotifiesPauseReconnectProblem() =
|
||||
runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connectFailure = CompletableDeferred<Pair<GatewaySession.ErrorShape, Boolean>>()
|
||||
val server =
|
||||
startGatewayServer(json = json) { webSocket, id, method ->
|
||||
if (method == "connect") {
|
||||
webSocket.send(
|
||||
"""
|
||||
{"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1"}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
val harness =
|
||||
createReconnectHarness { error, pauseReconnect ->
|
||||
connectFailure.complete(error to pauseReconnect)
|
||||
}
|
||||
|
||||
try {
|
||||
connectNodeSession(harness.session, server.port)
|
||||
val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() }
|
||||
|
||||
assertEquals("PAIRING_REQUIRED", error.details?.code)
|
||||
assertEquals("not-paired", error.details?.reason)
|
||||
assertEquals("request-1", error.details?.requestId)
|
||||
assertTrue(pauseReconnect)
|
||||
} finally {
|
||||
shutdownReconnectHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingRequiredFailureDropsUnsafeRequestId() =
|
||||
runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connectFailure = CompletableDeferred<Pair<GatewaySession.ErrorShape, Boolean>>()
|
||||
val server =
|
||||
startGatewayServer(json = json) { webSocket, id, method ->
|
||||
if (method == "connect") {
|
||||
webSocket.send(
|
||||
"""
|
||||
{"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1;echo unsafe"}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
val harness =
|
||||
createReconnectHarness { error, pauseReconnect ->
|
||||
connectFailure.complete(error to pauseReconnect)
|
||||
}
|
||||
|
||||
try {
|
||||
connectNodeSession(harness.session, server.port)
|
||||
val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() }
|
||||
|
||||
assertEquals("PAIRING_REQUIRED", error.details?.code)
|
||||
assertEquals("not-paired", error.details?.reason)
|
||||
assertNull(error.details?.requestId)
|
||||
assertTrue(pauseReconnect)
|
||||
} finally {
|
||||
shutdownReconnectHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createReconnectHarness(
|
||||
onConnectFailure: (GatewaySession.ErrorShape, Boolean) -> Unit = { _, _ -> },
|
||||
): ReconnectHarness {
|
||||
private fun createReconnectHarness(): ReconnectHarness {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val session =
|
||||
@@ -312,7 +243,6 @@ class GatewaySessionReconnectTest {
|
||||
deviceAuthStore = ReconnectDeviceAuthStore(),
|
||||
onConnected = {},
|
||||
onDisconnected = { _ -> },
|
||||
onConnectFailure = onConnectFailure,
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
|
||||
)
|
||||
|
||||
@@ -7,57 +7,66 @@ import org.junit.Test
|
||||
class CanvasActionTrustTest {
|
||||
@Test
|
||||
fun acceptsBundledScaffoldAsset() {
|
||||
assertTrue(CanvasActionTrust.isTrustedCanvasActionUrl(CanvasActionTrust.scaffoldAssetUrl))
|
||||
assertTrue(CanvasActionTrust.isTrustedCanvasActionUrl(CanvasActionTrust.scaffoldAssetUrl, emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsBundledA2uiAsset() {
|
||||
assertTrue(CanvasActionTrust.isTrustedCanvasActionUrl(CanvasActionTrust.localA2uiAssetUrl))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsRemoteHttpA2uiPageEvenWhenGatewayAdvertised() {
|
||||
assertFalse(
|
||||
CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = "http://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsRemoteHttpsA2uiPageEvenWhenGatewayAdvertised() {
|
||||
assertFalse(
|
||||
fun acceptsTrustedA2uiPageOnAdvertisedCanvasHost() {
|
||||
assertTrue(
|
||||
CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android",
|
||||
trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsRemoteCanvasPage() {
|
||||
fun rejectsDifferentOriginEvenIfPathMatches() {
|
||||
assertFalse(
|
||||
CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = "https://canvas.example.com:9443/__openclaw__/canvas/",
|
||||
rawUrl = "https://evil.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android",
|
||||
trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsDescendantPathUnderBundledA2uiRoot() {
|
||||
fun rejectsUntrustedCanvasPagePathOnTrustedOrigin() {
|
||||
assertFalse(
|
||||
CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = "file:///android_asset/CanvasA2UI/child/index.html",
|
||||
rawUrl = "https://canvas.example.com:9443/untrusted/index.html",
|
||||
trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsQueryOrFragmentChangesToBundledA2uiAsset() {
|
||||
assertFalse(
|
||||
fun acceptsFragmentOnlyDifferenceForTrustedA2uiPage() {
|
||||
assertTrue(
|
||||
CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = "${CanvasActionTrust.localA2uiAssetUrl}?platform=android",
|
||||
rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android#step2",
|
||||
trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsQueryMismatchOnTrustedOriginAndPath() {
|
||||
assertFalse(
|
||||
CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=ios",
|
||||
trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsDescendantPathUnderTrustedA2uiRoot() {
|
||||
assertFalse(
|
||||
CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/child/index.html?platform=android",
|
||||
trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"),
|
||||
),
|
||||
)
|
||||
assertFalse(CanvasActionTrust.isTrustedCanvasActionUrl("${CanvasActionTrust.localA2uiAssetUrl}#step2"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import ai.openclaw.app.gateway.isLoopbackGatewayHost
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCapability
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.app.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.app.protocol.OpenClawPhotosCommand
|
||||
@@ -476,15 +475,6 @@ class ConnectionManagerTest {
|
||||
assertTrue(options.caps.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildNodeConnectOptions_advertisesDeviceAppsOnlyWhenUserOptedIn() {
|
||||
val disabled = newManager(installedAppsSharingEnabled = false).buildNodeConnectOptions()
|
||||
val enabled = newManager(installedAppsSharingEnabled = true).buildNodeConnectOptions()
|
||||
|
||||
assertFalse(disabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue))
|
||||
assertTrue(enabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildNodeConnectOptions_omitsVoiceWakeWithoutMicrophonePermission() {
|
||||
val options =
|
||||
@@ -556,7 +546,6 @@ class ConnectionManagerTest {
|
||||
callLogAvailable: Boolean = false,
|
||||
photosAvailable: Boolean = false,
|
||||
hasRecordAudioPermission: Boolean = false,
|
||||
installedAppsSharingEnabled: Boolean = false,
|
||||
): ConnectionManager {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs =
|
||||
@@ -578,7 +567,6 @@ class ConnectionManagerTest {
|
||||
callLogAvailable = { callLogAvailable },
|
||||
photosAvailable = { photosAvailable },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission },
|
||||
installedAppsSharingEnabled = { installedAppsSharingEnabled },
|
||||
manualTls = { false },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
@@ -321,108 +320,6 @@ class DeviceHandlerTest {
|
||||
system["securityPatchLevel"]?.jsonPrimitive?.content
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDeviceApps_filtersAndLimitsVisibleApps() {
|
||||
val handler =
|
||||
DeviceHandler.forTesting(
|
||||
appContext = appContext(),
|
||||
appSource =
|
||||
FakeDeviceAppSource(
|
||||
listOf(
|
||||
DeviceAppEntry(
|
||||
label = "Calendar",
|
||||
packageName = "com.google.android.calendar",
|
||||
system = false,
|
||||
enabled = true,
|
||||
launchable = true,
|
||||
),
|
||||
DeviceAppEntry(
|
||||
label = "Android System",
|
||||
packageName = "android",
|
||||
system = true,
|
||||
enabled = true,
|
||||
launchable = false,
|
||||
),
|
||||
DeviceAppEntry(
|
||||
label = "Disabled App",
|
||||
packageName = "com.example.disabled",
|
||||
system = false,
|
||||
enabled = false,
|
||||
launchable = true,
|
||||
),
|
||||
DeviceAppEntry(
|
||||
label = "Gmail",
|
||||
packageName = "com.google.android.gm",
|
||||
system = false,
|
||||
enabled = true,
|
||||
launchable = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = handler.handleDeviceApps("""{"query":"google","limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
assertEquals("1", payload.getValue("count").jsonPrimitive.content)
|
||||
assertEquals("2", payload.getValue("totalMatched").jsonPrimitive.content)
|
||||
assertTrue(payload.getValue("truncated").jsonPrimitive.boolean)
|
||||
assertEquals("launcher", payload.getValue("visibility").jsonPrimitive.content)
|
||||
val apps = payload.getValue("apps").jsonArray
|
||||
assertEquals(1, apps.size)
|
||||
val app = apps.first().jsonObject
|
||||
assertEquals("Calendar", app.getValue("label").jsonPrimitive.content)
|
||||
assertEquals("com.google.android.calendar", app.getValue("packageName").jsonPrimitive.content)
|
||||
assertTrue(!app.getValue("system").jsonPrimitive.boolean)
|
||||
assertTrue(app.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertTrue(app.getValue("launchable").jsonPrimitive.boolean)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDeviceApps_canIncludeSystemAndNonLaunchableApps() {
|
||||
val source =
|
||||
FakeDeviceAppSource(
|
||||
listOf(
|
||||
DeviceAppEntry(
|
||||
label = "Android System",
|
||||
packageName = "android",
|
||||
system = true,
|
||||
enabled = true,
|
||||
launchable = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
val handler = DeviceHandler.forTesting(appContext = appContext(), appSource = source)
|
||||
|
||||
val result = handler.handleDeviceApps("""{"includeSystem":true,"includeNonLaunchable":true}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
assertEquals("android-visible", payload.getValue("visibility").jsonPrimitive.content)
|
||||
assertTrue(payload.getValue("includeSystem").jsonPrimitive.boolean)
|
||||
val app =
|
||||
payload
|
||||
.getValue("apps")
|
||||
.jsonArray
|
||||
.first()
|
||||
.jsonObject
|
||||
assertEquals("android", app.getValue("packageName").jsonPrimitive.content)
|
||||
assertTrue(app.getValue("system").jsonPrimitive.boolean)
|
||||
assertTrue(!app.getValue("launchable").jsonPrimitive.boolean)
|
||||
assertTrue(source.includeNonLaunchableRequests.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isSystemDeviceApp_treatsUpdatedBuiltInsAsSystemApps() {
|
||||
val appInfo =
|
||||
ApplicationInfo().apply {
|
||||
flags = ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||
}
|
||||
|
||||
assertTrue(isSystemDeviceApp(appInfo))
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun parsePayload(payloadJson: String?): JsonObject {
|
||||
@@ -430,14 +327,3 @@ class DeviceHandlerTest {
|
||||
return Json.parseToJsonElement(jsonString).jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeDeviceAppSource(
|
||||
private val apps: List<DeviceAppEntry>,
|
||||
) : DeviceAppSource {
|
||||
val includeNonLaunchableRequests = mutableListOf<Boolean>()
|
||||
|
||||
override fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry> {
|
||||
includeNonLaunchableRequests += includeNonLaunchable
|
||||
return apps
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,15 +115,6 @@ class InvokeCommandRegistryTest {
|
||||
assertMissingAll(commands, optionalCommands + debugCommands)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_includesDeviceAppsOnlyWhenUserOptedIn() {
|
||||
val disabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = false))
|
||||
val enabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = true))
|
||||
|
||||
assertFalse(disabled.contains(OpenClawDeviceCommand.Apps.rawValue))
|
||||
assertTrue(enabled.contains(OpenClawDeviceCommand.Apps.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
|
||||
val commands =
|
||||
@@ -160,7 +151,6 @@ class InvokeCommandRegistryTest {
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = false,
|
||||
installedAppsSharingEnabled = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
@@ -272,7 +262,6 @@ class InvokeCommandRegistryTest {
|
||||
voiceWakeEnabled: Boolean = false,
|
||||
motionActivityAvailable: Boolean = false,
|
||||
motionPedometerAvailable: Boolean = false,
|
||||
installedAppsSharingEnabled: Boolean = false,
|
||||
debugBuild: Boolean = false,
|
||||
): NodeRuntimeFlags =
|
||||
NodeRuntimeFlags(
|
||||
@@ -286,7 +275,6 @@ class InvokeCommandRegistryTest {
|
||||
voiceWakeEnabled = voiceWakeEnabled,
|
||||
motionActivityAvailable = motionActivityAvailable,
|
||||
motionPedometerAvailable = motionPedometerAvailable,
|
||||
installedAppsSharingEnabled = installedAppsSharingEnabled,
|
||||
debugBuild = debugBuild,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.app.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.app.protocol.OpenClawPhotosCommand
|
||||
@@ -171,20 +170,6 @@ class InvokeDispatcherTest {
|
||||
assertEquals("LOCATION_DISABLED: enable Location in Settings", result.error?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleInvoke_blocksDeviceAppsWhenSharingDisabled() =
|
||||
runTest {
|
||||
val result =
|
||||
newDispatcher(installedAppsSharingEnabled = false)
|
||||
.handleInvoke(OpenClawDeviceCommand.Apps.rawValue, """{"limit":1}""")
|
||||
|
||||
assertEquals("INSTALLED_APPS_SHARING_DISABLED", result.error?.code)
|
||||
assertEquals(
|
||||
"INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings",
|
||||
result.error?.message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleInvoke_blocksMotionActivityWhenUnavailable() =
|
||||
runTest {
|
||||
@@ -265,7 +250,6 @@ class InvokeDispatcherTest {
|
||||
smsTelephonyAvailable: Boolean = true,
|
||||
callLogAvailable: Boolean = false,
|
||||
photosAvailable: Boolean = true,
|
||||
installedAppsSharingEnabled: Boolean = true,
|
||||
debugBuild: Boolean = false,
|
||||
motionActivityAvailable: Boolean = false,
|
||||
motionPedometerAvailable: Boolean = false,
|
||||
@@ -299,6 +283,8 @@ class InvokeDispatcherTest {
|
||||
A2UIHandler(
|
||||
canvas = canvas,
|
||||
json = Json { ignoreUnknownKeys = true },
|
||||
getNodeCanvasHostUrl = { null },
|
||||
getOperatorCanvasHostUrl = { null },
|
||||
),
|
||||
debugHandler = DebugHandler(appContext, DeviceIdentityStore(appContext)),
|
||||
callLogHandler = CallLogHandler.forTesting(appContext, InvokeDispatcherFakeCallLogDataSource()),
|
||||
@@ -311,10 +297,10 @@ class InvokeDispatcherTest {
|
||||
smsTelephonyAvailable = { smsTelephonyAvailable },
|
||||
callLogAvailable = { callLogAvailable },
|
||||
photosAvailable = { photosAvailable },
|
||||
installedAppsSharingEnabled = { installedAppsSharingEnabled },
|
||||
debugBuild = { debugBuild },
|
||||
onCanvasA2uiPush = {},
|
||||
onCanvasA2uiReset = {},
|
||||
refreshCanvasHostUrl = { null },
|
||||
motionActivityAvailable = { motionActivityAvailable },
|
||||
motionPedometerAvailable = { motionPedometerAvailable },
|
||||
)
|
||||
|
||||
@@ -57,7 +57,6 @@ class OpenClawProtocolConstantsTest {
|
||||
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
|
||||
assertEquals("device.permissions", OpenClawDeviceCommand.Permissions.rawValue)
|
||||
assertEquals("device.health", OpenClawDeviceCommand.Health.rawValue)
|
||||
assertEquals("device.apps", OpenClawDeviceCommand.Apps.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -27,53 +26,6 @@ class OnboardingFlowLogicTest {
|
||||
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nearbyGatewayFoundStateIsConnectable() {
|
||||
assertEquals(
|
||||
NearbyGatewayUiState(subtitle = "Studio Gateway", status = "Found", canConnect = true),
|
||||
nearbyGatewayUiState(nearbyGatewayName = "Studio Gateway", discoveryStatusText = "Searching…", discoveryStarted = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nearbyGatewayBeforeDiscoveryStartsIsNotConnectable() {
|
||||
assertEquals(
|
||||
NearbyGatewayUiState(subtitle = "Starting discovery...", status = "Starting", canConnect = false),
|
||||
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching…", discoveryStarted = false, searchTimedOut = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nearbyGatewaySearchingStateIsNotConnectable() {
|
||||
assertEquals(
|
||||
NearbyGatewayUiState(subtitle = "Searching for gateways...", status = "Searching", canConnect = false),
|
||||
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching for gateways…"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nearbyGatewayTimedOutSearchShowsEmptyState() {
|
||||
assertEquals(
|
||||
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false),
|
||||
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching for gateways…", searchTimedOut = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nearbyGatewayEmptyResultStateIsNotConnectable() {
|
||||
assertEquals(
|
||||
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false),
|
||||
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Local: 0 • Wide: 0"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryGatewayNamePrefersServerThenAttemptedGateway() {
|
||||
assertEquals("Server Gateway", recoveryGatewayName(serverName = "Server Gateway", attemptedGatewayName = "Discovered Gateway"))
|
||||
assertEquals("Discovered Gateway", recoveryGatewayName(serverName = null, attemptedGatewayName = "Discovered Gateway"))
|
||||
assertEquals("Home Gateway", recoveryGatewayName(serverName = " ", attemptedGatewayName = " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsPairingStateForPairingRequiredGatewayStatus() {
|
||||
assertEquals(
|
||||
@@ -98,50 +50,6 @@ class OnboardingFlowLogicTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsApprovalRequiredForPausedPairingProblem() {
|
||||
assertEquals(
|
||||
GatewayRecoveryUiState.ApprovalRequired,
|
||||
gatewayRecoveryUiState(
|
||||
ready = false,
|
||||
statusText = "Connecting…",
|
||||
connectSettling = false,
|
||||
gatewayConnectionProblem =
|
||||
GatewayConnectionProblem(
|
||||
code = "PAIRING_REQUIRED",
|
||||
message = "pairing required: device approval is required",
|
||||
reason = "not-paired",
|
||||
requestId = "request-1",
|
||||
recommendedNextStep = null,
|
||||
pauseReconnect = true,
|
||||
retryable = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsPairingForRetryablePairingProblem() {
|
||||
assertEquals(
|
||||
GatewayRecoveryUiState.Pairing,
|
||||
gatewayRecoveryUiState(
|
||||
ready = false,
|
||||
statusText = "Connecting…",
|
||||
connectSettling = false,
|
||||
gatewayConnectionProblem =
|
||||
GatewayConnectionProblem(
|
||||
code = "PAIRING_REQUIRED",
|
||||
message = "pairing required: device approval is required",
|
||||
reason = "not-paired",
|
||||
requestId = "request-1",
|
||||
recommendedNextStep = "wait_then_retry",
|
||||
pauseReconnect = false,
|
||||
retryable = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showsFinishingStateWhileGatewayConnectionSettles() {
|
||||
assertEquals(
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayModelProviderSummary
|
||||
import ai.openclaw.app.GatewayModelSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -13,55 +10,8 @@ class ProviderModelStatusTest {
|
||||
assertTrue(modelProviderReady("static"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiringProviderStatusIsNotFullyReady() {
|
||||
assertFalse(modelProviderReady("expiring"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingProviderStatusIsNotReady() {
|
||||
assertFalse(modelProviderReady("missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerRowsIncludeConfiguredModelProvidersWithoutAuthRows() {
|
||||
val rows =
|
||||
providerRows(
|
||||
providers =
|
||||
listOf(
|
||||
GatewayModelProviderSummary(
|
||||
id = "openai",
|
||||
displayName = "OpenAI",
|
||||
status = "ok",
|
||||
profileCount = 1,
|
||||
),
|
||||
),
|
||||
models =
|
||||
listOf(
|
||||
model(provider = "openai", id = "gpt-5.5"),
|
||||
model(provider = "byteplus", id = "seed-1-8-251228"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("openai", "byteplus"), rows.map { it.id })
|
||||
assertEquals(1, rows.first { it.id == "openai" }.modelCount)
|
||||
assertEquals(1, rows.first { it.id == "byteplus" }.modelCount)
|
||||
assertTrue(rows.first { it.id == "byteplus" }.ready)
|
||||
}
|
||||
|
||||
private fun model(
|
||||
provider: String,
|
||||
id: String,
|
||||
): GatewayModelSummary =
|
||||
GatewayModelSummary(
|
||||
id = id,
|
||||
name = id,
|
||||
provider = provider,
|
||||
supportsVision = false,
|
||||
supportsAudio = false,
|
||||
supportsDocuments = false,
|
||||
supportsReasoning = false,
|
||||
contextTokens = null,
|
||||
available = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class SettingsScreensTest {
|
||||
@Test
|
||||
fun androidDistributionChannelUsesBuildFlavorLabels() {
|
||||
assertEquals("Play", androidDistributionChannel("play"))
|
||||
assertEquals("Third-party", androidDistributionChannel("thirdParty"))
|
||||
assertEquals("Unknown", androidDistributionChannel(""))
|
||||
}
|
||||
}
|
||||
@@ -32,46 +32,4 @@ class SettingsSheetNotificationAppsTest {
|
||||
|
||||
assertEquals(setOf("com.example.recent", "com.example.configured"), packages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filterNotificationAppsForPicker_keepsSelectedSystemPackagesVisible() {
|
||||
val apps =
|
||||
listOf(
|
||||
InstalledApp(label = "Android System", packageName = "android", isSystemApp = true),
|
||||
InstalledApp(label = "Phone Services", packageName = "com.android.phone", isSystemApp = true),
|
||||
InstalledApp(label = "Gmail", packageName = "com.google.android.gm", isSystemApp = false),
|
||||
)
|
||||
|
||||
val filtered =
|
||||
filterNotificationAppsForPicker(
|
||||
apps = apps,
|
||||
selectedPackages = setOf("com.android.phone"),
|
||||
query = "",
|
||||
showSystemApps = false,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("com.android.phone", "com.google.android.gm"),
|
||||
filtered.map { it.packageName },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filterNotificationAppsForPicker_matchesLabelsAndPackageNames() {
|
||||
val apps =
|
||||
listOf(
|
||||
InstalledApp(label = "Gmail", packageName = "com.google.android.gm", isSystemApp = false),
|
||||
InstalledApp(label = "Calendar", packageName = "com.google.android.calendar", isSystemApp = false),
|
||||
)
|
||||
|
||||
val filtered =
|
||||
filterNotificationAppsForPicker(
|
||||
apps = apps,
|
||||
selectedPackages = emptySet(),
|
||||
query = "gm",
|
||||
showSystemApps = false,
|
||||
)
|
||||
|
||||
assertEquals(listOf("com.google.android.gm"), filtered.map { it.packageName })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPendingDeviceSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ShellScreenLogicTest {
|
||||
@Test
|
||||
fun bottomNavHidesForKeyboardAndCommandPalette() {
|
||||
assertTrue(shellBottomNavVisible(keyboardVisible = false, commandOpen = false))
|
||||
assertFalse(shellBottomNavVisible(keyboardVisible = true, commandOpen = false))
|
||||
assertFalse(shellBottomNavVisible(keyboardVisible = false, commandOpen = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfaceGatewayWhenDisconnected() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = false,
|
||||
pendingApprovals = 0,
|
||||
channelsSummary = emptyChannels(),
|
||||
nodesDevicesSummary = emptyNodesDevices(),
|
||||
readyProviderCount = 0,
|
||||
)
|
||||
|
||||
assertEquals(listOf("Gateway"), rows.map { it.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfaceOnlyActionableConnectedIssues() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = true,
|
||||
pendingApprovals = 2,
|
||||
channelsSummary =
|
||||
GatewayChannelsSummary(
|
||||
channels =
|
||||
listOf(
|
||||
GatewayChannelSummary(
|
||||
id = "telegram",
|
||||
label = "Telegram",
|
||||
accountCount = 1,
|
||||
enabled = true,
|
||||
configured = true,
|
||||
linked = true,
|
||||
running = false,
|
||||
connected = false,
|
||||
error = "offline",
|
||||
),
|
||||
),
|
||||
),
|
||||
nodesDevicesSummary =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes = emptyList(),
|
||||
pendingDevices =
|
||||
listOf(
|
||||
GatewayPendingDeviceSummary(
|
||||
requestId = "request-1",
|
||||
deviceId = "device-1",
|
||||
displayName = "Phone",
|
||||
remoteIp = null,
|
||||
roles = emptyList(),
|
||||
scopes = emptyList(),
|
||||
requestedAtMs = null,
|
||||
repair = false,
|
||||
),
|
||||
),
|
||||
pairedDevices = emptyList(),
|
||||
),
|
||||
readyProviderCount = 0,
|
||||
)
|
||||
|
||||
assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title })
|
||||
val providersRow = rows.single { it.title == "Providers" }
|
||||
assertEquals(Tab.Settings, providersRow.tab)
|
||||
assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsStayQuietWhenConnectedAndHealthy() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = true,
|
||||
pendingApprovals = 0,
|
||||
channelsSummary = emptyChannels(),
|
||||
nodesDevicesSummary = emptyNodesDevices(),
|
||||
readyProviderCount = 1,
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), rows.map { it.title })
|
||||
}
|
||||
|
||||
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
|
||||
|
||||
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.VoiceCaptureMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class VoiceScreenLogicTest {
|
||||
@Test
|
||||
fun voiceAttentionStatusKeepsFailedTalkStartVisibleAfterModeStops() {
|
||||
val attention =
|
||||
voiceAttentionStatus(
|
||||
talkModeStatusText = "Start failed: Error: Realtime voice provider \"openai\" is not configured",
|
||||
voiceCaptureMode = VoiceCaptureMode.Off,
|
||||
micEnabled = false,
|
||||
micIsSending = false,
|
||||
talkModeEnabled = false,
|
||||
talkModeListening = false,
|
||||
talkModeSpeaking = false,
|
||||
)
|
||||
|
||||
assertEquals("Realtime voice provider is not configured.", attention)
|
||||
assertEquals(
|
||||
attention,
|
||||
voiceStatusLabel(
|
||||
gatewayStatus = "Online",
|
||||
voiceCaptureMode = VoiceCaptureMode.Off,
|
||||
micStatusText = "Mic off",
|
||||
micQueuedMessages = 0,
|
||||
micIsSending = false,
|
||||
talkModeListening = false,
|
||||
talkModeSpeaking = false,
|
||||
voiceAttentionStatus = attention,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceAttentionStatusDoesNotOverrideActiveTalkState() {
|
||||
assertNull(
|
||||
voiceAttentionStatus(
|
||||
talkModeStatusText = "Start failed: provider unavailable",
|
||||
voiceCaptureMode = VoiceCaptureMode.TalkMode,
|
||||
micEnabled = false,
|
||||
micIsSending = false,
|
||||
talkModeEnabled = true,
|
||||
talkModeListening = false,
|
||||
talkModeSpeaking = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceAttentionStatusDoesNotOverrideDictationState() {
|
||||
assertNull(
|
||||
voiceAttentionStatus(
|
||||
talkModeStatusText = "Start failed: provider unavailable",
|
||||
voiceCaptureMode = VoiceCaptureMode.ManualMic,
|
||||
micEnabled = true,
|
||||
micIsSending = false,
|
||||
talkModeEnabled = false,
|
||||
talkModeListening = false,
|
||||
talkModeSpeaking = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceRuntimeAttentionStatusSanitizesTranscriptionProviderFailures() {
|
||||
assertEquals(
|
||||
"Realtime transcription provider is not configured.",
|
||||
voiceRuntimeAttentionStatus("Transcription unavailable: UNAVAILABLE: Error: No realtime transcription provider registered"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package ai.openclaw.app.ui.chat
|
||||
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChatTimelineTest {
|
||||
@Test
|
||||
fun activeRunAnchorsNewestUserPromptInsteadOfThinkingRow() {
|
||||
val user = textMessage(id = "user-1", role = "user", text = "hello")
|
||||
|
||||
val timeline =
|
||||
buildChatTimeline(
|
||||
messages = listOf(user),
|
||||
pendingRunCount = 1,
|
||||
pendingToolCalls = emptyList(),
|
||||
streamingAssistantText = null,
|
||||
)
|
||||
|
||||
assertEquals(listOf("thinking", "message:user-1"), timeline.items.map(::chatTimelineItemKey))
|
||||
assertEquals(1, timeline.scrollTargetIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeRunAnchorsNewestUserPromptWhileAssistantStreams() {
|
||||
val olderAssistant = textMessage(id = "assistant-1", role = "assistant", text = "previous")
|
||||
val user = textMessage(id = "user-1", role = "user", text = "next")
|
||||
val tool =
|
||||
ChatPendingToolCall(
|
||||
toolCallId = "tool-1",
|
||||
name = "memory.search",
|
||||
startedAtMs = 1000L,
|
||||
)
|
||||
|
||||
val timeline =
|
||||
buildChatTimeline(
|
||||
messages = listOf(olderAssistant, user),
|
||||
pendingRunCount = 1,
|
||||
pendingToolCalls = listOf(tool),
|
||||
streamingAssistantText = "streaming",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("stream", "tools", "thinking", "message:user-1", "message:assistant-1"),
|
||||
timeline.items.map(::chatTimelineItemKey),
|
||||
)
|
||||
assertEquals(3, timeline.scrollTargetIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun finishedRunAnchorsNewestPersistedMessage() {
|
||||
val user = textMessage(id = "user-1", role = "user", text = "hello")
|
||||
val assistant = textMessage(id = "assistant-1", role = "assistant", text = "done")
|
||||
|
||||
val timeline =
|
||||
buildChatTimeline(
|
||||
messages = listOf(user, assistant),
|
||||
pendingRunCount = 0,
|
||||
pendingToolCalls = emptyList(),
|
||||
streamingAssistantText = null,
|
||||
)
|
||||
|
||||
assertEquals(listOf("message:assistant-1", "message:user-1"), timeline.items.map(::chatTimelineItemKey))
|
||||
assertEquals(0, timeline.scrollTargetIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyTimelineHasNoScrollTarget() {
|
||||
val timeline =
|
||||
buildChatTimeline(
|
||||
messages = emptyList(),
|
||||
pendingRunCount = 0,
|
||||
pendingToolCalls = emptyList(),
|
||||
streamingAssistantText = null,
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), timeline.items.map(::chatTimelineItemKey))
|
||||
assertEquals(null, timeline.scrollTargetIndex)
|
||||
}
|
||||
|
||||
private fun textMessage(
|
||||
id: String,
|
||||
role: String,
|
||||
text: String,
|
||||
): ChatMessage =
|
||||
ChatMessage(
|
||||
id = id,
|
||||
role = role,
|
||||
content = listOf(ChatMessageContent(type = "text", text = text)),
|
||||
timestampMs = null,
|
||||
)
|
||||
}
|
||||
@@ -32,29 +32,4 @@ class SessionFiltersTest {
|
||||
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "custom"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compactChoicesKeepMainAndCurrentWhileCappingRecentSessions() {
|
||||
val now = 1_700_000_000_000L
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(key = "recent-1", updatedAtMs = now - 1),
|
||||
ChatSessionEntry(key = "recent-2", updatedAtMs = now - 2),
|
||||
ChatSessionEntry(key = "recent-3", updatedAtMs = now - 3),
|
||||
ChatSessionEntry(key = "recent-4", updatedAtMs = now - 4),
|
||||
ChatSessionEntry(key = "main", updatedAtMs = now - 5),
|
||||
ChatSessionEntry(key = "active-old", updatedAtMs = now - 30 * 60 * 60 * 1000L),
|
||||
)
|
||||
|
||||
val result =
|
||||
resolveCompactSessionChoices(
|
||||
currentSessionKey = "active-old",
|
||||
sessions = sessions,
|
||||
mainSessionKey = "main",
|
||||
nowMs = now,
|
||||
maxOptions = 4,
|
||||
).map { it.key }
|
||||
|
||||
assertEquals(listOf("main", "active-old", "recent-1", "recent-2"), result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[versions]
|
||||
agp = "9.2.1"
|
||||
agp = "9.2.0"
|
||||
androidx-activity = "1.13.0"
|
||||
androidx-benchmark = "1.4.1"
|
||||
androidx-camera = "1.6.0"
|
||||
androidx-compose-bom = "2026.05.01"
|
||||
androidx-compose-bom = "2026.04.01"
|
||||
androidx-core = "1.18.0"
|
||||
androidx-exifinterface = "1.4.2"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
@@ -13,14 +13,14 @@ androidx-uiautomator = "2.4.0-beta02"
|
||||
androidx-webkit = "1.15.0"
|
||||
bcprov = "1.84"
|
||||
commonmark = "0.28.0"
|
||||
coroutines = "1.11.0"
|
||||
dnsjava = "3.6.5"
|
||||
coroutines = "1.10.2"
|
||||
dnsjava = "3.6.4"
|
||||
junit = "4.13.2"
|
||||
junit-vintage = "6.1.0"
|
||||
junit-vintage = "6.0.3"
|
||||
kotest = "6.1.11"
|
||||
ktlint-gradle = "14.2.0"
|
||||
kotlin = "2.3.21"
|
||||
material = "1.14.0"
|
||||
material = "1.13.0"
|
||||
okhttp = "5.3.2"
|
||||
play-services-code-scanner = "16.1.0"
|
||||
robolectric = "4.16.1"
|
||||
|
||||
BIN
apps/android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
apps/android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
@@ -1,9 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
2
apps/android/gradlew
vendored
2
apps/android/gradlew
vendored
@@ -57,7 +57,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user