Compare commits

..

1 Commits

Author SHA1 Message Date
Patrick Erichsen
c4c364cd27 fix: mark approval gateway calls as runtime clients 2026-05-17 21:24:42 -07:00
2669 changed files with 25461 additions and 125023 deletions

View File

@@ -23,13 +23,11 @@ Use when:
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
- Keep going until the selected review path returns no accepted/actionable findings.
- If a review-triggered fix changes code, rerun focused tests and rerun the review helper.
- Default to Codex review with no fallback. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. Use `--fallback-reviewer auto|claude|pi|opencode|droid|copilot` only when a second-model fallback is explicitly wanted and authenticated. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
- Default to Codex review. If Codex is unavailable or exits with an error, the helper falls back to the first configured CLI from `claude -p`, `pi -p`, `opencode run`, `droid exec`, or `copilot`. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
- Stop as soon as the review command/helper exits 0 with no accepted/actionable findings. Do not run an extra direct `codex review` just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
- If creating or updating a PR while rejecting any autoreview finding, record the rejected finding and reason in the PR description so later reviewers can distinguish intentional design decisions from missed review output.
- Do not push just to review. Push only when the user requested push/ship/PR update.
- For OpenClaw maintainers, keep autoreview validation Crabbox/Testbox-aware when maintainer validation mode is enabled (`OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`). A review pass may inspect files and run cheap non-Node probes, but it must not start local `pnpm`, Vitest, `tsgo`, `npm test`, or `node scripts/run-vitest.mjs` from a Codex/worktree review unless the operator explicitly requested local proof. For runtime proof, use existing evidence or route through Crabbox/Testbox and report the id. Do not apply this rule to ordinary contributors who do not have maintainer Testbox access.
## Pick Target
@@ -52,11 +50,7 @@ git fetch origin
codex review --base origin/main
```
Do not pass any prompt with `--base`, `--commit`, or `--uncommitted`. Codex CLI
review targets and custom review prompts are mutually exclusive: target modes
generate their own review prompt internally. Use plain target review for native
Codex closeout, or use custom prompt review (`codex review -`) only when you
intentionally want a generated diff prompt instead of native target review.
Do not pass an inline prompt with `--base`; current CLI rejects `--base` + `[PROMPT]` even though help text is ambiguous. If custom instructions are needed, run the plain base review first, then do a local/manual follow-up pass.
If an open PR exists, use its actual base:
@@ -113,17 +107,14 @@ The helper:
- chooses dirty `--uncommitted` first
- otherwise uses current PR base if `gh pr view` works
- otherwise uses `origin/main` for non-main branches
- auto-runs `PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check` in parallel when a repo has `package.json`, `pnpm-lock.yaml`, `node_modules`, and a `check` script; disable with `AUTOREVIEW_AUTO_TESTS=0`
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto` means Codex first
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is `none`
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is configured CLI fallback
- falls back only when Codex is unavailable or exits nonzero, not when Codex reports findings
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
- supports `--dry-run`, `--parallel-tests`, and commit refs
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access` by default
- with `OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`, disables auto local `pnpm run check` and routes Codex through generated prompt review (`codex review -`) so the no-local-heavy-tests policy is included; native Codex target review cannot accept extra prompt text
- non-Codex reviewers receive the generated diff prompt and maintainer validation policy text when maintainer validation is active
- keeps accepting `--full-access`; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt out
- still accepts legacy `CODEX_REVIEW_*` env vars when the matching `AUTOREVIEW_*` var is unset
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
@@ -137,10 +128,3 @@ Include:
- the clean review result from the final helper/review run, or why a remaining finding was consciously rejected
Do not run another Codex review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean.
## PR / CI Closeout
- Prefer direct run/job APIs after CI starts: `gh run view <run-id> --json jobs`; use PR rollup only for final mergeability.
- After rebase, compare `origin/main..HEAD`; drop CI-fix commits already upstream before pushing.
- For prompt snapshot CI failures, prove/generate with Linux Node 24 before rerunning the failed job.
- Update PR body once near the final head unless proof labels are missing or stale enough to block CI.

View File

@@ -11,9 +11,9 @@ Options:
--base REF Base ref for branch review. Default: PR base or origin/main.
--commit REF Commit ref for commit review. Default: HEAD.
--reviewer codex|claude|pi|opencode|droid|copilot|auto
Review engine. Default: Codex.
Review engine. Default: Codex with configured fallback on error.
--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none
Fallback when Codex is unavailable or exits nonzero. Default: none.
Fallback when Codex is unavailable or exits nonzero. Default: auto.
--codex-bin PATH Codex binary. Default: codex.
--claude-bin PATH Claude binary. Default: claude.
--pi-bin PATH Pi binary. Default: pi.
@@ -24,14 +24,9 @@ Options:
--no-yolo Run nested Codex review with normal sandbox/approval prompts.
--output FILE Also save output to file.
--parallel-tests CMD Run review and test command concurrently.
Default: PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check when available.
--dry-run Print selected commands, do not run.
-h, --help Show help.
Environment:
OPENCLAW_TESTBOX=1 or AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1
Enable maintainer-only OpenClaw Crabbox/Testbox validation policy.
Modes:
local codex review --uncommitted
branch codex review --base <base>
@@ -44,7 +39,7 @@ mode=auto
base_ref=
commit_ref=HEAD
reviewer=${AUTOREVIEW_REVIEWER:-${CODEX_REVIEW_REVIEWER:-auto}}
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-none}}
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-auto}}
codex_bin=${CODEX_BIN:-codex}
claude_bin=${CLAUDE_BIN:-claude}
pi_bin=${PI_BIN:-pi}
@@ -55,11 +50,7 @@ codex_args=()
yolo=${AUTOREVIEW_YOLO:-${CODEX_REVIEW_YOLO:-1}}
output=${AUTOREVIEW_OUTPUT:-${CODEX_REVIEW_OUTPUT:-}}
parallel_tests=
parallel_tests_auto=false
dry_run=false
codex_review_prompt=
codex_review_prompt_file=false
openclaw_maintainer_validation=${AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION:-${OPENCLAW_TESTBOX:-0}}
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -168,21 +159,6 @@ case "$fallback_reviewer" in
esac
repo_root=$(git rev-parse --show-toplevel)
printf -v quoted_repo_root '%q' "$repo_root"
has_package_check_script() {
command -v node >/dev/null 2>&1 || return 1
node -e 'const { readFileSync } = require("node:fs"); const p = JSON.parse(readFileSync(process.argv[1], "utf8")); process.exit(p.scripts?.check ? 0 : 1)' \
"$repo_root/package.json" \
>/dev/null 2>&1
}
auto_tests_disabled() {
case "${AUTOREVIEW_AUTO_TESTS:-${CODEX_REVIEW_AUTO_TESTS:-1}}" in
0|false|False|FALSE|no|No|NO|off|Off|OFF) return 0 ;;
*) return 1 ;;
esac
}
current_branch=$(git branch --show-current 2>/dev/null || true)
dirty=false
@@ -225,33 +201,6 @@ else
review_cmd=("$codex_bin" "${codex_args[@]}" review --base "$base_ref")
fi
repo_url=$(git -C "$repo_root" config --get remote.origin.url 2>/dev/null || true)
case "$openclaw_maintainer_validation" in
1|true|True|TRUE|yes|Yes|YES|on|On|ON) openclaw_maintainer_validation=1 ;;
*) openclaw_maintainer_validation=0 ;;
esac
if [[ -z "$parallel_tests" && "$openclaw_maintainer_validation" != 1 ]] &&
! auto_tests_disabled; then
if [[ -f "$repo_root/package.json" && -f "$repo_root/pnpm-lock.yaml" && -d "$repo_root/node_modules" ]] &&
command -v pnpm >/dev/null 2>&1 &&
has_package_check_script; then
parallel_tests="cd $quoted_repo_root && PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check"
parallel_tests_auto=true
fi
fi
if [[ "$repo_url" == *"openclaw/openclaw"* && "$openclaw_maintainer_validation" == 1 ]]; then
codex_review_prompt=$(cat <<'EOF'
OpenClaw maintainer autoreview validation policy:
- Review the diff by reading code, tests, and dependency contracts.
- Do not run local memory-heavy Node validation from review mode. This includes local pnpm checks/tests, Vitest, tsgo, npm test, and node scripts/run-vitest.mjs.
- If runtime proof is needed, use existing proof or route validation through Crabbox / Blacksmith Testbox and report the exact provider and id.
- If remote validation is not necessary for the finding, state the targeted proof that should be run instead of starting local tests.
EOF
)
review_cmd=("$codex_bin" "${codex_args[@]}" review -)
codex_review_prompt_file=true
fi
printf 'autoreview target: %s\n' "$review_kind"
printf 'branch: %s\n' "${current_branch:-detached}"
if [[ -n "$pr_url" ]]; then
@@ -272,18 +221,11 @@ if [[ "$reviewer" == auto || "$reviewer" == codex ]]; then
printf 'review:'
printf ' %q' "${review_cmd[@]}"
printf '\n'
if [[ "$codex_review_prompt_file" == true ]]; then
printf 'review policy: OpenClaw maintainer validation active; using generated prompt review because Codex target review cannot accept extra prompt text\n'
fi
else
printf 'review: %s prompt review\n' "$reviewer"
fi
if [[ -n "$parallel_tests" ]]; then
printf 'tests: %s' "$parallel_tests"
if [[ "$parallel_tests_auto" == true ]]; then
printf ' (auto)'
fi
printf '\n'
printf 'tests: %s\n' "$parallel_tests"
fi
if [[ "$review_kind" == branch ]]; then
printf 'fetch: git fetch origin --quiet\n'
@@ -322,16 +264,7 @@ cleanup() {
trap cleanup EXIT
run_review() {
local status=0
mkdir -p "$(dirname "$review_output")"
if [[ "$codex_review_prompt_file" == true ]]; then
build_prompt_file || return
"${review_cmd[@]}" < "$prompt_file" 2>&1 | tee "$review_output"
status=${PIPESTATUS[0]}
rm -f "$prompt_file"
prompt_file=
return "$status"
fi
"${review_cmd[@]}" 2>&1 | tee "$review_output"
}
@@ -373,7 +306,6 @@ Rules:
- Review the proposed code change as a closeout reviewer.
- Focus on the diff below. If your CLI exposes read-only repository tools, inspect surrounding code and tests to verify findings; never modify files.
- Do not modify files.
${codex_review_prompt}
- Report only discrete, actionable issues introduced by this change.
- Prioritize correctness, regressions, security, data loss, performance cliffs, and missing tests that would catch a real bug.
- Do not report pre-existing issues, speculative risks, broad rewrites, style nits, changelog gaps, or findings that depend on unstated assumptions.

View File

@@ -1,44 +0,0 @@
---
name: channel-message-flows
description: "Use when previewing local channel message flow fixtures."
---
# Channel Message Flows
Use this from the OpenClaw repo root to send canned channel preview flows while iterating on message UX. These are real sends/edits/deletes against the configured channel target.
## Telegram
Native Telegram `sendMessageDraft` tool progress, then a final answer:
```bash
node --import tsx scripts/dev/channel-message-flows.ts \
--channel telegram \
--target <telegram-chat-id> \
--flow working-final \
--duration-ms 20000
```
Thinking preview, then a final answer:
```bash
node --import tsx scripts/dev/channel-message-flows.ts \
--channel telegram \
--target <telegram-chat-id> \
--flow thinking-final
```
## Options
- `--account <accountId>`: Telegram account id when not using the default.
- `--thread-id <id>`: Telegram forum topic/message thread id.
- `--delay-ms <ms>`: Override preview update cadence.
- `--duration-ms <ms>`: Simulated working duration for `working-final`.
- `--final-text <text>`: Override the durable final message.
## Notes
- `--target` is the numeric Telegram chat id.
- `working-final` exercises native Telegram `sendMessageDraft` with static `Working` status and sample tool progress.
- `thinking-final` exercises formatted `Thinking` reasoning preview clearing before the final answer.
- Only `--channel telegram` is implemented for now.

View File

@@ -45,10 +45,6 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
shim can be stale.
- Check `.crabbox.yaml` for direct-provider defaults. Omitting `--provider`
means brokered AWS today.
- The brokered AWS default is a Linux developer image in `eu-west-1`; the repo
config pins hot `eu-west-1a/b/c` placement so Fast Snapshot Restore can apply.
If warmup drifts well past the minute-scale path, verify image promotion,
region/AZ placement, and FSR state before blaming OpenClaw.
- For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with
`--provider blacksmith-testbox` or the repo Testbox helpers when the standing
Testbox policy applies.
@@ -82,25 +78,6 @@ Use these only when the task needs an existing non-Linux host. OpenClaw broad
Linux validation uses the repo Crabbox config unless a provider is explicitly
requested.
Native brokered Windows is available for Windows-specific proof. Use the AWS
developer image in `us-west-2` on demand; it has the expected OpenClaw developer
toolchain and Docker image cache. Keep broad Linux gates on Linux/Testbox unless
the bug is Windows-specific:
```sh
../crabbox/bin/crabbox warmup \
--provider aws \
--target windows \
--windows-mode normal \
--region us-west-2 \
--market on-demand \
--timing-json
```
The hydrate workflow assumes Docker should already be baked into Linux images
and only installs it as a fallback. Do not add per-run Docker installs to proof
commands unless the image probe shows Docker is actually missing.
When the user explicitly asks for brokered macOS runners, use Crabbox AWS
macOS only after confirming the deployed coordinator supports EC2 Mac host
lifecycle/image routes and the operator has AWS EC2 Mac Dedicated Host quota

View File

@@ -1,64 +0,0 @@
---
name: openclaw-docker-e2e-authoring
description: "Author OpenClaw Docker E2E and live provider Docker lanes."
---
# OpenClaw Docker E2E Authoring
Use this when adding or changing Docker E2E lanes, release-path Docker tests,
or live-provider Docker proof.
## Lane Choice
- Deterministic Docker: fake the dependency/server and assert the exact runtime
contract crossing the boundary.
- Live Docker: use real provider credentials/model only when user-visible
behavior needs the real service.
- Prefer both when they prove different risks: deterministic for byte/payload
routing, live for actual provider behavior.
## Authoring Rules
- Test-only helpers live in `test/helpers` or `scripts/e2e/lib/<lane>/`, not
`src/**`, unless production imports them.
- Package-installed app runs from `/app`; mount only explicit harness/helper
paths read-only.
- Fake servers should log boundary requests as JSONL and clients should assert
the real dependency payload, not just process success.
- Add the package script and `scripts/lib/docker-e2e-scenarios.mjs` lane in the
same change.
- If a lane installs a plugin from npm, default the spec via env so published
and local override paths are both testable.
## Media And Vision
- Expected answer must exist only in pixels or provider output being tested.
- Use neutral filenames, neutral prompts, and no metadata leaks.
- Random bitmap/OCR tokens reuse the repo OCR-safe alphabet `24567ACEF` unless
the test owns a stronger glyph set.
- Make the expected answer unique per run when proving real image
understanding.
## `chat.send` E2E
- Require `chat.send` to return `status: "started"` and a string `runId`.
- Wait for completion with `agent.wait`.
- Assert final user-visible text via `chat.history` when event ordering is not
the behavior under test.
- Keep originating channel/account metadata only when the bug path needs queued
inbound/channel context.
## Verification
Run the smallest proof that covers the touched lane:
```bash
pnpm exec oxfmt --write <changed files>
node --check <new .mjs files>
bash -n <new .sh files>
node scripts/run-vitest.mjs test/scripts/docker-e2e-plan.test.ts
OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:<lane>
```
For real-provider lanes, run the matching live Docker script after deterministic
Docker is green. Finish with `$autoreview` before commit/PR.

View File

@@ -1,95 +0,0 @@
---
name: openclaw-mac-release
description: "Run or recover OpenClaw macOS release signing, notarization, appcast, and asset promotion."
---
# OpenClaw Mac Release
Use with `$openclaw-release-maintainer`, `$openclaw-release-ci`, and `$one-password` when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
## Credentials
- Canonical ASC item: vault `Molty`, title `API Key - App Store Connect - Personal - Release`.
- Fields: `private_key_p8`, `key_id`, `issuer_id`.
- Current known good key id: `AKVLXW849T`.
- Legacy mirror: vault `Private`, title `API Key - App Store Connect - Personal`; keep it synced for older refs.
- Stale/revoked key symptom: `xcrun notarytool submit` fails with `HTTP status code: 401. Unauthenticated`.
- Validate candidate ASC credentials with `xcrun notarytool history` before setting GitHub secrets.
## 1Password
- Use `$one-password`: all `op` work inside one persistent tmux session, no secret output.
- Prefer `OP_SERVICE_ACCOUNT_TOKEN` from `~/.profile` for Molty reads.
- Do not assume `MOLTY_OP_SERVICE_ACCOUNT_TOKEN` is alive; it has previously pointed at a deleted service account.
- If a service token fails, run status-only checks: token present/length and `op whoami`; never print token values.
- If desktop app auth is needed but Touch ID is unavailable, set `OP_BIOMETRIC_UNLOCK_ENABLED=false` for the manual `op account add --signin` path.
## GitHub Secrets
Target private repo environment: `openclaw/releases-private`, env `mac-release`.
Set only after local notary auth validation:
- `APP_STORE_CONNECT_API_KEY_P8`
- `APP_STORE_CONNECT_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
Do not update these from mixed sources. All three ASC fields must come from the same 1Password item.
## Workflow Shape
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.D` pointing at the original stable release commit.
- Real mac publish must reuse:
- a successful private mac preflight run for the same tag/source SHA
- a successful private mac validation run for the same tag/source SHA
- If preflight source SHA differs from tag SHA, validation must also use the same `source_ref`; promotion rejects mismatched proof.
## Notarization
- OpenClaw uses `scripts/notarize-mac-artifact.sh`.
- `xcrun notarytool submit` should use `--no-s3-acceleration`; accelerated upload can surface misleading 401s even when `notarytool history` succeeds.
- If signing succeeds but notarization fails immediately with 401, check ASC key freshness first.
- If notarization stays in progress for several minutes after key-file write, that is normal Apple wait time; do not edit blindly.
## Dispatch
Private preflight:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D \
-f preflight_only=true \
-f smoke_test_only=false \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
```
Private validation for a branch-variation preflight:
```bash
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D
```
Real publish:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f preflight_only=false \
-f smoke_test_only=false \
-f preflight_run_id=<successful-preflight-run> \
-f validate_run_id=<successful-validation-run> \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
```
## Verify
- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`.
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.

View File

@@ -28,10 +28,7 @@ git status --short --branch
git rev-parse HEAD
```
1Password service-account values are the first source for release provider
preflight. Inject those exact targeted keys first, then run the verifier; use
ambient env only when it was already intentionally injected for this release.
The script prints only provider status and HTTP class, never tokens.
If env lacks keys, use `$one-password` to inject or set them, then rerun the script. The script prints only provider status and HTTP class, never tokens.
## Dispatch

View File

@@ -170,13 +170,6 @@ live`; keep it clearly beta and avoid implying stable promotion.
CI, validation, or internal release mechanics unless the release is explicitly
about those. Peter prefers concrete user wins: features, integrations,
workflow improvements, and practical reliability fixes.
- Do not feature QA parity, test coverage, release gates, or validation lanes in
user-facing launch tweets. Keep them for release notes or maintainer proof
unless the operator explicitly asks for validation-focused copy.
- Do not feature plugin-author or developer tooling such as SDK helpers,
tool-plugin scaffolding, build/validate/init commands, or internal CLI
plumbing in general user-facing launch tweets unless the operator explicitly
asks for developer-focused copy.
- Tone: high-signal, slightly cheeky, confident, not corporate. One joke is
enough. Avoid punching down, insulting users, or promising what was not
verified.

View File

@@ -27,7 +27,7 @@ Prove the touched surface first. Do not reflexively run the whole suite.
use the Crabbox wrapper with the provider that matches the proof surface.
For maintainer heavy `pnpm` gates, that is usually delegated Blacksmith
Testbox through Crabbox, e.g. `node scripts/crabbox-wrapper.mjs run
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
Crabbox proof, omit `--provider` and let `.crabbox.yaml` choose AWS.
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
@@ -131,8 +131,6 @@ gh run view <run-id> --job <job-id> --log
- Check exact SHA. Ignore newer unrelated `main` unless asked.
- For cancelled same-branch runs, confirm whether a newer run superseded it.
- Fetch full logs only for failed or relevant jobs.
- Prefer `gh run view <run-id> --json jobs` over PR rollup while debugging; rollup can be stale/noisy.
- For `prompt:snapshots:check` failures, treat Linux Node 24 as CI truth. If macOS passes but CI drifts, reproduce in a Linux Node 24 container or Testbox, commit that generated output, then rerun.
## GitHub Release Workflows

View File

@@ -17,8 +17,7 @@ artifact bundle. The runner leases the shared burner account from Convex.
Run from the OpenClaw repo and branch under test:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" start \
pnpm qa:telegram-user:crabbox -- start \
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
```
@@ -40,8 +39,7 @@ For deterministic visual repros, put the exact mock-model reply in a file and
pass it to `start`:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" start \
pnpm qa:telegram-user:crabbox -- start \
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
--mock-response-file .artifacts/qa-e2e/telegram-user-crabbox/reply.txt \
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
@@ -57,16 +55,15 @@ For visual proof, first send or identify a bottom marker message, then open the
group/topic directly by message id:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" view \
pnpm qa:telegram-user:crabbox -- view \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--message-id <message-id>
```
This uses Telegram Desktop directly with `tg://privatepost`, not `xdg-open`.
It also resizes Telegram to `650x1000` at the tested desktop position so
the crop can isolate the chat pane even if Telegram keeps a split/sidebar
layout. Do not press Escape after this; Escape can close the selected chat.
Telegram switches to single-chat mode with no left chat list or right info
pane. Do not press Escape after this; Escape can close the selected chat.
Bottom behavior matters:
@@ -74,14 +71,13 @@ Bottom behavior matters:
later messages appear live in the recording
- deep-linking to an older message does not auto-scroll to new arrivals; link
again to the newest/final marker instead of clicking the down-arrow
- the cropped GIF intentionally uses the chat pane, not the whole desktop or
whole Telegram window
- `650px` is the largest tested clean width; `660px` switches Telegram back to
split/sidebar layout
Send as the real Telegram user:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" send \
pnpm qa:telegram-user:crabbox -- send \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--text /status
```
@@ -91,8 +87,7 @@ For slash commands, omit the bot username; the runner targets the SUT bot.
Run arbitrary commands on the Crabbox:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" run \
pnpm qa:telegram-user:crabbox -- run \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
-- bash -lc 'source /tmp/openclaw-telegram-user-crabbox/env.sh && python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py transcript --limit 20 --json'
```
@@ -111,16 +106,14 @@ python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py probe --text '@{sut}
Capture the current desktop without ending the session:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" screenshot \
pnpm qa:telegram-user:crabbox -- screenshot \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
```
Check lease state and get the WebVNC command:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" status \
pnpm qa:telegram-user:crabbox -- status \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
```
@@ -129,8 +122,7 @@ proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-pr
Always finish or explicitly keep the box:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" finish \
pnpm qa:telegram-user:crabbox -- finish \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--preview-crop telegram-window
```
@@ -158,8 +150,7 @@ Attach only the useful visual artifact to the PR unless logs are needed. The
runner is GIF-only by default:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" publish \
pnpm qa:telegram-user:crabbox -- publish \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--pr <pr-number> \
--summary 'Telegram real-user Crabbox session motion GIF'
@@ -198,8 +189,7 @@ experiments unless those artifacts are explicitly needed.
For a fast one-shot check, use:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" --text /status
pnpm qa:telegram-user:crabbox -- --text /status
```
This is a start/send/finish shortcut. Prefer the held session for PR review,

View File

@@ -6,10 +6,6 @@ capacity:
strategy: most-available
fallback: on-demand-after-120s
hints: true
availabilityZones:
- eu-west-1a
- eu-west-1b
- eu-west-1c
regions:
- eu-west-1
- eu-west-2

View File

@@ -40,7 +40,6 @@ runs:
id: pnpm-cache
uses: ./.github/actions/setup-pnpm-store-cache
with:
node-version: ${{ inputs.node-version }}
pnpm-version: ${{ inputs.pnpm-version }}
cache-key-suffix: ${{ inputs.cache-key-suffix }}
@@ -59,15 +58,14 @@ runs:
if command -v bun &>/dev/null; then bun -v; fi
- name: Capture node path
if: inputs.install-deps == 'true'
shell: bash
run: |
node_bin="$(dirname "$(node -p 'process.execPath')")"
if command -v cygpath >/dev/null 2>&1; then
node_bin="$(cygpath -u "$node_bin")"
fi
# zizmor: ignore[github-env] node_bin comes from trusted actions/setup-node output in this composite action.
echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV"
echo "$node_bin" >> "$GITHUB_PATH"
- name: Install dependencies
if: inputs.install-deps == 'true'

View File

@@ -5,10 +5,6 @@ inputs:
description: pnpm version to activate via corepack.
required: false
default: "11.0.8"
node-version:
description: Expected Node.js version already installed by actions/setup-node.
required: false
default: "24.x"
cache-key-suffix:
description: Suffix appended to the cache key.
required: false
@@ -45,85 +41,12 @@ runs:
env:
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
PNPM_VERSION: ${{ inputs.pnpm-version }}
REQUESTED_NODE_VERSION: ${{ inputs.node-version }}
run: |
set -euo pipefail
if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'"
exit 2
fi
requested_node="${REQUESTED_NODE_VERSION:-${NODE_VERSION:-}}"
requested_node="${requested_node#v}"
node_version_matches() {
local actual="$1"
local requested="$2"
if [[ -z "$requested" ]]; then
return 0
fi
case "$requested" in
*x)
[[ "${actual%%.*}" == "${requested%%.*}" ]]
;;
*.*.*)
[[ "$actual" == "$requested" ]]
;;
*.*)
[[ "$actual" == "$requested".* ]]
;;
*)
[[ "${actual%%.*}" == "$requested" ]]
;;
esac
}
active_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
if ! node_version_matches "$active_node_version" "$requested_node"; then
node_roots=()
for root in \
"${RUNNER_TOOL_CACHE:-}" \
"${AGENT_TOOLSDIRECTORY:-}" \
"${ACTIONS_RUNNER_TOOL_CACHE:-}" \
"/opt/hostedtoolcache" \
"/home/runner/_work/_tool" \
"/Users/runner/hostedtoolcache" \
"/c/hostedtoolcache/windows"
do
if [[ -d "$root/node" ]]; then
node_roots+=("$root/node")
elif [[ "$(basename "$root")" == "node" && -d "$root" ]]; then
node_roots+=("$root")
fi
done
node_bin=""
for node_root in "${node_roots[@]}"; do
while IFS= read -r candidate; do
candidate_version="$("$candidate" -p 'process.versions.node' 2>/dev/null || true)"
if node_version_matches "$candidate_version" "$requested_node"; then
node_bin="$candidate"
break 2
fi
done < <(find "$node_root" \( -name node -o -name node.exe \) -type f 2>/dev/null | sort -r)
done
if [[ -n "$node_bin" ]]; then
echo "Using Node $("$node_bin" -p 'process.versions.node') from $node_bin"
export PATH="$(dirname "$node_bin"):$PATH"
hash -r
fi
fi
active_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
if ! node_version_matches "$active_node_version" "$requested_node"; then
echo "::error::Expected Node '${requested_node}', but active node is '${active_node_version:-missing}' at $(command -v node || true)"
exit 1
fi
node -v
command -v node
command -v corepack
corepack enable
for attempt in 1 2 3; do
if corepack prepare "pnpm@$PNPM_VERSION" --activate; then

View File

@@ -18,10 +18,6 @@ Hard limits:
- Do not force GIFs for internal-only, workflow-only, test-only, docs-only, or
otherwise non-visual PRs. A no-visual-proof manifest is a successful workflow
outcome when GIFs would be misleading, but it is not proof that the PR passed.
- Do not skip Telegram-visible PRs just because the proof needs a specific
message, mock response, media attachment, command, button, reaction, stop
timing, approval prompt, or progress/final delivery sequence. First write a
concrete proof plan and try the standard harness path.
- Keep public-facing manifest summaries short and user-domain. Do not mention
harness internals, mock-provider limits, secret/trust boundaries, local paths,
transcript seeding, or workflow implementation details in the summary.
@@ -46,15 +42,7 @@ Required workflow:
2. Inspect the PR with `gh pr view "$MANTIS_PR_NUMBER"` and
`gh pr diff "$MANTIS_PR_NUMBER"`.
3. Decide whether the PR has a visibly reproducible Telegram Desktop
before/after. Treat these as visible until proven otherwise: message text
formatting/content, progress drafts, native drafts, final delivery, media or
document delivery, inline buttons, approval prompts, stop/abort behavior,
reactions/status indicators, guest/inline responses, TTS/voice/audio
delivery, and routing changes whose result is visible in the chat. For those
PRs, define the exact Telegram stimulus and expected main/PR visual delta
before deciding to skip.
If the PR does not have a Telegram-visible before/after, write
before/after. If it does not, write
`${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with `comparison.pass: true`, no
artifacts, and a summary that starts with
`Mantis did not generate before/after GIFs because`. Include a short
@@ -90,9 +78,8 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
```
If the PR appears visual but proof is blocked by Telegram Desktop session
state, authorization, credentials, Crabbox, missing Telegram client support,
unavailable media/provider setup, or another capture-infrastructure issue,
do not describe it as a no-visual PR. Write a manifest with
state, authorization, credentials, Crabbox, or another capture-infrastructure
issue, do not describe it as a no-visual PR. Write a manifest with
`comparison.pass: false`, skipped lanes, no artifacts, and a summary that
starts with `Mantis could not capture Telegram Desktop proof because`. The
publisher will keep that out of PR comments so the failure stays in the
@@ -119,10 +106,8 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
`$OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT`, the workflow-provided `crabbox`
binary, and the workflow-provided local `ffmpeg`/`ffprobe`; do not generate,
install, or patch replacement proof tooling during the run. Use the same
proof idea for baseline and candidate. Let `start` return or fail on its
own; do not kill it while Crabbox is still waiting for bootstrap. Use a long
command timeout for `start`, `send`, `view`, and `finish`. You may iterate
and rerun if the visual result is not convincing.
proof idea for baseline and candidate. You may iterate and rerun if the
visual result is not convincing.
7. Open Telegram Desktop directly to the newest relevant message with the
runner `view` command before finishing each recording. Keep the chat scrolled
to the bottom so new proof messages appear in-frame.

5
.github/labeler.yml vendored
View File

@@ -286,11 +286,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/oc-path/**"
"extensions: policy":
- changed-files:
- any-glob-to-any-file:
- "extensions/policy/**"
- "docs/cli/policy.md"
"extensions: open-prose":
- changed-files:
- any-glob-to-any-file:

View File

@@ -5,16 +5,10 @@ Describe the problem and fix in 25 bullets:
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
- Problem:
- Solution:
- Why it matters:
- What changed:
- What did NOT change (scope boundary):
## Motivation
Explain why this change should exist now. Link it to the user pain, failure mode, maintainer need, or product goal. If this is purely mechanical, write `N/A`.
-
## Change Type (select all)
- [ ] Bug fix

View File

@@ -60,11 +60,14 @@ jobs:
plugin_contracts_matrix: ${{ steps.manifest.outputs.plugin_contracts_matrix }}
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
run_checks: ${{ steps.manifest.outputs.run_checks }}
checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
run_checks_node_core_nondist: ${{ steps.manifest.outputs.run_checks_node_core_nondist }}
checks_node_core_nondist_matrix: ${{ steps.manifest.outputs.checks_node_core_nondist_matrix }}
run_checks_node_core_dist: ${{ steps.manifest.outputs.run_checks_node_core_dist }}
checks_node_core_dist_matrix: ${{ steps.manifest.outputs.checks_node_core_dist_matrix }}
run_check: ${{ steps.manifest.outputs.run_check }}
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
@@ -131,7 +134,6 @@ jobs:
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
@@ -173,7 +175,6 @@ jobs:
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? "";
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
const runNodeFastOnly =
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY);
@@ -198,7 +199,7 @@ jobs:
const checksFastCoreTasks = [];
if (runNodeFull) {
checksFastCoreTasks.push(
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
);
} else {
if (runNodeFastCiRouting) {
@@ -247,12 +248,21 @@ jobs:
runNodeFull ? createChannelContractTestShards() : [],
),
run_checks: runNodeFull,
checks_matrix: createMatrix(
runNodeFull
? [
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
]
: [],
),
run_checks_node_core_nondist: nodeTestNonDistShards.length > 0,
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
run_checks_node_core_dist: nodeTestDistShards.length > 0,
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
run_check: runNodeFull,
run_check_additional: runNodeFull,
run_check_docs: docsChanged && eventName !== "push",
run_build_smoke: runNodeFull,
run_check_docs: docsChanged,
run_control_ui_i18n: runControlUiI18n,
run_skills_python_job: runSkillsPython,
run_checks_windows: runWindows,
@@ -287,9 +297,9 @@ jobs:
}
EOF
# Run dependency-free security checks in parallel with scope detection so the
# Run the fast security/SCM checks in parallel with scope detection so the
# main Node jobs do not have to wait for Python/pre-commit setup.
security-fast:
security-scm-fast:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
@@ -382,6 +392,22 @@ jobs:
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
security-dependency-audit:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.target_ref || github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
@@ -391,6 +417,35 @@ jobs:
- name: Audit production dependencies
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
security-fast:
permissions: {}
needs: [security-scm-fast, security-dependency-audit]
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 5
steps:
- name: Verify fast security jobs
env:
DEPENDENCY_AUDIT_RESULT: ${{ needs.security-dependency-audit.result }}
SCM_RESULT: ${{ needs.security-scm-fast.result }}
run: |
set -euo pipefail
failed=0
for result in \
"security-scm-fast=${SCM_RESULT}" \
"security-dependency-audit=${DEPENDENCY_AUDIT_RESULT}"
do
job="${result%%=*}"
status="${result#*=}"
if [ "$status" != "success" ]; then
echo "::error::${job} ended with ${status}"
failed=1
fi
done
exit "$failed"
# Build dist once for Node-relevant changes and share it with downstream jobs.
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
# test/build feedback sooner instead of waiting behind a full `check` pass.
@@ -678,9 +733,14 @@ jobs:
run: |
set -euo pipefail
case "$TASK" in
bundled-protocol)
bundled)
pnpm test:bundled
pnpm protocol:check
;;
contracts-channels)
pnpm test:contracts:channels
;;
contracts-plugins)
pnpm test:contracts:plugins
;;
contracts-plugins-ci-routing)
pnpm test:contracts:plugins
@@ -863,6 +923,71 @@ jobs:
EOF
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels
checks-fast-protocol:
permissions:
contents: read
name: "checks-fast-protocol"
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 30
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
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 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.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: Run protocol check
run: pnpm protocol:check
checks-node-compat:
permissions:
contents: read
@@ -923,7 +1048,7 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: "22.19.0"
node-version: "22.18.0"
cache-key-suffix: "node22-pnpm11"
install-bun: "false"
@@ -1063,8 +1188,8 @@ jobs:
fail-fast: false
matrix:
include:
- check_name: check-guards
task: guards
- check_name: check-preflight-guards
task: preflight-guards
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-prod-types
task: prod-types
@@ -1075,9 +1200,15 @@ jobs:
- check_name: check-dependencies
task: dependencies
runner: blacksmith-8vcpu-ubuntu-2404
- check_name: check-policy-guards
task: policy-guards
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-test-types
task: test-types
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-strict-smoke
task: strict-smoke
runner: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
shell: bash
@@ -1140,18 +1271,12 @@ jobs:
run: |
set -euo pipefail
case "$TASK" in
guards)
preflight-guards)
pnpm check:no-conflict-markers
pnpm tool-display:check
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
pnpm deps:patches:check
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
pnpm lint:auth:pairing-account-scope
pnpm check:import-cycles
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
pnpm build:plugin-sdk:strict-smoke
;;
prod-types)
pnpm tsgo:prod
@@ -1168,9 +1293,19 @@ jobs:
pnpm deadcode:ci
fi
;;
policy-guards)
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
pnpm lint:auth:pairing-account-scope
pnpm check:import-cycles
;;
test-types)
pnpm check:test-types
;;
strict-smoke)
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
pnpm build:plugin-sdk:strict-smoke
;;
*)
echo "Unsupported check task: $TASK" >&2
exit 1
@@ -1200,9 +1335,15 @@ jobs:
- check_name: check-additional-boundaries-a
group: boundaries
boundary_shard: 1/4
- check_name: check-additional-boundaries-bcd
- check_name: check-additional-boundaries-b
group: boundaries
boundary_shard: 2/4,3/4,4/4
boundary_shard: 2/4
- check_name: check-additional-boundaries-c
group: boundaries
boundary_shard: 3/4
- check_name: check-additional-boundaries-d
group: boundaries
boundary_shard: 4/4
- check_name: check-additional-extension-channels
group: extension-channels
- check_name: check-additional-extension-bundled

View File

@@ -62,20 +62,17 @@ jobs:
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo ln -sf "$pnpm_bin" /usr/local/bin/pnpm
- name: Ensure Docker is running
- name: Ensure Docker is available
shell: bash
run: |
set -euo pipefail
if ! command -v docker >/dev/null 2>&1; then
echo "docker not found; installing fallback engine"
curl -fsSL https://get.docker.com | sudo sh
fi
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl start docker || true
elif command -v service >/dev/null 2>&1; then
sudo service docker start || true
sudo systemctl start docker
fi
if [ -S /var/run/docker.sock ]; then
@@ -85,10 +82,6 @@ jobs:
sudo chmod 666 /var/run/docker.sock
fi
docker version
docker buildx version || true
docker compose version || true
- name: Hydrate provider env helper
shell: bash
env:

View File

@@ -155,7 +155,7 @@ jobs:
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
OPENCLAW_EXTENSIONS=diagnostics-otel
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
sbom: true
@@ -253,7 +253,7 @@ jobs:
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
OPENCLAW_EXTENSIONS=diagnostics-otel
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
sbom: true

View File

@@ -43,7 +43,7 @@ jobs:
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
uses: actions/setup-node@v6
with:
node-version: "24.x"
node-version: "22.18.0"
- name: Clone publish repo
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''

View File

@@ -36,15 +36,5 @@ jobs:
with:
install-bun: "false"
- name: Checkout ClawHub docs source
uses: actions/checkout@v6
with:
repository: openclaw/clawhub
path: clawhub-source
fetch-depth: 1
persist-credentials: false
- name: Check docs
env:
OPENCLAW_DOCS_SYNC_CLAWHUB_REPO: ${{ github.workspace }}/clawhub-source
run: pnpm check:docs

View File

@@ -88,11 +88,6 @@ on:
required: false
default: ""
type: string
codex_plugin_spec:
description: Optional Codex plugin install spec for live Docker package checks; blank derives from release_package_spec or packs the selected ref
required: false
default: ""
type: string
npm_telegram_provider_mode:
description: Provider mode for the package Telegram E2E lane
required: false
@@ -113,7 +108,7 @@ permissions:
concurrency:
group: full-release-validation-${{ inputs.ref }}-${{ inputs.rerun_group }}
cancel-in-progress: ${{ (inputs.ref == 'main' && inputs.rerun_group == 'all') || startsWith(inputs.ref, 'tideclaw/alpha/') }}
cancel-in-progress: ${{ inputs.ref == 'main' && inputs.rerun_group == 'all' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -156,7 +151,6 @@ jobs:
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
EVIDENCE_PACKAGE_SPEC: ${{ inputs.evidence_package_spec }}
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'full' }}
RERUN_GROUP: ${{ inputs.rerun_group }}
@@ -214,43 +208,14 @@ jobs:
else
echo "- Package Acceptance package spec: SHA-built release artifact"
fi
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
docker_runtime_assets_preflight:
name: Verify Docker runtime-assets prune path
needs: [resolve_target]
if: inputs.rerun_group == 'all'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout target SHA
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.sha }}
fetch-depth: 1
persist-credentials: false
- name: Verify Docker runtime-assets prune path
env:
DOCKER_BUILDKIT: "1"
run: |
set -euo pipefail
timeout --foreground --kill-after=30s 35m docker build \
--target runtime-assets \
--build-arg OPENCLAW_EXTENSIONS="matrix" \
.
normal_ci:
name: Run normal full CI
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
needs: [resolve_target]
if: contains(fromJSON('["all","ci"]'), inputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
timeout-minutes: ${{ inputs.release_profile == 'full' && 240 || 60 }}
outputs:
run_id: ${{ steps.dispatch.outputs.run_id }}
url: ${{ steps.dispatch.outputs.url }}
@@ -347,10 +312,10 @@ jobs:
plugin_prerelease:
name: Run plugin prerelease validation
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
needs: [resolve_target]
if: contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || inputs.release_profile == 'stable' && 240 || 60 }}
timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || 60 }}
outputs:
run_id: ${{ steps.dispatch.outputs.run_id }}
url: ${{ steps.dispatch.outputs.url }}
@@ -447,10 +412,10 @@ jobs:
release_checks:
name: Run release/live/Docker/QA validation
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
needs: [resolve_target]
if: contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
timeout-minutes: ${{ inputs.release_profile == 'full' && 240 || 60 }}
outputs:
run_id: ${{ steps.dispatch.outputs.run_id }}
url: ${{ steps.dispatch.outputs.url }}
@@ -472,7 +437,6 @@ jobs:
CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
run: |
set -euo pipefail
@@ -568,9 +532,6 @@ jobs:
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`"
fi
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
child_rerun_group="$RERUN_GROUP"
@@ -599,16 +560,13 @@ jobs:
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
args+=(-f package_acceptance_package_spec="$PACKAGE_ACCEPTANCE_PACKAGE_SPEC")
fi
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC")
fi
dispatch_and_wait openclaw-release-checks.yml "${args[@]}"
prepare_release_package:
name: Prepare release package artifact
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' && needs.docker_runtime_assets_preflight.result == 'success' }}
needs: [resolve_target]
if: ${{ inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' }}
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
@@ -777,7 +735,7 @@ jobs:
summary:
name: Verify full validation
needs: [resolve_target, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, release_checks, npm_telegram]
needs: [resolve_target, normal_ci, plugin_prerelease, release_checks, npm_telegram]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
@@ -793,8 +751,6 @@ jobs:
PLUGIN_PRERELEASE_RESULT: ${{ needs.plugin_prerelease.result }}
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }}
DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT: ${{ needs.docker_runtime_assets_preflight.result }}
RERUN_GROUP: ${{ inputs.rerun_group }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
run: |
@@ -842,7 +798,6 @@ jobs:
echo
echo "| Child | Result | Minutes | Head SHA | Run |"
echo "| --- | --- | ---: | --- | --- |"
echo "| \`docker_runtime_assets_preflight\` | \`${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}\` | | current workflow | |"
} >> "$GITHUB_STEP_SUMMARY"
append_child_row() {
@@ -978,46 +933,23 @@ jobs:
}
failed=0
normal_ci_required=0
plugin_prerelease_required=0
release_checks_required=0
if [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then
echo "::error::Docker runtime-assets preflight ended with ${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}."
failed=1
elif [[ "$RERUN_GROUP" == "all" ]]; then
normal_ci_required=1
plugin_prerelease_required=1
release_checks_required=1
else
case "$RERUN_GROUP" in
ci)
normal_ci_required=1
;;
plugin-prerelease)
plugin_prerelease_required=1
;;
release-checks|install-smoke|cross-os|live-e2e|package|qa|qa-parity|qa-live)
release_checks_required=1
;;
esac
fi
append_child_overview
if [[ "$NORMAL_CI_RESULT" == "skipped" && -z "${NORMAL_CI_RUN_ID// }" ]]; then
check_child "normal_ci" "" "$normal_ci_required" || failed=1
check_child "normal_ci" "" 0 || failed=1
else
check_child "normal_ci" "$NORMAL_CI_RUN_ID" 1 || failed=1
fi
if [[ "$PLUGIN_PRERELEASE_RESULT" == "skipped" && -z "${PLUGIN_PRERELEASE_RUN_ID// }" ]]; then
check_child "plugin_prerelease" "" "$plugin_prerelease_required" || failed=1
check_child "plugin_prerelease" "" 0 || failed=1
else
check_child "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID" 1 || failed=1
fi
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" && -z "${RELEASE_CHECKS_RUN_ID// }" ]]; then
check_child "release_checks" "" "$release_checks_required" || failed=1
check_child "release_checks" "" 0 || failed=1
else
check_child "release_checks" "$RELEASE_CHECKS_RUN_ID" 1 || failed=1
fi
@@ -1062,17 +994,9 @@ jobs:
exit 0
fi
evidence_package_spec="$PACKAGE_SPEC"
if [[ -z "${evidence_package_spec// }" ]]; then
tag_ref="${TARGET_REF#refs/tags/}"
if [[ "$tag_ref" =~ ^v([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?)$ ]]; then
evidence_package_spec="openclaw@${BASH_REMATCH[1]}"
fi
fi
release_id="${TARGET_REF#refs/tags/}"
release_id="${release_id#v}"
if [[ "$evidence_package_spec" =~ ^openclaw@(.+)$ ]]; then
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
@@ -1086,7 +1010,7 @@ jobs:
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
--arg release_id "$release_id" \
--arg release_ref "$TARGET_REF" \
--arg package_spec "$evidence_package_spec" \
--arg package_spec "$PACKAGE_SPEC" \
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
'{
event_type: "openclaw_full_release_validation_completed",
@@ -1108,15 +1032,6 @@ jobs:
https://api.github.com/repos/openclaw/releases-private/dispatches \
-d "$payload"; then
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
{
echo "### Private release evidence dispatch failed"
echo
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases-private\`:"
echo
echo "\`\`\`bash"
echo "gh workflow run openclaw-release-evidence-from-full-validation.yml --repo openclaw/releases-private --ref main -f full_validation_run_id=${GITHUB_RUN_ID_VALUE} -f release_id=${release_id} -f release_ref=${TARGET_REF} -f package_spec=${evidence_package_spec}"
echo "\`\`\`"
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Write release validation manifest

View File

@@ -760,7 +760,6 @@ jobs:
core.info(`Processed ${processed} pull requests.`);
label-issues:
if: github.event_name == 'issues'
permissions:
issues: write
runs-on: ubuntu-24.04

View File

@@ -168,7 +168,7 @@ jobs:
- name: Upload Mantis artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-discord-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/mantis/

View File

@@ -528,7 +528,7 @@ jobs:
- name: Upload Mantis status reaction artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-discord-status-reactions-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}

View File

@@ -536,7 +536,7 @@ jobs:
- name: Upload Mantis thread attachment artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}

View File

@@ -359,7 +359,7 @@ jobs:
- name: Upload Mantis Slack desktop artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-slack-desktop-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}

View File

@@ -308,36 +308,16 @@ jobs:
run: |
set -euo pipefail
current_created="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq .created_at)"
stale_before="$(date -u -d '8 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
run_has_active_jobs() {
local run_id="$1"
local run_state="$2"
if [[ "$run_state" != "in_progress" ]]; then
return 0
fi
local active_jobs
active_jobs="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs --jq '[.jobs[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested")] | length')"
[[ "$active_jobs" != "0" ]]
}
while true; do
candidates="$(
blockers="$(
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
for status in queued in_progress waiting pending requested; do
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
--arg stale_before "$stale_before" \
'.[] | select(.databaseId != $current_id) | select(.createdAt >= $stale_before) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done | sort -u
)"
blockers=""
while IFS=$'\t' read -r created run_id run_state url; do
if [[ -n "$run_id" ]] && run_has_active_jobs "${run_id#\#}" "$run_state"; then
blockers+="${created}"$'\t'"${run_id}"$'\t'"${run_state}"$'\t'"${url}"$'\n'
fi
done <<<"$candidates"
if [[ -z "$blockers" ]]; then
break
fi
@@ -426,7 +406,7 @@ jobs:
printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"'
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER"'
printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_CANDIDATE_TRUST MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_CREDENTIAL_OWNER_ID OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"'
} | sudo tee /etc/sudoers.d/mantis-codex-env >/dev/null
sudo chmod 0440 /etc/sudoers.d/mantis-codex-env
@@ -462,7 +442,6 @@ jobs:
MANTIS_PR_NUMBER: ${{ needs.resolve_request.outputs.pr_number }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CREDENTIAL_OWNER_ID: mantis-telegram-desktop-${{ github.run_id }}-${{ github.run_attempt }}
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
OPENCLAW_TELEGRAM_USER_CRABBOX_BIN: /usr/local/bin/crabbox
@@ -481,47 +460,6 @@ jobs:
codex-user: codex
allow-bot-users: clawsweeper[bot]
- name: Release leaked Telegram proof leases
if: ${{ always() }}
env:
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
shell: bash
run: |
set -euo pipefail
if [[ ! -d .artifacts/qa-e2e ]]; then
exit 0
fi
status=0
mapfile -d '' session_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/session.json' -type f -print0)
for session_file in "${session_files[@]}"; do
lease_file="${session_file%/session.json}/.session/lease.json"
if [[ ! -f "$lease_file" ]]; then
continue
fi
if ! sudo -u codex env \
OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \
OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \
OPENCLAW_TELEGRAM_USER_CRABBOX_BIN=/usr/local/bin/crabbox \
OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER="$CRABBOX_PROVIDER" \
node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-crabbox-proof.ts" \
finish --session "$session_file" --preview-crop telegram-window; then
status=1
fi
done
mapfile -d '' lease_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/.session/lease.json' -type f -print0)
for lease_file in "${lease_files[@]}"; do
if ! sudo -u codex env \
OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \
OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \
node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-credential.ts" \
release --lease-file "$lease_file"; then
status=1
fi
done
exit "$status"
- name: Inspect Mantis evidence manifest
id: inspect
if: ${{ always() }}
@@ -541,7 +479,7 @@ jobs:
- name: Upload Mantis Telegram desktop artifacts
id: upload_artifact
if: ${{ always() && steps.inspect.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-telegram-desktop-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.inspect.outputs.output_dir }}

View File

@@ -272,36 +272,16 @@ jobs:
run: |
set -euo pipefail
current_created="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq .created_at)"
stale_before="$(date -u -d '8 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
run_has_active_jobs() {
local run_id="$1"
local run_state="$2"
if [[ "$run_state" != "in_progress" ]]; then
return 0
fi
local active_jobs
active_jobs="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs --jq '[.jobs[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested")] | length')"
[[ "$active_jobs" != "0" ]]
}
while true; do
candidates="$(
blockers="$(
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
for status in queued in_progress waiting pending requested; do
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
--arg stale_before "$stale_before" \
'.[] | select(.databaseId != $current_id) | select(.createdAt >= $stale_before) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done | sort -u
)"
blockers=""
while IFS=$'\t' read -r created run_id run_state url; do
if [[ -n "$run_id" ]] && run_has_active_jobs "${run_id#\#}" "$run_state"; then
blockers+="${created}"$'\t'"${run_id}"$'\t'"${run_state}"$'\t'"${url}"$'\n'
fi
done <<<"$candidates"
if [[ -z "$blockers" ]]; then
break
fi
@@ -499,7 +479,7 @@ jobs:
- name: Upload Mantis Telegram artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-telegram-live-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}

View File

@@ -270,7 +270,7 @@ jobs:
- name: Upload npm Telegram E2E artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/

View File

@@ -68,11 +68,6 @@ on:
required: false
default: ""
type: string
codex_plugin_spec:
description: Optional Codex plugin install spec for the live package lane; blank packs extensions/codex from the selected ref
required: false
default: ""
type: string
include_live_suites:
description: Whether to run live-provider coverage
required: false
@@ -178,11 +173,6 @@ on:
required: false
default: ""
type: string
codex_plugin_spec:
description: Optional Codex plugin install spec for the live package lane; blank packs extensions/codex from the selected ref
required: false
default: ""
type: string
include_live_suites:
description: Whether to run live-provider coverage
required: false
@@ -331,6 +321,9 @@ jobs:
set -euo pipefail
trusted_reason=""
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
git fetch --tags origin '+refs/tags/*:refs/tags/*'
# Resolve here instead of in actions/checkout so short SHAs work too.
if ! selected_sha="$(git rev-parse --verify "${INPUT_REF}^{commit}")"; then
echo "Ref '${INPUT_REF}' could not be resolved to a commit." >&2
@@ -641,7 +634,7 @@ jobs:
profiles: stable full
- chunk_id: package-update-openai
label: package/update OpenAI install
timeout_minutes: 45
timeout_minutes: 20
profiles: beta minimum stable full
- chunk_id: package-update-anthropic
label: package/update Anthropic install
@@ -744,7 +737,6 @@ jobs:
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
OPENCLAW_DOCKER_ALL_RELEASE_PROFILE: ${{ inputs.release_test_profile }}
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ inputs.published_upgrade_survivor_baselines }}
@@ -985,7 +977,6 @@ jobs:
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ matrix.group.published_upgrade_survivor_baselines || inputs.published_upgrade_survivor_baselines }}

View File

@@ -20,10 +20,6 @@ on:
description: Successful Full Release Validation run id for this tag/SHA, required for real publish
required: false
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
npm_dist_tag:
description: npm dist-tag to publish to
required: true
@@ -36,7 +32,7 @@ on:
concurrency:
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', inputs.tag, inputs.npm_dist_tag) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only && inputs.npm_dist_tag == 'alpha' }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -371,7 +367,6 @@ jobs:
if: ${{ !inputs.preflight_only }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Require trusted workflow ref for publish
@@ -394,7 +389,6 @@ jobs:
env:
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
run: |
set -euo pipefail
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
@@ -405,32 +399,6 @@ jobs:
echo "Real publish requires full_release_validation_run_id from a successful Full Release Validation run." >&2
exit 1
fi
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" && "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "Workflow-dispatched real publish requires release_publish_run_id from the approved OpenClaw Release Publish workflow." >&2
exit 1
fi
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "OpenClaw npm publish dispatched by another workflow must include release_publish_run_id." >&2
exit 1
fi
echo "Direct OpenClaw npm publish; relying on this workflow's npm-release environment approval."
exit 0
fi
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "OpenClaw npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
publish_openclaw_npm:
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.
@@ -543,25 +511,10 @@ jobs:
preferred_name="openclaw-npm-preflight-${RELEASE_TAG}"
rm -rf preflight-tarball
mkdir -p preflight-tarball
download_named_artifact() {
local artifact_name="$1"
for attempt in 1 2 3; do
if gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${artifact_name}" \
--dir preflight-tarball; then
return 0
fi
if [[ "$attempt" != "3" ]]; then
echo "::warning::Artifact download for ${artifact_name} failed on attempt ${attempt}; retrying."
sleep $((attempt * 10))
fi
done
return 1
}
if download_named_artifact "${preferred_name}"; then
if gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${preferred_name}" \
--dir preflight-tarball; then
echo "Downloaded ${preferred_name}."
return 0
fi
@@ -577,7 +530,10 @@ jobs:
exit 1
fi
fallback_name="${matches[0]}"
download_named_artifact "${fallback_name}"
gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${fallback_name}" \
--dir preflight-tarball
echo "Downloaded fallback preflight artifact ${fallback_name}."
}

View File

@@ -468,7 +468,7 @@ jobs:
- name: Upload Kova artifacts
if: ${{ always() && steps.lane.outputs.run == 'true' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v5
with:
name: openclaw-performance-${{ matrix.lane }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |

View File

@@ -78,15 +78,10 @@ on:
required: false
default: ""
type: string
codex_plugin_spec:
description: Optional Codex plugin install spec for live Docker package checks; blank derives from release_package_spec or packs the selected ref
required: false
default: ""
type: string
concurrency:
group: openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ inputs.rerun_group }}
cancel-in-progress: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -117,7 +112,6 @@ jobs:
qa_live_slack_enabled: ${{ steps.inputs.outputs.qa_live_slack_enabled }}
release_package_spec: ${{ steps.inputs.outputs.release_package_spec }}
package_acceptance_package_spec: ${{ steps.inputs.outputs.package_acceptance_package_spec }}
codex_plugin_spec: ${{ steps.inputs.outputs.codex_plugin_spec }}
steps:
- name: Require trusted workflow ref for release checks
env:
@@ -268,7 +262,6 @@ jobs:
RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED || 'false' }}
RELEASE_PACKAGE_SPEC_INPUT: ${{ inputs.release_package_spec }}
RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT: ${{ inputs.package_acceptance_package_spec }}
RELEASE_CODEX_PLUGIN_SPEC_INPUT: ${{ inputs.codex_plugin_spec }}
run: |
set -euo pipefail
qa_live_matrix_enabled=true
@@ -314,10 +307,6 @@ jobs:
if [[ "$release_profile" == "full" ]]; then
run_release_soak=true
fi
codex_plugin_spec="$RELEASE_CODEX_PLUGIN_SPEC_INPUT"
if [[ -z "${codex_plugin_spec// }" && "$RELEASE_PACKAGE_SPEC_INPUT" =~ ^openclaw@(.+)$ ]]; then
codex_plugin_spec="npm:@openclaw/codex@${BASH_REMATCH[1]}"
fi
filter="$(printf '%s' "$RELEASE_LIVE_SUITE_FILTER_INPUT" | tr '[:upper:]' '[:lower:]')"
if [[ -n "${filter// }" ]]; then
@@ -398,7 +387,6 @@ jobs:
printf 'qa_live_slack_enabled=%s\n' "$qa_live_slack_enabled"
printf 'release_package_spec=%s\n' "$RELEASE_PACKAGE_SPEC_INPUT"
printf 'package_acceptance_package_spec=%s\n' "$RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT"
printf 'codex_plugin_spec=%s\n' "$codex_plugin_spec"
} >> "$GITHUB_OUTPUT"
- name: Summarize validated ref
@@ -415,7 +403,6 @@ jobs:
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
CODEX_PLUGIN_SPEC: ${{ steps.inputs.outputs.codex_plugin_spec }}
run: |
{
echo "## Release checks"
@@ -445,11 +432,6 @@ jobs:
else
echo "- Package Acceptance package spec: prepared release artifact"
fi
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
else
echo "- Codex plugin spec: packed from selected ref"
fi
if [[ "$RUN_RELEASE_SOAK" == "true" ]]; then
echo "- This run will execute blocking release validation plus exhaustive live/Docker soak coverage."
else
@@ -658,7 +640,6 @@ jobs:
include_live_suites: false
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
package_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
codex_plugin_spec: ${{ needs.resolve_target.outputs.codex_plugin_spec }}
secrets: *live_e2e_release_secrets
package_acceptance_release_checks:
@@ -813,7 +794,7 @@ jobs:
- name: Upload parity lane artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -849,7 +830,7 @@ jobs:
install-bun: "true"
- name: Download parity lane artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -872,7 +853,7 @@ jobs:
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -945,20 +926,6 @@ jobs:
--runtime-pair pi,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity-standard"
- name: Run soak runtime parity tier
id: runtime_parity_soak_lane
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
run: |
set -euo pipefail
pnpm openclaw qa suite \
--provider-mode mock-openai \
--runtime-parity-tier soak \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.5-alt" \
--runtime-pair pi,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity-soak"
- name: Generate runtime parity report
if: always()
run: |
@@ -979,24 +946,9 @@ jobs:
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
- name: Generate soak runtime parity 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
summary=".artifacts/qa-e2e/runtime-parity-soak/qa-suite-summary.json"
if [[ ! -f "$summary" ]]; then
echo "No soak runtime parity summary was produced."
exit 0
fi
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--summary "$summary" \
--output-dir .artifacts/qa-e2e/runtime-parity-soak-report
- name: Upload runtime parity artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1031,7 +983,7 @@ jobs:
install-bun: "true"
- name: Download runtime parity artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1047,7 +999,7 @@ jobs:
- name: Upload runtime tool coverage artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/runtime-parity-standard-report/
@@ -1127,7 +1079,7 @@ jobs:
- name: Upload Matrix QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1223,7 +1175,7 @@ jobs:
- name: Upload Telegram QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1319,7 +1271,7 @@ jobs:
- name: Upload Discord QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1418,7 +1370,7 @@ jobs:
- name: Upload WhatsApp QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1514,7 +1466,7 @@ jobs:
- name: Upload Slack QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/

View File

@@ -153,25 +153,10 @@ jobs:
preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest"
rm -rf "${preflight_dir}"
mkdir -p "${preflight_dir}"
download_named_artifact() {
local artifact_name="$1"
for attempt in 1 2 3; do
if gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${artifact_name}" \
--dir "${preflight_dir}"; then
return 0
fi
if [[ "$attempt" != "3" ]]; then
echo "::warning::Artifact download for ${artifact_name} failed on attempt ${attempt}; retrying."
sleep $((attempt * 10))
fi
done
return 1
}
if download_named_artifact "${preferred_name}"; then
if gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${preferred_name}" \
--dir "${preflight_dir}"; then
echo "name=${preferred_name}" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -187,7 +172,10 @@ jobs:
exit 1
fi
fallback_name="${matches[0]}"
download_named_artifact "${fallback_name}"
gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${fallback_name}" \
--dir "${preflight_dir}"
echo "name=${fallback_name}" >> "$GITHUB_OUTPUT"
- name: Download full release validation manifest
@@ -348,7 +336,6 @@ jobs:
needs: [resolve_release_target]
runs-on: ubuntu-latest
timeout-minutes: 60
environment: npm-release
steps:
- name: Checkout release SHA
uses: actions/checkout@v6
@@ -451,7 +438,7 @@ jobs:
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
return 1
return 0
fi
approved=0
@@ -463,15 +450,13 @@ jobs:
gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \
-F "environment_ids[]=${env_id}" \
-f state=approved \
-f comment="Approve child release gate after parent release approval" >/dev/null
-f comment="Approve release gate from OpenClaw Release Publish wrapper" >/dev/null
approved=1
done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv')
if [[ "${approved}" == "1" ]]; then
echo "${workflow}: approved available pending environment gates"
return 0
fi
return 1
}
print_failed_run_summary() {
@@ -514,7 +499,7 @@ jobs:
if [[ "$state" != "$last_state" ]]; then
echo "${workflow} still ${status} (updated ${updated_at}): ${url}"
print_pending_deployments "${workflow}" "${run_id}"
approve_pending_deployments "${workflow}" "${run_id}" || true
approve_pending_deployments "${workflow}" "${run_id}"
last_state="$state"
fi
sleep 30
@@ -561,85 +546,6 @@ jobs:
wait_run_pid="$!"
}
wait_for_job_success() {
local workflow="$1"
local run_id="$2"
local job_name="$3"
local jobs_json job_json run_status run_conclusion status conclusion url deadline
deadline=$((SECONDS + 900))
while true; do
jobs_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,jobs)"
run_status="$(printf '%s' "$jobs_json" | jq -r '.status')"
run_conclusion="$(printf '%s' "$jobs_json" | jq -r '.conclusion // ""')"
job_json="$(printf '%s' "$jobs_json" | jq -c --arg name "$job_name" '.jobs[]? | select(.name == $name) | {status, conclusion, url}' | head -n 1)"
if [[ -n "$job_json" ]]; then
status="$(printf '%s' "$job_json" | jq -r '.status')"
conclusion="$(printf '%s' "$job_json" | jq -r '.conclusion // ""')"
url="$(printf '%s' "$job_json" | jq -r '.url // ""')"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" || "$conclusion" == "skipped" ]]; then
echo "${workflow} ${job_name} ${conclusion}: ${url}"
echo "- ${workflow} ${job_name}: ${conclusion} (${url})" >> "$GITHUB_STEP_SUMMARY"
return 0
fi
echo "${workflow} ${job_name} failed: ${conclusion} ${url}" >&2
print_failed_run_summary "${run_id}"
return 1
fi
echo "${workflow} ${job_name} still ${status}: ${url}"
elif [[ "$run_status" == "completed" ]]; then
if [[ "$run_conclusion" == "success" ]]; then
echo "${workflow} completed before ${job_name} was needed."
echo "- ${workflow} ${job_name}: not needed" >> "$GITHUB_STEP_SUMMARY"
return 0
fi
echo "${workflow} completed before ${job_name} with ${run_conclusion}." >&2
print_failed_run_summary "${run_id}"
return 1
else
echo "${workflow} waiting for ${job_name} to start: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
fi
if (( SECONDS >= deadline )); then
echo "${workflow} ${job_name} did not complete within 15 minutes." >&2
return 1
fi
sleep 10
done
}
approve_child_publish_environment() {
local workflow="$1"
local run_id="$2"
local run_json status conclusion deadline
deadline=$((SECONDS + 900))
while true; do
if approve_pending_deployments "${workflow}" "${run_id}"; then
echo "- ${workflow}: child environment gate approved" >> "$GITHUB_STEP_SUMMARY"
return 0
fi
run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)"
status="$(printf '%s' "$run_json" | jq -r '.status')"
conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion // ""')"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" ]]; then
echo "${workflow}: completed before child environment approval was needed"
return 0
fi
echo "${workflow}: completed before child environment approval with ${conclusion}" >&2
print_failed_run_summary "${run_id}"
return 1
fi
if (( SECONDS >= deadline )); then
echo "${workflow}: child environment approval was not available within 15 minutes." >&2
print_pending_deployments "${workflow}" "${run_id}"
return 1
fi
sleep 10
done
}
create_or_update_github_release() {
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
release_version="${RELEASE_TAG#v}"
@@ -739,14 +645,10 @@ jobs:
--workflow-ref "${CHILD_WORKFLOW_REF}"
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
--plugin-npm-run "${plugin_npm_run_id}"
--plugin-clawhub-run "${plugin_clawhub_run_id}"
--openclaw-npm-run "${openclaw_npm_run_id}"
--evidence-out "${evidence_path}"
)
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
else
verify_args+=(--skip-clawhub)
fi
if [[ -n "${PLUGINS// }" ]]; then
verify_args+=(--plugins "${PLUGINS}")
fi
@@ -761,97 +663,27 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
}
append_release_proof_to_github_release() {
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
telegram_line="- npm Telegram beta E2E: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${NPM_TELEGRAM_RUN_ID}"
else
telegram_line="- npm Telegram beta E2E: not supplied"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
else
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
fi
RELEASE_BODY_FILE="${body_file}" \
RELEASE_NOTES_FILE="${notes_file}" \
RELEASE_VERSION="${release_version}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_REPO="${GITHUB_REPOSITORY}" \
RELEASE_TARBALL="${tarball}" \
RELEASE_INTEGRITY="${integrity}" \
RELEASE_PUBLISH_RUN_ID="${GITHUB_RUN_ID}" \
PREFLIGHT_RUN_ID="${PREFLIGHT_RUN_ID}" \
FULL_RELEASE_VALIDATION_RUN_ID="${FULL_RELEASE_VALIDATION_RUN_ID}" \
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
CLAWHUB_LINE="${clawhub_line}" \
TELEGRAM_LINE="${telegram_line}" \
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from "node:fs";
const bodyFile = process.env.RELEASE_BODY_FILE;
const notesFile = process.env.RELEASE_NOTES_FILE;
if (!bodyFile || !notesFile) {
throw new Error("Missing release notes file paths.");
}
const body = readFileSync(bodyFile, "utf8").trimEnd();
const section = [
"### Release verification",
"",
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
`- full release CI report: https://github.com/openclaw/releases-private/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
process.env.CLAWHUB_LINE,
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
process.env.TELEGRAM_LINE,
].join("\n");
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
writeFileSync(notesFile, `${withoutOldProof.trimEnd()}\n\n${section}\n`);
NODE
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --notes-file "${notes_file}"
echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY"
}
{
echo "### Publish sequence"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Release approval: this workflow job"
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- OpenClaw npm publish: starts after plugin npm succeeds"
echo "- OpenClaw npm publish: starts after plugin npm succeeds; final verification waits for ClawHub"
else
echo "- OpenClaw npm publish: skipped by input"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
if [[ "${WAIT_FOR_CLAWHUB}" == "true" || "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- Workflow completion waits for ClawHub"
else
echo "- Workflow completion does not wait for ClawHub; monitor the dispatched ClawHub run separately"
fi
} >> "$GITHUB_STEP_SUMMARY"
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
if [[ -n "${PLUGINS}" ]]; then
npm_args+=(-f plugins="${PLUGINS}")
clawhub_args+=(-f plugins="${PLUGINS}")
@@ -877,7 +709,6 @@ jobs:
-f preflight_only=false \
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
-f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \
-f release_publish_run_id="${GITHUB_RUN_ID}" \
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")"
echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
else
@@ -886,19 +717,13 @@ jobs:
clawhub_result=""
clawhub_pid=""
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
if [[ "${WAIT_FOR_CLAWHUB}" == "true" || "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
wait_run_pid=""
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
else
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
:
else
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
echo "- plugin-clawhub-release.yml: not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
openclaw_result=""
@@ -935,7 +760,6 @@ jobs:
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
verify_published_release
append_release_proof_to_github_release
fi
if [[ "${failed}" != "0" ]]; then
exit 1

View File

@@ -62,7 +62,7 @@ jobs:
- name: Upload SARIF as workflow artifact
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: opengrep-full-sarif
path: .opengrep-out/precise.sarif

View File

@@ -92,7 +92,7 @@ jobs:
- name: Upload SARIF as workflow artifact
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: opengrep-pr-diff-sarif
path: .opengrep-out/precise.sarif

View File

@@ -20,10 +20,6 @@ on:
required: false
default: ""
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
concurrency:
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
@@ -200,37 +196,6 @@ jobs:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
validate_release_publish_approval:
name: Validate release publish approval
needs: preview_plugins_clawhub
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "Plugin ClawHub publish dispatched by another workflow must include release_publish_run_id." >&2
exit 1
fi
echo "Direct Plugin ClawHub Release dispatch; relying on this workflow's clawhub-plugin-release environment approval."
exit 0
fi
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "Plugin ClawHub publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
preview_plugin_pack:
needs: preview_plugins_clawhub
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
@@ -324,12 +289,11 @@ jobs:
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
publish_plugins_clawhub:
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
needs: [preview_plugins_clawhub, preview_plugin_pack]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions:
actions: read
contents: read
id-token: write
strategy:

View File

@@ -32,10 +32,6 @@ on:
description: Comma-separated plugin package names to publish when publish_scope=selected
required: false
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
concurrency:
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
@@ -177,37 +173,6 @@ jobs:
exit 1
fi
validate_release_publish_approval:
name: Validate release publish approval
needs: preview_plugins_npm
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "Plugin npm publish dispatched by another workflow must include release_publish_run_id." >&2
exit 1
fi
echo "Direct Plugin NPM Release dispatch; relying on this workflow's npm-release environment approval."
exit 0
fi
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "Plugin npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
preview_plugin_pack:
needs: preview_plugins_npm
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
@@ -240,12 +205,11 @@ jobs:
run: bash scripts/plugin-npm-publish.sh --pack-dry-run "${{ matrix.plugin.packageDir }}"
publish_plugins_npm:
needs: [preview_plugins_npm, preview_plugin_pack, validate_release_publish_approval]
needs: [preview_plugins_npm, preview_plugin_pack]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: npm-release
permissions:
actions: read
contents: read
id-token: write
strategy:

View File

@@ -222,7 +222,7 @@ jobs:
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-parity-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/
@@ -312,7 +312,7 @@ jobs:
- name: Upload live runtime token-efficiency artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-runtime-token-efficiency-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -389,7 +389,7 @@ jobs:
- name: Upload Matrix QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-matrix-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -474,7 +474,7 @@ jobs:
- name: Upload Matrix QA shard artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-matrix-${{ matrix.profile }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -569,7 +569,7 @@ jobs:
- name: Upload Telegram QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -664,7 +664,7 @@ jobs:
- name: Upload Discord QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-discord-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -762,7 +762,7 @@ jobs:
- name: Upload WhatsApp QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-whatsapp-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -857,7 +857,7 @@ jobs:
- name: Upload Slack QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-slack-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}

View File

@@ -18,7 +18,6 @@ jobs:
name: Real behavior proof
permissions:
contents: read
issues: read
pull-requests: read
runs-on: ubuntu-24.04
steps:
@@ -26,25 +25,5 @@ jobs:
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- uses: actions/create-github-app-token@v3
id: app-token
continue-on-error: true
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
permission-issues: read
permission-members: read
- uses: actions/create-github-app-token@v3
id: app-token-fallback
if: steps.app-token.outcome == 'failure'
continue-on-error: true
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
permission-issues: read
permission-members: read
- name: Check real behavior proof
env:
GH_APP_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/github/real-behavior-proof-check.mjs

View File

@@ -76,9 +76,11 @@ jobs:
- name: install.sh in Docker
run: |
docker run --rm \
-e OPENCLAW_NO_ONBOARD=1 \
-e OPENCLAW_NO_PROMPT=1 \
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
node:24-bookworm-slim \
bash -lc 'bash /tmp/install.sh --version latest && openclaw --version'
bash -lc 'bash /tmp/install.sh --no-prompt --no-onboard --version latest && openclaw --version'
- name: install-cli.sh in Docker
run: |

View File

@@ -8,7 +8,6 @@
},
"rules": {
"curly": "error",
"eslint/no-underscore-dangle": "error",
"eslint-plugin-unicorn/prefer-array-find": "error",
"eslint/no-array-constructor": "error",
"eslint/no-await-in-loop": "off",

View File

@@ -35,11 +35,9 @@ Skills own workflows; root owns hard policy and routing.
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
- Lean code is a goal. No internal shims, aliases, legacy names, broad fallbacks, or defensive branches just to reduce diff or handle unrealistic edge cases.
- Handle real production states, shipped upgrade paths, security boundaries, and dependency contracts. Public/hostile/observed malformed input gets care; hypothetical malformed input does not.
- Public plugin SDK/API is the compat exception. New API first, old path only via named compat/deprecation metadata, docs, warnings when useful, tests for old+new, planned removal.
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
- Fix shape: prefer bounded owner-boundary refactors over local patches/shims when they remove stale abstractions, duplicate policy, or wrong ownership.
- Compat default: no new internal shims, aliases, fallback APIs, or legacy names just to reduce diff. Migrate callers and delete old paths.
- Public plugin API is the only compat exception: document/version breaks, aggressively deprecate unused SDK surface, and migrate ALL bundled/internal plugins to the modern API in the same change.
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
- Do not fix repeated request-time discovery with scattered caches. Move the canonical fact earlier; reuse prepared runtime objects; delete duplicate lookup branches.
@@ -52,7 +50,7 @@ Skills own workflows; root owns hard policy and routing.
## Commands
- Runtime: Node 22.19+; Node 24 recommended. Keep Node + Bun paths working.
- Runtime: Node 22+. Keep Node + Bun paths working.
- Package manager/runtime: repo defaults only. No swaps without approval.
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
- Sharp/Homebrew libvips source-build fail: `SHARP_IGNORE_GLOBAL_LIBVIPS=1 pnpm install`.
@@ -79,12 +77,10 @@ Skills own workflows; root owns hard policy and routing.
- If proof is blocked, say exactly what is missing and why.
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
- Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed.
- Prompt snapshots: CI truth is Linux Node 24. If macOS local passes but CI drifts, reproduce/generate in Linux before rerun.
## GitHub / PRs
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
- Pasted GitHub issue/PR: first `git status -sb`; if dirty, yell; then `git push` + `git pull --ff-only`.
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
- Bare issue/PR URL/number means review/report in chat. Suggest comment/close/merge when appropriate; mutate only when asked.
- No unsolicited PR comments/reviews/labels/retitles/rebases/fixups/landing. Exception: close/duplicate action that needs a reason comment after explicit close/sweep/landing request.
@@ -112,10 +108,6 @@ Skills own workflows; root owns hard policy and routing.
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
- External boundaries: prefer `zod` or existing schema helpers.
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
- Formatter-friendly shape: when oxfmt explodes an expression vertically, extract named booleans, payloads, or small helpers. Do not change width or use format-ignore for local compactness.
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
- Use named intermediates only for domain meaning or readability; avoid temp-variable soup.
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
@@ -172,7 +164,6 @@ Skills own workflows; root owns hard policy and routing.
- "restart iOS/Android apps" = rebuild/reinstall/relaunch, not kill/launch.
- SwiftUI: Observation (`@Observable`, `@Bindable`) over new `ObservableObject`.
- Mac gateway: dev watch = `pnpm gateway:watch`; managed installs = `openclaw gateway restart/status --deep`; logs = `./scripts/clawlog.sh`. No launchd/ad-hoc tmux.
- Mac app permission testing: stable app path + real signing identity required. No `--no-sign`, `SIGN_IDENTITY=-`, or raw debug binary; TCC prompts/listing won't stick.
- Version bump surfaces live in `$openclaw-release-maintainer`.
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.

View File

@@ -6,209 +6,17 @@ Docs: https://docs.openclaw.ai
### Changes
- Discord: allow configuring a bounded `agentComponents.ttlMs` callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.
- Plugin SDK: add row-level session workflow helpers and deprecate `loadSessionStore` so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.
- Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.
- Plugins/SDK: add a general `embeddingProviders` capability contract and registration API so embeddings can become a reusable provider surface outside memory-specific adapters.
- Dependencies: refresh provider, plugin, UI, and tooling packages, update `protobufjs` to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to `@agentclientprotocol/claude-agent-acp` 0.36.1.
- Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.
- QA-Lab: add curated mock JSONL replay fixtures and first-drift reporting for runtime-parity audits. (#80323, refs #80176) Thanks @100yenadmin.
- QA-Lab: include the optional 100-turn runtime parity soak in release-soak artifacts so long-run Codex/Pi transcript drift stays visible outside the default gate. (#80395) Thanks @100yenadmin.
- QA-Lab: add a live-only long-context progress watchdog scenario for Codex app-server timeout and stalled-run sentinels. (#80323) Thanks @100yenadmin.
- QA-Lab: tag gateway restart recovery and streaming final-integrity scenarios as live-only runtime parity lanes. (#80323) Thanks @100yenadmin.
- QA-Lab: add a personal-agent failure recovery scenario that checks honest partial status, retry boundaries, and local recovery artifacts. (#83872) Thanks @iFiras-Max1.
- QA-Lab: include an opt-in `update.run` package self-upgrade sentinel for destructive latest-package recovery checks.
- QA-Lab: add Codex plugin lifecycle and auth-profile fixture coverage for missing installs, pinned-version drift, first-turn install ordering, and doctor migration safety. (#80323, refs #80174) Thanks @100yenadmin.
- Models/perf: pre-warm the provider auth-state map at gateway startup so `/models` and every model-listing call short-circuits the per-provider plugin / external-CLI discovery on the hot path. Per-call cost drops from ~20 s to ~5 ms (~4,100×); the one-time startup warm resets and re-warms after hot reloads. (#84816) Thanks @sjf.
- Tests/perf: isolate doctor core health check unit coverage from real skills/workspace discovery so `doctor-core-checks` no longer dominates unit perf while keeping one real skills-readiness smoke. (#84493) Thanks @frankekn.
### Fixes
- Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
- Auto-reply/ACP: wait for same-channel block reply delivery before starting tool work, while still honoring ACP dispatch aborts so stopped turns do not wait on slow channel sends. (#83722) Thanks @IWhatsskill.
- Codex/ACP: mark required child-run completions that only report progress, omit a final deliverable, or fail requester delivery as blocked while preserving real final reports. (#85110) Thanks @IWhatsskill.
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
- Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.
- Install/update: reject OpenClaw GitHub source package targets early and point moving-main users at the dev/git install path instead of the broken npm source-install flow.
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.
- Infra/json: retry transient `File changed during read` races while loading JSON state so config and state reads recover instead of failing the turn. (#84285)
- Gateway/chat: surface message-tool-only room-event failures in chat diagnostics and session transcripts so suppressed source replies stay debuggable. Thanks @amknight.
- Plugins/providers: fail closed for workspace provider plugins during setup-mode discovery unless explicitly trusted, preventing untrusted workspace plugin code from running during provider setup. (#81069) Thanks @mmaps.
- Providers/Ollama: resolve configured Ollama Cloud `OLLAMA_API_KEY` markers to the real discovery key so cloud provider entries keep authenticated model catalog access. (#85037)
- Discord: keep persistent component registry fallback warnings actionable by forwarding structured error and cause metadata through the runtime logger. Fixes #84185. (#84190) Thanks @100menotu001.
- Gateway/sessions: preserve compatible session auth profile overrides when switching models within the same provider, including provider-auth aliases. Fixes #81837. (#81886) Thanks @TurboTheTurtle.
- Gateway/status: surface inbound delivery telemetry counters and transport-liveness warnings in `openclaw status --all`. Fixes #49577. (#72724)
- Docker: prune package-excluded plugin source workspaces and dependency closures so runtime images do not keep packages for plugins that were not opted in.
- Providers/Ollama: treat Docker/OrbStack host aliases as local Ollama endpoints so `ollama-local` marker auth works when OpenClaw runs inside a VM/container and Ollama runs on the host. Fixes #84875.
- QA-Lab: keep explicitly searchable/deferred OpenClaw dynamic tool rows report-only by default so tool-coverage gates do not treat mock discovery gaps as hard product failures. (#80319) Thanks @100yenadmin.
- Agents/config: keep non-Google provider model refs from being rewritten by Google Gemini preview-id normalization. (#84762) Thanks @zhangguiping-xydt.
- Installer: require a real controlling terminal before launching onboarding so headless `curl | bash` installs finish cleanly after installing the CLI.
- Agents/Codex: promote a completed final assistant response when a prompt timeout races Codex app-server completion instead of returning an empty timeout envelope. Refs #84516.
- Agents: cap heartbeat model bleed context hints by the stored session window when runtime model metadata is unavailable, so overflow recovery advice does not suggest a larger window than the active session actually has.
- Control UI/Web Push: use `https://openclaw.ai` as the generated default VAPID subject instead of the old localhost mailbox so iOS PWA push setup uses an Apple-acceptable subject when `OPENCLAW_VAPID_SUBJECT` is unset. Fixes #83134. (#83317) Thanks @IWhatsskill.
- Agents/Pi: keep embedded session transcript writes from tripping false takeover detection after packaged npm onboarding agent turns.
- Codex/TUI: surface Codex-native post-turn compaction failures instead of continuing uncompacted, and keep successful native compaction serialized before local idle/next-turn handling. Fixes #84305. (#85160) Thanks @joshavant.
- Memory/search: stop recall tracking from writing dreaming side-effect artifacts when `dreaming.enabled=false`, while preserving normal search results. Fixes #84436. (#84444) Thanks @NianJiuZst.
- Diffs: render viewer toolbar icons from a closed icon-name map instead of HTML strings, removing the toolbar icon XSS sink. (#83955) Thanks @tanshanshan.
- QA: keep `pnpm qa:e2e` self-check runs inside the private QA runtime envelope even when inherited shell env disables bundled plugins.
- fix(config): validate browser sandbox bind sources [AI]. (#84799) Thanks @pgondhi987.
- doctor: constrain legacy plugin cleanup paths [AI]. (#84801) Thanks @pgondhi987.
- Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.
- Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.
- Discord: keep session recovery and `/stop` abort ownership on the source dispatch lane while bound ACP turns continue routing to their target session, so stalled pre-run work and late replies are cleared instead of leaking after stop. Fixes #84477. (#85100) Thanks @joshavant.
- Codex app-server: mark missing turn completion after observed execution as replay-unsafe and release the session so follow-up turns can run. Fixes #84076. (#85107) Thanks @joshavant.
- Codex app-server: add a dedicated post-tool raw assistant completion idle timeout config so trusted heavy turns can wait longer after tool handoff without weakening final assistant release.
- Matrix: keep explicitly configured two-person rooms on the room route before stale `m.direct` or strict two-member DM fallback can bypass mention gating. Fixes #85017. (#85137) Thanks @joshavant.
- Agents/subagents: require explicit subagent allowlist targets to be configured agents so stale deleted-agent ids are omitted from `agents_list` and rejected by `sessions_spawn`. Fixes #84811. (#85154) Thanks @joshavant.
- PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.
- Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.
- Agents/exec: omit raw command text and env values from denied exec failure logs while keeping safe correlation metadata. Fixes #85049. (#85140) Thanks @joshavant.
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.
- Agents/exec: preserve inherited XDG base-directory environment values for subprocesses while still rejecting agent-supplied XDG overrides. Fixes #84854. (#85139) Thanks @joshavant.
- Node/Linux: keep `OPENCLAW_GATEWAY_TOKEN` out of generated systemd unit files by writing node service token values to a node-specific env file. (#84408)
- Memory-core/dreaming: reuse stable narrative subagent session keys per workspace and phase while keeping per-run idempotency and bounded cleanup, so stale `dreaming-narrative-*` sessions do not accumulate. Fixes #68252, #69187, and #70402. (#70464) Thanks @chiyouYCH.
- Trajectory/support: tolerate partial skill snapshot entries when building support metadata so rejected skill path scans no longer abort trajectory capture. (#71185) Thanks @lukeboyett.
- TUI: coalesce repeated idle Esc abort notices into a single `no active run xN` system row instead of appending duplicate rows.
- Telegram: honor `channels.telegram.pollingStallThresholdMs` in the default isolated polling path, restarting silent workers instead of leaving inbound updates wedged. Fixes #83950. (#84861) Thanks @joshavant.
- Slack: suppress reasoning payloads before reply delivery and dispatch accounting, so Slack monitor, slash-command, fallback, and direct reply paths do not leak model reasoning. Fixes #84319. (#84322) Thanks @ffluk3 and @joshavant.
- Slack: deliver native plugin approval prompts and updates when Slack native approvals are enabled, while keeping plugin approval authorization separate from exec approvers.
- Agents/Pi: disable the embedded pi-coding-agent runtime auto-retry so OpenClaw's own retry and failover loop does not replay failed tool calls through a nested SDK retry. Fixes #73781. (#74434) Thanks @yelog.
- CLI/perf: keep `setup --help`, `onboard --help`, and `configure --help` out of the full wizard runtime while preserving the existing help output. (#84488) Thanks @frankekn.
- CLI/perf: keep `agents --help` out of agents action/runtime imports so help, completion, and command discovery paths avoid loading the full agents runtime. (#84483) Thanks @frankekn.
- CLI/perf: keep `secrets --help` and `nodes --help` on the precomputed help path so parent help avoids loading action-heavy command runtime modules. (#84818) Thanks @frankekn.
- CLI/perf: serve `doctor`, `gateway`, `models`, and `plugins` parent help from startup metadata so common subcommand help avoids full CLI program construction. (#84786) Thanks @frankekn.
- Codex/Lossless: keep context-engine history on the canonical run session when Telegram DMs use per-peer runtime policy keys. Fixes #84936. (#84954) Thanks @neeravmakwana.
- Auth/OAuth: skip the refresh adapter when a stored OAuth credential has no refresh token so agent turns fail fast on missing-key instead of waiting on the 120s refresh timeout. Thanks @romneyda.
- Auth/Codex: load legacy OAuth sidecar credentials in the embedded runner's secrets-runtime auth loaders so Telegram replies, cron-triggered turns, and other isolated sub-agent lanes can reach the existing #83312 refresh-and-rewrite migration instead of failing with `No API key found for provider "openai-codex"` until the user runs `openclaw doctor`. Thanks @Totalsolutionsync and @romneyda.
- Codex/failover: classify `deactivated_workspace` as a permanent auth failure so configured fallback models can advance when a Codex workspace is deactivated. (#55893) Thanks @litang9.
- Exec: keep configured `tools.exec.pathPrepend` entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.
- 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.
- 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
### Changes
- Exec approvals: remove the old `cat SKILL.md && printf ... && <skill-wrapper>` allowlist compatibility path so skill files must be loaded with the read tool and only the real skill executable is auto-allowed.
- Discord: let voice sessions follow configured Discord users into voice channels, with allowed-channel checks, multi-user handoff, bounded reconciliation, and DAVE recovery preservation. (#84264) Thanks @fuller-stack-dev.
- Discord/voice: include bounded `IDENTITY.md`, `USER.md`, and `SOUL.md` profile context in realtime voice session instructions by default, with `voice.realtime.bootstrapContextFiles: []` available to disable it. (#84499) Thanks @fuller-stack-dev.
- Dependencies: bump the bundled Codex harness to `@openai/codex` `0.132.0` and refresh the app-server model-list docs for the new catalog.
- CLI/policy: add the bundled Policy plugin for policy-backed channel conformance checks, doctor lint findings, and opt-in workspace repair. (#80407) Thanks @giodl73-repo.
- Agents/config: allow `agents.list[].experimental.localModelLean` so lean local-model mode can be enabled for one configured agent instead of globally. (#84073) Thanks @dutifulbob.
- Providers/xAI: add device-code OAuth login so remote and headless setups can authorize xAI without a localhost browser callback. (#84005) Thanks @fuller-stack-dev.
- Providers/OpenRouter: honor provider-level `params.provider` routing policy for OpenRouter requests, with model and agent params overriding the defaults. Thanks @amknight.
### Fixes
- CLI/tasks: include stale-running task maintenance decisions in `openclaw tasks maintenance --json` so retained and reconcile candidates explain backing-session, cron, CLI, and wedged-subagent state. (#84691) Thanks @efpiva.
- Codex app-server: keep system-prompt reports working when bootstrap hooks provide workspace files with only a path and content, so hook-supplied SOUL/IDENTITY/TOOLS/USER context still reports injected characters correctly. (#84736) Thanks @JARVIS-Glasses.
- Providers/MiniMax music: stop advertising `durationSeconds` control and remove prompt-injected duration hints, so `music_generate` reports MiniMax duration as an unsupported override instead of suggesting MiniMax can enforce track length. Fixes #84508. Thanks @neeravmakwana.
- Doctor: warn when sandbox tool policy hides configured MCP server tools before provider requests. (#84699) Thanks @nxmxbbd.
- WhatsApp: update Baileys to `7.0.0-rc12`.
- Build: suppress per-locale `rolldown-plugin-dts:fake-js` CommonJS dts warnings emitted while bundling the intentionally-inlined `zod/v4/locales/*.d.cts` files, so `pnpm build` output stays readable after the 0.25.1 plugin bump. Thanks @romneyda.
- CLI/nodes: route lazy plugin-registration logs to stderr for JSON-mode `openclaw nodes` commands so stdout stays parseable. (#84684) Thanks @TurboTheTurtle.
- Approvals: route manual `/approve` decisions through the trusted approval runtime so active exec and plugin approvals no longer look unknown or expired.
- Mac app: update the About settings copyright year to 2026. (#84385) Thanks @pejmanjohn.
- Dependencies: update `@openclaw/fs-safe` to `0.2.7` so OpenClaw's default Python-helper-off policy keeps best-effort Node write fallbacks for private stores, secret writes, run logs, and media attachments on Linux/macOS.
- Infra/secrets: restore the fail-closed contract for `tryReadSecretFileSync` so credential loaders that pass `rejectSymlink: true` (Telegram, LINE, Zalo, IRC, Nextcloud Talk tokens) refuse symlinked credential files instead of silently accepting them, and the infra-state CI shard's secret-file symlink test passes again. Thanks @romneyda.
- Browser: honor the configured image sanitization limit for screenshots and labeled snapshots so browser-captured images follow the same resize policy as other image results. (#84595)
- Doctor: remove unrecognized `models.providers.*.models[*].compat.thinkingFormat` values during `doctor --fix` so stale provider model config can validate after upgrade. Fixes #77803.
- Doctor: warn when `openclaw.json` stores plaintext secret-bearing config fields, including model provider API keys and sensitive provider headers. (#84718) Thanks @lukaIvanic.
- Status: show the configured default, session-selected model, reason, clear hint, and docs link when a session remains pinned to a model that differs from `agents.defaults.model.primary`.
- WebChat: clear stale typing indicators when session change events mark the active chat run complete.
- Mac app: keep local packaging signed with a stable app identity for permission testing and fix Control UI production builds under current Vite/Highlight.js exports.
- macOS app: update the embedded Peekaboo bridge to 3.2.1 so OpenClaw-hosted UI automation works with current Peekaboo CLI capture flows.
- Cron: deliver preferred final assistant output for successful scheduled runs when trailing plain tool warnings remain in diagnostics instead of marking the run failed.
- fix(mattermost): fail closed on missing channel type [AI]. (#84091) Thanks @pgondhi987.
- Recheck rebuilt system.run argv [AI]. (#84090) Thanks @pgondhi987.
- CLI: keep the private QA subcommand out of exported command descriptors unless `OPENCLAW_ENABLE_PRIVATE_QA_CLI=1`, so root help and subcommand markers match runtime registration. (#84519)
- CLI/cron: bound `openclaw cron show` job lookup pagination so non-advancing or unbounded `cron.list` responses fail instead of hanging the command. Fixes #83856. (#83989)
- Agents/messages: stop message-tool-only turns after a successful source-channel `message` send while keeping transcript mirrors under the session write lock. (#84289)
- Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev.
- Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving `strict=false` fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev.
- Agents/code mode: spell out the `exec` tool's JavaScript/TypeScript, no Node module, and catalog-bridge constraints in model-visible schema text so agents can use enabled tools without trial-and-error. (#84269) Thanks @Kaspre.
- Codex: give `image_generate` dynamic-tool calls a 120s default watchdog when no per-call or configured image timeout is set, so image generation no longer falls back to the generic 30s bridge timeout. (#84254) Thanks @moritzmmayerhofer.
- Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev.
- CLI/message: include a stable top-level `messageId` in `openclaw message --json` output when channel sends return one. (#84191) Thanks @100menotu001.
- Cron: preserve legacy top-level array `jobs.json` stores when loading or adding scheduled jobs so old cron jobs are no longer treated as an empty store during upgrade. Fixes #60799. (#84433) Thanks @IWhatsskill.
- Gateway/agents: use an agent's `identity.name` in Gateway agent summaries when `agents.list[].name` is unset, so configured agent labels remain visible in clients. (#84355; refs #57835) Thanks @luoyanglang.
- Channels/replies: keep normal `/verbose` failed-tool progress compact in message-tool replies and prevent late text-only tool output from appearing after the final answer. (#84303) Thanks @VACInc.
- Plugins/hooks: apply a default 30-second timeout to `before_compaction` and `after_compaction` hooks so a hung plugin handler no longer blocks compaction completion. (#84153)
- Discord: preserve reusable presentation buttons through portable conversion and Discord component registration. (#84187) Thanks @100menotu001.
- Discord: preserve disabled presentation buttons when adapting and rendering Discord message controls. (#84188) Thanks @100menotu001.
- Twitch: add a test-only client-manager registry reset helper so non-isolated Twitch tests can clear cached managers between cases. Fixes #83887. (#84244) Thanks @hclsys.
- Cron: run main-session scheduled work on a cron-owned wake lane while preserving reply delivery context, so background cron turns no longer block human main-session chat. Fixes #82766. (#82767) Thanks @galiniliev.
- Cron: use structured embedded-run denial metadata for isolated scheduled tasks so blocked exec requests fail the job without treating ordinary assistant prose as a denial. (#84067) Thanks @abnershang.
- Cron: keep recovered tool warnings diagnostic for successful scheduled runs so final cron output is delivered instead of being replaced by a post-processing warning. (#84045) Thanks @abnershang.
- Plugins/perf: thread explicit plugin discovery results through `loadBundledCapabilityRuntimeRegistry`, `resolveBundledPluginSources`, and `listChannelCatalogEntries` so callers that already hold a discovery result skip redundant filesystem walks. Thanks @SebTardif.
- harden update restart script creation [AI]. (#84088) Thanks @pgondhi987.
- Docker: keep the bundled Codex plugin in official release image keep lists so the default OpenAI agent harness remains available after Docker pruning. Fixes #83613. (#83626) Thanks @YuanHanzhong.
- CLI/channels: preserve the first line of `openclaw channels logs` output when the rolling tail window starts exactly on a line boundary, mirroring the already-fixed `readLogSlice` behavior in `src/logging/log-tail.ts`.
- Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057)
- CLI: preserve embedded equals signs in inline root option values instead of truncating after the second separator. (#83995) Thanks @ThiagoCAltoe.
- Matrix/config: accept `messages.queue.byChannel.matrix` queue overrides and keep queue provider schema/type keys aligned for Matrix, Google Chat, and Mattermost. Thanks @bdjben.
- CLI: format `openclaw acp client` failures through the shared error formatter so object-shaped errors stay readable instead of printing `[object Object]`. Fixes #83904. (#84080)
- Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when `/api/show` omits capabilities. (#84055) Thanks @dutifulbob.
- Codex app-server: disable native Code Mode, user MCP, and app-backed plugin execution while OpenClaw sandboxing is active, routing shell access through `sandbox_exec`/`sandbox_process` instead. (#84388) Thanks @joshavant.
- Installer/Windows: launch `install.ps1` onboarding as an attached child process so fresh native Windows installs do not freeze visibly at `Starting setup...` or corrupt the wizard's terminal rendering.
- CLI/update: keep restart health checks working across one-version CLI/Gateway protocol skew and use the managed Gateway service Node for all follow-up commands even when the package root is unchanged, so `openclaw update` no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight.
- CLI/gateway: include the running Gateway version in `gateway status` JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev.
- Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79.
- CLI/nodes: request pending node surface approval scopes before `openclaw nodes approve` so exec-capable node approval can use admin-scoped Gateway credentials instead of failing with `missing scope: operator.admin`. (#84392) Thanks @joshavant.
- Gateway: reject slow node event sends before outbound buffers grow unbounded and log the rejected payload diagnostic. (#84387) Thanks @samzong.
- Agents: include bounded trajectory queued-writer diagnostics in `pi-trajectory-flush` timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev.
- Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev.
- Agents/subagents: constrain wildcard subagent target allowlists to configured agents while preserving explicitly listed compatibility targets. Fixes #84040. (#84357) Thanks @joshavant.
- Providers/Anthropic: route Anthropic model refs selected with Claude CLI auth through the Claude CLI runtime so shorthand refs such as `anthropic/opus-4.7` no longer fall back to embedded Anthropic billing. Fixes #84222. (#84374) Thanks @joshavant.
- Agents: honor explicit `models.providers.<id>.timeoutSeconds` values above the default idle watchdog for cloud and self-hosted providers, so long first-token waits no longer fall back at ~120s when the provider timeout is higher. (#83979) Thanks @yujiawei.
- Agents/Codex: keep encrypted Responses reasoning replay provenance-bound so stale mirrored Codex transcripts drop invalid encrypted content before request assembly while preserving matching same-session replay. Fixes #83836. (#84367) Thanks @joshavant.
- Agents/subagents: skip stale embedded-run wake probes for dormant completion requesters, so late subagent completions go straight to requester-agent/direct handoff instead of producing `reason=no_active_run` queue noise. (#82964) Thanks @galiniliev.
- CLI: retry config snapshot reads after a transient failure so one rejected read no longer poisons later commands in the same process. (#83931) Thanks @honor2030.
- Media: decode URL path basenames before using them as remote media fallback filenames, so files like `My%20Report.pdf` are surfaced as `My Report.pdf`. Fixes #84050. (#84052) Thanks @jbetala7.
- WhatsApp: clarify inbound group diagnostics so observed but unregistered groups point to `channels.whatsapp.groups` without changing routing or sender authorization. (#83846) Thanks @neeravmakwana.
- WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga.
- CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent.
- Gateway/mobile: restore QR setup-code handoff of bounded operator tokens for iOS and Android onboarding while keeping admin and pairing scopes out of bootstrap. (#83684) Thanks @ngutman.
- iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman.
- Agents/compaction: bound plugin-owned CLI transcript compaction with the host safety timeout so a hung context engine can no longer stall post-turn cleanup. (#84083) Thanks @100yenadmin.
- Control UI/usage: truncate long context skill, tool, and file names in the usage panel while keeping the full name available on hover. (#42197) Thanks @Rain120.
- Codex: respect explicit `models auth order set` and `config.auth.order` precedence over stale `lastGood` in `/codex account`, and show `no working credential` when every explicit-order profile is ineligible instead of marking a lower-ranked profile as active. Fixes #84386. (#84412) Thanks @openperf.
- Agents: honor `messages.suppressToolErrors` for mutating tool failures so configured chat surfaces do not receive separate warning payloads. (#81561) Thanks @moeedahmed.
- Agents/fallback: surface billing guidance for mixed rate-limit plus billing fallback exhaustion instead of generic failure copy. Fixes #79396. (#79489) Thanks @aayushprsingh.
## 2026.5.19
### Changes
- Agents: clarify that fixes should default to clean bounded refactors, lean internals, and explicit plugin SDK/API deprecation paths.
- Agents/tools: normalize Swagger/OpenAPI refs and OpenAPI schema annotations when preparing tool parameter schemas.
- Dependencies: update `@openclaw/proxyline` to 0.3.3.
- Dependencies: update Pi packages to 0.75.1 and raise the minimum supported Node.js 22 line to 22.19.
- Docker/Podman: add `OPENCLAW_IMAGE_APT_PACKAGES` as the runtime-neutral image build arg for extra apt packages while keeping `OPENCLAW_DOCKER_APT_PACKAGES` as a legacy fallback. (#62431) Thanks @urtabajev.
- Gateway/ACPX: attribute startup probe, config, runtime, and resource-count costs in restart traces without changing readiness behavior. (#83300) Thanks @samzong.
- Gateway: overlap startup logging and plugin-service startup with channel sidecars to reduce restart ready latency while preserving `/readyz` sidecar gating. (#83301) Thanks @samzong.
- Plugins/admin-http-rpc: allow trusted admin HTTP RPC clients to start and wait for web QR login flows. (#83259) Thanks @liorb-mountapps.
- Mac app: redesign Settings pages with consistent card layouts, cached navigation, cleaner permissions/voice/skills/cron/exec/debug panes, and steadier spacing around the native sidebar.
- Mac app: refine Voice & Talk recognition-language and wake-phrase settings so they use the same compact card rows as the rest of Settings.
- Skills: rename the repo-local Codex closeout review skill and helper to `autoreview` while preserving the Codex-first fallback behavior.
- Skills: add a meme-maker skill for curated template search, local SVG/PNG rendering, Imgflip hosted rendering, and Know Your Meme provenance links.
- Skills CLI: allow `openclaw skills install` and `openclaw skills update` to target shared managed skills with `--global`. (#74466) Thanks @Marvae.
- Browser: surface pending and recently handled modal dialogs in snapshots, return `blockedByDialog` when an action opens a modal, and allow `browser dialog --dialog-id` to answer pending dialogs.
- Browser CLI: add `openclaw browser evaluate --timeout-ms` so long-running page functions can extend both the evaluate action and request timeout budgets. (#83447) Thanks @eefreenyc.
- Codex app-server: scope OpenClaw prompt guidance by runtime surface so native Codex keeps Codex-owned base/personality instructions while OpenClaw contributes only runtime context, delivery guidance, and explicitly scoped command hints. (#83454) Thanks @100yenadmin.
- Docker/Podman: add `OPENCLAW_IMAGE_PIP_PACKAGES` for opt-in Python package installation in local image builds. (#83771) Thanks @stephenredmond-straiteis.
- Agents/tools: shorten built-in tool descriptions and schema hints across media, messaging, sessions, cron, Gateway, web, image/PDF, TTS, nodes, and plan tools while preserving routing guardrails.
- Skills: add node inspector debugging, fused diagram generation, and throwaway spike workflow skills.
- CLI/plugins: add `defineToolPlugin` plus `openclaw plugins build`, `validate`, and `init` for typed simple tool plugins with generated manifest metadata, optional tool declarations, and context factories.
- Agents/skills: tighten bundled skill prompts and metadata, quote skill descriptions, refresh current CLI/API guidance, and update embedded sherpa-onnx runtime downloads.
- Skills: update the Obsidian skill to target the official `obsidian` CLI and require its registered binary instead of the third-party `obsidian-cli`.
- Skills: add a Python debugging skill for pdb, breakpoint(), post-mortem inspection, and debugpy remote attach.
- Codex: add `/codex plugins list`, `enable`, and `disable` for managing configured native Codex plugins from chat without editing config by hand.
- Plugins/messages: add presentation capability limits for channel renderers, adapt rich message controls before native rendering, and mark legacy `interactive`/Slack directive producer APIs as deprecated.
- Plugins/subagents: store channel delivery routes as canonical session metadata and deprecate ad hoc subagent hook delivery-origin fields in favor of core route projection.
- Proxy: support HTTPS managed forward-proxy endpoints and scoped `proxy.tls.caFile` CA trust for proxy endpoint TLS. (#79171) Thanks @jesse-merhi.
- QA-Lab: add first-hour 20-turn and optional 100-turn runtime parity scenarios, with tier metadata for standard and soak QA gates. Fixes #80338; refs #80337. Thanks @100yenadmin.
- QA-Lab: add `openclaw qa suite --runtime-parity-tier` and wire the standard Codex-vs-Pi tier into release checks separately from optional/live-only/soak lanes. Fixes #80337. Thanks @100yenadmin.
@@ -219,135 +27,29 @@ Docs: https://docs.openclaw.ai
- QA-Lab: schedule a live-frontier Codex-vs-Pi runtime token-efficiency artifact lane in the all-lanes QA workflow. Fixes #80175. Thanks @100yenadmin.
- QA-Lab: hard-gate required OpenClaw dynamic runtime-tool drift in the standard Codex-vs-Pi tier with a blocking release-check verifier and publish the tool coverage report artifact. Fixes #80339; refs #80319. Thanks @100yenadmin.
- QA-Lab: add the personal-agent approval-denial scenario so the benchmark pack verifies denied local reads stop cleanly without tool progress or fixture leaks. (#83150) Thanks @iFiras-Max1.
- QA-Lab: extend the personal-agent benchmark pack with a local task followthrough scenario for proof-backed pending, blocked, and done status reporting. Thanks @iFiras-Max1.
- QA-Lab: add a report-only dreaming shadow-trial scenario so candidate memory promotion can be evaluated without mutating `MEMORY.md`. Thanks @iFiras-Max1.
- Gateway/performance: add `pnpm test:restart:gateway` benchmark tooling for repeated restart readiness, downtime, trace, and resource-slope evidence. (#83299) Thanks @samzong.
- Android: switch Talk Mode to realtime Gateway relay voice sessions with streaming mic input, realtime audio playback, tool-result bridging, and on-screen transcripts. (#83130) Thanks @sliekens.
- Gateway/config: expose config lookup reload metadata so tools can distinguish restart-required, hot-reloadable, and no-op fields before applying config edits. Fixes #81409. (#81612) Thanks @LLagoon3.
- Telegram: add allowlisted native DM draft previews for transient tool progress while keeping final answers on the normal persistent delivery path. (#83622) Thanks @akrimm702.
- QA-Lab: add a personal-agent share-safe diagnostics artifact scenario so support handoffs keep useful status while omitting raw personal content. Thanks @iFiras-Max1.
- QA-Lab: add a personal-agent no-fake-progress scenario so completion claims stay tied to local evidence instead of unsupported external progress. (#83824) Thanks @iFiras-Max1.
### Fixes
- CLI/node: reject invalid explicit `node run --port` values instead of silently falling back to the configured or default port. Fixes #83923. Thanks @davinci282828.
- CLI: reject explicit port numbers above 65535 before they reach Gateway or Node bind paths. Fixes #83900. (#84008) Thanks @hclsys.
- Codex app-server: preserve plugin tool auth profiles when Codex owns model transport so OpenClaw dynamic tools can resolve their provider credentials. (#83603) Thanks @rubencu.
- Memory/search: scan the JS-side fallback vector path (used when the sqlite-vec index is unavailable or has a mismatched dimension) in bounded rowid batches and yield to the event loop between batches so large chunk tables can no longer pin the Node.js main thread for multi-second windows. Also keeps the SQL prepared statement rooted in a local so node:sqlite cannot finalize it mid-scan under heap pressure. Fixes #81172. Thanks @dev23xyz-oss.
- Memory Wiki: preserve fs-safe diagnostics when bridge source page writes fail for non-symlink filesystem safety reasons, so directory collisions are reported with the underlying error code. (#83776) Thanks @TurboTheTurtle.
- Telegram: keep forum topics from blocking sibling topic traffic by routing inbound serialization, media/text buffers, and account API queues on topic-aware lanes. (#83829)
- Telegram: keep queued forum-topic follow-up messages from inheriting superseded source abort signals, so later same-topic user turns can still run and reply after an active turn is replaced. (#83827) Thanks @VACInc.
- CLI/update: bypass npm freshness filters consistently during managed package and plugin installs so freshly published release plugins remain installable. Thanks @jalehman.
- CLI/update: guide root-owned npm install EACCES recovery by stopping the managed Gateway before manual package replacement, then reinstalling and restarting the service. Fixes #83747. (#83757) Thanks @brokemac79.
- Twitch: register refreshing chat tokens with Twurple's chat intent so automatic token refresh keeps chat access available. (#83750) Thanks @TurboTheTurtle.
- Agents/subagents: keep collect-mode announce queues batching unresolved-origin items with compatible same-route messages and resume collection after a true cross-channel drain when a later compatible batch remains. Fixes #83577.
- CLI/config: preserve numeric-looking record keys such as Discord guild IDs when creating missing config containers with `config set`. (#83769) Thanks @TurboTheTurtle.
- Skills: refresh existing session skill snapshots when watched skill roots change, so changed extra skill directories take effect without starting a new session. Fixes #83782. (#83800) Thanks @hclsys.
- Providers/Anthropic: preserve native image input for current Claude model rows when stale local catalog data marks them text-only. (#83756) Thanks @TurboTheTurtle.
- Providers/Anthropic: preserve Claude 4 image capability when configured model refs resolve through a stale local catalog row. (#83756) Thanks @TurboTheTurtle.
- Providers/DeepSeek: normalize MCP tool schemas with `anyOf`/`oneOf` unions before normal and compaction requests reach DeepSeek, preventing union-shaped parameters from being rejected. (#83766) Thanks @TurboTheTurtle.
- Control UI: render live tool progress from session-scoped `session.tool` Gateway events so externally started runs show their tool cards in the active session. (#83734) Thanks @TurboTheTurtle.
- Outbound: resolve send-capable channel plugins from the active runtime registry when the pinned startup registry only has setup metadata. (#83733) Thanks @TurboTheTurtle.
- Discord: preserve streamed reply previews when recovered tool-warning finals are delivered before or after the assistant's final reply. (#84169) Thanks @neeravmakwana.
- Control UI: keep the chat delete confirmation popover clamped inside the visible viewport on small screens. (#83804) Thanks @ThiagoCAltoe.
- Browser: enforce current-tab URL allowlist checks for `/act` evaluate/batch actions and `/highlight` routes while leaving tab-management actions unblocked. (#78523)
- CI: require real-behavior-proof verdict markers to come from the ClawSweeper GitHub App before accepting exact-head proof. (#83692)
- Models: show the effective OpenAI/Codex auth profile in `/models` provider headers instead of falling back to the OpenAI env-key label. (#83697) Thanks @yu-xin-c.
- CLI: include active bundled loopback MCP tools in CLI system prompts and reset provider-side CLI sessions when that prompt-visible tool surface changes. (#83785) Thanks @TurboTheTurtle.
- Browser: keep a profile `cdpPort` when its `cdpUrl` omits a port, while still letting explicitly written URL ports win. (#82166) Thanks @Marvae.
- Agents/image generation: allow distinct `image_generate` prompts to start separate session-backed background tasks while same-prompt retries still return the active task status. (#83614) Thanks @Elarwei001.
- Gateway/WebChat: honor configured `channels.webchat.textChunkLimit` and `chunkMode` overrides when chunking WebChat replies. (#83713)
- Control UI: stop the chat reading indicator from sticking after an assistant response finishes. (#83515) Thanks @njuboy11.
- Skills: reject empty or whitespace-only skill names and descriptions during quick validation. (#27061)
- Sessions: skip trailing custom transcript entries when checking tail assistant replies so embedded CLI gap-fill does not duplicate canonical assistant output. (#83635) Thanks @yaoyi1222.
- Memory Wiki: keep `wiki_lint` tool output path-safe by reporting vault-internal lint reports as relative paths in tool text and details while preserving absolute report paths for CLI/file callers. (#83439) Thanks @LLagoon3.
- Telegram: keep verbose tool progress visible without mirroring non-final progress into active session transcripts, preventing embedded provider replies from aborting mid-run. (#83631) Thanks @kurplunkin.
- Telegram: log successful outbound text and media deliveries with account, chat, message, operation, thread, reply, silent, and chunk metadata while keeping message bodies out of logs. Fixes #83196. (#83247) Thanks @jrwrest.
- Cron: link isolated scheduled task runs to their stable cron session so task status and cleanup can follow the backing agent run. (#83606) Thanks @jai.
- Codex app-server: mark Codex-native subagent task mirrors terminal when blocked or failed spawn-agent calls arrive with stale initializing child state, preventing task registry entries from staying running. Fixes #83852. (#83945) Thanks @joshavant.
- CLI: enforce the documented Node.js 22.19 runtime floor in the source launcher.
- Release stability: repair broad-gate regressions in requester-agent completion handoff, QA-Lab mock spawn attribution, Slack monitor test isolation, plugin uninstall peer fixtures, and Node-floor launcher contract coverage.
- Agents/replies: persist queued follow-up user messages and assistant error stubs only once across model-fallback retries, preventing repeated provider rejections from corrupted same-role session transcripts. Fixes #83404. (#83417) Thanks @yetval.
- Telegram: preserve reply-target context for bare mention replies on runtime-only turns so the model sees the replied-to message body. Fixes #83767. (#83953) Thanks @joshavant.
- ClawHub: preserve configured base URL path prefixes when building API request URLs, so self-hosted ClawHub instances mounted under a subpath keep routing correctly. (#83982) Thanks @ThiagoCAltoe.
- Slack: persist delivered inbound message IDs and fail closed when same-channel thread replies lose their thread context, preventing delayed duplicate replies and accidental channel-root posts. Fixes #83521. Thanks @shannon0430.
- Codex app-server: complete OpenClaw dynamic tool diagnostics at the request boundary so successful, failed, timed out, aborted, and blocked tool calls do not leave active tool state behind. Fixes #83474. Thanks @rozmiarD.
- Doctor/Codex: warn when Linux host policy blocks the Codex bwrap user or network namespace path used by sandboxed app-server turns, with Ubuntu/AppArmor repair guidance. Refs #83018.
- Gateway/config: keep config writes from failing on unrelated unresolved auth-profile SecretRefs while preserving live auth-profile runtime snapshots.
- Gateway/sessions: clear stored CLI provider resume bindings on non-subagent `/reset` so the next turn starts a fresh provider-side CLI conversation instead of resuming old context. (#83448) Thanks @jasonyliu.
- Doctor: preserve legacy whole-agent Claude CLI intent by moving matching Anthropic model selections to model-scoped runtime policy before removing stale runtime pins. Fixes #83491. Thanks @danielcrick.
- Discord/OpenAI: keep realtime Discord voice sessions hearing follow-up turns with OpenAI realtime and prebuffer assistant playback to avoid choppy starts. (#80505) Thanks @Solvely-Colin.
- LM Studio: resolve env-template API keys like `${LMSTUDIO_API_KEY}` through the standard SecretInput path instead of sending the raw template as the bearer token, and preserve header-auth and discovery-key precedence when the template is unset. Fixes #80495. (#80568) Thanks @MonkeyLeeT.
- Discord/subagents: route the initial reply from thread-bound delegated sessions into the bound Discord thread instead of the parent channel. Fixes #83170. (#83172) Thanks @100menotu001.
- Gateway/sessions: rotate failed agent sessions when their transcript file is missing instead of wedging per-channel lanes. Fixes #83488. (#83553) Thanks @LLagoon3.
- Agents: refresh final-delivery routing from fresh session state before declaring a no-send failure, keeping recovered runs on the normal durable delivery path. (#83835) Thanks @joshavant.
- Agents: guard final-delivery fresh session routing against mismatched logical sessions before reusing recovered delivery context. (#83928) Thanks @joshavant.
- Media: prevent image metadata probing from invoking external decoder delegates on unrecognized image bytes, and stop fallback chaining after real processing errors.
- Media: install Sharp with the root package and fall back to sips, Windows native imaging, ImageMagick, GraphicsMagick, or ffmpeg for image resizing/conversion when Sharp is unavailable. Fixes #83401. Thanks @scotthuang.
- Channels/bundled: append `openclaw doctor --fix` guidance to the bundled-channel load warnings emitted on `ERR_MODULE_NOT_FOUND` / `MODULE_NOT_FOUND` (including those wrapped on `.cause` by the native-require loader), so users hitting unstaged plugin runtime deps (e.g. `nostr-tools`) see an actionable repair hint instead of a bare module-not-found warning. (#76974) Thanks @BSG2000.
- Telegram: deliver generated media completions back into forum topics by preserving topic IDs across requester-agent handoff. (#83556) Thanks @fuller-stack-dev.
- Gateway: defer update-check startup until after readiness so package update checks no longer block sidecar-ready startup, while preserving update broadcasts and shutdown cleanup. (#83520) Thanks @samzong.
- Telegram: keep `/btw` and read-only status commands from aborting active runs, and avoid retaining raw update payloads in timed-out spool tombstones. Refs #83272.
- Agents: log strict-agentic execution contract diagnostics only when the planning-only retry path actually triggers.
- Agents: stop embedded session takeover and session write-lock errors from consuming model fallbacks while preserving provider fallback metadata. Fixes #83510. Thanks @luyao618.
- Agents/video: hide `video_generate` reference-audio parameters unless a registered video provider supports audio inputs.
- Plugins: fall back to npm for official ClawHub updates when artifact downloads are unavailable, including beta-to-default fallback and dry-run version reporting.
- Plugins/xAI: echo PKCE challenge fields during OAuth authorization-code token exchange for xAI token-endpoint compatibility. (#83499) Thanks @fuller-stack-dev.
- Codex app-server: hydrate current inbound image attachments before queued runs so Responses-backed agents receive Discord and other channel images as native vision input. Fixes #83466. Thanks @iannwu.
- Codex app-server: keep native code mode available without forcing code-mode-only so OpenClaw dynamic tool turns complete through the app-server tool bridge. Fixes #83109. Thanks @daswass.
- Codex app-server: expose OpenClaw's sandbox-routed shell as `sandbox_exec`/`sandbox_process` for non-Docker sandbox backends so SSH sandbox agents keep a correctly routed shell path without shadowing Codex native shell. Fixes #80322. Thanks @keramblock.
- Release stability: recover stale session diagnostics and Codex OAuth fallback state so stuck runs and reused refresh tokens clear without blocking follow-up work. (#83503) Thanks @100yenadmin.
- Messages/TTS: apply TTS directives before message-tool sends reach core, gateway, or plugin delivery so opt-in message-tool rooms and proactive sends attach voice notes instead of leaking raw tags. Fixes #81598. Thanks @CG-Intelligence-Agent-Jack and @CoronovirusG10.
- Messages/Codex: keep Codex direct/source chats on message-tool visible delivery by default while documenting and testing `messages.visibleReplies: "automatic"` as the old-mode opt-out; channel wildcard model overrides now apply to direct chats before harness delivery defaults.
- Memory/QMD: keep archived session transcript hits visible after QMD export while preserving normal `.md` session ids that only resemble archive names. (#83518; fixes #83506) Thanks @tanshanshan.
- Codex app-server: preserve network access for sandboxed Codex code-mode turns when the OpenClaw sandbox allows outbound egress. Fixes #83347. Thanks @YusukeIt0.
- Codex app-server: honor writable Docker bind mounts for sandboxed workspace-write turns while disabling native Code Mode when container-path aliases or read-only bind shadows cannot be represented safely host-side. Fixes #83737. (#83849) Thanks @joshavant.
- QA-Lab: keep the OTLP smoke decoder independent of removed OpenTelemetry generated-root internals.
- Messages: default group/channel visible replies to automatic final delivery again, keeping `message_tool` opt-in for ambient/shared rooms and tool-reliable models.
- CLI/TUI: force standalone `/exit` runs to terminate after `runTui` returns so onboarding-launched TUI children do not stay alive invisibly. (#83501) Thanks @fuller-stack-dev.
- Agents/code mode: honor per-agent code-mode config in schema, runtime catalog activation, and model payload filtering. Fixes #83388. Thanks @Kaspre.
- Agents/code mode: preserve agent, session, run, and channel context in `before_tool_call` hooks for top-level `exec`/`wait` dispatches. Fixes #83387.
- QQBot: shorten C2C typing indicators to a 10-second window renewed every 5 seconds, capped to keep a final passive-reply slot available. (#83469)
- Replies: keep final payload delivery after live preview updates so channels can finalize or send the completed answer instead of losing preview-only drafts. (#83468)
- Discord: deliver final replies in progress-mode preview streams instead of deduplicating the final visible message. (#83443) Thanks @compoodment.
- Providers/Xiaomi: replay MiMo Anthropic-compatible `reasoning_content` as provider-required thinking blocks even when OpenClaw thinking is disabled, fixing follow-up tool turns for `mimo-v2-flash`. Fixes #83407. Thanks @Xgenious7.
- Agents/exec approvals: forward approval-runtime credentials on agent-owned Gateway approval calls so approved async commands complete through the existing runtime path instead of stalling on unauthenticated follow-up calls. Thanks @IWhatsskill, @Patrick-Erichsen, and @jesse-merhi.
- Gateway/skills: preflight remote macOS skill-bin refreshes with a WebSocket connectivity check so stale node sessions skip quickly instead of logging slow `system.which` timeout warnings.
- CLI/config: keep broken discovered plugins that are not referenced by active config from failing `openclaw config validate`, while preserving fatal errors for explicitly configured plugin entries.
- GitHub Copilot: drop unsafe native Responses reasoning replay items with non-replayable IDs before dispatch, preventing affected Copilot sessions from failing with `invalid_request_body`. Fixes #83220. Thanks @galiniliev.
- Agents/Codex: fail closed when an explicitly requested Codex harness is not registered instead of silently trying configured model fallbacks. Fixes #83349. Thanks @r2-vibes.
- QA-Lab: make runtime tool coverage fail on missing required tool exercise instead of treating pass/pass parity envelope drift as missing coverage.
- Core/plugins: harden clawpatch-reported edge cases across gateway auth cleanup, Claude session id paths, plugin activation policy, apply-patch hunk handling, diagnostic redaction, and plugin metadata validation.
- UI: show reasoning choices as plain labels instead of leaking internal override wording in session and chat pickers.
- Mac app: avoid repeating the Configuration heading inside channel quick settings.
- Mac app: keep the Settings sidebar always visible and remove the redundant titlebar hide/show control.
- Mac app: normalize Settings pane content margins so pages share the same left and right rail.
- Mac app: prefer explicit private/Tailscale/LAN Gateway endpoints over SSH tunnels, preserve legacy loopback tunnel configs, persist transport choices, and show captured SSH stderr when tunneling really fails.
- Gateway/sessions: keep ACP/acpx and runtime child sessions visible in configured-only session lists when their owner or parent session belongs to a configured agent.
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected.
- Mac app: keep app-level menu commands and Dashboard failure states reachable when the remote Gateway is disconnected, and keep the Settings sidebar toggle in the leading titlebar area.
- Mac app: allow longer Gateway and Context errors to wrap in the menu instead of truncating the useful failure detail.
- Mac app: tighten remote Gateway fields in Settings so the Connection pane keeps readable labels and full action button text.
- Mac app: keep custom Settings card rows left-aligned and full-width so Discovery and status sections no longer appear centered or detached.
- Mac app: align Location permission controls to the same trailing column as the rest of Settings.
- Mac app: add Dashboard, Chat, Canvas, and Settings shortcuts to the Dock icon menu.
- Mac app: replace the Settings window's native split-view sidebar with an explicit layout so page content keeps its leading gutter when the sidebar is shown or hidden.
- Mac app: render channel quick config as aligned Settings rows and hide schema-only variants that cannot be edited safely from the quick pane.
- Gateway/webchat: hide internal runtime-context and other `display: false` transcript messages from Chat history and live message events. Fixes #83216. Thanks @EmpireCreator.
- CLI/help: keep `gateway`, `doctor`, `status`, and `health` help registration out of action/runtime imports so subcommand `--help` stays lightweight in constrained terminals. Fixes #83228. Thanks @dfguerrerom.
- CLI/help: show plugin-owned command help based on the active memory slot so LanceDB memory users see `ltm` instead of unavailable `memory` commands. Fixes #83745. (#83841) Thanks @joshavant.
- Cron/Discord: keep explicit announce runs in message-tool-only source-reply mode so scheduled agent turns post once instead of also echoing through automatic visible replies. Fixes #83261. Thanks @Theralley.
- Telegram: preserve forum-topic origin targets in inbound, audio-preflight, and skipped-message hook contexts so follow-up delivery stays bound to the originating topic. Fixes #83302. Thanks @M00zyx.
- Telegram: retry HTTP 421 Misdirected Request send failures on a fresh fallback transport so transient edge-node routing errors no longer drop outbound replies. Fixes #48892. (#48908) Thanks @MarsDoge.
- Telegram: fail topic sends closed when Telegram reports `message thread not found` instead of retrying without `message_thread_id` into the base chat. Refs #83302.
- Config/subagents: remove ignored agent-model `timeoutMs` keys, keep subagent model config to primary/fallback selection, and clean shipped stale config through doctor. Fixes #83291. Thanks @giodl73-repo.
- Mac app: align the Sessions settings pane with the standard Settings page gutter and row spacing.
- OpenAI/Codex: stop rejecting available `openai-codex` GPT-5.1, GPT-5.2, and GPT-5.3 model refs during config validation, while keeping removed Spark aliases suppressed. Fixes #83303.
- Plugins/xAI: complete OAuth-backed xAI login and sidecar auth fixes, including guarded loopback callback CORS handling, video generation polling/defaults, and native-host User-Agent attribution. (#83322) Thanks @Jaaneek.
- Codex app-server: preserve streamed native command output in mirrored transcripts and trajectory exports when final snapshots omit aggregated output. (#83200) Thanks @rozmiarD.
- Codex app-server: fail closed when chat or sender policy denies tools, disabling native code, app, environment, and user MCP surfaces for restricted turns. (#82374) Thanks @VACInc.
- Codex app-server: keep recent context-engine messages when oversized projected history is truncated, so short follow-ups in long channel sessions do not fall back to stale earlier turns. (#83127) Thanks @VACInc.
- Codex app-server: keep OpenClaw session spawning searchable while steering Codex-native delegation through native subagents, avoiding duplicate direct subagent surfaces. (#83329) Thanks @fuller-stack-dev.
- Codex app-server: recover stale childless Codex-native subagent task mirrors during maintenance and allow their registry rows to be cancelled without an OpenClaw child session. (#82836) Thanks @yshimadahrs-ship-it and @joshavant.
- Feishu: return bound subagent delivery origins from session thread setup so Feishu subagent completions route back to the same DM or topic. (#83190) Thanks @100menotu001.
- CLI/update: tailor post-update Gateway recovery hints by platform, showing systemd, LaunchAgent, Scheduled Task, or generic service-manager guidance instead of macOS-only recovery text. (#83096) Thanks @rubencu.
- Plugins: apply a default 15-second timeout to legacy `before_agent_start` hooks so hung plugin handlers no longer block agent startup. Fixes #48534. (#83136) Thanks @therahul-yo.
@@ -364,13 +66,10 @@ Docs: https://docs.openclaw.ai
- Agents/subagents: preserve run-mode keep subagent registry entries past the session sweep TTL, so kept subagent runs remain visible after cleanup completes. Fixes #83132. (#83168) Thanks @yetval.
- Agents/OpenAI streams: yield via `setTimeout(0)` instead of `setImmediate` between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462.
- Agents/Codex: keep legacy `oauthRef`-backed OAuth profiles usable while `openclaw doctor --fix` migrates them back to inline credentials, without creating new sidecar credentials. (#83312) Thanks @joshavant.
- Agents/Codex: load the selected provider owner alongside the Codex harness runtime so `openai-codex` models resolve when plugin allowlists scope runtime loading. Fixes #83380. (#83519) Thanks @joshavant.
- Telegram: fail stalled isolated-ingress handlers into tombstones and abort same-lane reply work before restarting, so later same-chat updates drain after a hung turn. Fixes #83272. (#83505) Thanks @joshavant.
- CLI/config: send SecretRef diagnostics to stderr so JSON command stdout remains parseable.
- CLI/doctor: seed Control UI allowed origins when migrating legacy non-loopback gateway bind host aliases like `0.0.0.0`. Fixes #83286. Thanks @giodl73-repo.
- CLI/plugins: ship the bundled memory CLI as a package entry so package-installed `openclaw memory` commands register correctly.
- CLI/update: defer doctor-time plugin package installs during package swaps and seed post-core repair from the updated install registry, preventing duplicate reinstall failures.
- CLI/update: preserve old-parent-readable config metadata during legacy package handoffs, fall back only to official `@openclaw/*` npm plugin packages when ClawHub plugin artifacts are unavailable, and keep managed service package roots authoritative during updates.
- Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing.
- Gateway/restart: keep ordinary unmanaged SIGUSR1/config restarts in-process instead of detach-spawning an orphaned child, preserving custom supervisor PID tracking while leaving update restarts on the fresh-process path. Fixes #65668.
- CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c.
@@ -381,9 +80,6 @@ Docs: https://docs.openclaw.ai
- Memory-core: distinguish sqlite-vec load failures from missing semantic vector embeddings in degraded `memory index` warnings, so vector recall diagnostics point at unresolved dimensions instead of blaming sqlite-vec when the store is ready. Fixes #75624. (#83056) Thanks @xuruiray and @Noah3521.
- Agents/subagents: preserve sandbox-peer controller ownership while routing completion announcements back to the originating run session, keeping subagent control and completion delivery scoped correctly. Fixes #80201. (#80242) Thanks @Jerry-Xin.
- Gateway: continue restarting remaining channels when one hot-reload channel restart fails, while still reporting aggregate reload failure and rolling back plugin pre-replace stops. Fixes #83054. Thanks @zqchris.
- Gateway/plugins: bind admin HTTP RPC dispatch to the accepting gateway instance so multi-gateway processes cannot execute plugin HTTP control-plane calls against another live gateway. Fixes #83486. (#83487) Thanks @coygeek.
- Telegram: keep hot-reload restarts from marking polling accounts manually stopped and restart isolated ingress cleanly after worker shutdown, preserving Telegram replies across config reloads. Fixes #83008. (#83410) Thanks @joshavant.
- Telegram/Ollama: pass current Telegram image attachments into native PI/Ollama vision turns so live photo prompts reach Ollama as native images. Fixes #83023. (#83516) Thanks @joshavant.
- Gateway/secrets: split the lightweight secrets runtime state and auth-store cache from the full secrets runtime and take a startup fast path when the gateway startup config has no SecretRef values, speeding up secrets startup while preserving cleanup and refresh semantics.
- Codex app-server: rotate oversized native Codex threads before resume and cap dynamic tool-result text entering native Codex sessions, preventing stale oversized context from surviving OpenClaw compaction. (#82981) Thanks @hansolo949.
- Gateway/restart: drain pending replies and active chat runs during restart shutdown before sockets and channels close, aborting timed-out chat runs through the normal cleanup path. (#69121) Thanks @alexlomt.
@@ -397,7 +93,6 @@ Docs: https://docs.openclaw.ai
- Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.
- Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.
- Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.
- Browser/CDP: keep loopback proxy bypass active across both `NO_PROXY` casings and redact home-relative Chrome MCP profile paths in attach-failure diagnostics.
- Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward `music_generate` audio creation instead of lyric-only replies, and reserve `lyrics` for exact sung words.
- Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.
- Codex/app-server: keep bound conversation sessions on the owning agent runtime so native Codex control and follow-up turns do not fall back to the default agent client. Fixes #82954. (#82993)
@@ -413,12 +108,9 @@ Docs: https://docs.openclaw.ai
- Agents/OpenAI: preserve deterministic tool payload ordering for prompt-cache reuse across OpenAI Responses and chat completions calls. (#82940) Thanks @galiniliev.
- ACP/Codex: honor terminal ACP turn results so failed Codex/acpx runs are not recorded as successful after only progress text. Fixes #79522. Thanks @dudaefj.
- Telegram: warn when a media group drops photos that fail to download, including albums where every photo is skipped. Fixes #55216. (#82987) Thanks @eldar702.
- Agents/diagnostics: treat repeated same-handle embedded-run cleanup as idempotent while preserving true replacement-handle mismatch diagnostics. Fixes #82959. (#82960) Thanks @galiniliev.
- Agents/subagents: preserve high-priority `AGENTS.md` policy in bootstrap context when oversized files are trimmed, and warn agents to read the full policy file before relying on scoped rules. Fixes #82920. (#82921) Thanks @galiniliev.
- Agents/skills: apply the full effective tool policy pipeline to inline `command-dispatch: tool` skill dispatch before owner-only filtering, preserving configured allow, deny, sandbox, sender, group, and subagent restrictions. (#78525)
- Codex: avoid spawning native hook relay subprocesses for post-tool/finalize events with no registered hook handlers while preserving pre-tool safety and approval relays. Fixes #76552. (#78004) Thanks @evgyur.
- Channel accounts: keep top-level default channel accounts visible when named accounts are added alongside default credential material, so mixed legacy/new account configs keep resolving `default` instead of silently dropping it.
- Agents/CLI: reject empty successful CLI subprocess replies as `empty_response` and keep them out of shared auth-profile health, so blank Claude CLI results no longer become green no-payload turns. Fixes #83231. (#83421) Thanks @joshavant.
- Codex/Telegram: synthesize native Codex tool progress from final turn snapshots so Telegram `/verbose` stays visible when command events arrive only at completion.
- Codex/Telegram: deliver Codex verbose tool summaries in direct message-tool-only turns while suppressing message-send and activity-log noise. (#83186) Thanks @kurplunkin.
- Mac app: make Channels settings open faster by deferring config-schema work, avoiding startup channel probes, caching decoded channel status rows, and showing only compact quick settings instead of the full generated channel schema.
@@ -431,7 +123,6 @@ Docs: https://docs.openclaw.ai
- Mac app: make Config settings open from shallow schema lookups and load selected paths on demand instead of fetching and rendering the full generated config schema up front.
- Codex: sanitize inline image payloads before Codex app-server and OpenAI Responses replay, and clear poisoned Codex thread bindings after invalid image errors. Fixes #82878.
- Providers/GitHub Copilot: request identity-encoded Copilot API responses across token exchange, catalog, model calls, usage, and embeddings so compressed Business-account error payloads no longer reach JSON parsers as gzip bytes. Fixes #82871. Thanks @tonyfe01.
- Telegram: redact nested raw-update identifiers and user metadata before verbose raw update logging, preserving useful update/message ids without exposing chat, user, command, or profile details. (#82945) Thanks @galiniliev and @joshavant.
- Telegram: preserve replied-to bot messages, captions, and media metadata in group reply chains so follow-up replies understand what the user is reacting to. (#82863)
- Providers/Together: update PI runtime packages to 0.74.1 and emit Together-style `reasoning.enabled`/`max_tokens` controls for reasoning-capable OpenAI-completions models.
- Agents/diagnostics: split slow embedded-run `attempt-dispatch` startup summaries into workspace, prompt, runtime-plan, and final dispatch subspans so traces identify the delayed setup phase. Fixes #82782. (#82783) Thanks @galiniliev.
@@ -451,7 +142,6 @@ Docs: https://docs.openclaw.ai
- Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827.
- Agents/tools: keep the `message` tool available in embedded runs when it is explicitly allowed through `tools.alsoAllow` or runtime tool allowlists, so channel plugins with custom reply delivery can still use configured message sends. Fixes #82833. Thanks @cn1313113.
- WhatsApp: honor forced document delivery for outbound image, GIF, and video media so `forceDocument`/`asDocument` sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.
- WhatsApp: reject symlinked Web credential files across auth checks and socket startup so unsafe `creds.json` paths cannot be read through. Thanks @mcaxtr.
- WhatsApp: name outbound document attachments from their MIME type when no filename is provided, so PDF and CSV sends arrive as `file.pdf` and `file.csv` instead of an extensionless `file`. Thanks @mcaxtr.
- Process/diagnostics: report active lane blockers in lane wait warnings so `queueAhead=0` no longer hides commands waiting behind active work. Fixes #82791. (#82792) Thanks @galiniliev.
- Process/diagnostics: stop counting the active processing turn as queued backlog in liveness warnings so transient max-only event-loop spikes do not surface as gateway warnings.
@@ -462,11 +152,6 @@ Docs: https://docs.openclaw.ai
- Agents/failover: classify Moonshot/Kimi exhausted-balance HTTP 429 payloads as billing instead of generic rate limits, preserving billing guidance and fallback behavior. Fixes #43447. (#83079) Thanks @leno23.
- Plugin SDK: bundle `openclaw/plugin-sdk/zod` into the published package artifact and verify the packed zod subpath stays self-contained, so pnpm global installs can register plugins without a package-local `zod` symlink. Fixes #78398. (#78515) Thanks @ggzeng.
- Providers/Google: drop compaction-truncated Gemini thought signatures before replay so malformed Base64 no longer aborts the next assistant turn. (#82995) Thanks @wAngByg.
- Gateway/mobile: allow paired iOS and Android clients to refresh same-family OS metadata on authenticated reconnect instead of requiring a new approval. (#83490) Thanks @ngutman.
- WhatsApp: treat `upload-file` as a supported media send intent by lowering path/URL uploads through the channel's normal send-media transport. (#81883) Thanks @ngutman.
- iOS: end Live Activities when OpenClaw is connected, idle, or disconnected, and show compact attention states for approval-required reconnects. (#83597) Thanks @ngutman.
- Control UI: hide child nav items when collapsing the active sidebar group. Fixes #42167. (#42223) Thanks @Aroool.
- CI/proof: skip the real-behavior-proof gate for private org maintainers by minting a least-privilege (`members: read`) GitHub App token and checking active membership in the `maintainer` team, instead of treating `author_association=CONTRIBUTOR` as definitively external. (#83418) Thanks @romneyda.
## 2026.5.17
@@ -831,7 +516,6 @@ Docs: https://docs.openclaw.ai
- Require canonical node platform IDs [AI]. (#81880) Thanks @pgondhi987.
- Agents/Azure OpenAI Responses: default unset Azure OpenAI API versions to `preview` so `/openai/v1/responses` calls use Azure's current Responses API route. (#82026) Thanks @leoge007.
- Control UI/WebChat: compact the desktop chat header controls into a single aligned row so the session, model, thinking, and action controls no longer waste vertical space. Thanks @BunsDev.
- Control UI/settings: widen the Personal quick-settings card to a 3/1 desktop split and keep Appearance/Automations below it on narrower layouts. Thanks @BunsDev.
- Agents/model catalog: reuse manifest model-id normalization metadata while loading persisted read-only catalog rows, avoiding repeated metadata scans.
- Agents: retry empty final turns for generic `anthropic-messages` providers instead of limiting non-visible recovery to Kimi, so custom/proxied Anthropic-compatible routes can recover with a visible answer. Addresses #46080. Thanks @wmgx, @w1tv, and @iFwu.
- Agents/replies: strip workflow `<function_response>` scaffolding from user-visible sanitizer paths so raw tool output does not leak into chat history, transcript mirrors, or channel replies. Fixes #47444. Thanks @5toCode.
@@ -1875,7 +1559,6 @@ Docs: https://docs.openclaw.ai
- Dependencies: bump transitive `basic-ftp` to 5.3.1 so the runtime lockfile no longer includes the vulnerable 5.3.0 build flagged by the production dependency audit. (#78637) Thanks @sallyom.
- Hooks/cron: log returned `/hooks/agent` isolated-run errors and failed cron jobs with cron diagnostic summaries, so rejected `payload.model` values are visible instead of looking like accepted-but-missing runs. Fixes #78597. (#78655) Thanks @kevinslin.
- Managed proxy/security: classify raw socket callsites and proxy runtime mutations in boundary checks so new direct egress or unmanaged proxy-state changes cannot land without explicit review. (#77126) Thanks @jesse-merhi.
- Memory indexing: propagate memory directory creation failures immediately instead of reporting an unusable directory as ready. Thanks @he-yufeng.
- Channels/iMessage: surface the silent group-allowlist drop at default log level by emitting a one-time `warn` per account at monitor startup when `channels.imessage.groupPolicy: "allowlist"` is set without a `channels.imessage.groups` block, plus a one-time `warn` per `chat_id` when the runtime gate drops a specific group, naming the exact `channels.imessage.groups[...]` key to add to allow it. Fixes #78749. (#79190) Thanks @omarshahine.
- WhatsApp: stop Gateway-originated outbound echoes from advancing inbound activity in `openclaw channels status`, so outbound self-sends no longer look like handled inbound messages. Fixes #79056. (#79057) Thanks @ai-hpc and @bittoby.
- Gateway/nodes: preserve the live node registry session and invoke ownership when an older same-node WebSocket closes after reconnecting. (#78351) Thanks @samzong.
@@ -3774,7 +3457,6 @@ Docs: https://docs.openclaw.ai
- Agents/LSP: terminate bundled stdio LSP process trees during runtime disposal and Gateway shutdown, so nested children such as `tsserver` do not survive stop or restart. Fixes #72357. Thanks @ai-hpc and @bittoby.
- Diagnostics/OTEL: capture privacy-safe model-call request payload bytes, streamed response bytes, first-response latency, and total duration in diagnostic events, plugin hooks, stability snapshots, and OTEL model-call spans/metrics without logging raw model content. Fixes #33832. Thanks @wwh830.
- Logging: write validated diagnostic trace context as top-level `traceId`, `spanId`, `parentSpanId`, and `traceFlags` fields in file-log JSONL records so traced requests and model calls are easier to correlate in log processors. Refs #40353. Thanks @liangruochong44-ui.
- Nextcloud-Talk: wire the existing reaction sender into the channel `actions` adapter so agents can react to messages via the shared `message` tool, instead of advertising the `reactions` capability without a dispatch path. Fixes #70110. Thanks @powerpaul17.
- Logging/sessions: apply configured redaction patterns to persisted session transcript text and accept escaped character classes in safe custom redaction regexes, so transcript JSONL no longer keeps matching sensitive text in the clear. Fixes #42982. Thanks @panpan0000.
- Providers/Ollama: honor `/api/show` capabilities when registering local models so non-tool Ollama models no longer receive the agent tool surface, and keep native Ollama thinking opt-in instead of enabling it by default. Fixes #64710 and duplicate #65343. Thanks @yuan-b, @netherby, @xilopaint, and @Diyforfun2026.
- Control UI/Agents: remount the Overview model controls when switching agents so the primary-model picker cannot retain stale per-agent selection. Fixes #39392; carries forward #39401, notes the duplicate #39495 approach, and keeps #46275/#54724 broader stabilization out of scope. Thanks @daijunyi002, @SergioChan, @aworki, and @wsyjh8.
@@ -5417,7 +5099,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/skills: require unique case-insensitive fallback matches in `openclaw skills info` so case-only collisions return not-found instead of showing guidance for the wrong skill. (#38713)
- Agents/Ollama: forward the configured embedded-run timeout into the global undici stream timeout tuning so slow local Ollama runs no longer inherit the default stream cutoff instead of the operator-set run timeout. (#63175) Thanks @mindcraftreader and @vincentkoc.
- Models/Codex: include `apiKey` in the codex provider catalog output so the Pi ModelRegistry validator no longer rejects the entry and silently drops all custom models from every provider in `models.json`. (#66180) Thanks @hoyyeva.
- Tools/image+pdf: normalize configured provider/model refs before media-tool registry lookup so image and PDF tool runs stop rejecting valid Ollama vision models as unknown just because the tool path skipped the usual model-ref normalization step. (#59943) Thanks @yqli2420 and @vincentkoc.

View File

@@ -72,8 +72,7 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile \
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
--config.supportedArchitectures.libc=glibc && \
pnpm store add source-map@0.6.1
--config.supportedArchitectures.libc=glibc
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
# paths. Matrix's native downloader can hit transient release CDN errors while
@@ -117,8 +116,8 @@ ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
# Prune dev dependencies, omitted plugin runtime packages, and build-only
# metadata before copying runtime assets into the final image.
# Prune dev dependencies and strip build-only metadata before copying
# runtime assets into the final image.
FROM build AS runtime-assets
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
@@ -128,8 +127,8 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
--config.supportedArchitectures.libc=glibc && \
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
node scripts/postinstall-bundled-plugins.mjs && \
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
node scripts/check-package-dist-imports.mjs /app
@@ -199,29 +198,13 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \
chmod -R a+rX "$COREPACK_HOME"
# Install additional system packages needed by your skills or extensions.
# Example: docker build --build-arg OPENCLAW_IMAGE_APT_PACKAGES="python3 wget" .
# Legacy alias: OPENCLAW_DOCKER_APT_PACKAGES is still accepted as a fallback.
ARG OPENCLAW_IMAGE_APT_PACKAGES
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
packages="${OPENCLAW_IMAGE_APT_PACKAGES-$OPENCLAW_DOCKER_APT_PACKAGES}"; \
if [ -n "$packages" ]; then \
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $packages; \
fi
# Install additional Python packages needed by your plugins or skills.
# Example: docker build --build-arg OPENCLAW_IMAGE_PIP_PACKAGES="requests humanize" .
ARG OPENCLAW_IMAGE_PIP_PACKAGES=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_IMAGE_PIP_PACKAGES" ]; then \
if ! python3 -m pip --version >/dev/null 2>&1; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-pip; \
fi && \
python3 -m pip install --no-cache-dir --break-system-packages $OPENCLAW_IMAGE_PIP_PACKAGES; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
fi
# Optionally install Chromium and Xvfb for browser automation.

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 OpenClaw Foundation
Copyright (c) 2025 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -96,7 +96,7 @@ Model note: while many providers and models are supported, prefer a current flag
## Install (recommended)
Runtime: **Node 24 (recommended) or Node 22.19+**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
```bash
npm install -g openclaw@latest
@@ -109,7 +109,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
## Quick start (TL;DR)
Runtime: **Node 24 (recommended) or Node 22.19+**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)

View File

@@ -312,7 +312,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
### Node.js Version
OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended default runtime for new installs. The minimum version includes important security patches:
OpenClaw requires **Node.js 22.16.0 or later** (LTS). This version includes important security patches:
- CVE-2025-59466: async_hooks DoS vulnerability
- CVE-2026-21636: Permission model bypass vulnerability
@@ -320,7 +320,7 @@ OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended
Verify your Node.js version:
```bash
node --version # Should be v22.19.0 or later
node --version # Should be v22.16.0 or later
```
### Docker Security

File diff suppressed because it is too large Load Diff

View File

@@ -209,16 +209,15 @@ Why these matter:
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
- The Play build removes these behind the `play` flavor.
- Photo library access is also removed from the Play build. Use third-party builds for `photos.latest`.
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
Current OpenClaw Android implication:
- APK / sideload build can keep SMS, Call Log, and recent-photo features.
- 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.
- APK / sideload build can keep SMS and Call Log features.
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
- 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.
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality.
- `play`: removes `READ_SMS`, `SEND_SMS`, and `READ_CALL_LOG`, and hides SMS / Call Log surfaces in onboarding, settings, and advertised node capabilities.
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log functionality.
Policy links:

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026052100
versionName = "2026.5.21"
versionCode = 2026051700
versionName = "2026.5.17"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -5,7 +5,6 @@ import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.SmsManager
@@ -82,40 +81,6 @@ class MainViewModel(
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
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 }
val gatewayUpdateAvailable: StateFlow<GatewayUpdateAvailableSummary?> = runtimeState(initial = null) { it.gatewayUpdateAvailable }
val modelCatalog: StateFlow<List<GatewayModelSummary>> = runtimeState(initial = emptyList()) { it.modelCatalog }
val modelAuthProviders: StateFlow<List<GatewayModelProviderSummary>> = runtimeState(initial = emptyList()) { it.modelAuthProviders }
val modelCatalogRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.modelCatalogRefreshing }
val modelCatalogErrorText: StateFlow<String?> = runtimeState(initial = null) { it.modelCatalogErrorText }
val gatewayDefaultAgentId: StateFlow<String?> = runtimeState(initial = null) { it.gatewayDefaultAgentId }
val gatewayAgents: StateFlow<List<GatewayAgentSummary>> = runtimeState(initial = emptyList()) { it.gatewayAgents }
val cronStatus: StateFlow<GatewayCronStatus> = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus }
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = runtimeState(initial = emptyList()) { it.cronJobs }
val cronRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.cronRefreshing }
val cronErrorText: StateFlow<String?> = runtimeState(initial = null) { it.cronErrorText }
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
val usageErrorText: StateFlow<String?> = runtimeState(initial = null) { it.usageErrorText }
val skillsSummary: StateFlow<GatewaySkillsSummary> = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary }
val skillsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillsRefreshing }
val skillsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillsErrorText }
val nodesDevicesSummary: StateFlow<GatewayNodesDevicesSummary> =
runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary }
val nodesDevicesRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.nodesDevicesRefreshing }
val nodesDevicesErrorText: StateFlow<String?> = runtimeState(initial = null) { it.nodesDevicesErrorText }
val channelsSummary: StateFlow<GatewayChannelsSummary> =
runtimeState(initial = GatewayChannelsSummary(channels = emptyList())) { it.channelsSummary }
val channelsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.channelsRefreshing }
val channelsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.channelsErrorText }
val dreamingSummary: StateFlow<GatewayDreamingSummary> =
runtimeState(initial = GatewayDreamingSummary()) { it.dreamingSummary }
val dreamingRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.dreamingRefreshing }
val dreamingErrorText: StateFlow<String?> = runtimeState(initial = null) { it.dreamingErrorText }
val healthLogsSummary: StateFlow<GatewayHealthLogsSummary> =
runtimeState(initial = GatewayHealthLogsSummary()) { it.healthLogsSummary }
val healthLogsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.healthLogsRefreshing }
val healthLogsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.healthLogsErrorText }
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
@@ -153,8 +118,6 @@ class MainViewModel(
val talkModeListening: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeListening }
val talkModeSpeaking: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeSpeaking }
val talkModeStatusText: StateFlow<String> = runtimeState(initial = "Off") { it.talkModeStatusText }
val talkModeConversation: StateFlow<List<VoiceConversationEntry>> =
runtimeState(initial = emptyList()) { it.talkModeConversation }
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
@@ -328,10 +291,6 @@ class MainViewModel(
ensureRuntime().setMicEnabled(enabled)
}
fun cancelMicCapture() {
ensureRuntime().cancelMicCapture()
}
fun setTalkModeEnabled(enabled: Boolean) {
ensureRuntime().setTalkModeEnabled(enabled)
}
@@ -394,42 +353,6 @@ class MainViewModel(
ensureRuntime().refreshHomeCanvasOverviewIfConnected()
}
fun refreshModelCatalog() {
ensureRuntime().refreshModelCatalog()
}
fun refreshAgents() {
ensureRuntime().refreshAgents()
}
fun refreshCronJobs() {
ensureRuntime().refreshCronJobs()
}
fun refreshUsage() {
ensureRuntime().refreshUsage()
}
fun refreshSkills() {
ensureRuntime().refreshSkills()
}
fun refreshNodesDevices() {
ensureRuntime().refreshNodesDevices()
}
fun refreshChannels() {
ensureRuntime().refreshChannels()
}
fun refreshDreaming() {
ensureRuntime().refreshDreaming()
}
fun refreshHealthLogs() {
ensureRuntime().refreshHealthLogs()
}
fun loadChat(sessionKey: String) {
ensureRuntime().loadChat(sessionKey)
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,148 +17,80 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
class PermissionRequester internal constructor(
class PermissionRequester(
private val activity: ComponentActivity,
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
) {
private data class PendingPermissionRequest(
val deferred: CompletableDeferred<Map<String, Boolean>>,
var timedOut: Boolean = false,
)
private class PermissionRequestSlot(
val launcher: ActivityResultLauncher<Array<String>>,
var request: PendingPermissionRequest? = null,
)
constructor(activity: ComponentActivity) : this(
activity = activity,
launcherFactory = { callback ->
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions(), callback)
},
)
private val mutex = Mutex()
private val requestSlotsLock = Any()
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
private val launcher: ActivityResultLauncher<Array<String>> =
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val p = pending
pending = null
p?.complete(result)
}
suspend fun requestIfMissing(
permissions: List<String>,
timeoutMs: Long = 20_000,
): Map<String, Boolean> {
return mutex.withLock {
while (true) {
val missing =
permissions.filter { perm ->
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
return permissions.associateWith { true }
): Map<String, Boolean> =
mutex.withLock {
val missing =
permissions.filter { perm ->
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
}
val needsRationale =
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
if (needsRationale) {
val proceed = showRationaleDialog(missing)
if (!proceed) {
return permissions.associateWith { perm ->
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
}
}
}
val deferred = CompletableDeferred<Map<String, Boolean>>()
val request = PendingPermissionRequest(deferred)
val slot = reservePermissionRequestSlot(request)
try {
withContext(Dispatchers.Main) {
slot.launcher.launch(missing.toTypedArray())
}
} catch (err: Throwable) {
clearPermissionRequestSlot(slot, request)
throw err
}
val result =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
request.timedOut = true
throw err
}
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
val denied =
merged.filterValues { !it }.keys.filter {
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
}
if (denied.isNotEmpty()) {
showSettingsDialog(denied)
}
return merged
if (missing.isEmpty()) {
return permissions.associateWith { true }
}
error("unreachable")
}
}
private fun createPermissionRequestSlot(
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
): PermissionRequestSlot {
var slot: PermissionRequestSlot? = null
val launcher = launcherFactory { result -> completePermissionRequest(checkNotNull(slot), result) }
val created = PermissionRequestSlot(launcher)
slot = created
return created
}
private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot =
synchronized(requestSlotsLock) {
val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy")
slot.request = request
slot
}
private fun completePermissionRequest(
slot: PermissionRequestSlot,
result: Map<String, Boolean>,
) {
val request =
synchronized(requestSlotsLock) {
slot.request.also {
slot.request = null
val needsRationale =
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
if (needsRationale) {
val proceed = showRationaleDialog(missing)
if (!proceed) {
return permissions.associateWith { perm ->
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
}
}
} ?: return
if (request.timedOut) return
request.deferred.complete(result)
}
private fun clearPermissionRequestSlot(
slot: PermissionRequestSlot,
request: PendingPermissionRequest,
) {
synchronized(requestSlotsLock) {
if (slot.request === request) {
slot.request = null
}
val deferred = CompletableDeferred<Map<String, Boolean>>()
pending = deferred
withContext(Dispatchers.Main) {
launcher.launch(missing.toTypedArray())
}
val result =
withContext(Dispatchers.Default) {
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
}
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
val denied =
merged.filterValues { !it }.keys.filter {
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
}
if (denied.isNotEmpty()) {
showSettingsDialog(denied)
}
return merged
}
}
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
withContext(Dispatchers.Main) {

View File

@@ -58,7 +58,6 @@ class ChatController(
private val pendingRuns = mutableSetOf<String>()
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
private val pendingRunTimeoutMs = 120_000L
private var lastHealthPollAtMs: Long? = null
@@ -77,7 +76,6 @@ class ChatController(
fun load(sessionKey: String) {
val key = normalizeRequestedSessionKey(sessionKey)
_sessionKey.value = key
optimisticMessagesByRunId.clear()
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
}
@@ -115,7 +113,6 @@ class ChatController(
if (key.isEmpty()) return
if (key == _sessionKey.value) return
_sessionKey.value = key
optimisticMessagesByRunId.clear()
// Keep the thread switch path lean: history + health are needed immediately,
// but the session list is usually unchanged and can refresh on explicit pull-to-refresh.
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
@@ -174,15 +171,14 @@ class ChatController(
)
}
}
val optimisticMessage =
_messages.value =
_messages.value +
ChatMessage(
id = UUID.randomUUID().toString(),
role = "user",
content = userContent,
timestampMs = System.currentTimeMillis(),
)
optimisticMessagesByRunId[runId] = optimisticMessage
_messages.value = _messages.value + optimisticMessage
armPendingRunTimeout(runId)
synchronized(pendingRuns) {
@@ -222,7 +218,6 @@ class ChatController(
val res = session.request("chat.send", params.toString())
val actualRunId = parseRunId(res) ?: runId
if (actualRunId != runId) {
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
clearPendingRun(runId)
armPendingRunTimeout(actualRunId)
synchronized(pendingRuns) {
@@ -233,7 +228,6 @@ class ChatController(
true
} catch (err: Throwable) {
clearPendingRun(runId)
removeOptimisticMessage(runId)
_errorText.value = err.message
false
}
@@ -308,7 +302,7 @@ class ChatController(
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_messages.value = history.messages
_sessionId.value = history.sessionId
history.thinkingLevel
?.trim()
@@ -375,13 +369,7 @@ class ChatController(
if (state == "error") {
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
}
if (runId != null) {
clearPendingRun(runId)
optimisticMessagesByRunId.remove(runId)
} else {
clearPendingRuns()
optimisticMessagesByRunId.clear()
}
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
@@ -390,7 +378,7 @@ class ChatController(
val historyJson =
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_messages.value = history.messages
_sessionId.value = history.sessionId
history.thinkingLevel
?.trim()
@@ -483,7 +471,6 @@ class ChatController(
}
if (!stillPending) return@launch
clearPendingRun(runId)
removeOptimisticMessage(runId)
_errorText.value = "Timed out waiting for a reply; try again or refresh."
}
}
@@ -501,18 +488,12 @@ class ChatController(
job.cancel()
}
pendingRunTimeoutJobs.clear()
optimisticMessagesByRunId.clear()
synchronized(pendingRuns) {
pendingRuns.clear()
_pendingRunCount.value = 0
}
}
private fun removeOptimisticMessage(runId: String) {
val message = optimisticMessagesByRunId.remove(runId) ?: return
_messages.value = _messages.value.filterNot { it.id == message.id }
}
private fun parseHistory(
historyJson: String,
sessionKey: String,
@@ -639,54 +620,11 @@ internal fun reconcileMessageIds(
}
}
internal fun mergeOptimisticMessages(
incoming: List<ChatMessage>,
optimistic: Collection<ChatMessage>,
): List<ChatMessage> {
if (optimistic.isEmpty()) return incoming
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 messageIdentityKey(message: ChatMessage): String? {
val contentKey = messageContentIdentityKey(message) ?: return null
val timestamp = message.timestampMs?.toString().orEmpty()
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
return listOf(contentKey, timestamp).joinToString(separator = "|")
}
private fun optimisticMessageIdentityKey(message: ChatMessage): String? = messageContentIdentityKey(message)
private fun incomingMessageConsumesOptimistic(
incoming: ChatMessage,
optimistic: ChatMessage,
): Boolean {
if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false
val incomingTimestamp = incoming.timestampMs ?: return false
val optimisticTimestamp = optimistic.timestampMs ?: return true
return incomingTimestamp >= optimisticTimestamp
}
private fun messageContentIdentityKey(message: ChatMessage): String? {
val role = message.role.trim().lowercase()
if (role.isEmpty()) return null
val timestamp = message.timestampMs?.toString().orEmpty()
val contentFingerprint =
message.content.joinToString(separator = "\u001E") { part ->
listOf(
@@ -704,7 +642,8 @@ private fun messageContentIdentityKey(message: ChatMessage): String? {
).joinToString(separator = "\u001F")
}
return listOf(role, contentFingerprint).joinToString(separator = "|")
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject

View File

@@ -68,20 +68,6 @@ data class GatewayConnectErrorDetails(
val reason: String? = null,
)
data class GatewayHelloSummary(
val serverName: String?,
val remoteAddress: String?,
val serverVersion: String?,
val mainSessionKey: String?,
val updateAvailable: GatewayUpdateAvailableSummary?,
)
data class GatewayUpdateAvailableSummary(
val currentVersion: String?,
val latestVersion: String?,
val channel: String?,
)
private data class SelectedConnectAuth(
val authToken: String?,
val authBootstrapToken: String?,
@@ -100,7 +86,7 @@ class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthTokenStore,
private val onConnected: (GatewayHelloSummary) -> Unit,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
@@ -163,10 +149,7 @@ class GatewaySession(
val tls: GatewayTlsParams?,
)
private val lifecycleLock = Any()
@Volatile private var desired: DesiredConnection? = null
private var desired: DesiredConnection? = null
private var job: Job? = null
@Volatile private var currentConnection: Connection? = null
@@ -185,39 +168,26 @@ class GatewaySession(
options: GatewayConnectOptions,
tls: GatewayTlsParams? = null,
) {
val connectionToClose: Connection?
synchronized(lifecycleLock) {
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
connectionToClose = currentConnection
if (job?.isActive != true) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
connectionToClose?.closeQuietly()
}
fun disconnect() {
val jobToCancel: Job?
val connectionToClose: Connection?
synchronized(lifecycleLock) {
desired = null
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
connectionToClose = currentConnection
jobToCancel = job
job = null
}
connectionToClose?.closeQuietly()
desired = null
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
currentConnection?.closeQuietly()
scope.launch(Dispatchers.IO) {
jobToCancel?.cancelAndJoin()
if (desired == null) {
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
job?.cancelAndJoin()
job = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
onDisconnected("Offline")
}
}
@@ -346,22 +316,6 @@ class GatewaySession(
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
}
suspend fun sendRequestFrame(
method: String,
paramsJson: String?,
timeoutMs: Long = 15_000,
onError: (ErrorShape) -> Unit = {},
) {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val params =
if (paramsJson.isNullOrBlank()) {
null
} else {
json.parseToJsonElement(paramsJson)
}
conn.sendRequestFrame(method = method, params = params, timeoutMs = timeoutMs, onError = onError)
}
private data class RpcResponse(
val id: String,
val ok: Boolean,
@@ -406,12 +360,14 @@ class GatewaySession(
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
} catch (err: Throwable) {
pending.remove(id)
throw err
}
val frame =
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (params != null) put("params", params)
}
sendJson(frame)
return try {
withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
@@ -420,57 +376,13 @@ class GatewaySession(
}
}
suspend fun sendRequestFrame(
method: String,
params: JsonElement?,
timeoutMs: Long,
onError: (ErrorShape) -> Unit,
) {
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
} catch (err: Throwable) {
pending.remove(id)
throw err
}
scope.launch(Dispatchers.IO) {
val response =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (_: TimeoutCancellationException) {
pending.remove(id)
onError(ErrorShape("UNAVAILABLE", "request timeout"))
return@launch
}
if (!response.ok) {
onError(response.error ?: ErrorShape("UNAVAILABLE", "request failed"))
}
}
}
suspend fun sendJson(obj: JsonObject) {
val jsonString = obj.toString()
writeLock.withLock {
if (socket?.send(jsonString) != true) {
throw IllegalStateException("gateway send failed")
}
socket?.send(jsonString)
}
}
private fun buildRequestFrame(
id: String,
method: String,
params: JsonElement?,
): JsonObject =
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (params != null) put("params", params)
}
suspend fun awaitClose() = closedDeferred.await()
fun closeQuietly() {
@@ -618,8 +530,8 @@ class GatewaySession(
val allowedOperatorScopes =
setOf(
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.talk.secrets",
"operator.write",
)
scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted()
@@ -662,9 +574,7 @@ class GatewaySession(
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
val server = obj["server"].asObjectOrNull()
val serverName = server?.get("host").asStringOrNull()
val serverVersion = server?.get("version").asStringOrNull()
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
@@ -702,33 +612,13 @@ class GatewaySession(
?.let { normalized -> surface to normalized }
} ?: emptyList()
pluginSurfaceUrls = normalizedPluginSurfaceUrls.toMap()
val snapshot = obj["snapshot"].asObjectOrNull()
val sessionDefaults =
snapshot
obj["snapshot"]
.asObjectOrNull()
?.get("sessionDefaults")
.asObjectOrNull()
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
onConnected(
GatewayHelloSummary(
serverName = serverName,
remoteAddress = remoteAddress,
serverVersion = serverVersion,
mainSessionKey = mainSessionKey,
updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()),
),
)
}
private fun parseUpdateAvailable(value: JsonObject?): GatewayUpdateAvailableSummary? {
if (value == null) return null
val latestVersion = value["latestVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val currentVersion = value["currentVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val channel = value["channel"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
return GatewayUpdateAvailableSummary(
currentVersion = currentVersion,
latestVersion = latestVersion,
channel = channel,
)
onConnected(serverName, remoteAddress, mainSessionKey)
}
private fun buildConnectParams(
@@ -1015,11 +905,9 @@ class GatewaySession(
conn.connect()
conn.awaitClose()
} finally {
if (currentConnection === conn) {
currentConnection = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
currentConnection = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
}
@@ -1202,10 +1090,8 @@ internal fun shouldPauseGatewayReconnectAfterAuthFailure(
role?.trim() == "node" &&
scopes.isEmpty() &&
error.details.reason == "not-paired" &&
(
error.details.pauseReconnect == false ||
error.details.recommendedNextStep == "wait_then_retry"
)
(error.details.pauseReconnect == false ||
error.details.recommendedNextStep == "wait_then_retry")
)
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
else -> false

View File

@@ -22,7 +22,6 @@ class ConnectionManager(
private val readSmsAvailable: () -> Boolean,
private val smsSearchPossible: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val photosAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
) {
@@ -97,7 +96,6 @@ class ConnectionManager(
readSmsAvailable = readSmsAvailable(),
smsSearchPossible = smsSearchPossible(),
callLogAvailable = callLogAvailable(),
photosAvailable = photosAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),
motionPedometerAvailable = motionPedometerAvailable(),
@@ -162,15 +160,7 @@ class ConnectionManager(
fun buildOperatorConnectOptions(): GatewayConnectOptions =
GatewayConnectOptions(
role = "operator",
// QR bootstrap hands Android a bounded operator token that includes approvals; keep the
// default operator reconnect request aligned so the post-bootstrap loop can approve work.
scopes =
listOf(
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.write",
),
scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),

View File

@@ -28,7 +28,6 @@ class DeviceHandler(
private val appContext: Context,
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
) {
companion object {
internal fun hasAnySmsCapability(
@@ -151,9 +150,7 @@ class DeviceHandler(
val smsReadGranted = hasPermission(Manifest.permission.READ_SMS)
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
val photosGranted =
if (!photosEnabled) {
false
} else if (Build.VERSION.SDK_INT >= 33) {
if (Build.VERSION.SDK_INT >= 33) {
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
} else {
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
@@ -251,7 +248,7 @@ class DeviceHandler(
"photos",
permissionStateJson(
granted = photosGranted,
promptableWhenDenied = photosEnabled,
promptableWhenDenied = true,
),
)
put(

View File

@@ -23,7 +23,6 @@ data class NodeRuntimeFlags(
val readSmsAvailable: Boolean,
val smsSearchPossible: Boolean,
val callLogAvailable: Boolean,
val photosAvailable: Boolean,
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
val motionPedometerAvailable: Boolean,
@@ -38,7 +37,6 @@ enum class InvokeCommandAvailability {
ReadSmsAvailable,
RequestableSmsSearchAvailable,
CallLogAvailable,
PhotosAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
DebugBuild,
@@ -50,7 +48,6 @@ enum class NodeCapabilityAvailability {
LocationEnabled,
SmsAvailable,
CallLogAvailable,
PhotosAvailable,
VoiceWakeEnabled,
MotionAvailable,
}
@@ -90,10 +87,7 @@ object InvokeCommandRegistry {
name = OpenClawCapability.Location.rawValue,
availability = NodeCapabilityAvailability.LocationEnabled,
),
NodeCapabilitySpec(
name = OpenClawCapability.Photos.rawValue,
availability = NodeCapabilityAvailability.PhotosAvailable,
),
NodeCapabilitySpec(name = OpenClawCapability.Photos.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Contacts.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Calendar.rawValue),
NodeCapabilitySpec(
@@ -194,7 +188,6 @@ object InvokeCommandRegistry {
),
InvokeCommandSpec(
name = OpenClawPhotosCommand.Latest.rawValue,
availability = InvokeCommandAvailability.PhotosAvailable,
),
InvokeCommandSpec(
name = OpenClawContactsCommand.Search.rawValue,
@@ -251,7 +244,6 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable
NodeCapabilityAvailability.PhotosAvailable -> flags.photosAvailable
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
}
@@ -268,7 +260,6 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
InvokeCommandAvailability.RequestableSmsSearchAvailable -> flags.smsSearchPossible
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
InvokeCommandAvailability.PhotosAvailable -> flags.photosAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild

View File

@@ -77,7 +77,6 @@ class InvokeDispatcher(
private val smsFeatureEnabled: () -> Boolean,
private val smsTelephonyAvailable: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val photosAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val onCanvasA2uiPush: () -> Unit,
private val onCanvasA2uiReset: () -> Unit,
@@ -326,15 +325,6 @@ class InvokeDispatcher(
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
)
}
InvokeCommandAvailability.PhotosAvailable ->
if (photosAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "PHOTOS_UNAVAILABLE",
message = "PHOTOS_UNAVAILABLE: photos not available on this build",
)
}
InvokeCommandAvailability.DebugBuild ->
if (debugBuild()) {
null

View File

@@ -27,23 +27,28 @@ internal object JpegSizeLimiter {
require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" }
require(maxBytes > 0) { "Invalid maxBytes" }
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
var width = initialWidth
var height = initialHeight
var best: JpegSizeLimiterResult? = null
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
var best =
JpegSizeLimiterResult(
bytes = encode(width, height, clampedStartQuality),
width = width,
height = height,
quality = clampedStartQuality,
)
if (best.bytes.size <= maxBytes) return best
repeat(maxScaleAttempts + 1) { scaleAttempt ->
repeat(maxScaleAttempts) {
var quality = clampedStartQuality
repeat(maxQualityAttempts) {
val bytes = encode(width, height, quality)
val attempt = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
best = attempt
best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
if (bytes.size <= maxBytes) return best
if (quality <= minQuality) return@repeat
quality = max(minQuality, (quality * 0.75).roundToInt())
}
if (scaleAttempt == maxScaleAttempts) return@repeat
val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0)
val nextScale = max(scaleStep, minScale)
val nextWidth = max(minSize, (width * nextScale).roundToInt())
@@ -53,11 +58,10 @@ internal object JpegSizeLimiter {
height = min(nextHeight, height)
}
val failed = checkNotNull(best)
if (failed.bytes.size > maxBytes) {
throw IllegalStateException("CAMERA_TOO_LARGE: ${failed.bytes.size} bytes > $maxBytes bytes")
if (best.bytes.size > maxBytes) {
throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes")
}
return failed
return best
}
}

View File

@@ -1,139 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
internal fun CanvasSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val currentUrl by viewModel.canvasCurrentUrl.collectAsState()
val hydrated by viewModel.canvasA2uiHydrated.collectAsState()
val rehydratePending by viewModel.canvasRehydratePending.collectAsState()
val rehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
val hasLivePage = currentUrl?.isNotBlank() == true
val showCanvasSurface = isConnected
val canvasLabel = if (hasLivePage) "Live page" else "Home canvas"
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshHomeCanvasOverviewIfConnected()
}
}
SettingsDetailFrame(
title = "Canvas",
subtitle = "Current screen output and interactive app surface.",
icon = Icons.AutoMirrored.Filled.ScreenShare,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Connection", if (isConnected) "Online" else "Offline"),
SettingsMetric("Surface", canvasLabel),
SettingsMetric("Bridge", if (hasLivePage && hydrated) "Ready" else "Standby"),
),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawPrimaryButton(
text = if (rehydratePending) "Refreshing" else "Refresh Screen",
onClick = { viewModel.requestCanvasRehydrate(source = "settings_canvas") },
enabled = isConnected && !rehydratePending,
modifier = Modifier.weight(1f),
)
ClawSecondaryButton(
text = "Reconnect",
onClick = viewModel::refreshGatewayConnection,
modifier = Modifier.weight(1f),
)
}
rehydrateErrorText?.let {
ClawPanel {
Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = canvasLabel, style = ClawTheme.type.section, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Surface(
modifier = Modifier.fillMaxWidth().height(520.dp).clip(RoundedCornerShape(ClawTheme.radii.panel)),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.canvas,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box {
if (showCanvasSurface) {
CanvasScreen(viewModel = viewModel, visible = true, modifier = Modifier.fillMaxWidth().height(520.dp))
} else {
CanvasStandbyPanel(isConnected = isConnected)
}
}
}
}
}
}
}
@Composable
private fun CanvasStandbyPanel(isConnected: Boolean) {
Column(
modifier = Modifier.fillMaxWidth().height(520.dp).padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Surface(
modifier = Modifier.size(54.dp),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surfacePressed,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
contentColor = ClawTheme.colors.text,
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.AutoMirrored.Filled.ScreenShare, contentDescription = null, modifier = Modifier.size(26.dp))
}
}
Text(
text = if (isConnected) "Screen surface ready" else "Connect the gateway",
style = ClawTheme.type.title,
color = ClawTheme.colors.text,
modifier = Modifier.padding(top = 18.dp),
)
Text(
text = if (isConnected) "Canvas output appears here when OpenClaw opens an app surface." else "Canvas output needs an active gateway connection.",
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
modifier = Modifier.padding(top = 6.dp),
)
}
}

View File

@@ -1,159 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayChannelSummary
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawListPanel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
internal fun ChannelsSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val summary by viewModel.channelsSummary.collectAsState()
val refreshing by viewModel.channelsRefreshing.collectAsState()
val errorText by viewModel.channelsErrorText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val channels = summary.channels
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshChannels()
}
}
SettingsDetailFrame(
title = "Channels",
subtitle = "Messaging surfaces connected to this gateway.",
icon = Icons.Default.Notifications,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Channels", channels.size.toString()),
SettingsMetric("Connected", channels.count { it.connected }.toString()),
SettingsMetric("Configured", channels.count { it.configured }.toString()),
SettingsMetric("Issues", channels.count { it.error != null }.toString()),
),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(
text = if (refreshing) "Refreshing" else "Refresh",
onClick = viewModel::refreshChannels,
enabled = isConnected && !refreshing,
modifier = Modifier.weight(1f),
)
}
errorText?.let { error ->
ClawPanel {
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
if (summary.partial || summary.warnings.isNotEmpty()) {
ClawPanel {
Text(text = channelsWarningText(summary), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
when {
!isConnected ->
ClawPanel {
Text(text = "Connect the gateway to load channels.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
channels.isEmpty() ->
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No channels found.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Telegram, WhatsApp, email, and other channels appear here after setup.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> ChannelsPanel(channels = channels)
}
}
}
@Composable
private fun ChannelsPanel(channels: List<GatewayChannelSummary>) {
ClawListPanel(items = channels) { channel ->
ChannelRow(channel = channel)
}
}
@Composable
private fun ChannelRow(channel: GatewayChannelSummary) {
ClawDetailRow(
title = channel.label,
subtitle = channelSubtitle(channel),
leading = { ClawTextBadge(text = channelBadge(channel.label)) },
trailing = { ClawStatusPill(text = channelStatusText(channel), status = channelStatus(channel)) },
)
}
private fun channelSubtitle(channel: GatewayChannelSummary): String {
val accounts =
when (channel.accountCount) {
0 -> null
1 -> "1 account"
else -> "${channel.accountCount} accounts"
}
val lifecycle =
when {
channel.connected -> "Connected"
channel.running -> "Running"
channel.linked -> "Linked"
channel.configured -> "Configured"
channel.enabled -> "Enabled"
else -> "Off"
}
return listOfNotNull(accounts, lifecycle, channel.error).joinToString(" · ")
}
private fun channelStatusText(channel: GatewayChannelSummary): String =
when {
channel.error != null -> "Issue"
channel.connected -> "Connected"
channel.running -> "Running"
channel.linked || channel.configured -> "Ready"
channel.enabled -> "Setup"
else -> "Off"
}
private fun channelStatus(channel: GatewayChannelSummary): ClawStatus =
when {
channel.error != null -> ClawStatus.Danger
channel.connected || channel.running -> ClawStatus.Success
channel.linked || channel.configured -> ClawStatus.Neutral
channel.enabled -> ClawStatus.Warning
else -> ClawStatus.Neutral
}
private fun channelBadge(label: String): String =
label
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.joinToString("")
.ifBlank { "C" }
private fun channelsWarningText(summary: GatewayChannelsSummary): String = summary.warnings.firstOrNull()?.takeIf { it.isNotBlank() } ?: "Some channel status checks did not complete."

View File

@@ -1,320 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayModelProviderSummary
import ai.openclaw.app.GatewayModelSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSeparatedColumn
import ai.openclaw.app.ui.design.ClawTextField
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
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.KeyboardArrowRight
import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.MicNone
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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
@Composable
internal fun CommandPalette(
viewModel: MainViewModel,
onDismiss: () -> Unit,
onOpenChat: () -> Unit,
onOpenVoice: () -> Unit,
onOpenSessions: () -> Unit,
onOpenProviders: () -> Unit,
onOpenSettings: () -> Unit,
onOpenSession: (String) -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
val models by viewModel.modelCatalog.collectAsState()
val providers by viewModel.modelAuthProviders.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
var query by rememberSaveable { mutableStateOf("") }
val normalizedQuery = query.trim().lowercase()
val quickActions =
listOf(
CommandItem("Open Chat", "Start or continue a conversation", Icons.Outlined.ChatBubbleOutline, onOpenChat),
CommandItem("Start Voice", "Talk or dictate with OpenClaw", Icons.Outlined.MicNone, onOpenVoice),
CommandItem("Browse Sessions", "Find previous conversations", Icons.Outlined.AccessTime, onOpenSessions),
CommandItem("Providers & Models", providerCommandSubtitle(isConnected, providers, models), Icons.Outlined.Inventory2, onOpenProviders),
CommandItem("Settings", "Gateway, voice, notifications, privacy", Icons.Outlined.Settings, onOpenSettings),
)
val actionRows = quickActions.filter { it.matches(normalizedQuery) }
val sessionRows =
sessions
.filter { session ->
val title = commandSessionTitle(session.displayName)
normalizedQuery.isEmpty() || title.lowercase().contains(normalizedQuery)
}.take(5)
Surface(modifier = Modifier.fillMaxSize(), color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
CommandIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search", onClick = onDismiss)
Text(text = "Search", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
CommandAvatar(text = "OC")
}
}
item {
ClawTextField(value = query, onValueChange = { query = it }, placeholder = "Search OpenClaw")
}
item {
CommandSectionLabel(title = "Quick actions")
}
if (actionRows.isEmpty()) {
item {
ClawEmptyState(title = "No actions found", body = "Try Chat, Voice, Sessions, Providers, or Settings.")
}
} else {
item {
CommandActionList(rows = actionRows)
}
}
item {
CommandSectionLabel(title = "Sessions")
}
if (sessionRows.isEmpty()) {
item {
ClawPanel {
Text(
text = if (isConnected) "No matching sessions yet." else "Connect the Gateway to search sessions.",
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
}
}
} else {
item {
CommandSessionList(
rows =
sessionRows.map { session ->
CommandSessionRow(
key = session.key,
title = commandSessionTitle(session.displayName),
subtitle = if (pendingRunCount > 0) "Assistant working" else "OpenClaw session",
metadata = session.updatedAtMs?.let(::commandRelativeTime) ?: "now",
)
},
onOpen = onOpenSession,
)
}
}
}
}
}
}
private data class CommandItem(
val title: String,
val subtitle: String,
val icon: ImageVector,
val onClick: () -> Unit,
) {
fun matches(query: String): Boolean = query.isEmpty() || title.lowercase().contains(query) || subtitle.lowercase().contains(query)
}
private data class CommandSessionRow(
val key: String,
val title: String,
val subtitle: String,
val metadata: String,
)
@Composable
private fun CommandActionList(rows: List<CommandItem>) {
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
ClawSeparatedColumn(items = rows) { row ->
CommandActionRow(row = row)
}
}
}
@Composable
private fun CommandActionRow(row: CommandItem) {
Surface(color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 52.dp)
.clip(RoundedCornerShape(ClawTheme.radii.row))
.clickable(onClick = row.onClick)
.padding(horizontal = 2.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(19.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)
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Open ${row.title}",
modifier = Modifier.size(17.dp),
tint = ClawTheme.colors.textMuted,
)
}
}
}
@Composable
private fun CommandSessionList(
rows: List<CommandSessionRow>,
onOpen: (String) -> Unit,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
ClawSeparatedColumn(items = rows) { row ->
CommandSessionListRow(row = row, onClick = { onOpen(row.key) })
}
}
}
@Composable
private fun CommandSessionListRow(
row: CommandSessionRow,
onClick: () -> Unit,
) {
Surface(color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 58.dp)
.clip(RoundedCornerShape(ClawTheme.radii.row))
.clickable(onClick = onClick)
.padding(horizontal = 2.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Surface(
modifier = Modifier.size(30.dp),
shape = CircleShape,
color = ClawTheme.colors.canvas,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(15.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)
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1)
}
Text(text = row.metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Open session",
modifier = Modifier.size(17.dp),
tint = ClawTheme.colors.textMuted,
)
}
}
}
@Composable
private fun CommandIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun CommandAvatar(text: String) {
Surface(
modifier = Modifier.size(34.dp),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
}
}
}
@Composable
private fun CommandSectionLabel(title: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
}
}
private fun providerCommandSubtitle(
isConnected: Boolean,
providers: List<GatewayModelProviderSummary>,
models: List<GatewayModelSummary>,
): String {
if (!isConnected) return "Connect Gateway to load models"
val readyProviderCount = providers.count { modelProviderReady(it.status) }
if (readyProviderCount > 0) return "$readyProviderCount providers ready"
if (models.isNotEmpty()) return "${models.size} models available"
return "Configure model access"
}
private fun commandSessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
private fun commandRelativeTime(updatedAtMs: Long): String {
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
val minutes = deltaMs / 60_000L
if (minutes < 1) return "now"
if (minutes < 60) return "${minutes}m"
val hours = minutes / 60
if (hours < 24) return "${hours}h"
return "${hours / 24}d"
}

View File

@@ -1,200 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayDreamDiaryEntry
import ai.openclaw.app.GatewayDreamingSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
internal fun DreamingSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val summary by viewModel.dreamingSummary.collectAsState()
val refreshing by viewModel.dreamingRefreshing.collectAsState()
val errorText by viewModel.dreamingErrorText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshDreaming()
}
}
SettingsDetailFrame(
title = "Dreaming",
subtitle = "Memory consolidation and dream diary.",
icon = Icons.Default.Storage,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Status", if (summary.enabled) "On" else "Off"),
SettingsMetric("Waiting", summary.shortTermCount.toString()),
SettingsMetric("Signals", summary.totalSignalCount.toString()),
SettingsMetric("Next Cycle", formatDreamingNextRun(summary.nextRunAtMs)),
),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(
text = if (refreshing) "Refreshing" else "Refresh",
onClick = viewModel::refreshDreaming,
enabled = isConnected && !refreshing,
modifier = Modifier.weight(1f),
)
}
errorText?.let { error ->
ClawPanel {
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
when {
!isConnected ->
ClawPanel {
Text(text = "Connect the gateway to load dreaming.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
else -> DreamingPanel(summary = summary)
}
}
}
@Composable
private fun DreamingPanel(summary: GatewayDreamingSummary) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
DreamingHealthRow(
title = "Memory Store",
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
healthy = summary.storeHealthy,
)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
DreamingHealthRow(
title = "Signal Index",
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
healthy = summary.phaseSignalHealthy,
)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
DreamingHealthRow(
title = "Promoted",
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
healthy = true,
)
}
}
DreamDiaryPanel(summary = summary)
}
}
@Composable
private fun DreamingHealthRow(
title: String,
value: String,
healthy: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Box(modifier = Modifier.size(7.dp))
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
@Composable
private fun DreamDiaryPanel(summary: GatewayDreamingSummary) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "DIARY", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
if (!summary.diaryFound) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No dream diary yet.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Entries appear after a dreaming cycle writes a narrative summary.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
return
}
if (summary.diaryEntries.isEmpty()) {
ClawPanel {
Text(text = "The diary is waiting for its first entry.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
return
}
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
summary.diaryEntries.forEachIndexed { index, entry ->
DreamDiaryRow(entry = entry)
if (index != summary.diaryEntries.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
}
}
@Composable
private fun DreamDiaryRow(entry: GatewayDreamDiaryEntry) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Surface(
modifier = Modifier.size(30.dp),
shape = CircleShape,
color = ClawTheme.colors.surfacePressed,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = "D", style = ClawTheme.type.label, color = ClawTheme.colors.text)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = entry.date, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = entry.text, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
private fun formatDreamingNextRun(nextRunAtMs: Long?): String {
val next = nextRunAtMs ?: return "Not scheduled"
val deltaMinutes = ((next - System.currentTimeMillis()) / 60_000L).coerceAtLeast(0L)
val hours = deltaMinutes / 60L
return when {
hours >= 24L -> "In ${hours / 24L}d"
hours >= 1L -> "In ${hours}h"
deltaMinutes >= 1L -> "In ${deltaMinutes}m"
else -> "Soon"
}
}

View File

@@ -143,15 +143,27 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
?.trim()
?.lowercase(Locale.US)
.orEmpty()
if (scheme !in setOf("ws", "wss", "http", "https")) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
}
val tls = scheme == "wss" || scheme == "https"
val tls =
when (scheme) {
"ws", "http" -> false
"wss", "https" -> true
else -> true
}
if (!tls && !isLoopbackGatewayHost(host)) {
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
}
val defaultPort = if (tls) 443 else 18789
val displayPort = if (tls) 443 else 80
val defaultPort =
when (scheme) {
"wss", "https" -> 443
"ws", "http" -> 18789
else -> 443
}
val displayPort =
when (scheme) {
"wss", "https" -> 443
"ws", "http" -> 80
else -> 443
}
val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort
val displayHost = if (host.contains(":")) "[$host]" else host
val displayUrl =

View File

@@ -1,220 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayHealthLogsSummary
import ai.openclaw.app.GatewayLogEntry
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
internal fun HealthLogsSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
val chatHealthOk by viewModel.chatHealthOk.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val modelCount by viewModel.modelCatalog.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val talkStatus by viewModel.talkModeStatusText.collectAsState()
val logsSummary by viewModel.healthLogsSummary.collectAsState()
val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState()
val logsErrorText by viewModel.healthLogsErrorText.collectAsState()
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshHealthLogs()
}
}
SettingsDetailFrame(
title = "Health",
subtitle = "Gateway status, phone node readiness, and recent log stream.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Gateway", if (isConnected) "Online" else "Offline"),
SettingsMetric("Node", if (isNodeConnected) "Online" else "Waiting"),
SettingsMetric("Models", modelCount.size.toString()),
SettingsMetric("Logs", logsSummary.entries.size.toString()),
),
)
HealthStatusPanel(
gateway = statusText,
node = if (isNodeConnected) "Online" else "Waiting",
chat = if (chatHealthOk) "Ready" else "Needs connection",
models = "${modelCount.size} available",
voice = talkStatus,
runs = if (pendingRunCount > 0) "$pendingRunCount active" else "Idle",
isConnected = isConnected,
isNodeConnected = isNodeConnected,
chatHealthOk = chatHealthOk,
modelsReady = modelCount.isNotEmpty(),
voiceReady = talkStatus.lowercase() != "off",
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(
text = if (logsRefreshing) "Refreshing" else "Refresh Logs",
onClick = viewModel::refreshHealthLogs,
enabled = isConnected && !logsRefreshing,
modifier = Modifier.weight(1f),
)
}
logsErrorText?.let { error ->
ClawPanel {
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
}
}
@Composable
private fun HealthStatusPanel(
gateway: String,
node: String,
chat: String,
models: String,
voice: String,
runs: String,
isConnected: Boolean,
isNodeConnected: Boolean,
chatHealthOk: Boolean,
modelsReady: Boolean,
voiceReady: Boolean,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
HealthStatusRow(title = "Runs", value = runs, healthy = true)
}
}
}
@Composable
private fun HealthStatusRow(
title: String,
value: String,
healthy: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
@Composable
private fun GatewayLogsPanel(
isConnected: Boolean,
summary: GatewayHealthLogsSummary,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(text = "RECENT LOGS", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
summary.fileName?.let { fileName ->
Text(text = fileName, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
when {
!isConnected ->
ClawPanel {
Text(text = "Connect the gateway to load recent logs.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
summary.entries.isEmpty() ->
ClawPanel {
Text(text = "No recent log entries.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
else ->
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
val entries = summary.entries.takeLast(12)
Column {
entries.forEachIndexed { index, entry ->
GatewayLogRow(entry = entry)
if (index != entries.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
}
if (summary.truncated) {
Text(text = "Showing the latest log chunk.", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
}
}
}
@Composable
private fun GatewayLogRow(entry: GatewayLogEntry) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(text = compactLogTime(entry.time), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, modifier = Modifier.weight(0.72f), maxLines = 1)
Column(modifier = Modifier.weight(2.7f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = entry.message, style = ClawTheme.type.caption, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis)
entry.subsystem?.let { subsystem ->
Text(text = subsystem, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
}
}
private fun compactLogTime(value: String?): String {
val raw = value?.trim().orEmpty()
if (raw.isEmpty()) return "--:--"
val time =
raw
.substringAfter('T', raw)
.substringBefore('.')
.substringBefore('+')
.substringBefore('Z')
return time.takeIf { it.length >= 5 }?.take(5) ?: raw.take(5)
}
private fun logLevelStatus(level: String?): ClawStatus =
when (level?.lowercase()) {
"error", "fatal" -> ClawStatus.Danger
"warn" -> ClawStatus.Warning
"info" -> ClawStatus.Success
else -> ClawStatus.Neutral
}

View File

@@ -1,266 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayDeviceTokenSummary
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPairedDeviceSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
internal fun NodesDevicesSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val summary by viewModel.nodesDevicesSummary.collectAsState()
val refreshing by viewModel.nodesDevicesRefreshing.collectAsState()
val errorText by viewModel.nodesDevicesErrorText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshNodesDevices()
}
}
SettingsDetailFrame(
title = "Nodes & Devices",
subtitle = "Live nodes, paired phones, and pending device requests.",
icon = Icons.Default.Cloud,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Nodes", summary.nodes.size.toString()),
SettingsMetric("Online", summary.nodes.count { it.connected }.toString()),
SettingsMetric("Devices", if (summary.devicePairingAvailable) summary.pairedDevices.size.toString() else "Locked"),
SettingsMetric("Pending", summary.pendingDevices.size.toString()),
),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(
text = if (refreshing) "Refreshing" else "Refresh",
onClick = viewModel::refreshNodesDevices,
enabled = isConnected && !refreshing,
modifier = Modifier.weight(1f),
)
}
errorText?.let {
ClawPanel {
Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
when {
!isConnected ->
ClawPanel {
Text(text = "Connect the gateway to load nodes and paired devices.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
summary.isEmpty() ->
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No nodes or paired devices.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Linked phones and node hosts will appear here after pairing.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> NodesDevicesPanel(summary = summary)
}
}
}
@Composable
private fun NodesDevicesPanel(summary: GatewayNodesDevicesSummary) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (!summary.devicePairingAvailable) {
ClawPanel {
Text(text = "Pairing controls are not available from this connection.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
if (summary.pendingDevices.isNotEmpty()) {
NodesSection(title = "Pending Requests") {
summary.pendingDevices.forEachIndexed { index, device ->
PendingDeviceRow(device = device)
if (index != summary.pendingDevices.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
if (summary.nodes.isNotEmpty()) {
NodesSection(title = "Nodes") {
summary.nodes.forEachIndexed { index, node ->
NodeRow(node = node)
if (index != summary.nodes.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
if (summary.pairedDevices.isNotEmpty()) {
NodesSection(title = "Paired Devices") {
summary.pairedDevices.forEachIndexed { index, device ->
PairedDeviceRow(device = device)
if (index != summary.pairedDevices.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
}
}
@Composable
private fun NodesSection(
title: String,
content: @Composable () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
content()
}
}
}
}
@Composable
private fun NodeRow(node: GatewayNodeSummary) {
DeviceListRow(
badge = nodeBadge(node.displayName ?: node.id),
title = node.displayName ?: node.id,
subtitle = nodeSubtitle(node),
statusText = if (node.connected) "Online" else "Offline",
status = if (node.connected) ClawStatus.Success else ClawStatus.Warning,
)
}
@Composable
private fun PendingDeviceRow(device: GatewayPendingDeviceSummary) {
DeviceListRow(
badge = nodeBadge(device.displayName ?: device.deviceId),
title = device.displayName ?: "New device",
subtitle = pendingDeviceSubtitle(device),
statusText = if (device.repair) "Repair" else "Review",
status = ClawStatus.Warning,
)
}
@Composable
private fun PairedDeviceRow(device: GatewayPairedDeviceSummary) {
DeviceListRow(
badge = nodeBadge(device.displayName ?: device.deviceId),
title = device.displayName ?: "Paired device",
subtitle = pairedDeviceSubtitle(device),
statusText = pairedDeviceStatusText(device.tokens),
status = pairedDeviceStatus(device.tokens),
)
}
@Composable
private fun DeviceListRow(
badge: String,
title: String,
subtitle: String,
statusText: String,
status: ClawStatus,
) {
ClawDetailRow(
title = title,
subtitle = subtitle,
leading = { ClawTextBadge(text = badge) },
trailing = { ClawStatusPill(text = statusText, status = status) },
)
}
private fun GatewayNodesDevicesSummary.isEmpty(): Boolean = nodes.isEmpty() && pendingDevices.isEmpty() && pairedDevices.isEmpty()
private fun nodeSubtitle(node: GatewayNodeSummary): String {
val kind = node.deviceFamily ?: "Node host"
val version = node.version?.let { "OpenClaw $it" }
val status = if (node.paired) "Paired" else "Unpaired"
val commands =
node.commands
.take(2)
.joinToString(", ")
.takeIf { it.isNotBlank() }
return listOfNotNull(kind, version, status, commands).joinToString(" · ")
}
private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String {
val roles = formatDeviceList(device.roles, "role")
val scopes = formatDeviceList(device.scopes, "scope")
val requested = device.requestedAtMs?.let { "requested ${relativeDeviceTime(it)}" }
return listOfNotNull(roles, scopes, requested, device.remoteIp).joinToString(" · ")
}
private fun pairedDeviceSubtitle(device: GatewayPairedDeviceSummary): String {
val roles = formatDeviceList(device.roles, "role")
val scopes = formatDeviceList(device.scopes, "scope")
val tokens = "${device.tokens.count { !it.revoked }}/${device.tokens.size} active tokens"
return listOfNotNull(roles, scopes, tokens, device.remoteIp).joinToString(" · ")
}
private fun pairedDeviceStatusText(tokens: List<GatewayDeviceTokenSummary>): String =
when {
tokens.isEmpty() -> "Paired"
tokens.any { !it.revoked } -> "Active"
else -> "Needs Token"
}
private fun pairedDeviceStatus(tokens: List<GatewayDeviceTokenSummary>): ClawStatus =
when {
tokens.isEmpty() -> ClawStatus.Neutral
tokens.any { !it.revoked } -> ClawStatus.Success
else -> ClawStatus.Warning
}
private fun formatDeviceList(
values: List<String>,
fallback: String,
): String? =
when (values.size) {
0 -> null
1 -> values.first()
else -> "${values.size} ${fallback}s"
}
private fun nodeBadge(value: String): String =
value
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.joinToString("")
.ifBlank { "N" }
private fun relativeDeviceTime(timeMs: Long): String {
val minutes = ((System.currentTimeMillis() - timeMs).coerceAtLeast(0L)) / 60_000L
if (minutes < 1) return "now"
if (minutes < 60) return "${minutes}m ago"
val hours = minutes / 60L
if (hours < 24) return "${hours}h ago"
return "${hours / 24L}d ago"
}

View File

@@ -1,558 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayModelProviderSummary
import ai.openclaw.app.GatewayModelSummary
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
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
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.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
import androidx.compose.material3.Text
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
@Composable
internal fun ProvidersModelsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
onAddProvider: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val models by viewModel.modelCatalog.collectAsState()
val providers by viewModel.modelAuthProviders.collectAsState()
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) {
viewModel.refreshModelCatalog()
}
}
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 13.dp)) {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp), contentPadding = PaddingValues(bottom = 112.dp)) {
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
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 = "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,
)
}
}
}
item {
ProviderOverviewPanel(
isConnected = isConnected,
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 and model catalog.")
} else {
ProviderList(rows = providerRows, refreshing = refreshing)
}
}
errorText?.let { message ->
item {
ClawPanel {
Text(text = message, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
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))
}
}
}
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,
val ready: Boolean,
val modelCount: Int,
)
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 setup",
ready = ready,
modelCount = modelCounts[provider.id] ?: 0,
)
}
val missingAuthRows =
modelCounts.keys
.filter { provider -> authRows.none { it.id == provider } }
.map { provider ->
ProviderRow(
id = provider,
name = providerDisplayName(provider),
status = "Ready",
ready = true,
modelCount = modelCounts[provider] ?: 0,
)
}
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"
}
internal fun modelProviderReady(status: String): Boolean {
val normalized = status.trim().lowercase()
return normalized == "ok" ||
normalized == "ready" ||
normalized == "healthy" ||
normalized == "configured" ||
normalized == "static"
}
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 =
when (provider.trim().lowercase()) {
"openai" -> 0
"anthropic" -> 1
"google" -> 2
"openrouter" -> 3
"ollama", "ollama-local" -> 4
"codex", "openai-codex" -> 5
else -> 100
}
@Composable
private fun ProviderList(
rows: List<ProviderRow>,
refreshing: Boolean,
) {
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))
} else {
val visibleRows = rows.take(5)
visibleRows.forEachIndexed { index, row ->
ProviderListRow(row)
if (index != visibleRows.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
}
}
@Composable
private fun ProviderOverviewPanel(
isConnected: Boolean,
providerRows: List<ProviderRow>,
modelCount: Int,
refreshing: Boolean,
onRefresh: () -> Unit,
onSetup: () -> Unit,
) {
val readyCount = providerRows.count { it.ready }
val needsSetupCount = providerRows.count { !it.ready }
ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
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 = "Setup", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
}
Text(
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,
)
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))
}
}
}
}
@Composable
private fun ProviderMetricTile(
label: String,
value: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surface,
border = BorderStroke(1.dp, ClawTheme.colors.border),
contentColor = ClawTheme.colors.text,
) {
Column(modifier = Modifier.padding(horizontal = 9.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = value, style = ClawTheme.type.title, color = ClawTheme.colors.text, maxLines = 1)
Text(text = label, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
}
}
}
@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 "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))
Text(text = row.status, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
}
}
}
@Composable
private fun ProviderBadge(text: String) {
Surface(modifier = Modifier.size(30.dp), shape = RoundedCornerShape(ClawTheme.radii.row), color = ClawTheme.colors.surfacePressed, border = BorderStroke(1.dp, ClawTheme.colors.border)) {
Box(contentAlignment = Alignment.Center) {
Text(text = providerInitials(text), style = ClawTheme.type.label, color = ClawTheme.colors.text, textAlign = TextAlign.Center)
}
}
}
private fun providerInitials(value: String): String =
value
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.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))
}
}
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) {
Text(text = title.uppercase(), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
}
}
@Composable
private fun ProviderHeaderIconButton(
icon: ImageVector,
contentDescription: String,
outlined: Boolean = false,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
shape = CircleShape,
color = Color.Transparent,
contentColor = ClawTheme.colors.text,
border = if (outlined) BorderStroke(1.dp, ClawTheme.colors.borderStrong) else null,
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(if (outlined) 17.dp else 20.dp))
}
}
}
@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)
}
}

View File

@@ -16,5 +16,5 @@ fun RootScreen(viewModel: MainViewModel) {
return
}
ShellScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize())
PostOnboardingTabs(viewModel = viewModel, modifier = Modifier.fillMaxSize())
}

View File

@@ -1,334 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.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.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.MicNone
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
internal fun SessionsScreen(
viewModel: MainViewModel,
onOpenCommand: () -> Unit,
onOpenChat: () -> Unit,
) {
val sessions by viewModel.chatSessions.collectAsState()
val chatSessionKey by viewModel.chatSessionKey.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
var filter by rememberSaveable { mutableStateOf(SessionFilter.Recent) }
var compactLayout by rememberSaveable { mutableStateOf(false) }
var recentFirst by rememberSaveable { mutableStateOf(true) }
val visibleSessions =
sessions
.let { rows ->
when (filter) {
SessionFilter.Recent -> rows
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
}
}.let { rows ->
if (recentFirst) {
rows.sortedByDescending { it.updatedAtMs ?: 0L }
} else {
rows.sortedBy { it.updatedAtMs ?: 0L }
}
}
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshChatSessions(limit = 200)
}
}
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(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 17.4.sp, lineHeight = 21.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
SessionPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
SessionPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
}
}
item {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Row(
modifier =
Modifier
.clip(RoundedCornerShape(ClawTheme.radii.row))
.clickable { recentFirst = !recentFirst }
.padding(horizontal = 2.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(text = "Sort: ${if (recentFirst) "Newest" else "Oldest"}", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(11.dp), tint = ClawTheme.colors.textMuted)
}
SessionOutlineIconButton(icon = Icons.Default.Storage, contentDescription = "Toggle session layout", onClick = { compactLayout = !compactLayout })
}
}
item {
Text(text = if (compactLayout) "Layout: Compact" else "Layout: Detailed", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
}
if (visibleSessions.isEmpty()) {
item {
ClawEmptyState(
title = emptySessionTitle(filter),
body = emptySessionBody(filter),
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
)
}
} else {
items(visibleSessions, key = { it.key }) { session ->
val active = session.key == chatSessionKey
SessionRow(
title = displaySessionTitle(session.displayName),
subtitle = if (active) "Current session" else "OpenClaw session",
metadata = session.updatedAtMs?.let(::relativeSessionTime) ?: "now",
active = active,
compact = compactLayout,
onClick = {
viewModel.switchChatSession(session.key)
onOpenChat()
},
)
}
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
@Composable
private fun FilterPill(
text: String,
icon: ImageVector? = null,
active: Boolean = false,
live: Boolean = false,
dropdown: Boolean = false,
onClick: (() -> Unit)? = null,
) {
Surface(
onClick = onClick ?: {},
enabled = onClick != null,
shape = RoundedCornerShape(7.dp),
color = if (active) ClawTheme.colors.surfaceRaised else Color.Transparent,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
icon?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.text) }
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
if (live) {
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
}
if (dropdown) {
Icon(imageVector = Icons.Default.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(11.dp), tint = ClawTheme.colors.textMuted)
}
}
}
}
@Composable
private fun SessionRow(
title: String,
subtitle: String,
metadata: String,
active: Boolean,
compact: Boolean,
onClick: () -> Unit,
) {
Surface(onClick = onClick, color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
Column {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
) {
Surface(
modifier = Modifier.size(30.dp),
shape = CircleShape,
color = Color.Transparent,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = if (active) Icons.Default.StarBorder else Icons.Outlined.ChatBubbleOutline,
contentDescription = null,
modifier = Modifier.size(15.dp),
tint = ClawTheme.colors.text,
)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = title,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (active) {
Box(modifier = Modifier.size(3.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
}
}
if (!compact) {
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
SessionMiniTag(text = "Workspace")
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
}
}
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(5.dp)) {
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textMuted)
Text(text = metadata, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
}
}
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
@Composable
private fun SessionPlainIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun SessionOutlineIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
shape = RoundedCornerShape(7.dp),
color = Color.Transparent,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(14.dp))
}
}
}
@Composable
private fun SessionMiniTag(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)
}
}
private enum class SessionFilter {
Recent,
Live,
}
private fun emptySessionTitle(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "No sessions yet"
SessionFilter.Live -> "No live session"
}
private fun emptySessionBody(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "Start a new conversation and it will show up here."
SessionFilter.Live -> "Open Chat to start or resume the current session."
}
private fun relativeSessionTime(updatedAtMs: Long): String {
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
val minutes = deltaMs / 60_000L
if (minutes < 1) return "now"
if (minutes < 60) return "${minutes}m"
val hours = minutes / 60
if (hours < 24) return "${hours}h"
return "${hours / 24}d"
}
private fun displaySessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"

View File

@@ -209,7 +209,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
val callLogPermissionAvailable = remember { SensitiveFeatureConfig.callLogEnabled }
val photosPermissionAvailable = remember { SensitiveFeatureConfig.photosEnabled }
val photosPermission =
if (Build.VERSION.SDK_INT >= 33) {
Manifest.permission.READ_MEDIA_IMAGES
@@ -246,11 +245,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
var photosPermissionGranted by
remember {
mutableStateOf(
if (photosPermissionAvailable) {
ContextCompat.checkSelfPermission(context, photosPermission) == PackageManager.PERMISSION_GRANTED
} else {
false
},
ContextCompat.checkSelfPermission(context, photosPermission) ==
PackageManager.PERMISSION_GRANTED,
)
}
val photosPermissionLauncher =
@@ -351,11 +347,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
notificationListenerEnabled = isNotificationListenerEnabled(context)
installedNotificationApps = queryInstalledApps(context, notificationForwardingPackages)
photosPermissionGranted =
if (photosPermissionAvailable) {
ContextCompat.checkSelfPermission(context, photosPermission) == PackageManager.PERMISSION_GRANTED
} else {
false
}
ContextCompat.checkSelfPermission(context, photosPermission) ==
PackageManager.PERMISSION_GRANTED
contactsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED &&
@@ -987,33 +980,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
Column(modifier = Modifier.settingsRowModifier()) {
if (photosPermissionAvailable) {
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Photos", style = mobileHeadline) },
supportingContent = { Text("Access recent photos.", style = mobileCallout) },
trailingContent = {
Button(
onClick = {
if (photosPermissionGranted) {
openAppSettings(context)
} else {
photosPermissionLauncher.launch(photosPermission)
}
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (photosPermissionGranted) "Manage" else "Grant",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
HorizontalDivider(color = mobileBorder)
}
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Photos", style = mobileHeadline) },
supportingContent = { Text("Access recent photos.", style = mobileCallout) },
trailingContent = {
Button(
onClick = {
if (photosPermissionGranted) {
openAppSettings(context)
} else {
photosPermissionLauncher.launch(photosPermission)
}
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (photosPermissionGranted) "Manage" else "Grant",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,

View File

@@ -1,155 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewaySkillSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawListPanel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
internal fun SkillsSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val skillsSummary by viewModel.skillsSummary.collectAsState()
val skillsRefreshing by viewModel.skillsRefreshing.collectAsState()
val skillsErrorText by viewModel.skillsErrorText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val skills = skillsSummary.skills
val readyCount = skills.count { skillReady(it) }
val needsSetupCount = skills.count { skillNeedsSetup(it) }
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshSkills()
}
}
SettingsDetailFrame(
title = "Skills",
subtitle = "Installed capabilities available to OpenClaw.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Installed", skills.size.toString()),
SettingsMetric("Ready", readyCount.toString()),
SettingsMetric("Needs Setup", needsSetupCount.toString()),
),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(
text = if (skillsRefreshing) "Refreshing" else "Refresh",
onClick = viewModel::refreshSkills,
enabled = isConnected && !skillsRefreshing,
modifier = Modifier.weight(1f),
)
}
skillsErrorText?.let { errorText ->
ClawPanel {
Text(text = errorText, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
when {
!isConnected ->
ClawPanel {
Text(text = "Connect the gateway to load skills.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
skills.isEmpty() ->
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No skills installed.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> SkillsPanel(skills = skills)
}
}
}
@Composable
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
ClawListPanel(items = skills) { skill ->
SkillListRow(skill = skill)
}
}
@Composable
private fun SkillListRow(skill: GatewaySkillSummary) {
ClawDetailRow(
title = skill.name,
subtitle = skillSubtitle(skill),
leading = { ClawTextBadge(text = skillBadge(skill)) },
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
)
}
private fun skillReady(skill: GatewaySkillSummary): Boolean = !skill.disabled && skill.eligible && skill.missingCount == 0
private fun skillNeedsSetup(skill: GatewaySkillSummary): Boolean = !skill.disabled && (skill.blockedByAllowlist || !skill.eligible || skill.missingCount > 0)
private fun skillStatusText(skill: GatewaySkillSummary): String =
when {
skill.disabled -> "Off"
skillNeedsSetup(skill) -> "Setup"
else -> "Ready"
}
private fun skillStatus(skill: GatewaySkillSummary): ClawStatus =
when {
skill.disabled -> ClawStatus.Neutral
skillNeedsSetup(skill) -> ClawStatus.Warning
else -> ClawStatus.Success
}
private fun skillSubtitle(skill: GatewaySkillSummary): String {
val issue =
when {
skill.disabled -> "Disabled"
skill.blockedByAllowlist -> "Blocked"
skill.missingCount > 0 -> "${skill.missingCount} missing"
!skill.eligible -> "Needs setup"
else -> null
}
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
}
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
when (skill.source) {
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"
"openclaw-managed" -> "Installed"
"openclaw-workspace" -> "Workspace"
"openclaw-extra" -> "Extra"
else -> "Skill"
}
private fun skillBadge(skill: GatewaySkillSummary): String {
skill.emoji?.let { return it }
return skill.name
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.joinToString("")
.ifBlank { "S" }
}

View File

@@ -1,954 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.VoiceCaptureMode
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.voice.VoiceConversationEntry
import ai.openclaw.app.voice.VoiceConversationRole
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
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.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.automirrored.filled.Send
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.PhoneDisabled
import androidx.compose.material.icons.filled.RecordVoiceOver
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
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
import androidx.core.content.ContextCompat
@Composable
fun VoiceScreen(
viewModel: MainViewModel,
onOpenCommand: () -> Unit,
onOpenVoiceSettings: () -> Unit,
) {
val context = LocalContext.current
val gatewayStatus by viewModel.statusText.collectAsState()
val voiceCaptureMode by viewModel.voiceCaptureMode.collectAsState()
val micEnabled by viewModel.micEnabled.collectAsState()
val micCooldown by viewModel.micCooldown.collectAsState()
val speakerEnabled by viewModel.speakerEnabled.collectAsState()
val micStatusText by viewModel.micStatusText.collectAsState()
val micLiveTranscript by viewModel.micLiveTranscript.collectAsState()
val micQueuedMessages by viewModel.micQueuedMessages.collectAsState()
val micConversation by viewModel.micConversation.collectAsState()
val micIsSending by viewModel.micIsSending.collectAsState()
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
val talkModeListening by viewModel.talkModeListening.collectAsState()
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
var pendingAction by remember { mutableStateOf<VoiceAction?>(null) }
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
val requestMicPermission =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
hasMicPermission = granted
if (granted) {
when (pendingAction) {
VoiceAction.Talk -> viewModel.setTalkModeEnabled(true)
VoiceAction.Dictation -> viewModel.setMicEnabled(true)
null -> Unit
}
}
pendingAction = null
}
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
val voiceActive = micEnabled || micIsSending || talkModeEnabled
val activeStatus =
voiceStatusLabel(
gatewayStatus = gatewayStatus,
voiceCaptureMode = voiceCaptureMode,
micStatusText = micStatusText,
micQueuedMessages = micQueuedMessages.size,
micIsSending = micIsSending,
talkModeListening = talkModeListening,
talkModeSpeaking = talkModeSpeaking,
)
if (talkModeEnabled) {
TalkSessionScreen(
entries = talkModeConversation,
listening = talkModeListening,
speaking = talkModeSpeaking,
speakerEnabled = speakerEnabled,
onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) },
onEndTalk = { viewModel.setTalkModeEnabled(false) },
onOpenVoiceSettings = onOpenVoiceSettings,
)
return
}
if (voiceCaptureMode == VoiceCaptureMode.ManualMic || micEnabled || micIsSending) {
DictationScreen(
liveTranscript = micLiveTranscript,
conversation = micConversation,
listening = micEnabled,
sending = micIsSending,
statusText = activeStatus,
gatewayStatus = gatewayStatus,
onCancel = { viewModel.cancelMicCapture() },
onSend = { viewModel.setMicEnabled(false) },
onOpenVoiceSettings = onOpenVoiceSettings,
)
return
}
Column(
modifier =
Modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
VoiceHeader(
statusText = if (voiceActive) activeStatus else "Your voice command center.",
speakerEnabled = speakerEnabled,
onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) },
onOpenCommand = onOpenCommand,
)
VoiceHero(
gatewayStatus = gatewayStatus,
voiceCaptureMode = voiceCaptureMode,
micEnabled = micEnabled,
talkModeEnabled = talkModeEnabled,
talkModeListening = talkModeListening,
talkModeSpeaking = talkModeSpeaking,
micLiveTranscript = micLiveTranscript,
onStartTalk = {
runVoiceAction(
action = VoiceAction.Talk,
hasMicPermission = hasMicPermission,
requestPermission = {
pendingAction = VoiceAction.Talk
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
},
run = { viewModel.setTalkModeEnabled(!talkModeEnabled) },
)
},
onStartDictation = {
if (micCooldown) return@VoiceHero
runVoiceAction(
action = VoiceAction.Dictation,
hasMicPermission = hasMicPermission,
requestPermission = {
pendingAction = VoiceAction.Dictation
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
},
run = { viewModel.setMicEnabled(!micEnabled) },
)
},
)
if (!hasMicPermission) {
VoicePermissionPanel(
onRequestPermission = {
pendingAction = VoiceAction.Talk
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
},
)
}
VoiceTranscript(
entries = activeConversation,
showThinking = micIsSending && activeConversation.none { it.role == VoiceConversationRole.Assistant && it.isStreaming },
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun DictationScreen(
liveTranscript: String?,
conversation: List<VoiceConversationEntry>,
listening: Boolean,
sending: Boolean,
statusText: String,
gatewayStatus: String,
onCancel: () -> Unit,
onSend: () -> Unit,
onOpenVoiceSettings: () -> Unit,
) {
val lastUserText = conversation.lastOrNull { it.role == VoiceConversationRole.User }?.text
val draftText = liveTranscript?.takeIf { it.isNotBlank() } ?: lastUserText.orEmpty()
val speechProviderReady = gatewayStatus.isVoiceGatewayReady()
Column(
modifier =
Modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = "Dictation", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Text(text = "Transcribe then send", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
}
Surface(
modifier = Modifier.fillMaxWidth().aspectRatio(0.82f),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.canvas,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
) {
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 12.dp), verticalArrangement = Arrangement.SpaceBetween) {
Text(
text = draftText.ifBlank { if (sending) "Sending to chat..." else "Start speaking..." },
style = ClawTheme.type.title.copy(fontSize = 15.sp, lineHeight = 19.sp),
color = if (draftText.isBlank()) ClawTheme.colors.textSubtle else ClawTheme.colors.text,
maxLines = 7,
overflow = TextOverflow.Ellipsis,
)
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
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 = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
}
ClawPanel(contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Surface(
modifier = Modifier.size(30.dp),
shape = CircleShape,
color = ClawTheme.colors.surfacePressed,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(16.dp), tint = ClawTheme.colors.text)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = "Speech provider", style = ClawTheme.type.section, color = ClawTheme.colors.text)
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"
speechProviderReady -> "Ready"
else -> "Offline"
},
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
color =
when {
sending -> ClawTheme.colors.warning
speechProviderReady -> ClawTheme.colors.success
else -> ClawTheme.colors.textMuted
},
)
Box(
modifier =
Modifier
.size(6.dp)
.clip(CircleShape)
.background(
when {
sending -> ClawTheme.colors.warning
speechProviderReady -> ClawTheme.colors.success
else -> ClawTheme.colors.textSubtle
},
),
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(imageVector = Icons.Default.Info, contentDescription = null, modifier = Modifier.size(16.dp), tint = ClawTheme.colors.textMuted)
Text(text = "Tip: stop listening to send the captured turn.", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ClawSecondaryButton(text = "Cancel", icon = Icons.Default.Close, onClick = onCancel, modifier = Modifier.weight(0.95f))
ClawPrimaryButton(text = if (sending) "Sending" else "Send to Chat", icon = Icons.AutoMirrored.Filled.Send, onClick = onSend, enabled = !sending, modifier = Modifier.weight(1.25f))
}
}
}
@Composable
private fun DictationWaveform(active: Boolean) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
List(48) { index ->
val height = if (active) 3 + ((index * 7) % 16) else 3 + (index % 3) * 2
Box(
modifier =
Modifier
.size(width = 2.dp, height = height.dp)
.clip(RoundedCornerShape(999.dp))
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
)
}
}
}
@Composable
private fun TalkSessionScreen(
entries: List<VoiceConversationEntry>,
listening: Boolean,
speaking: Boolean,
speakerEnabled: Boolean,
onToggleSpeaker: () -> Unit,
onEndTalk: () -> Unit,
onOpenVoiceSettings: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(11.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Realtime Talk", style = ClawTheme.type.title.copy(fontSize = 14.sp, lineHeight = 17.sp), color = ClawTheme.colors.text)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (speaking || listening) ClawTheme.colors.success else ClawTheme.colors.textSubtle))
Text(
text =
if (speaking) {
"OpenClaw speaking"
} else if (listening) {
"Realtime voice"
} else {
"Connected"
},
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
}
}
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
}
Surface(
modifier = Modifier.fillMaxWidth().height(58.dp),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.canvas,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
) {
Box(contentAlignment = Alignment.Center) {
TalkWaveform(active = listening || speaking)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = "Live transcript", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
TalkTranscript(entries = entries, modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
TalkControl(icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff, label = if (speakerEnabled) "Mute" else "Unmute", onClick = onToggleSpeaker)
TalkControl(icon = Icons.Default.PhoneDisabled, label = "End", primary = true, onClick = onEndTalk)
TalkControl(icon = Icons.Default.GraphicEq, label = "Voice", onClick = onOpenVoiceSettings)
}
}
}
@Composable
private fun TalkTranscript(
entries: List<VoiceConversationEntry>,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(7.dp)) {
if (entries.isEmpty()) {
item {
TalkTranscriptCard(label = "OpenClaw", text = "Listening for your next turn.", muted = true)
}
} else {
items(entries.takeLast(6), key = { it.id }) { entry ->
TalkTranscriptCard(
label = if (entry.role == VoiceConversationRole.User) "You" else "OpenClaw",
text = if (entry.isStreaming && entry.text.isBlank()) "Listening response..." else entry.text,
muted = entry.isStreaming,
)
}
}
}
}
@Composable
private fun TalkTranscriptCard(
label: String,
text: String,
muted: Boolean = false,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surface,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(text = label, style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = text, style = ClawTheme.type.body, color = if (muted) ClawTheme.colors.textMuted else ClawTheme.colors.text)
}
}
}
@Composable
private fun TalkControl(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
primary: Boolean = false,
onClick: () -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(5.dp)) {
Surface(
onClick = onClick,
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
shape = CircleShape,
color = if (primary) ClawTheme.colors.primary else ClawTheme.colors.canvas,
contentColor = if (primary) ClawTheme.colors.primaryText else ClawTheme.colors.text,
border = BorderStroke(1.dp, if (primary) ClawTheme.colors.primary else ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = label, modifier = Modifier.size(if (primary) 20.dp else 18.dp))
}
}
Text(text = label, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
}
}
@Composable
private fun TalkWaveform(active: Boolean) {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp), verticalAlignment = Alignment.CenterVertically) {
listOf(4, 12, 24, 34, 46, 28, 12, 38, 44, 24, 12, 30, 42, 18, 6).forEachIndexed { index, height ->
Box(
modifier =
Modifier
.size(width = 3.dp, height = (if (active) height else 6 + index % 4 * 5).dp)
.clip(RoundedCornerShape(999.dp))
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
)
}
}
}
@Composable
private fun VoiceHeader(
statusText: String,
speakerEnabled: Boolean,
onToggleSpeaker: () -> Unit,
onOpenCommand: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = "O P E N C L A W",
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
)
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
VoiceAvatar(text = "OC")
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Text(
text = statusText,
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
VoicePlainIconButton(
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
onClick = onToggleSpeaker,
)
}
}
}
@Composable
private fun VoiceAvatar(text: String) {
Surface(
modifier = Modifier.size(34.dp),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
}
}
}
@Composable
private fun VoicePlainIconButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun VoiceHero(
gatewayStatus: String,
voiceCaptureMode: VoiceCaptureMode,
micEnabled: Boolean,
talkModeEnabled: Boolean,
talkModeListening: Boolean,
talkModeSpeaking: Boolean,
micLiveTranscript: String?,
onStartTalk: () -> Unit,
onStartDictation: () -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
VoiceOrb(
active = micEnabled || talkModeEnabled,
listening = talkModeListening || voiceCaptureMode == VoiceCaptureMode.ManualMic,
speaking = talkModeSpeaking,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
modifier =
Modifier
.size(7.dp)
.clip(CircleShape)
.background(if (micEnabled || talkModeEnabled) ClawTheme.colors.success else ClawTheme.colors.textSubtle),
)
Text(
text =
when {
talkModeSpeaking -> "OpenClaw is replying"
talkModeListening -> "Listening"
talkModeEnabled -> "Talk is live"
micEnabled -> "Dictation is listening"
else -> "Ready to talk"
},
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
textAlign = TextAlign.Center,
)
}
if (!micLiveTranscript.isNullOrBlank()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surface,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
) {
Text(
text = micLiveTranscript.trim(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
)
}
}
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
VoiceModeRow(
title = if (talkModeEnabled) "End Talk" else "Realtime Talk",
subtitle = if (talkModeEnabled) "Conversation is live" else "Natural conversation in real time",
icon = if (talkModeEnabled) Icons.Default.PhoneDisabled else Icons.Default.RecordVoiceOver,
onClick = onStartTalk,
)
VoiceModeRow(
title = if (micEnabled) "Stop Dictation" else "Dictation",
subtitle = if (micEnabled) "Listening for one turn" else "Convert speech to text",
icon = if (micEnabled) Icons.Default.MicOff else Icons.Default.TextFields,
onClick = onStartDictation,
)
}
VoiceProviderCard(gatewayStatus = gatewayStatus)
VoicePrimaryAction(
text = if (talkModeEnabled) "End Talk" else "Start Talk",
icon = if (talkModeEnabled) Icons.Default.PhoneDisabled else Icons.Default.Phone,
onClick = onStartTalk,
)
}
}
@Composable
private fun VoiceModeRow(
title: String,
subtitle: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
) {
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Surface(
modifier = Modifier.size(34.dp),
shape = CircleShape,
color = ClawTheme.colors.surface,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp))
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(text = subtitle, 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 = null, modifier = Modifier.size(21.dp), tint = ClawTheme.colors.textMuted)
}
}
}
@Composable
private fun VoiceProviderCard(gatewayStatus: String) {
val ready = gatewayStatus.isVoiceGatewayReady()
Surface(
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surface,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Surface(
modifier = Modifier.size(34.dp),
shape = CircleShape,
color = ClawTheme.colors.canvas,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Default.GraphicEq, contentDescription = null, modifier = Modifier.size(17.dp))
}
}
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 = 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(
modifier =
Modifier
.size(7.dp)
.clip(CircleShape)
.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)
}
}
}
}
@Composable
private fun VoicePrimaryAction(
text: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
) {
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 = icon, contentDescription = null, modifier = Modifier.size(17.dp))
Text(text = text, modifier = Modifier.padding(start = 8.dp), style = ClawTheme.type.label)
}
}
}
@Composable
private fun VoiceOrb(
active: Boolean,
listening: Boolean,
speaking: Boolean,
) {
Surface(
modifier = Modifier.size(132.dp),
shape = CircleShape,
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
imageVector =
when {
speaking -> Icons.Default.RecordVoiceOver
listening -> Icons.Default.GraphicEq
else -> Icons.Default.Mic
},
contentDescription = null,
modifier = Modifier.size(38.dp),
tint = ClawTheme.colors.text,
)
Waveform(active = active)
}
}
}
}
@Composable
private fun Waveform(active: Boolean) {
Row(horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically) {
listOf(6, 11, 17, 23, 14, 9, 20, 14, 7).forEachIndexed { index, height ->
Box(
modifier =
Modifier
.size(width = 2.dp, height = (if (active) height else 6 + index % 3 * 3).dp)
.clip(RoundedCornerShape(999.dp))
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
)
}
}
}
@Composable
private fun VoiceTranscript(
entries: List<VoiceConversationEntry>,
showThinking: Boolean,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
LaunchedEffect(entries.size, showThinking) {
if (entries.isNotEmpty() || showThinking) {
listState.animateScrollToItem(0)
}
}
LazyColumn(
modifier = modifier.fillMaxWidth(),
state = listState,
reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 8.dp),
) {
if (showThinking) {
item(key = "thinking") {
VoiceThinkingCard()
}
}
items(entries.asReversed(), key = { it.id }) { entry ->
VoiceTurnCard(entry = entry)
}
if (entries.isEmpty() && !showThinking) {
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = "Live transcript", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
ClawPanel(contentPadding = PaddingValues(horizontal = 10.dp, vertical = 9.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = "No transcript yet", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(
text = "Your words and OpenClaw replies will appear here.",
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
}
}
}
}
}
}
}
@Composable
private fun VoiceTurnCard(entry: VoiceConversationEntry) {
val isUser = entry.role == VoiceConversationRole.User
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) {
Surface(
modifier = Modifier.fillMaxWidth(if (isUser) 0.82f else 0.92f),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = if (isUser) ClawTheme.colors.surfacePressed else ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, if (entry.isStreaming) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(
text = if (isUser) "You" else "OpenClaw",
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
color = ClawTheme.colors.textSubtle,
)
Text(
text = if (entry.isStreaming && entry.text.isBlank()) "Listening..." else entry.text,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
)
}
}
}
}
@Composable
private fun VoiceThinkingCard() {
ClawPanel {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ClawStatusPill(text = "Sending", status = ClawStatus.Warning)
Text(text = "OpenClaw is preparing a response.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
@Composable
private fun VoicePermissionPanel(onRequestPermission: () -> Unit) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ClawStatusPill(text = "Permission needed", status = ClawStatus.Warning)
Text(text = "Microphone access is needed.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(
text = "OpenClaw only listens when you start Talk or Dictation.",
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
ClawSecondaryButton(text = "Enable Microphone", icon = Icons.Default.Mic, onClick = onRequestPermission)
}
}
}
private enum class VoiceAction {
Talk,
Dictation,
}
private fun runVoiceAction(
action: VoiceAction,
hasMicPermission: Boolean,
requestPermission: () -> Unit,
run: () -> Unit,
) {
if (hasMicPermission) {
run()
} else {
requestPermission()
}
}
private fun voiceStatusLabel(
gatewayStatus: String,
voiceCaptureMode: VoiceCaptureMode,
micStatusText: String,
micQueuedMessages: Int,
micIsSending: Boolean,
talkModeListening: Boolean,
talkModeSpeaking: Boolean,
): String =
when {
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "OpenClaw is speaking"
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Listening"
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk is live"
micIsSending -> "Sending dictation"
voiceCaptureMode == VoiceCaptureMode.ManualMic -> micStatusText.ifBlank { "Listening" }
micQueuedMessages > 0 -> "$micQueuedMessages queued"
!gatewayStatus.isVoiceGatewayReady() -> "Gateway offline"
else -> "Ready to talk"
}
private fun String.isVoiceGatewayReady(): Boolean {
val status = lowercase()
return !status.contains("offline") && !status.contains("not connected") && !status.contains("failed") && !status.contains("error")
}
private fun String.voiceGatewayLabel(): String = if (isVoiceGatewayReady()) "Connected and ready" else "Gateway not connected"
private fun Context.hasRecordAudioPermission(): Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED

View File

@@ -96,10 +96,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
val talkModeListening by viewModel.talkModeListening.collectAsState()
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
val hasStreamingAssistant = activeConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
val showThinkingBubble = micIsSending && !hasStreamingAssistant
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
@@ -133,8 +131,8 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
pendingVoicePermissionAction = null
}
LaunchedEffect(voiceCaptureMode, activeConversation.size, showThinkingBubble) {
val total = activeConversation.size + if (showThinkingBubble) 1 else 0
LaunchedEffect(micConversation.size, showThinkingBubble) {
val total = micConversation.size + if (showThinkingBubble) 1 else 0
if (total > 0) {
listState.animateScrollToItem(total - 1)
}
@@ -156,7 +154,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
contentPadding = PaddingValues(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (activeConversation.isEmpty() && !showThinkingBubble) {
if (micConversation.isEmpty() && !showThinkingBubble) {
item {
Box(
modifier = Modifier.fillParentMaxHeight().fillMaxWidth(),
@@ -187,7 +185,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
}
}
items(items = activeConversation, key = { it.id }) { entry ->
items(items = micConversation, key = { it.id }) { entry ->
VoiceTurnBubble(entry = entry)
}
@@ -349,8 +347,10 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "Talk speaking"
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Talk listening"
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk on"
micEnabled || micIsSending || micCooldown -> micStatusText
queueCount > 0 -> "$queueCount queued"
micIsSending -> "Sending"
micCooldown -> "Cooldown"
micEnabled -> "Listening"
else -> "Mic off"
}
val stateColor =

View File

@@ -141,7 +141,7 @@ fun ChatComposer(
if (!healthOk) {
Text(
text = "Gateway is offline. Open Settings to reconnect.",
text = "Gateway is offline. Connect first in the Connect tab.",
style = mobileCallout,
color = ai.openclaw.app.ui.mobileWarning,
)

View File

@@ -13,7 +13,7 @@ import kotlin.math.max
import kotlin.math.roundToInt
private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600
internal const val CHAT_IMAGE_MAX_BASE64_CHARS = 300 * 1024
private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024
private const val CHAT_ATTACHMENT_START_QUALITY = 85
private const val CHAT_DECODE_MAX_DIMENSION = 1600
private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024
@@ -35,7 +35,7 @@ internal fun loadSizedImageAttachment(
if (bitmap == null) {
throw IllegalStateException("unsupported attachment")
}
val maxBytes = (CHAT_IMAGE_MAX_BASE64_CHARS / 4) * 3
val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3
val encoded =
JpegSizeLimiter.compressToLimit(
initialWidth = bitmap.width,

View File

@@ -75,13 +75,10 @@ import org.commonmark.node.SoftLineBreak
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.ThematicBreak
import org.commonmark.parser.Parser
import java.net.URI
import java.util.Locale
import org.commonmark.node.Image as MarkdownImage
import org.commonmark.node.Text as MarkdownTextNode
private const val LIST_INDENT_DP = 14
private const val DATA_IMAGE_HEADER_MAX_CHARS = 64
private val dataImageRegex = Regex("^data:image/([a-zA-Z0-9+.-]+);base64,([A-Za-z0-9+/=\\n\\r]+)$")
private val markdownParser: Parser by lazy {
@@ -550,13 +547,15 @@ private fun AnnotatedString.Builder.appendLinkNode(
color = linkColor,
textDecoration = TextDecoration.Underline,
)
if (destination.isEmpty() || !isSafeMarkdownLinkDestination(destination)) {
appendInlineNode(
link.firstChild,
inlineCodeBg = inlineCodeBg,
inlineCodeColor = inlineCodeColor,
linkColor = linkColor,
)
if (destination.isEmpty()) {
withStyle(linkStyle) {
appendInlineNode(
link.firstChild,
inlineCodeBg = inlineCodeBg,
inlineCodeColor = inlineCodeColor,
linkColor = linkColor,
)
}
return
}
@@ -570,14 +569,6 @@ private fun AnnotatedString.Builder.appendLinkNode(
}
}
private fun isSafeMarkdownLinkDestination(destination: String): Boolean {
val scheme =
runCatching { URI(destination).scheme?.lowercase(Locale.US) }
.getOrNull()
?: return false
return scheme == "http" || scheme == "https"
}
internal fun buildChatInlineMarkdown(
text: String,
linkColor: Color = Color.Blue,
@@ -615,10 +606,9 @@ private fun standaloneDataImage(paragraph: Paragraph): ParsedDataImage? {
return parseDataImageDestination(only.destination)
}
internal fun parseDataImageDestination(destination: String?): ParsedDataImage? {
private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
val raw = destination?.trim().orEmpty()
if (raw.isEmpty()) return null
if (raw.length > CHAT_IMAGE_MAX_BASE64_CHARS + DATA_IMAGE_HEADER_MAX_CHARS) return null
val match = dataImageRegex.matchEntire(raw) ?: return null
val subtype =
match.groupValues
@@ -633,7 +623,6 @@ internal fun parseDataImageDestination(destination: String?): ParsedDataImage? {
?.trim()
.orEmpty()
if (base64.isEmpty()) return null
if (base64.length > CHAT_IMAGE_MAX_BASE64_CHARS) return null
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
}
@@ -661,7 +650,7 @@ private data class TableRenderRow(
val cells: List<AnnotatedString>,
)
internal data class ParsedDataImage(
private data class ParsedDataImage(
val mimeType: String,
val base64: String,
)

View File

@@ -1,853 +0,0 @@
package ai.openclaw.app.ui.chat
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.OutgoingAttachment
import ai.openclaw.app.ui.design.ClawListItem
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
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.Refresh
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
import java.util.Locale
@Composable
fun ChatScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
onVoice: () -> Unit,
) {
val messages by viewModel.chatMessages.collectAsState()
val errorText by viewModel.chatError.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val healthOk by viewModel.chatHealthOk.collectAsState()
val sessionKey by viewModel.chatSessionKey.collectAsState()
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
val chatDraft by viewModel.chatDraft.collectAsState()
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
val context = LocalContext.current
val resolver = context.contentResolver
val scope = rememberCoroutineScope()
val attachments = remember { mutableStateListOf<PendingImageAttachment>() }
val pickImages =
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
if (uris.isNullOrEmpty()) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
val next =
uris.take(8).mapNotNull { uri ->
try {
loadSizedImageAttachment(resolver, uri)
} catch (_: Throwable) {
null
}
}
withContext(Dispatchers.Main) {
attachments.addAll(next)
}
}
}
LaunchedEffect(Unit) {
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
if (loadSessionKey != null) {
viewModel.loadChat(loadSessionKey)
}
viewModel.refreshChatSessions(limit = 100)
}
LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) {
val accepted =
dispatchPendingAssistantAutoSend(
pendingPrompt = pendingAssistantAutoSend,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
) { prompt ->
viewModel.sendChatAwaitAcceptance(message = prompt, thinking = thinkingLevel, attachments = emptyList())
}
if (accepted) {
viewModel.clearPendingAssistantAutoSend()
}
}
var input by rememberSaveable { mutableStateOf("") }
LaunchedEffect(chatDraft) {
val draft = chatDraft?.trim()?.ifEmpty { null } ?: return@LaunchedEffect
input = draft
viewModel.clearChatDraft()
}
Column(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 18.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
ChatHeader(
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
thinkingLevel = thinkingLevel,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
onBack = onBack,
onMore = {
viewModel.refreshChat()
viewModel.refreshChatSessions(limit = 100)
},
)
errorText?.takeIf { it.isNotBlank() }?.let { error ->
ChatNotice(title = "Chat needs attention", body = userFacingChatError(error))
}
ChatMessageList(
messages = messages,
pendingRunCount = pendingRunCount,
pendingToolCalls = pendingToolCalls,
streamingAssistantText = streamingAssistantText,
healthOk = healthOk,
onStarterPrompt = { prompt -> input = prompt },
modifier = Modifier.weight(1f),
)
ChatComposer(
value = input,
onValueChange = { input = it },
attachments = attachments,
thinkingLevel = thinkingLevel,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
onThinkingLevelChange = viewModel::setChatThinkingLevel,
onPickImages = { pickImages.launch("image/*") },
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
onVoice = onVoice,
onAbort = viewModel::abortChat,
onSend = {
val message = input.trim()
if (message.isEmpty() && attachments.isEmpty()) return@ChatComposer
val outgoing =
attachments.map { attachment ->
OutgoingAttachment(
type = "image",
mimeType = attachment.mimeType,
fileName = attachment.fileName,
base64 = attachment.base64,
)
}
input = ""
attachments.clear()
scope.launch {
viewModel.sendChat(message = message, thinking = thinkingLevel, attachments = outgoing)
}
},
)
}
}
@Composable
private fun ChatHeader(
sessionTitle: String,
thinkingLevel: String,
healthOk: Boolean,
pendingRunCount: Int,
onBack: () -> Unit,
onMore: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
HeaderIcon(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
text = sessionTitle,
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
color = ClawTheme.colors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
ModelPill(
text =
when {
pendingRunCount > 0 -> "Working"
healthOk -> "auto"
else -> "offline"
},
status =
when {
pendingRunCount > 0 -> ClawStatus.Warning
healthOk -> ClawStatus.Neutral
else -> ClawStatus.Danger
},
)
}
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
}
}
@Composable
private fun ModelPill(
text: String,
status: ClawStatus,
) {
val borderColor =
if (status == ClawStatus.Warning) {
ClawTheme.colors.warning
} else {
ClawTheme.colors.border
}
Surface(
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.textMuted,
border = BorderStroke(1.dp, borderColor),
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 7.dp, vertical = 1.5.dp),
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
maxLines = 1,
)
}
}
@Composable
private fun HeaderIcon(
icon: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
shape = CircleShape,
color = Color.Transparent,
contentColor = ClawTheme.colors.text,
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(20.dp))
}
}
}
@Composable
private fun ChatMessageList(
messages: List<ChatMessage>,
pendingRunCount: Int,
pendingToolCalls: List<ChatPendingToolCall>,
streamingAssistantText: String?,
healthOk: Boolean,
onStarterPrompt: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
val displayMessages = remember(messages) { messages.asReversed() }
val stream = streamingAssistantText?.trim()
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
listState.animateScrollToItem(index = 0)
}
LaunchedEffect(stream) {
if (!stream.isNullOrEmpty()) {
listState.scrollToItem(index = 0)
}
}
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
verticalArrangement = Arrangement.spacedBy(5.dp),
contentPadding = PaddingValues(top = 6.dp, bottom = 3.dp),
) {
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 (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && stream.isNullOrBlank()) {
EmptyChatHint(healthOk = healthOk, onStarterPrompt = onStarterPrompt, modifier = Modifier.align(Alignment.Center))
}
}
}
@Composable
private fun EmptyChatHint(
healthOk: Boolean,
onStarterPrompt: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(text = if (healthOk) "Ready when you are" else "Gateway offline", style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp), color = ClawTheme.colors.text)
Text(
text =
if (healthOk) {
"Start with a prompt, or use voice."
} else {
"Reconnect from Settings to send messages."
},
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
textAlign = TextAlign.Center,
)
}
if (healthOk) {
StarterPromptList(onStarterPrompt = onStarterPrompt)
}
}
}
@Composable
private fun StarterPromptList(onStarterPrompt: (String) -> Unit) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
starterPrompts.forEachIndexed { index, prompt ->
StarterPromptRow(prompt = prompt, onClick = { onStarterPrompt(prompt.message) })
if (index != starterPrompts.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
}
@Composable
private fun StarterPromptRow(
prompt: StarterPrompt,
onClick: () -> Unit,
) {
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 54.dp).padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier =
Modifier
.size(30.dp)
.background(ClawTheme.colors.surfacePressed, RoundedCornerShape(ClawTheme.radii.row)),
contentAlignment = Alignment.Center,
) {
Text(text = prompt.mark, style = ClawTheme.type.label, color = ClawTheme.colors.text)
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = prompt.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(text = prompt.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
private data class StarterPrompt(
val mark: String,
val title: String,
val subtitle: String,
val message: String,
)
private val starterPrompts =
listOf(
StarterPrompt(mark = "1", title = "Catch me up", subtitle = "Summarize recent sessions and next steps.", message = "Catch me up on my recent OpenClaw sessions and suggest next steps."),
StarterPrompt(mark = "2", title = "Plan the work", subtitle = "Turn a goal into an actionable checklist.", message = "Help me turn this goal into a practical checklist: "),
StarterPrompt(mark = "3", title = "Use this phone", subtitle = "Ask OpenClaw to use Android capabilities.", message = "What can you help me do from this phone right now?"),
)
@Composable
private fun ChatBubble(
role: String,
live: Boolean,
content: List<ChatMessageContent>,
timestampMs: Long?,
) {
val normalizedRole = role.trim().lowercase(Locale.US)
val isUser = normalizedRole == "user"
val displayableContent =
content.filter { part ->
when (part.type) {
"text" -> !part.text.isNullOrBlank()
else -> part.base64 != null
}
}
if (displayableContent.isEmpty()) return
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Surface(
modifier = Modifier.fillMaxWidth(if (isUser) 0.64f else 0.56f),
shape = RoundedCornerShape(7.dp),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.5.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text =
when {
live -> "OpenClaw · Live"
isUser -> "You"
normalizedRole == "system" -> "System"
else -> "OpenClaw"
},
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
color = ClawTheme.colors.text,
)
displayableContent.forEach { part ->
if (part.type == "text") {
ChatText(text = part.text.orEmpty(), textColor = ClawTheme.colors.text)
} else {
Text(text = part.fileName ?: "Attachment", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
timestampMs?.let {
Text(
text = formatChatTimestamp(it),
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
color = ClawTheme.colors.textMuted,
modifier = Modifier.align(Alignment.End),
)
}
}
}
}
}
@Composable
private fun ChatText(
text: String,
textColor: Color,
) {
if (text.hasMarkdownSyntax()) {
ChatMarkdown(text = text, textColor = textColor)
} else {
Text(
text = text,
style = ClawTheme.type.body,
color = textColor,
)
}
}
@Composable
private fun ToolBubble(toolCalls: List<ChatPendingToolCall>) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ClawStatusPill(text = "Tools running", status = ClawStatus.Warning)
toolCalls.take(4).forEach { tool ->
ClawListItem(title = tool.name, subtitle = "OpenClaw is working")
}
if (toolCalls.size > 4) {
Text(text = "+${toolCalls.size - 4} more", style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle)
}
}
}
}
@Composable
private fun ChatThinkingBubble() {
ClawPanel {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ClawStatusPill(text = "Thinking", status = ClawStatus.Warning)
Text(text = "OpenClaw is preparing a response.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
@Composable
private fun ChatNotice(
title: String,
body: String,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surface,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Box(modifier = Modifier.size(6.dp).background(ClawTheme.colors.warning, CircleShape))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = body, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
@Composable
private fun ChatComposer(
value: String,
onValueChange: (String) -> Unit,
attachments: List<PendingImageAttachment>,
thinkingLevel: String,
healthOk: Boolean,
pendingRunCount: Int,
onThinkingLevelChange: (String) -> Unit,
onPickImages: () -> Unit,
onRemoveAttachment: (String) -> Unit,
onVoice: () -> Unit,
onAbort: () -> Unit,
onSend: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().imePadding(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (attachments.isNotEmpty()) {
AttachmentStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
}
ChatContextMeter(thinkingLevel = thinkingLevel, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) })
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ChatInputPill(value = value, onValueChange = onValueChange, onPickImages = onPickImages, onVoice = onVoice, modifier = Modifier.weight(1f))
SendButton(
enabled = healthOk && pendingRunCount == 0 && (value.trim().isNotEmpty() || attachments.isNotEmpty()),
onClick = onSend,
)
}
if (pendingRunCount > 0) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Surface(
onClick = onAbort,
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.canvas,
contentColor = ClawTheme.colors.text,
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(modifier = Modifier.size(8.dp).background(ClawTheme.colors.danger, RoundedCornerShape(2.dp)))
Text(text = "Stop", style = ClawTheme.type.label)
}
}
}
}
}
}
@Composable
private fun ChatContextMeter(
thinkingLevel: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier.width(178.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
) {
Surface(
onClick = onClick,
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.canvas,
contentColor = ClawTheme.colors.text,
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
Text(text = "Context ${contextPercent(thinkingLevel)}%", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
}
}
Box(
modifier =
Modifier
.weight(1f)
.height(3.dp)
.background(ClawTheme.colors.surfacePressed, RoundedCornerShape(999.dp)),
) {
Box(
modifier =
Modifier
.fillMaxWidth(thinkingMeterWidth(thinkingLevel))
.height(3.dp)
.background(ClawTheme.colors.primary, RoundedCornerShape(999.dp)),
)
}
}
}
@Composable
private fun ChatInputPill(
value: String,
onValueChange: (String) -> Unit,
onPickImages: () -> Unit,
onVoice: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget),
shape = RoundedCornerShape(ClawTheme.radii.control),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
) {
Surface(onClick = onPickImages, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = ClawTheme.colors.surfaceRaised, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Default.AttachFile, contentDescription = "Attach image", modifier = Modifier.size(16.dp))
}
}
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = ClawTheme.type.body.copy(color = ClawTheme.colors.text),
cursorBrush = SolidColor(ClawTheme.colors.primary),
minLines = 1,
maxLines = 4,
modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField ->
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterStart) {
if (value.isEmpty()) {
Text(text = "Message OpenClaw", style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle)
}
innerTextField()
}
},
)
}
Surface(
onClick = onVoice,
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Default.Mic, contentDescription = "Voice", modifier = Modifier.size(18.dp))
}
}
}
}
}
@Composable
private fun AttachmentStrip(
attachments: List<PendingImageAttachment>,
onRemoveAttachment: (String) -> Unit,
) {
Row(modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
attachments.forEach { attachment ->
AttachmentChip(fileName = attachment.fileName, onRemove = { onRemoveAttachment(attachment.id) })
}
}
}
@Composable
private fun AttachmentChip(
fileName: String,
onRemove: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(start = 9.dp, top = 5.dp, end = 5.dp, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(text = fileName, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
Surface(onClick = onRemove, modifier = Modifier.size(22.dp), shape = CircleShape, color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Default.Close, contentDescription = "Remove attachment", modifier = Modifier.size(13.dp))
}
}
}
}
}
private fun currentSessionTitle(
sessionKey: String,
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)
}
@Composable
private fun SendButton(
enabled: Boolean,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
shape = CircleShape,
color = if (enabled) ClawTheme.colors.primary else ClawTheme.colors.surfacePressed,
contentColor = if (enabled) ClawTheme.colors.primaryText else ClawTheme.colors.textSubtle,
border = BorderStroke(1.dp, if (enabled) ClawTheme.colors.primary else ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Send", modifier = Modifier.size(18.dp))
}
}
}
private fun userFacingChatError(error: String): String {
val lower = error.lowercase(Locale.US)
return when {
lower.contains("not connected") -> "Gateway is offline. Open Settings to reconnect."
lower.contains("unauthorized") || lower.contains("auth") -> "Gateway authentication needs attention."
else -> error
}
}
private fun thinkingDisplay(value: String): String =
when (value.lowercase(Locale.US)) {
"low" -> "Low"
"medium" -> "Medium"
"high" -> "High"
else -> "Off"
}
private fun thinkingValue(display: String): String =
when (display.lowercase(Locale.US)) {
"low" -> "low"
"medium" -> "medium"
"high" -> "high"
else -> "off"
}
private fun nextThinkingValue(value: String): String =
when (value.lowercase(Locale.US)) {
"off" -> "low"
"low" -> "medium"
"medium" -> "high"
else -> "off"
}
private fun thinkingMeterWidth(value: String): Float =
when (value.lowercase(Locale.US)) {
"low" -> 0.34f
"medium" -> 0.58f
"high" -> 0.82f
else -> 0.18f
}
private fun contextPercent(value: String): Int = (thinkingMeterWidth(value) * 100).toInt()
private fun formatChatTimestamp(timestampMs: Long): String = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault()).format(Date(timestampMs))
private fun String.hasMarkdownSyntax(): Boolean =
any { it == '#' || it == '*' || it == '`' || it == '[' || it == '|' } ||
contains("\n- ") ||
contains("\n1. ")

View File

@@ -71,16 +71,6 @@ internal suspend fun dispatchPendingAssistantAutoSend(
return dispatch(prompt)
}
internal fun resolveInitialChatLoadSessionKey(
sessionKey: String,
mainSessionKey: String,
): String? {
val current = sessionKey.trim()
val main = mainSessionKey.trim().ifEmpty { "main" }
if (current.isNotEmpty() && current != "main" && current != main) return null
return main
}
@Composable
fun ChatSheetContent(viewModel: MainViewModel) {
val messages by viewModel.chatMessages.collectAsState()
@@ -97,10 +87,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
LaunchedEffect(Unit) {
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
if (loadSessionKey != null) {
viewModel.loadChat(loadSessionKey)
}
viewModel.loadChat(mainSessionKey)
}
LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) {

View File

@@ -1,543 +0,0 @@
package ai.openclaw.app.ui.design
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.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.filled.ChatBubble
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
internal enum class ClawStatus {
Neutral,
Success,
Warning,
Danger,
}
@Composable
internal fun ClawScaffold(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(horizontal = ClawTheme.spacing.lg, vertical = ClawTheme.spacing.lg),
content: @Composable () -> Unit,
) {
Box(
modifier =
modifier
.fillMaxSize()
.background(ClawTheme.colors.canvas)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(contentPadding),
) {
content()
}
}
@Composable
internal fun ClawSectionHeader(
title: String,
modifier: Modifier = Modifier,
action: (@Composable () -> Unit)? = null,
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = title,
style = ClawTheme.type.section,
color = ClawTheme.colors.text,
)
action?.invoke()
}
}
@Composable
internal fun ClawPrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: ImageVector? = null,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget),
shape = RoundedCornerShape(ClawTheme.radii.pill),
colors =
ButtonDefaults.buttonColors(
containerColor = ClawTheme.colors.primary,
contentColor = ClawTheme.colors.primaryText,
disabledContainerColor = ClawTheme.colors.surfacePressed,
disabledContentColor = ClawTheme.colors.textSubtle,
),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp, pressedElevation = 0.dp),
) {
if (icon != null) {
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = text, style = ClawTheme.type.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
internal fun ClawSecondaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
icon: ImageVector? = null,
) {
Surface(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = ClawTheme.spacing.touchTarget),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = if (enabled) ClawTheme.colors.surfaceRaised else ClawTheme.colors.surface,
contentColor = if (enabled) ClawTheme.colors.text else ClawTheme.colors.textSubtle,
border = BorderStroke(1.dp, if (enabled) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
if (icon != null) {
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(7.dp))
}
Text(text = text, style = ClawTheme.type.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
internal fun ClawIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Surface(
onClick = onClick,
enabled = enabled,
modifier = modifier.size(ClawTheme.spacing.touchTarget),
shape = CircleShape,
color = if (enabled) ClawTheme.colors.surfaceRaised else ClawTheme.colors.surface,
contentColor = if (enabled) ClawTheme.colors.text else ClawTheme.colors.textSubtle,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
internal fun ClawStatusPill(
text: String,
status: ClawStatus,
modifier: Modifier = Modifier,
) {
val colors = ClawTheme.colors
val (dotColor, backgroundColor) =
when (status) {
ClawStatus.Neutral -> colors.textSubtle to colors.surfaceRaised
ClawStatus.Success -> colors.success to colors.successSoft
ClawStatus.Warning -> colors.warning to colors.warningSoft
ClawStatus.Danger -> colors.danger to colors.dangerSoft
}
Surface(
modifier = modifier,
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = backgroundColor,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(7.dp),
) {
Box(
modifier =
Modifier
.size(6.dp)
.clip(CircleShape)
.background(dotColor),
)
Text(text = text, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
}
}
}
@Composable
internal fun ClawPill(
text: String,
modifier: Modifier = Modifier,
selected: Boolean = false,
onClick: (() -> Unit)? = null,
) {
val surfaceModifier =
if (onClick == null) {
modifier
} else {
modifier.clickable(onClick = onClick)
}
Surface(
modifier = surfaceModifier,
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = if (selected) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
border = BorderStroke(1.dp, if (selected) ClawTheme.colors.primary else ClawTheme.colors.border),
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = ClawTheme.type.caption,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
internal fun <T> ClawListPanel(
items: List<T>,
modifier: Modifier = Modifier,
row: @Composable (T) -> Unit,
) {
ClawPanel(modifier = modifier, contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
ClawSeparatedColumn(items = items, row = row)
}
}
@Composable
internal fun <T> ClawSeparatedColumn(
items: List<T>,
modifier: Modifier = Modifier,
row: @Composable (T) -> Unit,
) {
Column(modifier = modifier) {
items.forEachIndexed { index, item ->
row(item)
if (index != items.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
@Composable
internal fun ClawDetailRow(
title: String,
subtitle: String,
modifier: Modifier = Modifier,
leading: @Composable () -> Unit,
trailing: @Composable () -> Unit,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.heightIn(min = 52.dp)
.padding(horizontal = 12.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
leading()
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
trailing()
}
}
@Composable
internal fun ClawTextBadge(
text: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.size(30.dp),
shape = CircleShape,
color = ClawTheme.colors.surfacePressed,
border = BorderStroke(1.dp, ClawTheme.colors.border),
contentColor = ClawTheme.colors.text,
) {
Box(contentAlignment = Alignment.Center) {
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
}
}
}
@Composable
internal fun ClawIconBadge(
icon: ImageVector,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.size(30.dp),
shape = CircleShape,
color = ClawTheme.colors.surfacePressed,
border = BorderStroke(1.dp, ClawTheme.colors.border),
contentColor = ClawTheme.colors.text,
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
}
}
}
@Composable
internal fun ClawListItem(
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
metadata: String? = null,
leading: (@Composable () -> Unit)? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: (() -> Unit)? = null,
) {
val rowModifier =
if (onClick == null) {
modifier
} else {
modifier.clickable(onClick = onClick)
}
Row(
modifier =
rowModifier
.fillMaxWidth()
.heightIn(min = ClawTheme.spacing.touchTarget)
.clip(RoundedCornerShape(ClawTheme.radii.row))
.padding(horizontal = 2.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
leading?.invoke()
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = title,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textSubtle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (metadata != null) {
Text(text = metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1)
}
trailing?.invoke()
}
}
@Composable
internal fun ClawSegmentedControl(
options: List<String>,
selected: String,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.clip(RoundedCornerShape(ClawTheme.radii.pill))
.border(1.dp, ClawTheme.colors.border, RoundedCornerShape(ClawTheme.radii.pill))
.padding(2.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
options.forEach { option ->
val active = option == selected
Box(
modifier =
Modifier
.weight(1f)
.clip(RoundedCornerShape(ClawTheme.radii.pill))
.background(if (active) ClawTheme.colors.primary else Color.Transparent)
.clickable { onSelect(option) }
.padding(horizontal = 9.dp, vertical = 7.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = option,
style = ClawTheme.type.caption,
color = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@Composable
internal fun ClawTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
minLines: Int = 1,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier =
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ClawTheme.radii.control))
.background(ClawTheme.colors.surfaceRaised)
.border(1.dp, ClawTheme.colors.border, RoundedCornerShape(ClawTheme.radii.control))
.padding(horizontal = 11.dp, vertical = 8.dp),
textStyle = ClawTheme.type.body.copy(color = ClawTheme.colors.text),
cursorBrush = SolidColor(ClawTheme.colors.primary),
minLines = minLines,
decorationBox = { innerTextField ->
Box(modifier = Modifier.fillMaxWidth()) {
if (value.isEmpty()) {
Text(text = placeholder, style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle)
}
innerTextField()
}
},
)
}
@Composable
internal fun ClawComponentShowcase(modifier: Modifier = Modifier) {
var selected by rememberSaveable { mutableStateOf("Chat") }
var prompt by rememberSaveable { mutableStateOf("") }
ClawScaffold(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
ClawTopBar(
title = "OpenClaw",
subtitle = "Local command center",
navigation = { ClawAvatarMark(text = "OC") },
actions = {
ClawIconButton(icon = Icons.Default.Search, contentDescription = "Search", onClick = {})
},
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = "OpenClaw", style = ClawTheme.type.display, color = ClawTheme.colors.text)
Text(text = "Design system prototype", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
ClawStatusPill(text = "Connected", status = ClawStatus.Success)
}
ClawSegmentedControl(
options = listOf("Chat", "Voice", "Sessions"),
selected = selected,
onSelect = { selected = it },
modifier = Modifier.fillMaxWidth(),
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
ClawSectionHeader(title = "Sessions")
ClawListItem(
title = "Testing testing 1 2 3",
subtitle = "14 messages · Android",
metadata = "now",
)
ClawListItem(
title = "Provider setup",
subtitle = "OpenClaw gateway",
metadata = "8m",
)
}
ClawTextField(value = prompt, onValueChange = { prompt = it }, placeholder = "Ask OpenClaw anything", minLines = 3)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ClawPrimaryButton(text = "Start Chat", onClick = {}, modifier = Modifier.weight(1f))
ClawSecondaryButton(text = "Voice", onClick = {}, modifier = Modifier.weight(1f))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawPill(text = "Realtime", selected = true)
ClawPill(text = "Dictation")
ClawPill(text = "Screen")
}
ClawEmptyState(
title = "Nothing needs your attention",
body = "OpenClaw will surface approvals, failed jobs, and channel issues here.",
)
ClawBottomNav(
items =
listOf(
ClawNavItem(key = "overview", label = "Home", icon = Icons.Default.Home),
ClawNavItem(key = "chat", label = "Chat", icon = Icons.Default.ChatBubble),
ClawNavItem(key = "voice", label = "Voice", icon = Icons.Default.Mic),
ClawNavItem(key = "settings", label = "Settings", icon = Icons.Default.Settings),
),
selectedKey = "chat",
onSelect = {},
)
}
}
}

View File

@@ -1,152 +0,0 @@
package ai.openclaw.app.ui.design
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Immutable
internal data class ClawNavItem(
val key: String,
val label: String,
val icon: ImageVector,
)
@Composable
internal fun ClawTopBar(
title: String,
modifier: Modifier = Modifier,
subtitle: String? = null,
navigation: (@Composable () -> Unit)? = null,
actions: (@Composable () -> Unit)? = null,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = ClawTheme.spacing.lg, vertical = ClawTheme.spacing.sm),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
navigation?.invoke()
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = title,
style = ClawTheme.type.section,
color = ClawTheme.colors.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textSubtle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
actions?.invoke()
}
}
@Composable
internal fun ClawBottomNav(
items: List<ClawNavItem>,
selectedKey: String,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val safeInsets = WindowInsets.navigationBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Bottom)
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),
) {
items.forEach { item ->
ClawBottomNavItem(
item = item,
selected = item.key == selectedKey,
onClick = { onSelect(item.key) },
modifier = Modifier.weight(1f),
)
}
}
}
}
@Composable
private fun ClawBottomNavItem(
item: ClawNavItem,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
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.textSubtle,
) {
Column(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(imageVector = item.icon, contentDescription = item.label, modifier = Modifier.size(18.dp))
Text(text = item.label, style = ClawTheme.type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
internal fun ClawAvatarMark(
text: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.size(38.dp),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
}
}
}

View File

@@ -1,16 +0,0 @@
package ai.openclaw.app.ui.design
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@Preview(
name = "OpenClaw Design System",
showBackground = true,
backgroundColor = 0xFF030303,
)
@Composable
private fun ClawComponentShowcasePreview() {
ClawDesignTheme {
ClawComponentShowcase()
}
}

View File

@@ -1,108 +0,0 @@
package ai.openclaw.app.ui.design
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
internal fun ClawPanel(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(9.dp),
content: @Composable () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(contentPadding)) {
content()
}
}
}
@Composable
internal fun ClawSheetSurface(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(18.dp),
content: @Composable () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
color = ClawTheme.colors.surface,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(contentPadding)) {
content()
}
}
}
@Composable
internal fun ClawEmptyState(
title: String,
body: String,
modifier: Modifier = Modifier,
action: (@Composable () -> Unit)? = null,
) {
ClawPanel(modifier = modifier) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = body, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
action?.invoke()
}
}
}
@Composable
internal fun ClawLoadingState(
title: String,
modifier: Modifier = Modifier,
) {
ClawPanel(modifier = modifier) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
CircularProgressIndicator(color = ClawTheme.colors.primary, strokeWidth = 2.dp)
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
@Composable
internal fun ClawErrorState(
title: String,
body: String,
modifier: Modifier = Modifier,
action: (@Composable () -> Unit)? = null,
) {
ClawPanel(modifier = modifier) {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
ClawStatusPill(text = "Needs attention", status = ClawStatus.Danger)
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = body, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
action?.invoke()
}
}
}

View File

@@ -1,273 +0,0 @@
package ai.openclaw.app.ui.design
import ai.openclaw.app.ui.mobileFontFamily
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Immutable
internal data class ClawColors(
val canvas: Color,
val surface: Color,
val surfaceRaised: Color,
val surfacePressed: Color,
val border: Color,
val borderStrong: Color,
val text: Color,
val textMuted: Color,
val textSubtle: Color,
val primary: Color,
val primaryText: Color,
val success: Color,
val successSoft: Color,
val warning: Color,
val warningSoft: Color,
val danger: Color,
val dangerSoft: Color,
)
@Immutable
internal data class ClawSpacing(
val xxxs: Dp = 4.dp,
val xxs: Dp = 8.dp,
val xs: Dp = 12.dp,
val sm: Dp = 16.dp,
val md: Dp = 20.dp,
val lg: Dp = 24.dp,
val xl: Dp = 32.dp,
val xxl: Dp = 40.dp,
val touchTarget: Dp = 48.dp,
)
@Immutable
internal data class ClawRadii(
val row: Dp = 4.dp,
val panel: Dp = 7.dp,
val control: Dp = 8.dp,
val sheet: Dp = 12.dp,
val pill: Dp = 999.dp,
)
@Immutable
internal data class ClawTypography(
val display: TextStyle,
val title: TextStyle,
val section: TextStyle,
val body: TextStyle,
val label: TextStyle,
val caption: TextStyle,
val mono: TextStyle,
)
private val ClawDarkColors =
ClawColors(
canvas = Color(0xFF030303),
surface = Color(0xFF0A0A0A),
surfaceRaised = Color(0xFF111111),
surfacePressed = Color(0xFF1A1A1A),
border = Color(0xFF242424),
borderStrong = Color(0xFF3A3A3A),
text = Color(0xFFF8F8F8),
textMuted = Color(0xFFA8A8A8),
textSubtle = Color(0xFF707070),
primary = Color(0xFFFFFFFF),
primaryText = Color(0xFF050505),
success = Color(0xFF3EDB82),
successSoft = Color(0xFF102719),
warning = Color(0xFFE6B956),
warningSoft = Color(0xFF2B2412),
danger = Color(0xFFFF6B6B),
dangerSoft = Color(0xFF2C1414),
)
private val ClawLightColors =
ClawColors(
canvas = Color(0xFFF7F7F7),
surface = Color(0xFFFFFFFF),
surfaceRaised = Color(0xFFFFFFFF),
surfacePressed = Color(0xFFEDEDED),
border = Color(0xFFE0E0E0),
borderStrong = Color(0xFFBDBDBD),
text = Color(0xFF070707),
textMuted = Color(0xFF595959),
textSubtle = Color(0xFF8A8A8A),
primary = Color(0xFF050505),
primaryText = Color(0xFFFFFFFF),
success = Color(0xFF157A3E),
successSoft = Color(0xFFEAF8EF),
warning = Color(0xFF9A6A12),
warningSoft = Color(0xFFFFF5DD),
danger = Color(0xFFB42323),
dangerSoft = Color(0xFFFFE9E9),
)
private val LocalClawColors = staticCompositionLocalOf { ClawDarkColors }
private val LocalClawSpacing = staticCompositionLocalOf { ClawSpacing() }
private val LocalClawRadii = staticCompositionLocalOf { ClawRadii() }
private val LocalClawTypography = staticCompositionLocalOf { clawTypography(mobileFontFamily) }
internal object ClawTheme {
val colors: ClawColors
@Composable
@ReadOnlyComposable
get() = LocalClawColors.current
val spacing: ClawSpacing
@Composable
@ReadOnlyComposable
get() = LocalClawSpacing.current
val radii: ClawRadii
@Composable
@ReadOnlyComposable
get() = LocalClawRadii.current
val type: ClawTypography
@Composable
@ReadOnlyComposable
get() = LocalClawTypography.current
}
@Composable
internal fun ClawDesignTheme(
dark: Boolean = true,
content: @Composable () -> Unit,
) {
val colors = if (dark) ClawDarkColors else ClawLightColors
val typography = clawTypography(mobileFontFamily)
CompositionLocalProvider(
LocalClawColors provides colors,
LocalClawSpacing provides ClawSpacing(),
LocalClawRadii provides ClawRadii(),
LocalClawTypography provides typography,
) {
MaterialTheme(
colorScheme = clawMaterialColorScheme(colors, dark),
typography = materialTypography(typography),
shapes = Shapes(),
content = content,
)
}
}
@Composable
internal fun rememberClawDarkPreference(): Boolean = isSystemInDarkTheme()
private fun clawTypography(fontFamily: FontFamily) =
ClawTypography(
display =
TextStyle(
fontFamily = fontFamily,
fontWeight = FontWeight.Bold,
fontSize = 26.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
),
title =
TextStyle(
fontFamily = fontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 25.sp,
letterSpacing = 0.sp,
),
section =
TextStyle(
fontFamily = fontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp,
),
body =
TextStyle(
fontFamily = fontFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 19.sp,
letterSpacing = 0.sp,
),
label =
TextStyle(
fontFamily = fontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 18.sp,
letterSpacing = 0.sp,
),
caption =
TextStyle(
fontFamily = fontFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.5.sp,
lineHeight = 16.sp,
letterSpacing = 0.sp,
),
mono =
TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium,
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = 0.sp,
),
)
private fun materialTypography(type: ClawTypography) =
Typography(
displayMedium = type.display,
titleLarge = type.title,
titleMedium = type.section,
bodyLarge = type.body,
labelLarge = type.label,
labelSmall = type.caption,
)
private fun clawMaterialColorScheme(
colors: ClawColors,
dark: Boolean,
) = if (dark) {
darkColorScheme(
primary = colors.primary,
onPrimary = colors.primaryText,
background = colors.canvas,
onBackground = colors.text,
surface = colors.surface,
onSurface = colors.text,
surfaceVariant = colors.surfaceRaised,
onSurfaceVariant = colors.textMuted,
outline = colors.border,
error = colors.danger,
onError = colors.primaryText,
)
} else {
lightColorScheme(
primary = colors.primary,
onPrimary = colors.primaryText,
background = colors.canvas,
onBackground = colors.text,
surface = colors.surface,
onSurface = colors.text,
surfaceVariant = colors.surfaceRaised,
onSurfaceVariant = colors.textMuted,
outline = colors.border,
error = colors.danger,
onError = colors.primaryText,
)
}

View File

@@ -1,30 +1,29 @@
package ai.openclaw.app.voice
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID
import kotlin.coroutines.coroutineContext
enum class VoiceConversationRole {
User,
@@ -41,13 +40,6 @@ data class VoiceConversationEntry(
class MicCaptureManager(
private val context: Context,
private val scope: CoroutineScope,
private val createTranscriptionSession: suspend () -> String,
private val appendTranscriptionAudio: suspend (
sessionId: String,
audio: ByteArray,
onError: (String) -> Unit,
) -> Unit,
private val closeTranscriptionSession: suspend (sessionId: String) -> Unit,
/**
* Send [message] to the gateway and return the run ID.
* [onRunIdKnown] is called with the idempotency key *before* the network
@@ -58,15 +50,15 @@ class MicCaptureManager(
) {
companion object {
private const val tag = "MicCapture"
private const val transcriptionSampleRateHz = 8_000
private const val transcriptionAudioFrameMs = 100
private const val pcmuBias = 0x84
private const val pcmuClip = 32635
private const val speechMinSessionMs = 30_000L
private const val speechCompleteSilenceMs = 1_500L
private const val speechPossibleSilenceMs = 900L
private const val transcriptIdleFlushMs = 1_600L
private const val maxConversationEntries = 40
private const val pendingRunTimeoutMs = 45_000L
}
private val mainHandler = Handler(Looper.getMainLooper())
private val json = Json { ignoreUnknownKeys = true }
private val _micEnabled = MutableStateFlow(false)
@@ -103,11 +95,9 @@ class MicCaptureManager(
private var pendingAssistantEntryId: String? = null
private var gatewayConnected = false
@Volatile private var transcriptionSessionId: String? = null
private var transcriptionStartJob: Job? = null
private var transcriptionCaptureJob: Job? = null
private var transcriptionAppendJob: Job? = null
private var transcriptionDrainJob: Job? = null
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var drainJob: Job? = null
private var transcriptFlushJob: Job? = null
private var pendingRunTimeoutJob: Job? = null
private var stopRequested = false
@@ -163,47 +153,40 @@ class MicCaptureManager(
_statusText.value = if (_isSending.value) "Speaking · waiting for reply" else "Speaking…"
return
}
transcriptionDrainJob?.cancel()
transcriptionDrainJob = null
_micCooldown.value = false
start()
sendQueuedIfIdle()
} else {
transcriptionDrainJob?.cancel()
// Give the recognizer time to finish processing buffered audio.
// Cancel any prior drain to prevent duplicate sends on rapid toggle.
drainJob?.cancel()
_micCooldown.value = true
transcriptionDrainJob =
drainJob =
scope.launch {
delay(2000L)
stop()
// Capture any partial transcript that didn't get a final result from the recognizer
val partial = _liveTranscript.value?.trim().orEmpty()
if (partial.isNotEmpty()) {
queueRecognizedMessage(partial)
}
transcriptionDrainJob = null
drainJob = null
_micCooldown.value = false
sendQueuedIfIdle()
}
}
}
fun cancelMicCapture() {
transcriptionDrainJob?.cancel()
transcriptionDrainJob = null
_micEnabled.value = false
_micCooldown.value = false
_liveTranscript.value = null
stop()
}
suspend fun pauseForTts() {
val shouldPause =
synchronized(ttsPauseLock) {
ttsPauseDepth += 1
if (ttsPauseDepth > 1) return@synchronized false
resumeMicAfterTts = _micEnabled.value
val active = resumeMicAfterTts || transcriptionSessionId != null || _isListening.value
val active = resumeMicAfterTts || recognizer != null || _isListening.value
if (!active) return@synchronized false
stopRequested = true
restartJob?.cancel()
restartJob = null
transcriptFlushJob?.cancel()
transcriptFlushJob = null
_isListening.value = false
@@ -213,7 +196,11 @@ class MicCaptureManager(
true
}
if (!shouldPause) return
stopTranscription(preserveStatus = true)
withContext(Dispatchers.Main) {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
}
suspend fun resumeAfterTts() {
@@ -244,14 +231,9 @@ class MicCaptureManager(
fun onGatewayConnectionChanged(connected: Boolean) {
gatewayConnected = connected
if (connected) {
if (_micEnabled.value && transcriptionSessionId == null) {
start()
}
sendQueuedIfIdle()
return
}
stopRequested = true
stopTranscription(preserveStatus = true)
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
pendingRunId = null
@@ -266,10 +248,6 @@ class MicCaptureManager(
event: String,
payloadJson: String?,
) {
if (event == "talk.event") {
handleTranscriptionEvent(payloadJson)
return
}
if (event != "chat") return
if (payloadJson.isNullOrBlank()) return
val payload =
@@ -326,96 +304,93 @@ class MicCaptureManager(
private fun start() {
stopRequested = false
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
_statusText.value = "Speech recognizer unavailable"
_micEnabled.value = false
return
}
if (!hasMicPermission()) {
_statusText.value = "Microphone permission required"
_micEnabled.value = false
return
}
if (!gatewayConnected) {
_statusText.value = "Mic on · waiting for gateway"
return
}
if (transcriptionSessionId != null || transcriptionStartJob?.isActive == true) return
val startJob =
scope.launch {
var restartAfterCancellation = false
try {
val sessionId = createTranscriptionSession()
if (stopRequested || !_micEnabled.value) {
closeTranscriptionSession(sessionId)
return@launch
}
transcriptionSessionId = sessionId
_isListening.value = true
_statusText.value = listeningStatus()
startTranscriptionCapture(sessionId)
Log.d(tag, "transcription session started sessionId=$sessionId")
} catch (err: Throwable) {
if (err is CancellationException) {
restartAfterCancellation = _micEnabled.value && gatewayConnected && !stopRequested
return@launch
}
_statusText.value = "Transcription unavailable: ${err.message ?: err::class.simpleName}"
_micEnabled.value = false
stopTranscription(preserveStatus = true)
} finally {
if (transcriptionStartJob === coroutineContext[Job]) {
transcriptionStartJob = null
}
if (restartAfterCancellation) {
start()
}
mainHandler.post {
try {
if (recognizer == null) {
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
}
startListeningSession()
} catch (err: Throwable) {
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
_micEnabled.value = false
}
transcriptionStartJob = startJob
}
}
private fun stop() {
stopRequested = true
stopTranscription()
}
private fun stopTranscription(preserveStatus: Boolean = false) {
val status = _statusText.value
val sessionId = transcriptionSessionId
transcriptionSessionId = null
if (sessionId != null) {
transcriptionStartJob?.cancel()
transcriptionStartJob = null
} else if (transcriptionStartJob?.isActive != true) {
transcriptionStartJob = null
}
transcriptionCaptureJob?.cancel()
transcriptionAppendJob?.cancel()
transcriptionCaptureJob = null
transcriptionAppendJob = null
restartJob?.cancel()
restartJob = null
transcriptFlushJob?.cancel()
transcriptFlushJob = null
_isListening.value = false
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
_inputLevel.value = 0f
if (!preserveStatus) {
_statusText.value = if (_isSending.value) "Mic off · sending…" else "Mic off"
} else {
_statusText.value = status
mainHandler.post {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
if (!sessionId.isNullOrBlank()) {
}
private fun startListeningSession() {
val recognizerInstance = recognizer ?: return
val intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_MINIMUM_LENGTH_MILLIS, speechMinSessionMs)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, speechCompleteSilenceMs)
putExtra(
RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS,
speechPossibleSilenceMs,
)
}
_statusText.value =
when {
_isSending.value -> "Listening · sending queued voice"
hasQueuedMessages() -> "Listening · ${queuedMessageCount()} queued"
else -> "Listening"
}
_isListening.value = true
recognizerInstance.startListening(intent)
}
private fun scheduleRestart(delayMs: Long = 300L) {
if (stopRequested) return
if (!_micEnabled.value) return
restartJob?.cancel()
restartJob =
scope.launch {
try {
closeTranscriptionSession(sessionId)
} catch (err: Throwable) {
if (err !is CancellationException) {
Log.d(tag, "transcription close ignored: ${err.message ?: err::class.simpleName}")
delay(delayMs)
mainHandler.post {
if (stopRequested || !_micEnabled.value) return@post
try {
startListeningSession()
} catch (_: Throwable) {
// retry through onError
}
}
}
}
}
private fun queueRecognizedMessage(text: String) {
val message = text.trim()
_liveTranscript.value = null
if (!message.hasTranscriptContent()) return
if (message.isEmpty()) return
appendConversation(
role = VoiceConversationRole.User,
text = message,
@@ -597,208 +572,21 @@ class MicCaptureManager(
}
}
@SuppressLint("MissingPermission")
private fun startTranscriptionCapture(sessionId: String) {
transcriptionCaptureJob?.cancel()
transcriptionAppendJob?.cancel()
val audioFrames =
Channel<ByteArray>(
capacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
transcriptionAppendJob =
scope.launch(Dispatchers.IO) {
for (frame in audioFrames) {
if (transcriptionSessionId != sessionId) continue
try {
appendTranscriptionAudio(sessionId, pcm16ToPcmu(frame)) { message ->
failTranscription(sessionId, message)
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
failTranscription(sessionId, err.message ?: err::class.simpleName ?: "request failed")
}
}
}
transcriptionCaptureJob =
scope.launch(Dispatchers.IO) {
var audioRecord: AudioRecord? = null
try {
val frameBytes = transcriptionSampleRateHz * 2 * transcriptionAudioFrameMs / 1000
val minBuffer =
AudioRecord.getMinBufferSize(
transcriptionSampleRateHz,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
if (minBuffer <= 0) {
throw IllegalStateException("AudioRecord buffer unavailable")
}
audioRecord =
AudioRecord
.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(transcriptionSampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
.build()
val buffer = ByteArray(frameBytes)
audioRecord.startRecording()
while (coroutineContext.isActive && _micEnabled.value && transcriptionSessionId == sessionId) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read <= 0) continue
_inputLevel.value = pcm16Level(buffer, read)
audioFrames.trySend(buffer.copyOf(read))
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
failTranscription(sessionId, err.message ?: err::class.simpleName ?: "capture failed")
} finally {
audioFrames.close()
audioRecord?.let { record ->
try {
record.stop()
} catch (_: Throwable) {
}
record.release()
}
}
}
}
private fun handleTranscriptionEvent(payloadJson: String?) {
if (payloadJson.isNullOrBlank()) return
val obj =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return
val sessionId = obj["transcriptionSessionId"].asStringOrNull() ?: obj["sessionId"].asStringOrNull()
val currentSessionId = transcriptionSessionId
if (currentSessionId == null || sessionId != currentSessionId) return
when (obj["type"].asStringOrNull()) {
"ready", "inputAudio", "speechStart" -> {
_isListening.value = true
_statusText.value = listeningStatus()
}
"partial" -> {
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
if (text.isNotEmpty()) {
_liveTranscript.value = text
scheduleTranscriptFlush(text)
}
}
"transcript" -> {
transcriptFlushJob?.cancel()
transcriptFlushJob = null
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
if (text.isNotEmpty()) {
if (text != flushedPartialTranscript) {
queueRecognizedMessage(text)
sendQueuedIfIdle()
} else {
flushedPartialTranscript = null
_liveTranscript.value = null
}
}
}
"error" -> {
val message =
obj["message"]
.asStringOrNull()
?.trim()
.orEmpty()
.ifEmpty { "transcription failed" }
failTranscription(currentSessionId, message)
}
"close" -> {
_micEnabled.value = false
stopTranscription()
}
}
}
private fun failTranscription(
sessionId: String,
message: String,
) {
if (transcriptionSessionId != sessionId) return
_statusText.value = "Transcription failed: $message"
private fun disableMic(status: String) {
stopRequested = true
restartJob?.cancel()
restartJob = null
transcriptFlushJob?.cancel()
transcriptFlushJob = null
_micEnabled.value = false
stopTranscription(preserveStatus = true)
}
private fun listeningStatus(): String =
when {
_isSending.value -> "Listening · sending queued voice"
hasQueuedMessages() -> "Listening · ${queuedMessageCount()} queued"
else -> "Listening"
_isListening.value = false
_inputLevel.value = 0f
_statusText.value = status
mainHandler.post {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
private fun pcm16Level(
frame: ByteArray,
length: Int,
): Float {
var total = 0L
var count = 0
var index = 0
val limit = length - (length % 2)
while (index < limit) {
val sample =
(frame[index].toInt() and 0xff) or
(frame[index + 1].toInt() shl 8)
total += kotlin.math.abs(sample.toShort().toInt())
count += 1
index += 2
}
if (count == 0) return 0f
return ((total / count).toFloat() / Short.MAX_VALUE).coerceIn(0f, 1f)
}
private fun pcm16ToPcmu(pcm16: ByteArray): ByteArray {
val output = ByteArray(pcm16.size / 2)
var inputIndex = 0
var outputIndex = 0
while (inputIndex + 1 < pcm16.size) {
val sample =
(
(pcm16[inputIndex].toInt() and 0xff) or
(pcm16[inputIndex + 1].toInt() shl 8)
).toShort().toInt()
output[outputIndex] = linear16ToPcmu(sample)
inputIndex += 2
outputIndex += 1
}
return output
}
private fun linear16ToPcmu(sample: Int): Byte {
var sign = 0
var magnitude = sample
if (magnitude < 0) {
sign = 0x80
magnitude = -magnitude
}
if (magnitude > pcmuClip) {
magnitude = pcmuClip
}
magnitude += pcmuBias
var exponent = 7
var mask = 0x4000
while ((magnitude and mask) == 0 && exponent > 0) {
exponent -= 1
mask = mask shr 1
}
val mantissa = (magnitude shr (exponent + 3)) and 0x0f
return (sign or (exponent shl 4) or mantissa).inv().toByte()
}
private fun hasMicPermission(): Boolean =
@@ -808,10 +596,103 @@ class MicCaptureManager(
)
private fun parseAssistantText(payload: JsonObject): String? = ChatEventText.assistantTextFromPayload(payload)
private val listener =
object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
_isListening.value = true
}
override fun onBeginningOfSpeech() {}
override fun onRmsChanged(rmsdB: Float) {
val level = ((rmsdB + 2f) / 12f).coerceIn(0f, 1f)
_inputLevel.value = level
}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEndOfSpeech() {
_inputLevel.value = 0f
scheduleRestart()
}
override fun onError(error: Int) {
if (stopRequested) return
_isListening.value = false
_inputLevel.value = 0f
val status =
when (error) {
SpeechRecognizer.ERROR_AUDIO -> "Audio error"
SpeechRecognizer.ERROR_CLIENT -> "Client error"
SpeechRecognizer.ERROR_NETWORK -> "Network error"
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
SpeechRecognizer.ERROR_NO_MATCH -> "Listening"
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
SpeechRecognizer.ERROR_SERVER -> "Server error"
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Listening"
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required"
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED -> "Language not supported on this device"
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE -> "Language unavailable on this device"
SpeechRecognizer.ERROR_SERVER_DISCONNECTED -> "Speech service disconnected"
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> "Speech requests limited; retrying"
else -> "Speech error ($error)"
}
_statusText.value = status
if (
error == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ||
error == SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED ||
error == SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE
) {
disableMic(status)
return
}
val restartDelayMs =
when (error) {
SpeechRecognizer.ERROR_NO_MATCH,
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
-> 1_200L
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS -> 2_500L
else -> 600L
}
scheduleRestart(delayMs = restartDelayMs)
}
override fun onResults(results: Bundle?) {
transcriptFlushJob?.cancel()
transcriptFlushJob = null
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
if (!text.isNullOrBlank()) {
val trimmed = text.trim()
if (trimmed != flushedPartialTranscript) {
queueRecognizedMessage(trimmed)
sendQueuedIfIdle()
} else {
flushedPartialTranscript = null
_liveTranscript.value = null
}
}
scheduleRestart()
}
override fun onPartialResults(partialResults: Bundle?) {
val text = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION).orEmpty().firstOrNull()
if (!text.isNullOrBlank()) {
val trimmed = text.trim()
_liveTranscript.value = trimmed
scheduleTranscriptFlush(trimmed)
}
}
override fun onEvent(
eventType: Int,
params: Bundle?,
) {}
}
}
private fun kotlinx.serialization.json.JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun String.hasTranscriptContent(): Boolean = any { it.isLetterOrDigit() }

View File

@@ -11,14 +11,8 @@ internal data class TalkModeGatewayConfigState(
val mainSessionKey: String,
val interruptOnSpeech: Boolean?,
val silenceTimeoutMs: Long,
val executionMode: TalkModeExecutionMode,
)
internal enum class TalkModeExecutionMode {
Native,
RealtimeRelay,
}
internal object TalkModeGatewayConfigParser {
fun parse(config: JsonObject?): TalkModeGatewayConfigState {
val talk = config?.get("talk").asObjectOrNull()
@@ -27,22 +21,9 @@ internal object TalkModeGatewayConfigParser {
mainSessionKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()),
interruptOnSpeech = talk?.get("interruptOnSpeech").asBooleanOrNull(),
silenceTimeoutMs = resolvedSilenceTimeoutMs(talk),
executionMode = resolvedExecutionMode(talk),
)
}
fun resolvedExecutionMode(talk: JsonObject?): TalkModeExecutionMode {
val realtime = talk?.get("realtime").asObjectOrNull() ?: return TalkModeExecutionMode.Native
val mode = realtime["mode"].asStringOrNull()
val transport = realtime["transport"].asStringOrNull()
val brain = realtime["brain"].asStringOrNull()
return if (mode == "realtime" && transport == "gateway-relay" && (brain == null || brain == "agent-consult")) {
TalkModeExecutionMode.RealtimeRelay
} else {
TalkModeExecutionMode.Native
}
}
fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long {
val fallback = TalkDefaults.defaultSilenceTimeoutMs
val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback

View File

@@ -2,17 +2,12 @@ package ai.openclaw.app.voice
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -22,7 +17,6 @@ import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Base64
import android.util.Log
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
@@ -31,23 +25,17 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.LinkedHashMap
import java.util.Locale
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
@@ -74,16 +62,6 @@ data class TalkPttStopPayload(
}.toString()
}
internal data class RealtimeToolRun(
val callId: String,
val relaySessionId: String,
)
private data class RealtimeToolCompletion(
val state: String,
val messageEl: JsonElement?,
)
class TalkModeManager internal constructor(
private val context: Context,
private val scope: CoroutineScope,
@@ -92,19 +70,15 @@ class TalkModeManager internal constructor(
private val isConnected: () -> Boolean,
private val onBeforeSpeak: suspend () -> Unit = {},
private val onAfterSpeak: suspend () -> Unit = {},
private val onStoppedByRelay: () -> Unit = {},
private val talkSpeakClient: TalkSpeechSynthesizing = TalkSpeakClient(session = session),
private val talkAudioPlayer: TalkAudioPlaying = TalkAudioPlayer(context),
) {
companion object {
private const val tag = "TalkMode"
private const val realtimeSampleRateHz = 24_000
private const val realtimeAudioFrameMs = 100
private const val listenWatchdogMs = 12_000L
private const val chatFinalWaitWithSubscribeMs = 45_000L
private const val chatFinalWaitWithoutSubscribeMs = 6_000L
private const val maxCachedRunCompletions = 128
private const val maxConversationEntries = 40
}
private val mainHandler = Handler(Looper.getMainLooper())
@@ -124,9 +98,6 @@ class TalkModeManager internal constructor(
private val _lastAssistantText = MutableStateFlow<String?>(null)
val lastAssistantText: StateFlow<String?> = _lastAssistantText
private val _conversation = MutableStateFlow<List<VoiceConversationEntry>>(emptyList())
val conversation: StateFlow<List<VoiceConversationEntry>> = _conversation
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var stopRequested = false
@@ -155,29 +126,8 @@ class TalkModeManager internal constructor(
private val completedRunTexts = LinkedHashMap<String, String>()
private var chatSubscribedSessionKey: String? = null
private var configLoaded = false
private var executionMode = TalkModeExecutionMode.Native
private val startGeneration = AtomicLong(0L)
@Volatile private var realtimeSessionId: String? = null
private var realtimeCaptureJob: Job? = null
private var realtimeAppendJob: Job? = null
private val realtimeToolRuns = LinkedHashMap<String, RealtimeToolRun>()
private val pendingRealtimeToolCalls = LinkedHashSet<String>()
private val pendingRealtimeToolCompletions = LinkedHashMap<String, RealtimeToolCompletion>()
private var realtimeUserEntryId: String? = null
private var realtimeAssistantEntryId: String? = null
private val realtimePlaybackLock = Any()
private var realtimeAudioTrack: AudioTrack? = null
private var realtimePlaybackIdleJob: Job? = null
@Volatile
private var realtimePlaybackEndsAtMs = 0L
@Volatile
private var realtimeOutputSuppressed = false
@Volatile
private var playbackEnabled = true
@Volatile private var playbackEnabled = true
private val playbackGeneration = AtomicLong(0L)
private var ttsJob: Job? = null
@@ -406,10 +356,6 @@ class TalkModeManager internal constructor(
event: String,
payloadJson: String?,
) {
if (event == "talk.event") {
handleRealtimeTalkEvent(payloadJson)
return
}
if (ttsOnAllResponses) {
Log.d(tag, "gateway event: $event")
}
@@ -433,13 +379,6 @@ class TalkModeManager internal constructor(
val activeSession = mainSessionKey.ifBlank { "main" }
if (eventSession != null && eventSession != activeSession) return
if (maybeCompleteRealtimeToolCall(runId = runId, state = state, messageEl = obj["message"])) {
return
}
if (holdPendingRealtimeToolCompletion(runId = runId, state = state, messageEl = obj["message"])) {
return
}
// If this is a response we initiated, handle normally below.
// Otherwise, if ttsOnAllResponses, finish streaming TTS on terminal events.
val pending = pendingRunId
@@ -484,7 +423,6 @@ class TalkModeManager internal constructor(
if (playbackEnabled == enabled) return
playbackEnabled = enabled
if (!enabled) {
stopRealtimePlayback()
stopSpeaking()
}
}
@@ -504,43 +442,16 @@ class TalkModeManager internal constructor(
}
private fun start() {
if (realtimeSessionId != null || realtimeCaptureJob?.isActive == true) return
val generation = startGeneration.incrementAndGet()
stopRequested = false
listeningMode = true
Log.d(tag, "start")
scope.launch {
try {
ensureConfigLoaded()
if (generation != startGeneration.get() || !_isEnabled.value || stopRequested) return@launch
if (executionMode == TalkModeExecutionMode.RealtimeRelay) {
startRealtimeRelay(generation)
} else {
startNativeRecognition(generation)
}
} catch (err: Throwable) {
if (err is CancellationException) return@launch
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}")
if (executionMode == TalkModeExecutionMode.RealtimeRelay) {
stopRealtimeRelay(closeSession = false, preserveStatus = true)
disableRealtimeModeAndNotifyOwner()
}
}
}
}
private suspend fun startNativeRecognition(generation: Long) {
withContext(Dispatchers.Main) {
if (generation != startGeneration.get()) return@withContext
if (!_isEnabled.value || stopRequested) return@withContext
if (_isListening.value) return@withContext
Log.d(tag, "start native")
mainHandler.post {
if (_isListening.value) return@post
stopRequested = false
listeningMode = true
Log.d(tag, "start")
if (!SpeechRecognizer.isRecognitionAvailable(context)) {
_statusText.value = "Speech recognizer unavailable"
Log.w(tag, "speech recognizer unavailable")
return@withContext
return@post
}
val micOk =
@@ -549,14 +460,19 @@ class TalkModeManager internal constructor(
if (!micOk) {
_statusText.value = "Microphone permission required"
Log.w(tag, "microphone permission required")
return@withContext
return@post
}
recognizer?.destroy()
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
startListeningInternal(markListening = true)
startSilenceMonitor()
Log.d(tag, "listening")
try {
recognizer?.destroy()
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also { it.setRecognitionListener(listener) }
startListeningInternal(markListening = true)
startSilenceMonitor()
Log.d(tag, "listening")
} catch (err: Throwable) {
_statusText.value = "Start failed: ${err.message ?: err::class.simpleName}"
Log.w(tag, "start failed: ${err.message ?: err::class.simpleName}")
}
}
}
@@ -568,7 +484,6 @@ class TalkModeManager internal constructor(
pttAutoStopEnabled = false
pttCompletion?.cancel()
pttCompletion = null
startGeneration.incrementAndGet()
pttTimeoutJob?.cancel()
pttTimeoutJob = null
restartJob?.cancel()
@@ -579,7 +494,6 @@ class TalkModeManager internal constructor(
lastHeardAtMs = null
_isListening.value = false
_statusText.value = "Off"
stopRealtimeRelay()
stopSpeaking()
chatSubscribedSessionKey = null
pendingRunId = null
@@ -598,565 +512,6 @@ class TalkModeManager internal constructor(
shutdownTextToSpeech()
}
private suspend fun startRealtimeRelay(generation: Long) {
if (!isConnected()) {
_statusText.value = "Gateway not connected"
Log.w(tag, "realtime start: gateway not connected")
disableRealtimeModeAndNotifyOwner()
return
}
val micOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
if (!micOk) {
_statusText.value = "Microphone permission required"
Log.w(tag, "realtime start: microphone permission required")
disableRealtimeModeAndNotifyOwner()
return
}
ensureConfigLoaded()
cancelActivePlayback()
stopTextToSpeechPlayback()
withContext(Dispatchers.Main) {
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
_statusText.value = "Connecting…"
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
put("mode", JsonPrimitive("realtime"))
put("transport", JsonPrimitive("gateway-relay"))
put("brain", JsonPrimitive("agent-consult"))
}
val payload = session.request("talk.session.create", params.toString(), timeoutMs = 15_000)
val root = json.parseToJsonElement(payload).asObjectOrNull()
val relaySession = root?.get("relaySessionId").asStringOrNull()
val sessionId = relaySession ?: root?.get("sessionId").asStringOrNull()
if (sessionId.isNullOrBlank()) {
throw IllegalStateException("talk.session.create returned no session id")
}
if (generation != startGeneration.get() || !_isEnabled.value || stopRequested) {
closeRealtimeSession(sessionId)
throw CancellationException("realtime talk stopped while connecting")
}
realtimeSessionId = sessionId
realtimeOutputSuppressed = false
_isListening.value = true
_statusText.value = "Listening"
startRealtimeCapture(sessionId)
Log.d(tag, "realtime session started relaySessionId=$sessionId")
}
private fun disableRealtimeModeAndNotifyOwner() {
if (!_isEnabled.value) return
_isEnabled.value = false
_isListening.value = false
onStoppedByRelay()
}
private fun failRealtimeRelay(
sessionId: String,
message: String,
) {
if (realtimeSessionId != sessionId) return
_statusText.value = "Talk failed: $message"
stopRealtimeRelay(cancelCapture = false, cancelAppend = false, preserveStatus = true)
disableRealtimeModeAndNotifyOwner()
}
@SuppressLint("MissingPermission")
private fun startRealtimeCapture(sessionId: String) {
realtimeCaptureJob?.cancel()
realtimeAppendJob?.cancel()
val audioFrames =
Channel<ByteArray>(
capacity = 4,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
realtimeAppendJob =
scope.launch(Dispatchers.IO) {
for (frame in audioFrames) {
if (realtimeSessionId != sessionId) continue
if (isRealtimePlaybackActive()) continue
val audioBase64 = Base64.encodeToString(frame, Base64.NO_WRAP)
val params =
buildJsonObject {
put("sessionId", JsonPrimitive(sessionId))
put("audioBase64", JsonPrimitive(audioBase64))
put("timestamp", JsonPrimitive(SystemClock.elapsedRealtime()))
}
try {
session.sendRequestFrame(
"talk.session.appendAudio",
params.toString(),
timeoutMs = 8_000,
) { error ->
Log.w(tag, "realtime appendAudio failed: ${error.message}")
failRealtimeRelay(sessionId, error.message)
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime appendAudio failed: ${err.message ?: err::class.simpleName}")
failRealtimeRelay(sessionId, err.message ?: err::class.simpleName ?: "request failed")
}
}
}
realtimeCaptureJob =
scope.launch(Dispatchers.IO) {
var audioRecord: AudioRecord? = null
try {
val frameBytes = realtimeSampleRateHz * 2 * realtimeAudioFrameMs / 1000
val minBuffer =
AudioRecord.getMinBufferSize(
realtimeSampleRateHz,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
if (minBuffer <= 0) {
throw IllegalStateException("AudioRecord buffer unavailable")
}
audioRecord =
AudioRecord
.Builder()
.setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
.setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(realtimeSampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
).setBufferSizeInBytes(maxOf(minBuffer, frameBytes * 4))
.build()
val buffer = ByteArray(frameBytes)
audioRecord.startRecording()
while (coroutineContext.isActive && _isEnabled.value && realtimeSessionId == sessionId) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read <= 0) continue
if (!shouldAppendRealtimeCapturedFrame(read)) continue
audioFrames.trySend(buffer.copyOf(read))
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime capture failed: ${err.message ?: err::class.simpleName}")
failRealtimeRelay(sessionId, err.message ?: err::class.simpleName ?: "capture failed")
} finally {
audioFrames.close()
audioRecord?.let { record ->
try {
record.stop()
} catch (_: Throwable) {
}
record.release()
}
}
}
}
private fun shouldAppendRealtimeCapturedFrame(length: Int): Boolean = !isRealtimePlaybackActive() && length > 0
private fun isRealtimePlaybackActive(): Boolean = _isSpeaking.value || SystemClock.elapsedRealtime() < realtimePlaybackEndsAtMs
private fun handleRealtimeTalkEvent(payloadJson: String?) {
if (payloadJson.isNullOrBlank()) return
val obj =
try {
json.parseToJsonElement(payloadJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return
val sessionId = obj["relaySessionId"].asStringOrNull() ?: obj["sessionId"].asStringOrNull()
val currentSessionId = realtimeSessionId
if (currentSessionId == null || sessionId != currentSessionId) return
when (val type = obj["type"].asStringOrNull()) {
"ready" -> {
_isListening.value = true
_statusText.value = "Listening"
}
"inputAudio" -> {
_isListening.value = true
}
"audio" -> {
if (realtimeOutputSuppressed) return
val audioBase64 = obj["audioBase64"].asStringOrNull() ?: return
val bytes =
try {
Base64.decode(audioBase64, Base64.DEFAULT)
} catch (err: Throwable) {
Log.w(tag, "realtime audio decode failed: ${err.message ?: err::class.simpleName}")
return
}
playRealtimeAudio(bytes)
}
"clear" -> stopRealtimePlayback()
"mark" -> Unit
"transcript" -> {
val role = obj["role"].asStringOrNull()
val text = obj["text"].asStringOrNull()?.trim().orEmpty()
val isFinal = obj["final"].asBooleanOrNull() == true
if (text.isNotEmpty()) {
when (role) {
"user" -> upsertRealtimeConversation(VoiceConversationRole.User, text, isFinal)
"assistant" -> upsertRealtimeConversation(VoiceConversationRole.Assistant, text, isFinal)
}
}
if (role == "assistant" && text.isNotEmpty()) {
_lastAssistantText.value = text
}
if (isFinal && role == "user") {
realtimeOutputSuppressed = false
_statusText.value = "Thinking…"
} else if (isFinal && role == "assistant") {
scheduleRealtimePlaybackIdle()
}
}
"toolCall" -> {
val callId = obj["callId"].asStringOrNull() ?: return
val name = obj["name"].asStringOrNull() ?: return
handleRealtimeToolCall(
callId = callId,
name = name,
args = obj["args"],
)
}
"toolResult" -> Unit
"error" -> {
val message = obj["message"].asStringOrNull() ?: "realtime talk error"
_statusText.value = "Talk failed: $message"
Log.w(tag, "realtime error: $message")
}
"close" -> {
Log.d(tag, "realtime close reason=${obj["reason"].asStringOrNull()}")
stopRealtimeRelay(closeSession = false)
if (_isEnabled.value) {
_isEnabled.value = false
_statusText.value = "Off"
onStoppedByRelay()
}
}
else -> {
if (type != null) Log.d(tag, "ignored realtime event type=$type")
}
}
}
private fun playRealtimeAudio(bytes: ByteArray) {
if (!playbackEnabled || realtimeOutputSuppressed || bytes.isEmpty()) return
synchronized(realtimePlaybackLock) {
val track =
realtimeAudioTrack ?: run {
val minBuffer =
AudioTrack.getMinBufferSize(
realtimeSampleRateHz,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
)
val created =
AudioTrack
.Builder()
.setAudioAttributes(
AudioAttributes
.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
).setAudioFormat(
AudioFormat
.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(realtimeSampleRateHz)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
).setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(maxOf(minBuffer, bytes.size * 4))
.build()
created.play()
realtimeAudioTrack = created
created
}
_isSpeaking.value = true
_statusText.value = "Speaking…"
track.write(bytes, 0, bytes.size)
val durationMs = ((bytes.size / 2.0) / realtimeSampleRateHz * 1000.0).toLong()
val now = SystemClock.elapsedRealtime()
realtimePlaybackEndsAtMs = maxOf(now, realtimePlaybackEndsAtMs) + durationMs
scheduleRealtimePlaybackIdle()
}
}
private fun scheduleRealtimePlaybackIdle() {
realtimePlaybackIdleJob?.cancel()
val delayMs = maxOf(0L, realtimePlaybackEndsAtMs - SystemClock.elapsedRealtime())
realtimePlaybackIdleJob =
scope.launch {
delay(delayMs)
val idle =
synchronized(realtimePlaybackLock) {
val playbackIdle = SystemClock.elapsedRealtime() >= realtimePlaybackEndsAtMs
if (playbackIdle) _isSpeaking.value = false
playbackIdle
}
if (idle && _isEnabled.value && realtimeSessionId != null) {
_statusText.value = "Listening"
}
}
}
private fun stopRealtimePlayback() {
realtimePlaybackIdleJob?.cancel()
realtimePlaybackIdleJob = null
realtimePlaybackEndsAtMs = 0L
synchronized(realtimePlaybackLock) {
realtimeAudioTrack?.let { track ->
try {
track.pause()
track.flush()
track.stop()
} catch (_: Throwable) {
}
track.release()
}
realtimeAudioTrack = null
}
_isSpeaking.value = false
if (_isEnabled.value) {
_statusText.value = "Listening"
}
}
private fun stopRealtimeRelay(
closeSession: Boolean = true,
cancelCapture: Boolean = true,
cancelAppend: Boolean = true,
preserveStatus: Boolean = false,
) {
val status = _statusText.value
val sessionId = realtimeSessionId
realtimeSessionId = null
realtimeOutputSuppressed = false
if (cancelCapture) {
realtimeCaptureJob?.cancel()
}
if (cancelAppend) {
realtimeAppendJob?.cancel()
}
realtimeCaptureJob = null
realtimeAppendJob = null
realtimeToolRuns.clear()
pendingRealtimeToolCalls.clear()
pendingRealtimeToolCompletions.clear()
realtimeUserEntryId = null
realtimeAssistantEntryId = null
stopRealtimePlayback()
if (preserveStatus) {
_statusText.value = status
}
_isListening.value = false
if (closeSession && !sessionId.isNullOrBlank()) {
scope.launch {
closeRealtimeSession(sessionId)
}
}
}
private suspend fun closeRealtimeSession(sessionId: String) {
try {
val params = buildJsonObject { put("sessionId", JsonPrimitive(sessionId)) }
session.request("talk.session.close", params.toString(), timeoutMs = 5_000)
} catch (err: Throwable) {
if (err !is CancellationException) {
Log.d(tag, "realtime close ignored: ${err.message ?: err::class.simpleName}")
}
}
}
private fun handleRealtimeToolCall(
callId: String,
name: String,
args: JsonElement?,
) {
val relaySessionId = realtimeSessionId ?: return
pendingRealtimeToolCalls.add(callId)
scope.launch {
try {
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
put("callId", JsonPrimitive(callId))
put("name", JsonPrimitive(name))
put("relaySessionId", JsonPrimitive(relaySessionId))
if (args != null) put("args", args)
}
val response =
session.request("talk.client.toolCall", params.toString(), timeoutMs = 15_000)
val runId = parseRunId(response)
if (!runId.isNullOrBlank()) {
if (realtimeSessionId != relaySessionId) return@launch
realtimeToolRuns[runId] =
RealtimeToolRun(callId = callId, relaySessionId = relaySessionId)
val completion = pendingRealtimeToolCompletions.remove(runId)
if (completion != null) {
maybeCompleteRealtimeToolCall(
runId = runId,
state = completion.state,
messageEl = completion.messageEl,
)
} else {
_statusText.value = "Thinking…"
}
} else {
submitRealtimeToolError(callId, "tool call returned no run id", relaySessionId)
}
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime toolCall failed: ${err.message ?: err::class.simpleName}")
submitRealtimeToolError(callId, err.message ?: "tool call failed", relaySessionId)
} finally {
pendingRealtimeToolCalls.remove(callId)
}
}
}
private fun holdPendingRealtimeToolCompletion(
runId: String,
state: String,
messageEl: JsonElement?,
): Boolean {
if (realtimeSessionId == null || pendingRealtimeToolCalls.isEmpty()) return false
if (state != "final" && state != "aborted" && state != "error") return false
pendingRealtimeToolCompletions[runId] =
RealtimeToolCompletion(state = state, messageEl = messageEl)
return true
}
private fun maybeCompleteRealtimeToolCall(
runId: String,
state: String,
messageEl: JsonElement?,
): Boolean {
val toolRun = realtimeToolRuns[runId] ?: return false
if (toolRun.relaySessionId != realtimeSessionId) {
realtimeToolRuns.remove(runId)
return true
}
when (state) {
"final" -> {
realtimeToolRuns.remove(runId)
val text = extractTextFromChatEventMessage(messageEl).orEmpty()
scope.launch {
submitRealtimeToolResult(
callId = toolRun.callId,
result = buildJsonObject { put("text", JsonPrimitive(text)) },
sessionId = toolRun.relaySessionId,
)
}
return true
}
"aborted", "error" -> {
realtimeToolRuns.remove(runId)
scope.launch {
submitRealtimeToolError(toolRun.callId, state, toolRun.relaySessionId)
}
return true
}
}
return false
}
private suspend fun submitRealtimeToolError(
callId: String,
message: String,
sessionId: String? = realtimeSessionId,
) {
submitRealtimeToolResult(
callId = callId,
result = buildJsonObject { put("error", JsonPrimitive(message)) },
sessionId = sessionId,
)
}
private suspend fun submitRealtimeToolResult(
callId: String,
result: JsonObject,
sessionId: String? = realtimeSessionId,
) {
val activeSessionId = sessionId ?: return
val params =
buildJsonObject {
put("sessionId", JsonPrimitive(activeSessionId))
put("callId", JsonPrimitive(callId))
put("result", result)
}
try {
session.request("talk.session.submitToolResult", params.toString(), timeoutMs = 15_000)
} catch (err: Throwable) {
if (err is CancellationException) throw err
Log.w(tag, "realtime submitToolResult failed: ${err.message ?: err::class.simpleName}")
}
}
private fun upsertRealtimeConversation(
role: VoiceConversationRole,
text: String,
isFinal: Boolean,
) {
val entryId =
when (role) {
VoiceConversationRole.User -> realtimeUserEntryId
VoiceConversationRole.Assistant -> realtimeAssistantEntryId
}
val resolvedEntryId =
if (entryId == null) {
appendConversation(role = role, text = text, isStreaming = !isFinal)
} else {
updateConversationEntry(id = entryId, text = text, isStreaming = !isFinal)
entryId
}
when (role) {
VoiceConversationRole.User -> realtimeUserEntryId = if (isFinal) null else resolvedEntryId
VoiceConversationRole.Assistant -> realtimeAssistantEntryId = if (isFinal) null else resolvedEntryId
}
}
private fun appendConversation(
role: VoiceConversationRole,
text: String,
isStreaming: Boolean,
): String {
val id = UUID.randomUUID().toString()
_conversation.value =
(_conversation.value + VoiceConversationEntry(id = id, role = role, text = text, isStreaming = isStreaming))
.takeLast(maxConversationEntries)
return id
}
private fun updateConversationEntry(
id: String,
text: String,
isStreaming: Boolean,
) {
val current = _conversation.value
val targetIndex =
when {
current.isEmpty() -> -1
current[current.lastIndex].id == id -> current.lastIndex
else -> current.indexOfFirst { it.id == id }
}
if (targetIndex < 0) return
val entry = current[targetIndex]
if (entry.text == text && entry.isStreaming == isStreaming) return
val updated = current.toMutableList()
updated[targetIndex] = entry.copy(text = text, isStreaming = isStreaming)
_conversation.value = updated
}
private fun startListeningInternal(markListening: Boolean) {
val r = recognizer ?: return
val intent =
@@ -1167,8 +522,8 @@ class TalkModeManager internal constructor(
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
// Use cloud recognition — it handles natural speech and pauses better
// than on-device which cuts off aggressively after short silences.
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 2500)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 1800)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 2500L)
putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 1800L)
}
if (markListening) {
@@ -1407,7 +762,7 @@ class TalkModeManager internal constructor(
}
}
internal suspend fun waitForChatFinal(runId: String): Boolean {
private suspend fun waitForChatFinal(runId: String): Boolean {
consumeRunCompletion(runId)?.let { return it }
val deferred =
if (pendingRunId == runId) {
@@ -1418,12 +773,13 @@ class TalkModeManager internal constructor(
consumeRunCompletion(runId)?.let { return it }
val timeoutMs = if (supportsChatSubscribe) chatFinalWaitWithSubscribeMs else chatFinalWaitWithoutSubscribeMs
val result =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (_: TimeoutCancellationException) {
false
withContext(Dispatchers.IO) {
try {
kotlinx.coroutines.withTimeout(120_000) { deferred.await() }
} catch (_: Throwable) {
false
}
}
if (!result && pendingRunId == runId) {
@@ -1721,32 +1077,11 @@ class TalkModeManager internal constructor(
}
fun stopTts() {
realtimeOutputSuppressed = true
stopRealtimePlayback()
cancelRealtimeOutput(reason = "android-stop-tts")
stopSpeaking(resetInterrupt = true)
_isSpeaking.value = false
_statusText.value = "Listening"
}
private fun cancelRealtimeOutput(reason: String) {
val sessionId = realtimeSessionId ?: return
scope.launch {
try {
val params =
buildJsonObject {
put("sessionId", JsonPrimitive(sessionId))
put("reason", JsonPrimitive(reason))
}
session.request("talk.session.cancelOutput", params.toString(), timeoutMs = 5_000)
} catch (err: Throwable) {
if (err !is CancellationException) {
Log.d(tag, "realtime cancelOutput ignored: ${err.message ?: err::class.simpleName}")
}
}
}
}
private fun stopSpeaking(resetInterrupt: Boolean = true) {
playbackGeneration.incrementAndGet()
if (!_isSpeaking.value) {
@@ -1910,11 +1245,9 @@ class TalkModeManager internal constructor(
val parsed = TalkModeGatewayConfigParser.parse(root?.get("config").asObjectOrNull())
silenceWindowMs = parsed.silenceTimeoutMs
parsed.interruptOnSpeech?.let { interruptOnSpeech = it }
executionMode = parsed.executionMode
configLoaded = true
} catch (_: Throwable) {
silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs
executionMode = TalkModeExecutionMode.Native
configLoaded = false
}
}

View File

@@ -33,7 +33,7 @@ class VoiceWakeManager(
private var recognizer: SpeechRecognizer? = null
private var restartJob: Job? = null
private var lastCycleDispatched: String? = null
private var lastDispatched: String? = null
private var stopRequested = false
fun setTriggerWords(words: List<String>) {
@@ -110,8 +110,8 @@ class VoiceWakeManager(
private fun handleTranscription(text: String) {
val command = VoiceWakeCommandExtractor.extractCommand(text, triggerWords) ?: return
if (command == lastCycleDispatched) return
lastCycleDispatched = command
if (command == lastDispatched) return
lastDispatched = command
scope.launch { onCommand(command) }
_statusText.value = "Triggered"
@@ -121,7 +121,6 @@ class VoiceWakeManager(
private val listener =
object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
lastCycleDispatched = null
_statusText.value = "Listening"
}

View File

@@ -1,12 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission
android:name="android.permission.READ_MEDIA_IMAGES"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
tools:node="remove" />
</manifest>

View File

@@ -3,5 +3,4 @@ package ai.openclaw.app
object SensitiveFeatureConfig {
const val smsEnabled: Boolean = false
const val callLogEnabled: Boolean = false
const val photosEnabled: Boolean = false
}

View File

@@ -1,40 +0,0 @@
package ai.openclaw.app
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CronJobStatusParsingTest {
@Test
fun cronJobLastRunStatusReadsGatewayLastStatus() {
val state =
buildJsonObject {
put("lastStatus", JsonPrimitive(" error "))
put("lastRunStatus", JsonPrimitive("success"))
}
assertEquals("error", cronJobLastRunStatus(state))
}
@Test
fun cronJobLastRunStatusReadsLastRunStatus() {
val state =
buildJsonObject {
put("lastRunStatus", JsonPrimitive("error"))
}
assertEquals("error", cronJobLastRunStatus(state))
}
@Test
fun cronJobLastRunStatusIgnoresEmptyStatus() {
val state =
buildJsonObject {
put("lastStatus", JsonPrimitive(" "))
}
assertNull(cronJobLastRunStatus(state))
}
}

View File

@@ -65,7 +65,7 @@ class GatewayBootstrapAuthTest {
storedOperatorToken = "stored-token",
),
)
assertTrue(
assertFalse(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
storedOperatorToken = null,
@@ -95,17 +95,6 @@ class GatewayBootstrapAuthTest {
assertNull(resolved)
}
@Test
fun resolveOperatorSessionConnectAuthUsesNoAuthWhenGatewayHasNoAuth() {
val resolved =
resolveOperatorSessionConnectAuth(
auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null),
storedOperatorToken = null,
)
assertEquals(NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null), resolved)
}
@Test
fun resolveOperatorSessionConnectAuthPrefersExplicitSharedAuth() {
val resolved =

View File

@@ -1,129 +0,0 @@
package ai.openclaw.app
import android.Manifest
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityOptionsCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PermissionRequesterTest {
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun timedOutRequestCallbackDoesNotCompleteNextRequest() =
runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val launchers = mutableListOf<FakePermissionLauncher>()
val requester =
PermissionRequester(activity()) { callback ->
FakePermissionLauncher(callback).also { launchers += it }
}
try {
val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) }
runCurrent()
advanceTimeBy(11)
runCurrent()
assertTrue(first.isCompleted)
assertTrue(first.getCompletionExceptionOrNull() is TimeoutCancellationException)
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[0].launches)
val second = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) }
runCurrent()
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[1].launches)
launchers[0].deliver(mapOf(Manifest.permission.CAMERA to false))
runCurrent()
assertFalse(second.isCompleted)
launchers[1].deliver(mapOf(Manifest.permission.CAMERA to true))
runCurrent()
assertEquals(mapOf(Manifest.permission.CAMERA to true), second.await())
} finally {
Dispatchers.resetMain()
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun timedOutRequestWithoutCallbackDoesNotBlockNextRequest() =
runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
val launchers = mutableListOf<FakePermissionLauncher>()
val requester =
PermissionRequester(activity()) { callback ->
FakePermissionLauncher(callback).also { launchers += it }
}
try {
val first = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 10) }
runCurrent()
advanceTimeBy(11)
runCurrent()
assertTrue(first.isCompleted)
assertTrue(first.getCompletionExceptionOrNull() is TimeoutCancellationException)
val second = async { requester.requestIfMissing(listOf(Manifest.permission.CAMERA), timeoutMs = 1_000) }
runCurrent()
assertEquals(listOf(listOf(Manifest.permission.CAMERA)), launchers[1].launches)
launchers[1].deliver(mapOf(Manifest.permission.CAMERA to true))
runCurrent()
assertEquals(mapOf(Manifest.permission.CAMERA to true), second.await())
} finally {
Dispatchers.resetMain()
}
}
private fun activity(): ComponentActivity =
Robolectric
.buildActivity(ComponentActivity::class.java)
.setup()
.get()
}
private class FakePermissionLauncher(
private val callback: (Map<String, Boolean>) -> Unit,
) : ActivityResultLauncher<Array<String>>() {
val launches = mutableListOf<List<String>>()
override val contract: ActivityResultContract<Array<String>, *> = ActivityResultContracts.RequestMultiplePermissions()
override fun launch(
input: Array<String>,
options: ActivityOptionsCompat?,
) {
launches += input.toList()
}
override fun unregister() {}
fun deliver(result: Map<String, Boolean>) {
callback(result)
}
}

View File

@@ -78,91 +78,4 @@ class ChatControllerMessageIdentityTest {
assertEquals("new-2", reconciled[1].id)
assertNotEquals(reconciled[0].id, reconciled[1].id)
}
@Test
fun mergeOptimisticMessagesKeepsOutgoingUserTurnWhenHistoryOmitsIt() {
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 merged = mergeOptimisticMessages(incoming = listOf(assistant), optimistic = listOf(optimistic))
assertEquals(listOf("local-user", "remote-assistant"), merged.map { it.id })
}
@Test
fun mergeOptimisticMessagesDoesNotDuplicateHistoryTurns() {
val user =
ChatMessage(
id = "local-user",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
)
val remoteUser = user.copy(id = "remote-user")
val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(user))
assertEquals(listOf("remote-user"), merged.map { it.id })
}
@Test
fun mergeOptimisticMessagesDoesNotDuplicateGatewayPersistedUserTurnWithDifferentTimestamp() {
val optimistic =
ChatMessage(
id = "local-user",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
)
val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 2000L)
val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic))
assertEquals(listOf("remote-user"), merged.map { it.id })
}
@Test
fun mergeOptimisticMessagesKeepsRepeatedOptimisticTurnWhenHistoryOnlyHasOneMatch() {
val first =
ChatMessage(
id = "local-user-1",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
)
val second = first.copy(id = "local-user-2", timestampMs = 1100L)
val remoteUser = first.copy(id = "remote-user", timestampMs = 2000L)
val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(first, second))
assertEquals(listOf("local-user-2", "remote-user"), merged.map { it.id })
}
@Test
fun mergeOptimisticMessagesDoesNotConsumeOlderIdenticalHistoryTurn() {
val optimistic =
ChatMessage(
id = "local-user",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "ok")),
timestampMs = 2000L,
)
val oldHistoryUser = optimistic.copy(id = "remote-old-user", timestampMs = 1000L)
val merged = mergeOptimisticMessages(incoming = listOf(oldHistoryUser), optimistic = listOf(optimistic))
assertEquals(listOf("remote-old-user", "local-user"), merged.map { it.id })
}
}

View File

@@ -335,7 +335,7 @@ class GatewaySessionInvokeTest {
connectResponseFrame(
id,
authJson =
"""{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.pairing","operator.read","operator.talk.secrets","operator.write"]}]}""",
"""{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.read","operator.talk.secrets","operator.write"]}]}""",
),
)
webSocket.close(1000, "done")
@@ -365,7 +365,7 @@ class GatewaySessionInvokeTest {
assertEquals(emptyList<String>(), nodeEntry?.scopes)
assertEquals("bootstrap-operator-token", operatorEntry?.token)
assertEquals(
listOf("operator.approvals", "operator.pairing", "operator.read", "operator.write"),
listOf("operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"),
operatorEntry?.scopes,
)
} finally {
@@ -642,7 +642,7 @@ class GatewaySessionInvokeTest {
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = deviceAuthStore,
onConnected = {
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
onDisconnected = { message ->

Some files were not shown because too many files have changed in this diff Show More