Compare commits

..

7 Commits

Author SHA1 Message Date
Peter Steinberger
bfd9fcac18 test: remove redundant task flow temp dir args 2026-06-04 23:40:09 +01:00
Peter Steinberger
4f7b5d8f44 fix: refresh node plugin tools after plugin load 2026-06-04 23:39:46 +01:00
Peter Steinberger
32caafd4ed test: align rebased runtime defaults 2026-06-04 23:39:46 +01:00
Peter Steinberger
60becfb941 fix: avoid node plugin tool registry cycle 2026-06-04 23:39:45 +01:00
Peter Steinberger
3f4ea59779 build: refresh generated gateway protocol 2026-06-04 23:39:45 +01:00
Peter Steinberger
cde2b5f718 fix: keep node plugin tools fresh 2026-06-04 23:39:45 +01:00
Peter Steinberger
2af75a93c2 feat: expose node-hosted plugin tools 2026-06-04 23:39:45 +01:00
9524 changed files with 21363 additions and 103807 deletions

4
.github/labeler.yml vendored
View File

@@ -574,10 +574,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/openshell/**"
"extensions: parallel":
- changed-files:
- any-glob-to-any-file:
- "extensions/parallel/**"
"extensions: perplexity":
- changed-files:
- any-glob-to-any-file:

View File

@@ -2,14 +2,19 @@
What problem does this PR solve?
Why does this matter now?
What is the intended outcome?
What is intentionally out of scope?
What does success look like?
What should reviewers focus on?
<details>
@@ -70,10 +75,13 @@ Be mindful of private information like IP addresses, API keys, phone numbers, no
Which commands did you run?
What regression coverage was added or updated?
What failed before this fix, if known?
If no test was added, why not?
<details>
@@ -87,12 +95,16 @@ List focused commands, not every incidental check. CI is useful support, but ext
Did user-visible behavior change? (`Yes/No`)
Did config, environment, or migration behavior change? (`Yes/No`)
Did security, auth, secrets, network, or tool execution behavior change? (`Yes/No`)
What is the highest-risk area?
How is that risk mitigated?
<details>
@@ -106,8 +118,10 @@ Use this for author judgment that is not obvious from the diff. ClawSweeper can
What is the next action?
What is still waiting on author, maintainer, CI, or external proof?
Which bot or reviewer comments were addressed?
<details>

View File

@@ -722,7 +722,7 @@ jobs:
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
start_check "gateway-watch" \
node scripts/check-gateway-watch-regression.mjs --skip-build
node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000
fi
for index in "${!pids[@]}"; do

View File

@@ -35,7 +35,7 @@ jobs:
java-version: "21"
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: java-kotlin
build-mode: manual
@@ -46,6 +46,6 @@ jobs:
run: ./gradlew --no-daemon :app:assemblePlayDebug
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-security/android"

View File

@@ -342,13 +342,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-core-auth-secrets-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/core-auth-secrets"
@@ -365,13 +365,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-config-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/config-boundary"
@@ -388,13 +388,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-gateway-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/gateway-runtime-boundary"
@@ -411,13 +411,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/channel-runtime-boundary"
@@ -460,7 +460,7 @@ jobs:
- name: Initialize CodeQL
if: ${{ github.event_name != 'pull_request' }}
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-network-runtime-boundary-critical-quality.yml
@@ -468,7 +468,7 @@ jobs:
- name: Analyze
id: analyze
if: ${{ github.event_name != 'pull_request' }}
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
output: sarif-results
category: "/codeql-critical-quality/network-runtime-boundary"
@@ -518,13 +518,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/agent-runtime-boundary"
@@ -541,13 +541,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-mcp-process-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/mcp-process-runtime-boundary"
@@ -564,13 +564,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/memory-runtime-boundary"
@@ -587,13 +587,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-session-diagnostics-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/session-diagnostics-boundary"
@@ -610,13 +610,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-plugin-sdk-reply-runtime-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/plugin-sdk-reply-runtime"
@@ -633,13 +633,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-provider-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/provider-runtime-boundary"
@@ -655,13 +655,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-ui-control-plane-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/ui-control-plane"
@@ -677,13 +677,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-web-media-runtime-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/web-media-runtime-boundary"
@@ -700,13 +700,13 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/plugin-boundary"
@@ -723,12 +723,12 @@ jobs:
submodules: false
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: javascript-typescript
config-file: ./.github/codeql/codeql-plugin-sdk-package-contract-critical-quality.yml
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-critical-quality/plugin-sdk-package-contract"

View File

@@ -35,7 +35,7 @@ jobs:
swift --version
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: swift
build-mode: manual
@@ -46,7 +46,7 @@ jobs:
- name: Analyze
id: analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
output: sarif-results
upload: failure-only
@@ -83,7 +83,7 @@ jobs:
done
- name: Upload filtered SARIF
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
sarif_file: sarif-results-filtered
category: "/codeql-critical-security/macos"

View File

@@ -101,12 +101,12 @@ jobs:
.github/codeql
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
languages: ${{ matrix.language }}
config-file: ${{ matrix.config_file }}
- name: Analyze
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
with:
category: "/codeql-security-high/${{ matrix.category }}"

View File

@@ -89,7 +89,7 @@ jobs:
fetch-depth: 0
- name: Set up Docker Builder
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
@@ -161,7 +161,7 @@ jobs:
- name: Build and push amd64 image
id: build
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64
@@ -179,7 +179,7 @@ jobs:
id: build-browser
if: steps.tags.outputs.browser != ''
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64
@@ -280,7 +280,7 @@ jobs:
fetch-depth: 0
- name: Set up Docker Builder
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
@@ -352,7 +352,7 @@ jobs:
- name: Build and push arm64 image
id: build
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/arm64
@@ -370,7 +370,7 @@ jobs:
id: build-browser
if: steps.tags.outputs.browser != ''
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/arm64
@@ -562,7 +562,7 @@ jobs:
fetch-depth: 1
- name: Set up Docker Builder
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4

View File

@@ -149,7 +149,7 @@ jobs:
- name: Run Codex docs agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
env:
DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}

View File

@@ -1139,16 +1139,7 @@ jobs:
summary:
name: Verify full validation
needs:
[
resolve_target,
docker_runtime_assets_preflight,
normal_ci,
plugin_prerelease,
release_checks,
npm_telegram,
performance,
]
needs: [resolve_target, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, release_checks, npm_telegram, performance]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5

View File

@@ -29,7 +29,7 @@ jobs:
uses: actions/checkout@v6
- name: Login to GHCR
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -13,7 +13,7 @@ on:
default: true
type: boolean
public_release_branch:
description: Public branch that contains the release tag commit, usually main or release/YYYY.M.PATCH
description: Public branch that contains the release tag commit, usually main or release/YYYY.M.D
required: false
default: main
type: string
@@ -73,7 +73,7 @@ jobs:
run: |
set -euo pipefail
if [[ "${PUBLIC_RELEASE_BRANCH}" != "main" && ! "${PUBLIC_RELEASE_BRANCH}" =~ ^release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
echo "public_release_branch must be main or release/YYYY.M.PATCH, got ${PUBLIC_RELEASE_BRANCH}." >&2
echo "public_release_branch must be main or release/YYYY.M.D, got ${PUBLIC_RELEASE_BRANCH}." >&2
exit 1
fi
RELEASE_SHA=$(git rev-parse HEAD)

View File

@@ -37,7 +37,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);

View File

@@ -56,7 +56,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
@@ -91,7 +91,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const defaultBaseline = "0bf06e953fdda290799fc9fb9244a8f67fdae593";
@@ -581,7 +581,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;

View File

@@ -56,7 +56,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
@@ -91,7 +91,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const defaultBaseline = "synthetic-reverted-thread-filepath-fix";
@@ -603,7 +603,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;

View File

@@ -81,7 +81,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);

View File

@@ -79,7 +79,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
if (context.eventName === "pull_request_target") {
@@ -125,7 +125,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const eventName = context.eventName;
@@ -445,7 +445,7 @@ jobs:
sudo chown -R codex:codex "$GITHUB_WORKSPACE"
- name: Run Codex Mantis Telegram agent
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
env:
BASELINE_REF: ${{ needs.resolve_request.outputs.baseline_ref }}
BASELINE_SHA: ${{ needs.validate_refs.outputs.baseline_revision }}
@@ -709,7 +709,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;

View File

@@ -68,7 +68,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const allowed = new Set(["admin", "maintain", "write"]);
@@ -105,7 +105,7 @@ jobs:
steps:
- name: Resolve refs and target PR
id: resolve
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const eventName = context.eventName;
@@ -573,7 +573,7 @@ jobs:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;

View File

@@ -887,7 +887,7 @@ jobs:
summary=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker chunk summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 1
exit 0
fi
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: ${DOCKER_E2E_CHUNK:-unknown}" >> "$GITHUB_STEP_SUMMARY"
@@ -897,7 +897,7 @@ jobs:
with:
name: docker-e2e-${{ matrix.chunk_id }}
path: .artifacts/docker-tests/
if-no-files-found: error
if-no-files-found: ignore
plan_docker_lane_groups:
needs: validate_selected_ref
@@ -1147,7 +1147,7 @@ jobs:
summary=".artifacts/docker-tests/targeted-${{ steps.plan.outputs.artifact_suffix }}/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker targeted summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 1
exit 0
fi
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E targeted lanes" >> "$GITHUB_STEP_SUMMARY"
@@ -1157,7 +1157,7 @@ jobs:
with:
name: docker-e2e-${{ steps.plan.outputs.artifact_suffix }}
path: .artifacts/docker-tests/
if-no-files-found: error
if-no-files-found: ignore
validate_docker_openwebui:
needs: [validate_selected_ref, prepare_docker_e2e_image]
@@ -1274,7 +1274,7 @@ jobs:
summary=".artifacts/docker-tests/release-openwebui/summary.json"
if [[ ! -f "$summary" ]]; then
echo "Docker Open WebUI summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
exit 1
exit 0
fi
node .release-harness/scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: openwebui" >> "$GITHUB_STEP_SUMMARY"
@@ -1284,7 +1284,7 @@ jobs:
with:
name: docker-e2e-openwebui
path: .artifacts/docker-tests/
if-no-files-found: error
if-no-files-found: ignore
prepare_docker_e2e_image:
needs: validate_selected_ref
@@ -1918,7 +1918,7 @@ jobs:
profiles: stable full
- suite_id: native-live-src-gateway-core
label: Native live gateway core
command: OPENCLAW_LIVE_CODEX_HARNESS=1 OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-core
timeout_minutes: 60
profile_env_only: false
profiles: beta minimum stable full
@@ -2038,7 +2038,7 @@ jobs:
profiles: full
- suite_id: native-live-src-gateway-backends
label: Native live gateway backends
command: OPENCLAW_LIVE_CODEX_HARNESS=1 OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-backends
command: node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-backends
timeout_minutes: 60
profile_env_only: false
profiles: stable full

View File

@@ -391,7 +391,7 @@ jobs:
tideclaw_alpha_publish=true
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]]; then
echo "Real publish runs must be dispatched from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
echo "Real publish runs must be dispatched from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
exit 1
fi

View File

@@ -244,8 +244,8 @@ jobs:
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "OPENAI_API_KEY is not configured; live GPT 5.5 lane cannot run without live evidence." >> "$GITHUB_STEP_SUMMARY"
exit 1
echo "OPENAI_API_KEY is not configured; live GPT 5.5 lane will be skipped." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
kova setup --ci --json
kova setup --non-interactive --auth env-only --provider openai --env-var OPENAI_API_KEY --json
@@ -262,6 +262,11 @@ jobs:
set -euo pipefail
mkdir -p "$REPORT_DIR" "$BUNDLE_DIR" "$SUMMARY_DIR"
if [[ "$MATRIX_LIVE" == "true" && -z "${OPENAI_API_KEY:-}" ]]; then
echo "skipped=true" >> "$GITHUB_OUTPUT"
exit 0
fi
repeat="$REQUESTED_REPEAT"
if [[ "$MATRIX_REPEAT" != "input" ]]; then
repeat="$MATRIX_REPEAT"
@@ -304,7 +309,24 @@ jobs:
report_md="${report_json%.json}.md"
effective_status="$status"
if [[ "$FAIL_ON_REGRESSION" == "true" && "$status" != "0" ]]; then
if node "$PERFORMANCE_HELPER_DIR/scripts/lib/kova-report-gate.mjs" "$report_json"
if REPORT_JSON="$report_json" node <<'NODE'
const fs = require("node:fs");
const report = JSON.parse(fs.readFileSync(process.env.REPORT_JSON, "utf8"));
const statuses = report.summary?.statuses ?? {};
const nonPassStatuses = Object.entries(statuses)
.filter(([status, count]) => status !== "PASS" && Number(count) > 0);
const baselineRegressionCount =
Number(report.baseline?.comparison?.regressionCount ?? report.gate?.baseline?.regressionCount ?? 0);
const gate = report.gate;
const toleratedPartial =
gate?.verdict === "PARTIAL" &&
Number(gate.blockingCount ?? 0) === 0 &&
baselineRegressionCount === 0 &&
nonPassStatuses.length === 0;
if (!toleratedPartial) {
process.exit(1);
}
NODE
then
effective_status=0
{
@@ -355,28 +377,6 @@ jobs:
exit "$effective_status"
fi
- name: Validate Kova evidence
if: ${{ always() && steps.lane.outputs.run == 'true' }}
shell: bash
run: |
set -euo pipefail
missing=0
if ! find "$REPORT_DIR" -maxdepth 1 -type f -name '*.json' -size +0c -print -quit | grep -q .; then
echo "::error::Kova JSON report is missing for ${LANE_ID}."
missing=1
fi
if [[ ! -s "$BUNDLE_DIR/bundle.json" ]]; then
echo "::error::Kova bundle evidence is missing for ${LANE_ID}."
missing=1
fi
if [[ ! -s "$SUMMARY_DIR/${LANE_ID}.md" ]]; then
echo "::error::Kova summary evidence is missing for ${LANE_ID}."
missing=1
fi
if [[ "$missing" != "0" ]]; then
exit 1
fi
- name: Fetch previous source performance baseline
if: ${{ steps.lane.outputs.run == 'true' && matrix.lane == 'mock-provider' && steps.clawgrit.outputs.present == 'true' }}
env:
@@ -547,7 +547,7 @@ jobs:
.artifacts/kova/bundles/${{ matrix.lane }}
.artifacts/kova/summaries/${{ matrix.lane }}.md
.artifacts/openclaw-performance/source/${{ matrix.lane }}
if-no-files-found: error
if-no-files-found: ignore
retention-days: ${{ matrix.deep_profile == 'true' && 14 || 30 }}
- name: Prepare clawgrit reports checkout

View File

@@ -132,7 +132,7 @@ jobs:
fi
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]] && [[ "${tideclaw_alpha_check}" != "true" ]]; then
echo "Release checks must be dispatched from main, release/YYYY.M.PATCH, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
echo "Release checks must be dispatched from main, release/YYYY.M.D, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
exit 1
fi
@@ -346,7 +346,6 @@ jobs:
discord_selected=false
whatsapp_selected=false
slack_selected=false
disabled_required_lanes=()
IFS=', ' read -r -a filter_tokens <<< "$filter"
for token in "${filter_tokens[@]}"; do
@@ -362,9 +361,6 @@ jobs:
discord_selected="$qa_live_discord_ci_enabled"
whatsapp_selected="$qa_live_whatsapp_ci_enabled"
slack_selected="$qa_live_slack_ci_enabled"
[[ "$qa_live_discord_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-discord")
[[ "$qa_live_whatsapp_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-whatsapp")
[[ "$qa_live_slack_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-slack")
;;
qa-live-non-slack|qa-non-slack|non-slack|no-slack|without-slack)
qa_filter_seen=true
@@ -372,8 +368,6 @@ jobs:
telegram_selected=true
discord_selected="$qa_live_discord_ci_enabled"
whatsapp_selected="$qa_live_whatsapp_ci_enabled"
[[ "$qa_live_discord_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-discord")
[[ "$qa_live_whatsapp_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-whatsapp")
;;
qa-live-matrix|qa-matrix|matrix)
qa_filter_seen=true
@@ -386,27 +380,18 @@ jobs:
qa-live-discord|qa-discord|discord)
qa_filter_seen=true
discord_selected="$qa_live_discord_ci_enabled"
[[ "$qa_live_discord_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-discord")
;;
qa-live-whatsapp|qa-whatsapp|whatsapp)
qa_filter_seen=true
whatsapp_selected="$qa_live_whatsapp_ci_enabled"
[[ "$qa_live_whatsapp_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-whatsapp")
;;
qa-live-slack|qa-slack|slack)
qa_filter_seen=true
slack_selected="$qa_live_slack_ci_enabled"
[[ "$qa_live_slack_ci_enabled" == "true" ]] || disabled_required_lanes+=("qa-live-slack")
;;
esac
done
if [[ "${#disabled_required_lanes[@]}" -gt 0 ]]; then
echo "live_suite_filter explicitly requested disabled QA live lane(s): ${disabled_required_lanes[*]}" >&2
echo "Enable the matching OPENCLAW_RELEASE_QA_*_LIVE_CI_ENABLED repo variable or remove the lane from live_suite_filter." >&2
exit 1
fi
if [[ "$qa_filter_seen" == "true" ]]; then
qa_live_matrix_enabled="$matrix_selected"
qa_live_telegram_enabled="$telegram_selected"
@@ -816,7 +801,6 @@ jobs:
run: node scripts/build-all.mjs qaRuntime
- name: Run parity lane
id: run_lane
env:
QA_PARITY_LANE: ${{ matrix.lane }}
QA_PARITY_OUTPUT_DIR: ${{ matrix.output_dir }}
@@ -847,7 +831,6 @@ jobs:
--output-dir ".artifacts/qa-e2e/${QA_PARITY_OUTPUT_DIR}"
- name: Upload parity lane artifacts
id: upload_parity_lane_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -856,52 +839,6 @@ jobs:
retention-days: 14
if-no-files-found: warn
- name: Record advisory status
if: always()
shell: bash
env:
RELEASE_CHECK_JOB: qa_lab_parity_lane_release_checks
RELEASE_CHECK_VARIANT: ${{ matrix.lane }}
JOB_STATUS: ${{ job.status }}
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_parity_lane_artifacts.outcome }}
run: |
set -euo pipefail
status="success"
mark_status() {
case "$1" in
failure) status="failure" ;;
cancelled)
if [[ "$status" != "failure" ]]; then
status="cancelled"
fi
;;
success|skipped|"") ;;
*) status="failure" ;;
esac
}
mark_status "${JOB_STATUS:-}"
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
mark_status "$outcome"
done
mkdir -p .artifacts/release-check-status
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}-${RELEASE_CHECK_VARIANT}.env"
{
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
printf 'variant=%s\n' "$RELEASE_CHECK_VARIANT"
printf 'status=%s\n' "$status"
printf 'job_status=%s\n' "${JOB_STATUS:-}"
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
} > "$status_path"
- name: Upload advisory status
if: always()
uses: actions/upload-artifact@v7
with:
name: release-check-status-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/release-check-status/qa_lab_parity_lane_release_checks-${{ matrix.lane }}.env
retention-days: 14
if-no-files-found: error
qa_lab_parity_report_release_checks:
name: Run QA Lab parity report
needs: [resolve_target, qa_lab_parity_lane_release_checks]
@@ -942,7 +879,6 @@ jobs:
run: node scripts/build-all.mjs qaRuntime
- name: Generate parity report
id: generate_report
run: |
pnpm openclaw qa parity-report \
--repo-root . \
@@ -953,7 +889,6 @@ jobs:
--output-dir .artifacts/qa-e2e/parity
- name: Upload parity artifacts
id: upload_parity_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -962,50 +897,6 @@ jobs:
retention-days: 14
if-no-files-found: warn
- name: Record advisory status
if: always()
shell: bash
env:
RELEASE_CHECK_JOB: qa_lab_parity_report_release_checks
JOB_STATUS: ${{ job.status }}
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.generate_report.outcome }} ${{ steps.upload_parity_artifacts.outcome }}
run: |
set -euo pipefail
status="success"
mark_status() {
case "$1" in
failure) status="failure" ;;
cancelled)
if [[ "$status" != "failure" ]]; then
status="cancelled"
fi
;;
success|skipped|"") ;;
*) status="failure" ;;
esac
}
mark_status "${JOB_STATUS:-}"
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
mark_status "$outcome"
done
mkdir -p .artifacts/release-check-status
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
{
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
printf 'status=%s\n' "$status"
printf 'job_status=%s\n' "${JOB_STATUS:-}"
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
} > "$status_path"
- name: Upload advisory status
if: always()
uses: actions/upload-artifact@v7
with:
name: release-check-status-qa-parity-report-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/release-check-status/qa_lab_parity_report_release_checks.env
retention-days: 14
if-no-files-found: error
qa_lab_runtime_parity_release_checks:
name: Run QA Lab runtime parity lane
needs: [resolve_target]
@@ -1059,7 +950,6 @@ jobs:
--output-dir ".artifacts/qa-e2e/runtime-parity"
- name: Run standard runtime parity tier
id: runtime_parity_standard_lane
if: ${{ always() && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
run: |
set -euo pipefail
@@ -1087,7 +977,6 @@ jobs:
--output-dir ".artifacts/qa-e2e/runtime-parity-soak"
- name: Generate runtime parity report
id: generate_runtime_parity_report
if: always()
run: |
set -euo pipefail
@@ -1098,7 +987,6 @@ jobs:
--output-dir .artifacts/qa-e2e/runtime-parity-report
- name: Generate standard runtime parity report
id: generate_runtime_parity_standard_report
if: always()
run: |
set -euo pipefail
@@ -1109,7 +997,6 @@ jobs:
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
- name: Generate soak runtime parity report
id: generate_runtime_parity_soak_report
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_soak_lane.outcome != 'skipped' && steps.runtime_parity_soak_lane.outcome != 'cancelled' }}
run: |
set -euo pipefail
@@ -1125,7 +1012,6 @@ jobs:
--output-dir .artifacts/qa-e2e/runtime-parity-soak-report
- name: Upload runtime parity artifacts
id: upload_runtime_parity_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -1134,50 +1020,6 @@ jobs:
retention-days: 14
if-no-files-found: warn
- name: Record advisory status
if: always()
shell: bash
env:
RELEASE_CHECK_JOB: qa_lab_runtime_parity_release_checks
JOB_STATUS: ${{ job.status }}
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.runtime_parity_lane.outcome }} ${{ steps.runtime_parity_standard_lane.outcome }} ${{ steps.runtime_parity_soak_lane.outcome }} ${{ steps.generate_runtime_parity_report.outcome }} ${{ steps.generate_runtime_parity_standard_report.outcome }} ${{ steps.generate_runtime_parity_soak_report.outcome }} ${{ steps.upload_runtime_parity_artifacts.outcome }}
run: |
set -euo pipefail
status="success"
mark_status() {
case "$1" in
failure) status="failure" ;;
cancelled)
if [[ "$status" != "failure" ]]; then
status="cancelled"
fi
;;
success|skipped|"") ;;
*) status="failure" ;;
esac
}
mark_status "${JOB_STATUS:-}"
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
mark_status "$outcome"
done
mkdir -p .artifacts/release-check-status
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
{
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
printf 'status=%s\n' "$status"
printf 'job_status=%s\n' "${JOB_STATUS:-}"
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
} > "$status_path"
- name: Upload advisory status
if: always()
uses: actions/upload-artifact@v7
with:
name: release-check-status-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/release-check-status/qa_lab_runtime_parity_release_checks.env
retention-days: 14
if-no-files-found: error
runtime_tool_coverage_release_checks:
name: Enforce QA Lab runtime tool coverage
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
@@ -1299,7 +1141,6 @@ jobs:
done
- name: Upload Matrix QA artifacts
id: upload_matrix_qa_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -1308,50 +1149,6 @@ jobs:
retention-days: 14
if-no-files-found: warn
- name: Record advisory status
if: always()
shell: bash
env:
RELEASE_CHECK_JOB: qa_live_matrix_release_checks
JOB_STATUS: ${{ job.status }}
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_matrix_qa_artifacts.outcome }}
run: |
set -euo pipefail
status="success"
mark_status() {
case "$1" in
failure) status="failure" ;;
cancelled)
if [[ "$status" != "failure" ]]; then
status="cancelled"
fi
;;
success|skipped|"") ;;
*) status="failure" ;;
esac
}
mark_status "${JOB_STATUS:-}"
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
mark_status "$outcome"
done
mkdir -p .artifacts/release-check-status
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
{
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
printf 'status=%s\n' "$status"
printf 'job_status=%s\n' "${JOB_STATUS:-}"
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
} > "$status_path"
- name: Upload advisory status
if: always()
uses: actions/upload-artifact@v7
with:
name: release-check-status-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/release-check-status/qa_live_matrix_release_checks.env
retention-days: 14
if-no-files-found: error
qa_live_telegram_release_checks:
name: Run QA Lab live Telegram lane
needs: [resolve_target]
@@ -1440,7 +1237,6 @@ jobs:
done
- name: Upload Telegram QA artifacts
id: upload_telegram_qa_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -1449,54 +1245,10 @@ jobs:
retention-days: 14
if-no-files-found: warn
- name: Record advisory status
if: always()
shell: bash
env:
RELEASE_CHECK_JOB: qa_live_telegram_release_checks
JOB_STATUS: ${{ job.status }}
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_telegram_qa_artifacts.outcome }}
run: |
set -euo pipefail
status="success"
mark_status() {
case "$1" in
failure) status="failure" ;;
cancelled)
if [[ "$status" != "failure" ]]; then
status="cancelled"
fi
;;
success|skipped|"") ;;
*) status="failure" ;;
esac
}
mark_status "${JOB_STATUS:-}"
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
mark_status "$outcome"
done
mkdir -p .artifacts/release-check-status
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
{
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
printf 'status=%s\n' "$status"
printf 'job_status=%s\n' "${JOB_STATUS:-}"
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
} > "$status_path"
- name: Upload advisory status
if: always()
uses: actions/upload-artifact@v7
with:
name: release-check-status-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/release-check-status/qa_live_telegram_release_checks.env
retention-days: 14
if-no-files-found: error
qa_live_discord_release_checks:
name: Run QA Lab live Discord lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_discord_enabled == 'true'
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_discord_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_DISCORD_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 60
@@ -1580,7 +1332,6 @@ jobs:
done
- name: Upload Discord QA artifacts
id: upload_discord_qa_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -1589,54 +1340,10 @@ jobs:
retention-days: 14
if-no-files-found: warn
- name: Record advisory status
if: always()
shell: bash
env:
RELEASE_CHECK_JOB: qa_live_discord_release_checks
JOB_STATUS: ${{ job.status }}
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_discord_qa_artifacts.outcome }}
run: |
set -euo pipefail
status="success"
mark_status() {
case "$1" in
failure) status="failure" ;;
cancelled)
if [[ "$status" != "failure" ]]; then
status="cancelled"
fi
;;
success|skipped|"") ;;
*) status="failure" ;;
esac
}
mark_status "${JOB_STATUS:-}"
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
mark_status "$outcome"
done
mkdir -p .artifacts/release-check-status
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
{
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
printf 'status=%s\n' "$status"
printf 'job_status=%s\n' "${JOB_STATUS:-}"
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
} > "$status_path"
- name: Upload advisory status
if: always()
uses: actions/upload-artifact@v7
with:
name: release-check-status-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/release-check-status/qa_live_discord_release_checks.env
retention-days: 14
if-no-files-found: error
qa_live_whatsapp_release_checks:
name: Run QA Lab live WhatsApp lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_whatsapp_enabled == 'true'
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_whatsapp_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_WHATSAPP_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 60
@@ -1723,7 +1430,6 @@ jobs:
done
- name: Upload WhatsApp QA artifacts
id: upload_whatsapp_qa_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -1732,54 +1438,10 @@ jobs:
retention-days: 14
if-no-files-found: warn
- name: Record advisory status
if: always()
shell: bash
env:
RELEASE_CHECK_JOB: qa_live_whatsapp_release_checks
JOB_STATUS: ${{ job.status }}
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_whatsapp_qa_artifacts.outcome }}
run: |
set -euo pipefail
status="success"
mark_status() {
case "$1" in
failure) status="failure" ;;
cancelled)
if [[ "$status" != "failure" ]]; then
status="cancelled"
fi
;;
success|skipped|"") ;;
*) status="failure" ;;
esac
}
mark_status "${JOB_STATUS:-}"
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
mark_status "$outcome"
done
mkdir -p .artifacts/release-check-status
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
{
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
printf 'status=%s\n' "$status"
printf 'job_status=%s\n' "${JOB_STATUS:-}"
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
} > "$status_path"
- name: Upload advisory status
if: always()
uses: actions/upload-artifact@v7
with:
name: release-check-status-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/release-check-status/qa_live_whatsapp_release_checks.env
retention-days: 14
if-no-files-found: error
qa_live_slack_release_checks:
name: Run QA Lab live Slack lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true'
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
timeout-minutes: 60
@@ -1863,7 +1525,6 @@ jobs:
done
- name: Upload Slack QA artifacts
id: upload_slack_qa_artifacts
if: always()
uses: actions/upload-artifact@v7
with:
@@ -1872,50 +1533,6 @@ jobs:
retention-days: 14
if-no-files-found: warn
- name: Record advisory status
if: always()
shell: bash
env:
RELEASE_CHECK_JOB: qa_live_slack_release_checks
JOB_STATUS: ${{ job.status }}
RELEASE_CHECK_STEP_OUTCOMES: ${{ steps.run_lane.outcome }} ${{ steps.upload_slack_qa_artifacts.outcome }}
run: |
set -euo pipefail
status="success"
mark_status() {
case "$1" in
failure) status="failure" ;;
cancelled)
if [[ "$status" != "failure" ]]; then
status="cancelled"
fi
;;
success|skipped|"") ;;
*) status="failure" ;;
esac
}
mark_status "${JOB_STATUS:-}"
for outcome in ${RELEASE_CHECK_STEP_OUTCOMES:-}; do
mark_status "$outcome"
done
mkdir -p .artifacts/release-check-status
status_path=".artifacts/release-check-status/${RELEASE_CHECK_JOB}.env"
{
printf 'job=%s\n' "$RELEASE_CHECK_JOB"
printf 'status=%s\n' "$status"
printf 'job_status=%s\n' "${JOB_STATUS:-}"
printf 'step_outcomes=%s\n' "${RELEASE_CHECK_STEP_OUTCOMES:-}"
} > "$status_path"
- name: Upload advisory status
if: always()
uses: actions/upload-artifact@v7
with:
name: release-check-status-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/release-check-status/qa_live_slack_release_checks.env
retention-days: 14
if-no-files-found: error
summary:
name: Verify release checks
needs:
@@ -1936,19 +1553,9 @@ jobs:
- qa_live_slack_release_checks
if: always()
runs-on: ubuntu-24.04
permissions:
actions: read
permissions: {}
timeout-minutes: 5
steps:
- name: Download advisory status artifacts
if: always()
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: release-check-status-*
path: .artifacts/release-check-status
merge-multiple: true
- name: Verify release check results
shell: bash
env:
@@ -1960,49 +1567,6 @@ jobs:
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha=true
fi
release_check_result() {
local name="$1"
local fallback="$2"
local status_dir=".artifacts/release-check-status"
local saw=0
local saw_failure=0
local saw_cancelled=0
if [[ -d "$status_dir" ]]; then
while IFS= read -r -d '' file; do
saw=1
status="$(sed -n 's/^status=//p' "$file" | tail -n 1)"
case "$status" in
success|skipped) ;;
cancelled) saw_cancelled=1 ;;
failure|"") saw_failure=1 ;;
*) saw_failure=1 ;;
esac
done < <(find "$status_dir" -type f -name "${name}*.env" -print0)
fi
if [[ "$saw_failure" == "1" ]]; then
printf 'failure\n'
elif [[ "$saw_cancelled" == "1" ]]; then
printf 'cancelled\n'
elif [[ "$fallback" != "success" && "$fallback" != "skipped" ]]; then
printf '%s\n' "$fallback"
elif [[ "$saw" == "1" ]]; then
printf 'success\n'
elif [[ "$fallback" == "success" ]]; then
printf 'failure\n'
else
printf '%s\n' "$fallback"
fi
}
advisory_status_override_allowed() {
case "$1" in
qa_lab_parity_lane_release_checks|qa_lab_parity_report_release_checks|qa_lab_runtime_parity_release_checks|qa_live_matrix_release_checks|qa_live_telegram_release_checks|qa_live_discord_release_checks|qa_live_whatsapp_release_checks|qa_live_slack_release_checks)
return 0
;;
*)
return 1
;;
esac
}
for item in \
"prepare_release_package=${{ needs.prepare_release_package.result }}" \
"install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \
@@ -2021,12 +1585,7 @@ jobs:
"qa_live_slack_release_checks=${{ needs.qa_live_slack_release_checks.result }}"
do
name="${item%%=*}"
raw_result="${item#*=}"
if advisory_status_override_allowed "$name"; then
result="$(release_check_result "$name" "$raw_result")"
else
result="$raw_result"
fi
result="${item#*=}"
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
if [[ "$tideclaw_alpha" == "true" ]]; then
case "$name" in
@@ -2037,6 +1596,10 @@ jobs:
;;
esac
fi
if [[ "$name" == qa_* ]]; then
echo "::warning::${name} ended with ${result}; QA release-check lanes are advisory and do not block release validation."
continue
fi
echo "::error::${name} ended with ${result}"
failed=1
fi

View File

@@ -120,7 +120,7 @@ jobs:
tideclaw_alpha_publish=true
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.PATCH, or a Tideclaw alpha branch for alpha prereleases." >&2
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases." >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PLUGIN_PUBLISH_SCOPE}" != "all-publishable" ]]; then

View File

@@ -53,7 +53,7 @@ jobs:
scripts/run-opengrep.sh --sarif --error
- name: Upload SARIF to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v4.36.1
uses: github/codeql-action/upload-sarif@v4
# Only upload if the scan actually produced a SARIF file.
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
with:

View File

@@ -84,7 +84,7 @@ jobs:
scripts/run-opengrep.sh --changed --sarif --error
- name: Upload SARIF to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v4.36.1
uses: github/codeql-action/upload-sarif@v4
# Only upload if the scan actually produced a SARIF file.
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
with:

View File

@@ -65,7 +65,7 @@ jobs:
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
if (context.eventName === "schedule") {

View File

@@ -35,7 +35,7 @@ jobs:
submodules: false
- name: Set up Docker Builder
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build minimal sandbox base (USER sandbox)
shell: bash

View File

@@ -129,7 +129,7 @@ jobs:
- name: Run Codex test performance agent
if: steps.gate.outputs.run_agent == 'true'
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
with:
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/test-performance-agent.md

View File

@@ -2,51 +2,6 @@
Docs: https://docs.openclaw.ai
## 2026.6.5
### Highlights
- QQBot now strips model reasoning/thinking scaffolding before native delivery, preventing raw `<thinking>` content from leaking into channel replies. (#89913, #90132) Thanks @openperf.
- MCP tool results now coerce `resource_link`, `resource`, `audio`, malformed image, and future non-text/image blocks at the materialize boundary, preventing Anthropic 400s and poisoned session history after a tool returns richer MCP content. (#90710, #90728) Thanks @RanSHammer and @849261680.
- Anthropic extended-thinking sessions recover after prompt-cache expiry or Gateway restart because stream start events wait for `message_start`, letting pre-generation signature errors trigger the existing recovery retry. (#90667, #90697) Thanks @openperf.
- Parallel is now a bundled `web_search` provider with `PARALLEL_API_KEY` discovery, guarded endpoint handling, cache-safe session ids, onboarding picker support, and docs. (#85158) Thanks @NormallyGaussian.
- Google Vertex ADC users get static catalog rows and runtime model resolution again, while single-provider cooldown recovery and memory adapter status checks are more reliable. (#90506, #90609, #90717, #90816) Thanks @849261680.
- Matrix can preflight voice notes before mention gating, preserve thread reads/replies through Matrix relations pagination, and carry QA coverage for voice and thread flows. (#78016, #90415)
- Auth and plugin install state is more durable: auth profiles now live in SQLite, official npm plugin install records keep their trusted pins, and prerelease fallback integrity checks avoid carrying stale integrity forward. (#89102, #88585)
- macOS node mode no longer silently self-reconnects away from a healthy direct Gateway session, reducing unexpected companion app session churn. (#90668, #90815) Thanks @vrurg.
- Upgrade and service paths are safer: cron legacy JSON stores migrate during doctor preflight, service env placeholders no longer mask state-dir secrets, WhatsApp startup waits are bounded, and disabled WhatsApp accounts tear down on config reload. (#90072, #90208, #90277, #90488, #90486, #87951, #87965) Thanks @MonkeyLeeT, @sallyom, @mcaxtr, and @MukundaKatta.
### Changes
- Search/providers: add the Parallel bundled web-search plugin, live provider tests, registration contracts, onboarding/docs wiring, and guarded `api.parallel.ai/v1/search` support. (#85158) Thanks @NormallyGaussian.
- Matrix/channels: add voice-message preflight and thread-aware read/reply behavior, including Matrix QA scenario wiring and docs for voice-message behavior. (#78016, #90415)
- Skills/ClawHub: install ClawHub skills backed by GitHub repositories through the resolved install API, download the pinned GitHub commit, keep install-policy checks, and report install telemetry after success. (#90478) Thanks @Patrick-Erichsen.
- Google Chat/channels: add native approval card actions and click handling so Google Chat approvals use platform-native cards instead of generic message flow.
- Mobile: Android provider/model screens now surface expiring, unavailable, unresolved, and attention states more clearly, while iOS settings and Talk tabs keep diagnostics, gateway rows, attachment labels, and unavailable Talk controls reachable.
- Memory: QMD search can use the new rerank toggle, and memory adapter status uses the resolved default model identity when checking plain status. (#61834)
- Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward `web_fetch`, clarify legacy `openai-codex` auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.
- Release/process: switch release trains to `YYYY.M.PATCH` monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at `2026.6.5` after the published beta.
- Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)
### Fixes
- Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.
- Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.
- Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until `message_start`, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.
- Provider/model resolution: preserve Google Vertex ADC auth markers in generated catalogs, re-probe a single-provider primary after cooldown, share Codex model visibility, fail closed for unknown model auth, preserve Codex alias availability, keep unresolved profile refs unknown, and avoid resolving auth while listing models. (#90506, #90609, #90717, #90702) Thanks @849261680.
- Gateway/macOS/mobile: avoid duplicate Gateway probe warnings by identity, rate-limit node pairing requests while preserving paired-node reconnects, keep macOS node mode on a healthy direct Gateway session, keep iOS diagnostics and gateway rows reachable, and avoid Linux ARM Gradle resource tasks during Android builds. (#85791, #90147, #90668, #90815) Thanks @giodl73-repo and @vrurg.
- TUI/chat/Workboard/auto-reply: optimistic user messages stay stable across stale history reloads, runId reassignment, and abort windows instead of disappearing, jumping, or lingering as ghost rows; Workboard stale lifecycle bulk updates no longer overwrite newer status/provenance; message-tool sends now count as delivery. (#86205, #89600, #88592, #90123) Thanks @RomneyDa.
- Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, service env planning skips unresolved placeholders that would mask state-dir `.env` values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.
- Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)
- Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on `globalThis`; Feishu streaming cards preserve full merged content; voice-call tracks Twilio streams after connect; ClickClack reply tools respect `toolsAllow`. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, and @sahibzada-allahyar.
- Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.
- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
- Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.
- Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.
- Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.
- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)
## 2026.6.2
### Highlights

View File

@@ -1,25 +0,0 @@
package ai.openclaw.app
/** User-selectable app theme mode for Android appearance settings. */
enum class AppearanceThemeMode(
val rawValue: String,
val displayLabel: String,
) {
System(rawValue = "system", displayLabel = "System"),
Dark(rawValue = "dark", displayLabel = "Dark"),
Light(rawValue = "light", displayLabel = "Light"),
;
fun isDark(systemDark: Boolean): Boolean =
when (this) {
System -> systemDark
Dark -> true
Light -> false
}
companion object {
fun fromRawValue(value: String?): AppearanceThemeMode = entries.firstOrNull { it.rawValue == value?.trim()?.lowercase() } ?: Dark
fun fromDisplayLabel(label: String): AppearanceThemeMode = entries.firstOrNull { it.displayLabel.equals(label.trim(), ignoreCase = true) } ?: Dark
}
}

View File

@@ -2,36 +2,18 @@ package ai.openclaw.app
import ai.openclaw.app.ui.OpenClawTheme
import ai.openclaw.app.ui.RootScreen
import android.content.Intent
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Main Android activity that owns Compose UI attachment and runtime UI wiring.
@@ -39,89 +21,18 @@ import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var permissionRequester: PermissionRequester
private var initializedViewModel: MainViewModel? = null
private var didAttachRuntimeUi = false
private var didStartNodeService = false
private var didStartViewModelCollectors = false
private var foreground = false
private var pendingIntent: Intent? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingIntent = intent
handleAssistantIntent(intent)
WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester = PermissionRequester(this)
setContent {
var activeViewModel by remember { mutableStateOf<MainViewModel?>(null) }
LaunchedEffect(Unit) {
withFrameNanos { }
withContext(Dispatchers.Default) {
(application as NodeApp).prefs
}
val readyViewModel = viewModel
activateViewModel(readyViewModel)
activeViewModel = readyViewModel
}
val currentViewModel = activeViewModel
if (currentViewModel == null) {
OpenClawTheme {
StartupSurface()
}
} else {
val appearanceThemeMode by currentViewModel.appearanceThemeMode.collectAsState()
OpenClawTheme(themeMode = appearanceThemeMode) {
RootScreen(viewModel = currentViewModel)
}
}
}
}
override fun onStart() {
super.onStart()
foreground = true
initializedViewModel?.setForeground(true)
}
override fun onStop() {
foreground = false
initializedViewModel?.setForeground(false)
super.onStop()
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
pendingIntent = intent
initializedViewModel?.let { handleAssistantIntent(viewModel = it, intent = intent) }
}
/**
* Wires MainViewModel only after Activity first draw and background prefs warm-up.
*/
private fun activateViewModel(readyViewModel: MainViewModel) {
if (initializedViewModel != null) return
initializedViewModel = readyViewModel
readyViewModel.setForeground(foreground)
startViewModelCollectors(readyViewModel)
pendingIntent?.let { initialIntent ->
handleAssistantIntent(viewModel = readyViewModel, intent = initialIntent)
pendingIntent = null
}
}
/**
* Starts lifecycle collectors after ViewModel construction so they cannot force early startup.
*/
private fun startViewModelCollectors(readyViewModel: MainViewModel) {
if (didStartViewModelCollectors) return
didStartViewModelCollectors = true
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
readyViewModel.preventSleep.collect { enabled ->
viewModel.preventSleep.collect { enabled ->
if (enabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
@@ -133,10 +44,10 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
readyViewModel.runtimeInitialized.collect { ready ->
viewModel.runtimeInitialized.collect { ready ->
if (!ready || didAttachRuntimeUi) return@collect
// Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready.
readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
didAttachRuntimeUi = true
if (!didStartNodeService) {
NodeForegroundService.start(this@MainActivity)
@@ -145,15 +56,36 @@ class MainActivity : ComponentActivity() {
}
}
}
setContent {
OpenClawTheme {
Surface(modifier = Modifier) {
RootScreen(viewModel = viewModel)
}
}
}
}
override fun onStart() {
super.onStart()
viewModel.setForeground(true)
}
override fun onStop() {
viewModel.setForeground(false)
super.onStop()
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleAssistantIntent(intent)
}
/**
* Routes assistant/app-action intents into ViewModel state without recreating the activity.
*/
private fun handleAssistantIntent(
viewModel: MainViewModel,
intent: Intent?,
) {
private fun handleAssistantIntent(intent: android.content.Intent?) {
parseHomeDestinationIntent(intent)?.let { destination ->
viewModel.requestHomeDestination(destination)
return
@@ -162,23 +94,3 @@ class MainActivity : ComponentActivity() {
viewModel.handleAssistantLaunch(request)
}
}
@Composable
private fun StartupSurface() {
Surface(
modifier = Modifier.fillMaxSize(),
color = Color.Black,
contentColor = Color.White,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "OPENCLAW",
fontSize = 22.sp,
fontWeight = FontWeight.Medium,
)
}
}
}

View File

@@ -4,8 +4,6 @@ import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.gateway.DeviceAuthStore
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.node.CameraCaptureManager
@@ -16,7 +14,6 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -24,7 +21,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows.
@@ -36,11 +32,7 @@ class MainViewModel(
private val nodeApp = app as NodeApp
private val prefs = nodeApp.prefs
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
@Volatile private var foreground = false
@Volatile private var runtimeStartupQueued = false
private var foreground = true
private val _requestedHomeDestination = MutableStateFlow<HomeDestination?>(null)
val requestedHomeDestination: StateFlow<HomeDestination?> = _requestedHomeDestination
private val _startOnboardingAtGatewaySetup = MutableStateFlow(false)
@@ -61,19 +53,6 @@ class MainViewModel(
return runtime
}
/**
* Starts the node runtime off the main thread so fresh installs can render
* the shell before encrypted prefs, device identity, and gateway setup warm up.
*/
private fun queueRuntimeStartup() {
if (runtimeRef.value != null || runtimeStartupQueued) return
runtimeStartupQueued = true
viewModelScope.launch(Dispatchers.Default) {
runCatching { ensureRuntime() }
runtimeStartupQueued = false
}
}
/**
* Adapts a runtime StateFlow to a stable ViewModel StateFlow before runtime startup.
*/
@@ -112,7 +91,6 @@ class MainViewModel(
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
@@ -172,7 +150,6 @@ class MainViewModel(
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
val appearanceThemeMode: StateFlow<AppearanceThemeMode> = prefs.appearanceThemeMode
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
@@ -203,6 +180,12 @@ class MainViewModel(
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
init {
if (prefs.onboardingCompleted.value) {
ensureRuntime()
}
}
val canvas: CanvasController
get() = ensureRuntime().canvas
@@ -230,10 +213,13 @@ class MainViewModel(
*/
fun setForeground(value: Boolean) {
foreground = value
if (value && prefs.onboardingCompleted.value) {
queueRuntimeStartup()
}
runtimeRef.value?.setForeground(value)
val runtime =
if (value && prefs.onboardingCompleted.value) {
ensureRuntime()
} else {
runtimeRef.value
}
runtime?.setForeground(value)
}
fun setDisplayName(value: String) {
@@ -284,51 +270,9 @@ class MainViewModel(
prefs.setGatewayPassword(value)
}
/** Clears setup credentials without starting the runtime just to discard first-run pairing auth. */
private fun resetGatewaySetupAuth() {
runtimeRef.value?.resetGatewaySetupAuth() ?: resetGatewaySetupAuthWithoutRuntime()
}
private fun resetGatewaySetupAuthWithoutRuntime() {
prefs.clearGatewaySetupAuth()
val deviceId = DeviceIdentityStore(nodeApp).loadOrCreate().deviceId
val deviceAuthStore = DeviceAuthStore(prefs)
deviceAuthStore.clearToken(deviceId, "node")
deviceAuthStore.clearToken(deviceId, "operator")
}
fun saveGatewayConfigAndConnect(
host: String,
port: Int,
tls: Boolean,
token: String,
bootstrapToken: String,
password: String,
resetSetupAuth: Boolean,
) {
// Gateway pairing touches encrypted prefs, identity files, and sockets; keep
// the whole sequence off the Compose thread so retries cannot trigger ANRs.
viewModelScope.launch(Dispatchers.Default) {
if (resetSetupAuth) {
resetGatewaySetupAuth()
}
prefs.setManualEnabled(true)
prefs.setManualHost(host)
prefs.setManualPort(port)
prefs.setManualTls(tls)
prefs.setGatewayBootstrapToken(bootstrapToken)
prefs.setGatewayToken(token)
prefs.setGatewayPassword(password)
ensureRuntime()
.connect(
GatewayEndpoint.manual(host = host, port = port),
NodeRuntime.GatewayConnectAuth(
token = token.ifEmpty { null },
bootstrapToken = bootstrapToken.ifEmpty { null },
password = password.ifEmpty { null },
),
)
}
/** Clears setup credentials through the runtime so active gateway sessions drop stale auth state. */
fun resetGatewaySetupAuth() {
ensureRuntime().resetGatewaySetupAuth()
}
/** Marks onboarding complete and starts the runtime before UI observes connected-state flows. */
@@ -341,12 +285,10 @@ class MainViewModel(
/** Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */
fun pairNewGateway() {
viewModelScope.launch(Dispatchers.Default) {
runtimeRef.value?.disconnect()
resetGatewaySetupAuth()
prefs.setOnboardingCompleted(false)
_startOnboardingAtGatewaySetup.value = true
}
runtimeRef.value?.disconnect()
resetGatewaySetupAuth()
_startOnboardingAtGatewaySetup.value = true
prefs.setOnboardingCompleted(false)
}
/** Acknowledges the one-shot request that opens onboarding at the gateway setup step. */
@@ -441,30 +383,14 @@ class MainViewModel(
ensureRuntime().setSpeakerEnabled(enabled)
}
fun setAppearanceThemeMode(mode: AppearanceThemeMode) {
prefs.setAppearanceThemeMode(mode)
}
fun refreshGatewayConnection() {
viewModelScope.launch(Dispatchers.Default) {
ensureRuntime().refreshGatewayConnection()
}
}
fun startGatewayDiscovery() {
queueRuntimeStartup()
ensureRuntime().refreshGatewayConnection()
}
fun connect(endpoint: GatewayEndpoint) {
ensureRuntime().connect(endpoint)
}
fun connectInBackground(endpoint: GatewayEndpoint) {
viewModelScope.launch(Dispatchers.Default) {
ensureRuntime().connect(endpoint)
}
}
fun connect(
endpoint: GatewayEndpoint,
token: String?,

View File

@@ -78,25 +78,6 @@ import java.util.concurrent.atomic.AtomicLong
/**
* Process runtime that owns gateway sessions, node command handlers, capture managers, and UI-facing state.
*/
data class GatewayConnectionProblem(
val code: String?,
val message: String,
val reason: String?,
val requestId: String?,
val recommendedNextStep: String?,
val pauseReconnect: Boolean,
val retryable: Boolean,
) {
val isPairingRequired: Boolean = code == "PAIRING_REQUIRED"
val canAutoRetry: Boolean =
isPairingRequired &&
(
retryable ||
!pauseReconnect ||
recommendedNextStep == "wait_then_retry"
)
}
class NodeRuntime(
context: Context,
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
@@ -304,8 +285,6 @@ class NodeRuntime(
private val _statusText = MutableStateFlow("Offline")
val statusText: StateFlow<String> = _statusText.asStateFlow()
private val _gatewayConnectionProblem = MutableStateFlow<GatewayConnectionProblem?>(null)
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = _gatewayConnectionProblem.asStateFlow()
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
@@ -431,7 +410,6 @@ class NodeRuntime(
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { hello ->
_gatewayConnectionProblem.value = null
operatorConnected = true
operatorStatusText = "Connected"
_serverName.value = hello.serverName
@@ -479,7 +457,6 @@ class NodeRuntime(
updateStatus()
micCapture.onGatewayConnectionChanged(false)
},
onConnectFailure = ::handleGatewayConnectFailure,
onEvent = { event, payloadJson ->
handleGatewayEvent(event, payloadJson)
},
@@ -491,7 +468,6 @@ class NodeRuntime(
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = {
_gatewayConnectionProblem.value = null
_nodeConnected.value = true
nodeStatusText = "Connected"
didAutoRequestCanvasRehydrate = false
@@ -517,7 +493,6 @@ class NodeRuntime(
updateStatus()
showLocalCanvasOnDisconnect()
},
onConnectFailure = ::handleGatewayConnectFailure,
onEvent = { _, _ -> },
onInvoke = { req ->
invokeDispatcher.handleInvoke(req.command, req.paramsJson)
@@ -712,23 +687,6 @@ class NodeRuntime(
updateHomeCanvasState()
}
private fun handleGatewayConnectFailure(
error: GatewaySession.ErrorShape,
pauseReconnect: Boolean,
) {
val details = error.details
_gatewayConnectionProblem.value =
GatewayConnectionProblem(
code = details?.code ?: error.code,
message = error.message,
reason = details?.reason,
requestId = details?.requestId,
recommendedNextStep = details?.recommendedNextStep,
pauseReconnect = pauseReconnect || details?.pauseReconnect == true,
retryable = details?.retryable == true,
)
}
private fun resolveMainSessionKey(): String {
val trimmed = _mainSessionKey.value.trim()
return if (trimmed.isEmpty()) "main" else trimmed
@@ -1452,14 +1410,11 @@ class NodeRuntime(
}
fun refreshGatewayConnection() {
val endpoint = connectedEndpoint
if (endpoint == null) {
resolvePreferredGatewayEndpoint()?.let(::connect)
?: run {
_statusText.value = "Failed: no saved gateway endpoint"
}
return
}
val endpoint =
connectedEndpoint ?: run {
_statusText.value = "Failed: no cached gateway endpoint"
return
}
operatorStatusText = "Connecting…"
updateStatus()
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
@@ -1569,7 +1524,6 @@ class NodeRuntime(
connectAttemptId: Long,
) {
if (!isCurrentConnectAttempt(connectAttemptId)) return
_gatewayConnectionProblem.value = null
connectedEndpoint = endpoint
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
@@ -1666,7 +1620,6 @@ class NodeRuntime(
stopActiveVoiceSession()
connectedEndpoint = null
activeGatewayAuth = null
_gatewayConnectionProblem.value = null
_pendingGatewayTrust.value = null
operatorSession.disconnect()
nodeSession.disconnect()
@@ -1905,7 +1858,7 @@ class NodeRuntime(
return
}
try {
val modelsRes = operatorSession.request("models.list", "{}")
val modelsRes = operatorSession.request("models.list", """{"view":"all"}""")
val modelsRoot = json.parseToJsonElement(modelsRes).asObjectOrNull()
_modelCatalog.value = parseGatewayModels(modelsRoot?.get("models") as? JsonArray)
@@ -2132,7 +2085,6 @@ class NodeRuntime(
id = id,
name = obj["name"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: id,
provider = provider,
available = obj.optionalBoolean("available"),
supportsVision = "image" in inputTypes,
supportsAudio = "audio" in inputTypes,
supportsDocuments = "document" in inputTypes,
@@ -2749,7 +2701,6 @@ data class GatewayModelSummary(
val id: String,
val name: String,
val provider: String,
val available: Boolean?,
val supportsVision: Boolean,
val supportsAudio: Boolean,
val supportsDocuments: Boolean,
@@ -2932,15 +2883,6 @@ private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonP
private fun JsonObject?.boolean(key: String): Boolean = (this?.get(key) as? JsonPrimitive)?.content?.trim() == "true"
private fun JsonObject?.optionalBoolean(key: String): Boolean? =
(this?.get(key) as? JsonPrimitive)?.content?.trim()?.lowercase()?.let { value ->
when (value) {
"true" -> true
"false" -> false
else -> null
}
}
internal fun cronJobLastRunStatus(state: JsonObject?): String? =
state
.cronStatus("lastStatus")

View File

@@ -53,7 +53,6 @@ class PermissionRequester internal constructor(
private val mutex = Mutex()
private val requestSlotsLock = Any()
private val mainHandler = Handler(Looper.getMainLooper())
// ActivityResult launchers cannot be registered after start; pre-register a small pool for nested UI flows.
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }

View File

@@ -42,7 +42,6 @@ class SecurePrefs(
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled"
private const val voiceMicEnabledKey = "voice.micEnabled"
private const val appearanceThemeModeKey = "appearance.themeMode"
}
private val appContext = context.applicationContext
@@ -182,10 +181,6 @@ class SecurePrefs(
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
private val _appearanceThemeMode =
MutableStateFlow(AppearanceThemeMode.fromRawValue(plainPrefs.getString(appearanceThemeModeKey, null)))
val appearanceThemeMode: StateFlow<AppearanceThemeMode> = _appearanceThemeMode
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
@@ -530,11 +525,6 @@ class SecurePrefs(
_speakerEnabled.value = value
}
fun setAppearanceThemeMode(mode: AppearanceThemeMode) {
plainPrefs.edit { putString(appearanceThemeModeKey, mode.rawValue) }
_appearanceThemeMode.value = mode
}
private fun loadNotificationForwardingPackages(): Set<String> {
val raw = plainPrefs.getString(notificationsForwardingPackagesKey, null)?.trim()
if (raw.isNullOrEmpty()) {

View File

@@ -61,11 +61,9 @@ class ChatController(
private val pendingRuns = mutableSetOf<String>()
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
// Preserve sent messages locally until chat.history includes the gateway-confirmed copy.
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
private val pendingRunTimeoutMs = 120_000L
// Drops stale history responses after session switches or refresh races.
private val historyLoadGeneration = AtomicLong(0)
@@ -227,7 +225,6 @@ class ChatController(
role = "user",
content = userContent,
timestampMs = System.currentTimeMillis(),
idempotencyKey = "$runId:user",
)
optimisticMessagesByRunId[runId] = optimisticMessage
_messages.value = _messages.value + optimisticMessage
@@ -353,7 +350,6 @@ class ChatController(
)
if (!isCurrentHistoryLoad(sessionKey, _sessionKey.value, generation, historyLoadGeneration.get())) return
val history = parseHistory(historyJson, sessionKey = sessionKey, previousMessages = _messages.value)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
_historyLoading.value = false
@@ -426,8 +422,10 @@ class ChatController(
}
if (runId != null) {
clearPendingRun(runId)
optimisticMessagesByRunId.remove(runId)
} else {
clearPendingRuns(clearOptimisticMessages = false)
clearPendingRuns()
optimisticMessagesByRunId.clear()
}
pendingToolCallsById.clear()
publishPendingToolCalls()
@@ -457,7 +455,6 @@ class ChatController(
sessionKey = currentSessionKey,
previousMessages = _messages.value,
)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
history.thinkingLevel
@@ -564,14 +561,12 @@ class ChatController(
}
}
private fun clearPendingRuns(clearOptimisticMessages: Boolean = true) {
private fun clearPendingRuns() {
for ((_, job) in pendingRunTimeoutJobs) {
job.cancel()
}
pendingRunTimeoutJobs.clear()
if (clearOptimisticMessages) {
optimisticMessagesByRunId.clear()
}
optimisticMessagesByRunId.clear()
synchronized(pendingRuns) {
pendingRuns.clear()
_pendingRunCount.value = 0
@@ -583,15 +578,6 @@ class ChatController(
_messages.value = _messages.value.filterNot { it.id == message.id }
}
private fun prunePersistedOptimisticMessages(incoming: List<ChatMessage>) {
val retained =
retainUnmatchedOptimisticMessages(
incoming = incoming,
optimistic = optimisticMessagesByRunId.values,
).toSet()
optimisticMessagesByRunId.entries.removeAll { entry -> entry.value !in retained }
}
private fun parseHistory(
historyJson: String,
sessionKey: String,
@@ -606,14 +592,13 @@ class ChatController(
array.mapNotNull { item ->
val obj = item.asObjectOrNull() ?: return@mapNotNull null
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
val content = parseChatMessageContents(obj)
val content = obj["content"].asArrayOrNull()?.mapNotNull(::parseChatMessageContent) ?: emptyList()
val ts = obj["timestamp"].asLongOrNull()
ChatMessage(
id = UUID.randomUUID().toString(),
role = role,
content = content,
timestampMs = ts,
idempotencyKey = obj["idempotencyKey"].asStringOrNull(),
)
}
@@ -689,19 +674,6 @@ internal fun parseChatMessageContent(el: JsonElement): ChatMessageContent? {
}
}
internal fun parseChatMessageContents(obj: JsonObject): List<ChatMessageContent> {
obj["content"].asArrayOrNull()?.let { content ->
return content.mapNotNull(::parseChatMessageContent)
}
obj["content"].asStringOrNull()?.let { text ->
return listOf(ChatMessageContent(type = "text", text = text))
}
obj["text"].asStringOrNull()?.let { text ->
return listOf(ChatMessageContent(type = "text", text = text))
}
return emptyList()
}
internal data class MainSessionState(
val currentSessionKey: String,
val appliedMainSessionKey: String,
@@ -760,41 +732,29 @@ internal fun mergeOptimisticMessages(
): List<ChatMessage> {
if (optimistic.isEmpty()) return incoming
val missingOptimistic = retainUnmatchedOptimisticMessages(incoming = incoming, optimistic = optimistic)
val unmatchedIncoming = incoming.toMutableList()
val missingOptimistic =
optimistic.filter { message ->
val matchIndex =
unmatchedIncoming.indexOfFirst { incomingMessage ->
incomingMessageConsumesOptimistic(incomingMessage, message)
}
if (matchIndex >= 0) {
unmatchedIncoming.removeAt(matchIndex)
false
} else {
true
}
}
if (missingOptimistic.isEmpty()) return incoming
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
}
internal fun retainUnmatchedOptimisticMessages(
incoming: List<ChatMessage>,
optimistic: Collection<ChatMessage>,
): List<ChatMessage> {
if (optimistic.isEmpty()) return emptyList()
val unmatchedIncoming = incoming.toMutableList()
return optimistic.filter { message ->
val matchIndex =
unmatchedIncoming.indexOfFirst { incomingMessage ->
incomingMessageConsumesOptimistic(incomingMessage, message)
}
if (matchIndex >= 0) {
unmatchedIncoming.removeAt(matchIndex)
false
} else {
true
}
}
}
/**
* Message identity used only for refresh reconciliation; it avoids exposing gateway ids as UI keys.
*/
internal fun messageIdentityKey(message: ChatMessage): String? {
val idempotencyKey = message.idempotencyKey?.trim().orEmpty()
if (idempotencyKey.isNotEmpty()) {
return listOf(message.role.trim().lowercase(), idempotencyKey).joinToString(separator = "|")
}
val contentKey = messageContentIdentityKey(message) ?: return null
val timestamp = message.timestampMs?.toString().orEmpty()
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
@@ -807,10 +767,6 @@ private fun incomingMessageConsumesOptimistic(
incoming: ChatMessage,
optimistic: ChatMessage,
): Boolean {
val optimisticIdempotencyKey = optimistic.idempotencyKey?.trim().orEmpty()
if (optimisticIdempotencyKey.isNotEmpty()) {
return incoming.idempotencyKey?.trim() == optimisticIdempotencyKey
}
if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false
val incomingTimestamp = incoming.timestampMs ?: return false
val optimisticTimestamp = optimistic.timestampMs ?: return true

View File

@@ -8,7 +8,6 @@ data class ChatMessage(
val role: String,
val content: List<ChatMessageContent>,
val timestampMs: Long?,
val idempotencyKey: String? = null,
)
/**

View File

@@ -66,12 +66,10 @@ class GatewayDiscovery(
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
/** Current discovered gateway list, merged from local DNS-SD and optional wide-area DNS-SD. */
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
private val _statusText = MutableStateFlow("Searching…")
/** Short diagnostic text shown by connect UI while discovery is running. */
val statusText: StateFlow<String> = _statusText.asStateFlow()

View File

@@ -77,8 +77,6 @@ data class GatewayConnectErrorDetails(
val recommendedNextStep: String?,
val pauseReconnect: Boolean? = null,
val reason: String? = null,
val requestId: String? = null,
val retryable: Boolean = false,
)
/**
@@ -122,7 +120,6 @@ class GatewaySession(
private val deviceAuthStore: DeviceAuthTokenStore,
private val onConnected: (GatewayHelloSummary) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onConnectFailure: (error: ErrorShape, pauseReconnect: Boolean) -> Unit = { _, _ -> },
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
@@ -130,7 +127,6 @@ class GatewaySession(
private companion object {
// Keep connect timeout above observed gateway unauthorized close on lower-end devices.
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
private val PAIRING_REQUEST_ID_PATTERN = Regex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
}
/**
@@ -927,8 +923,6 @@ class GatewaySession(
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
pauseReconnect = it["pauseReconnect"].asBooleanOrNull(),
reason = it["reason"].asStringOrNull(),
requestId = normalizePairingRequestId(it["requestId"].asStringOrNull()),
retryable = it["retryable"].asBooleanOrNull() == true,
)
}
ErrorShape(code, msg, details)
@@ -954,11 +948,6 @@ class GatewaySession(
onEvent(event, payloadJson)
}
private fun normalizePairingRequestId(requestId: String?): String? {
val trimmed = requestId?.trim()?.takeIf { it.isNotEmpty() } ?: return null
return trimmed.takeIf { PAIRING_REQUEST_ID_PATTERN.matches(it) }
}
private suspend fun awaitConnectNonce(): String =
try {
withTimeout(2_000) { connectNonceDeferred.await() }
@@ -1072,14 +1061,10 @@ class GatewaySession(
} catch (err: Throwable) {
attempt += 1
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
val gatewayConnectFailure = err as? GatewayConnectFailure
val pauseForAuthFailure =
gatewayConnectFailure
?.let { shouldPauseReconnectAfterAuthFailure(it.gatewayError) } == true
if (gatewayConnectFailure != null) {
onConnectFailure(gatewayConnectFailure.gatewayError, pauseForAuthFailure)
}
if (pauseForAuthFailure) {
if (
err is GatewayConnectFailure &&
shouldPauseReconnectAfterAuthFailure(err.gatewayError)
) {
reconnectPausedForAuthFailure = true
continue
}

View File

@@ -30,7 +30,8 @@ private const val MAX_DEVICE_APPS_LIMIT = 200
private const val DEVICE_APPS_SYSTEM_FLAGS =
ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
internal fun isSystemDeviceApp(appInfo: ApplicationInfo): Boolean = (appInfo.flags and DEVICE_APPS_SYSTEM_FLAGS) != 0
internal fun isSystemDeviceApp(appInfo: ApplicationInfo): Boolean =
(appInfo.flags and DEVICE_APPS_SYSTEM_FLAGS) != 0
internal data class DeviceAppEntry(
val label: String,

View File

@@ -297,15 +297,17 @@ private fun CommandSectionLabel(title: String) {
}
}
internal fun providerCommandSubtitle(
/** Builds provider quick-action metadata from current gateway/catalog state. */
private fun providerCommandSubtitle(
isConnected: Boolean,
providers: List<GatewayModelProviderSummary>,
models: List<GatewayModelSummary>,
): String {
if (!isConnected) return "Connect Gateway to view providers"
val readyProviderCount = providerRows(providers = providers, models = models).count { it.ready }
if (!isConnected) return "Connect Gateway to load models"
val readyProviderCount = providers.count { modelProviderReady(it.status) }
if (readyProviderCount > 0) return "$readyProviderCount providers ready"
return "No ready providers"
if (models.isNotEmpty()) return "${models.size} models available"
return "Configure model access"
}
/** Falls back to the canonical main-session label when gateway display names are blank. */

View File

@@ -1,7 +1,7 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.ui.mobileCardSurface
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke
@@ -66,7 +66,6 @@ private enum class ConnectInputMode {
fun ConnectTabScreen(viewModel: MainViewModel) {
val context = LocalContext.current
val statusText by viewModel.statusText.collectAsState()
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val manualHost by viewModel.manualHost.collectAsState()
@@ -148,10 +147,13 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
}
}
val showDiagnostics = !isConnected && (gatewayConnectionProblem != null || gatewayStatusHasDiagnostics(statusText))
val pairingRequired = !isConnected && (gatewayConnectionProblem?.isPairingRequired == true || gatewayStatusLooksLikePairing(statusText))
val pairingInstruction = gatewayPairingInstruction(gatewayConnectionProblem)
val statusLabel = gatewayStatusForDisplay(gatewayConnectionProblem?.message ?: statusText)
val showDiagnostics = !isConnected && gatewayStatusHasDiagnostics(statusText)
val pairingRequired = !isConnected && gatewayStatusLooksLikePairing(statusText)
val statusLabel = gatewayStatusForDisplay(statusText)
PairingAutoRetryEffect(enabled = pairingRequired) {
viewModel.refreshGatewayConnection()
}
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 16.dp),
@@ -289,14 +291,27 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
}
validationText = null
viewModel.saveGatewayConfigAndConnect(
host = config.host,
port = config.port,
tls = config.tls,
token = config.token,
bootstrapToken = config.bootstrapToken,
password = config.password,
resetSetupAuth = inputMode == ConnectInputMode.SetupCode,
if (inputMode == ConnectInputMode.SetupCode) {
// Setup-code auth should replace old bootstrap/shared credentials;
// manual reconnects keep existing typed credentials.
viewModel.resetGatewaySetupAuth()
}
viewModel.setManualEnabled(true)
viewModel.setManualHost(config.host)
viewModel.setManualPort(config.port)
viewModel.setManualTls(config.tls)
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
if (config.token.isNotBlank()) {
viewModel.setGatewayToken(config.token)
} else if (config.bootstrapToken.isNotBlank()) {
viewModel.setGatewayToken("")
}
viewModel.setGatewayPassword(config.password)
viewModel.connect(
GatewayEndpoint.manual(host = config.host, port = config.port),
token = config.token.ifEmpty { null },
bootstrapToken = config.bootstrapToken.ifEmpty { null },
password = config.password.ifEmpty { null },
)
},
modifier = Modifier.fillMaxWidth().height(52.dp),
@@ -326,7 +341,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
Text(statusLabel, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
if (pairingRequired) {
Text(
pairingInstruction,
"Approve this phone on the gateway. OpenClaw retries automatically while this screen stays open.",
style = mobileCallout,
color = mobileTextSecondary,
)
@@ -575,13 +590,6 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
}
}
private fun gatewayPairingInstruction(problem: GatewayConnectionProblem?): String =
if (problem?.canAutoRetry == true) {
"Approve this phone on the gateway. OpenClaw will reconnect automatically."
} else {
"Approve this phone on the gateway, then retry the connection."
}
@Composable
private fun MethodChip(
label: String,

View File

@@ -0,0 +1,53 @@
package ai.openclaw.app.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.delay
internal const val PAIRING_INITIAL_AUTO_RETRY_MS = 1_500L
internal const val PAIRING_AUTO_RETRY_MS = 4_000L
/** Retries pairing-only gateway refreshes while the screen is visible and started. */
@Composable
internal fun PairingAutoRetryEffect(
enabled: Boolean,
onRetry: () -> Unit,
) {
val lifecycleOwner = LocalLifecycleOwner.current
var lifecycleStarted by
remember(lifecycleOwner) {
mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED))
}
DisposableEffect(lifecycleOwner) {
val observer =
LifecycleEventObserver { _, _ ->
lifecycleStarted = lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(enabled, lifecycleStarted) {
if (!enabled || !lifecycleStarted) {
return@LaunchedEffect
}
// Give the gateway a short settling window before the first retry so an
// approval response is not immediately chased by a redundant reconnect.
delay(PAIRING_INITIAL_AUTO_RETRY_MS)
while (true) {
onRetry()
delay(PAIRING_AUTO_RETRY_MS)
}
}
}

View File

@@ -41,27 +41,27 @@ internal data class MobileColors(
internal fun lightMobileColors() =
MobileColors(
surface = Color(0xFFFAFBFC),
surfaceStrong = Color(0xFFEFF3F8),
surface = Color(0xFFF6F7FA),
surfaceStrong = Color(0xFFECEEF3),
cardSurface = Color(0xFFFFFFFF),
border = Color(0xFFDDE3EC),
borderStrong = Color(0xFFC7D0DC),
text = Color(0xFF16181D),
textSecondary = Color(0xFF505B6A),
textTertiary = Color(0xFF8E98A7),
accent = Color(0xFF1B5ACB),
accentSoft = Color(0xFFEAF2FF),
accentBorderStrong = Color(0xFF174CA9),
success = Color(0xFF287F52),
successSoft = Color(0xFFEAF7F0),
warning = Color(0xFFAF7418),
warningSoft = Color(0xFFFFF4DF),
danger = Color(0xFFC94343),
dangerSoft = Color(0xFFFFECEC),
codeBg = Color(0xFFEFF3F8),
codeText = Color(0xFF172033),
codeBorder = Color(0xFFD7DDE7),
codeAccent = Color(0xFF287F52),
border = Color(0xFFE5E7EC),
borderStrong = Color(0xFFD6DAE2),
text = Color(0xFF17181C),
textSecondary = Color(0xFF5D6472),
textTertiary = Color(0xFF99A0AE),
accent = Color(0xFF1D5DD8),
accentSoft = Color(0xFFECF3FF),
accentBorderStrong = Color(0xFF184DAF),
success = Color(0xFF2F8C5A),
successSoft = Color(0xFFEEF9F3),
warning = Color(0xFFC8841A),
warningSoft = Color(0xFFFFF8EC),
danger = Color(0xFFD04B4B),
dangerSoft = Color(0xFFFFF2F2),
codeBg = Color(0xFF15171B),
codeText = Color(0xFFE8EAEE),
codeBorder = Color(0xFF2B2E35),
codeAccent = Color(0xFF3FC97A),
chipBorderConnected = Color(0xFFCFEBD8),
chipBorderConnecting = Color(0xFFD5E2FA),
chipBorderWarning = Color(0xFFEED8B8),

View File

@@ -1,10 +1,9 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionProblem
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.node.DeviceNotificationListenerService
import ai.openclaw.app.ui.design.ClawDesignTheme
import ai.openclaw.app.ui.design.ClawErrorState
@@ -32,31 +31,24 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
@@ -96,13 +88,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -131,19 +120,14 @@ fun OnboardingFlow(
viewModel: MainViewModel,
modifier: Modifier = Modifier,
) {
val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState()
val onboardingDark = appearanceThemeMode.isDark(systemDark = isSystemInDarkTheme())
ClawDesignTheme(dark = onboardingDark) {
ClawDesignTheme {
val context = LocalContext.current
val statusText by viewModel.statusText.collectAsState()
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
val serverName by viewModel.serverName.collectAsState()
val remoteAddress by viewModel.remoteAddress.collectAsState()
val gateways by viewModel.gateways.collectAsState()
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
val savedToken by viewModel.gatewayToken.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
val startAtGatewaySetup by viewModel.startOnboardingAtGatewaySetup.collectAsState()
@@ -158,12 +142,9 @@ fun OnboardingFlow(
var password by rememberSaveable { mutableStateOf("") }
var setupError by rememberSaveable { mutableStateOf<String?>(null) }
var attemptedConnect by rememberSaveable { mutableStateOf(false) }
var attemptedGatewayName by rememberSaveable { mutableStateOf<String?>(null) }
var connectAttemptStartedAtMs by rememberSaveable { mutableLongStateOf(0L) }
var recoveryNowMs by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) }
OpenClawSystemBarAppearance(lightAppearance = !onboardingDark && step != OnboardingStep.Welcome)
val qrScannerOptions =
remember {
GmsBarcodeScannerOptions
@@ -182,12 +163,6 @@ fun OnboardingFlow(
}
}
LaunchedEffect(step) {
if (step == OnboardingStep.Gateway) {
viewModel.startGatewayDiscovery()
}
}
LaunchedEffect(ready, attemptedConnect) {
if (attemptedConnect && ready) {
step = OnboardingStep.Permissions
@@ -228,12 +203,10 @@ fun OnboardingFlow(
when (step) {
OnboardingStep.Welcome ->
ClawDesignTheme(dark = true) {
WelcomeScreen(
modifier = modifier,
onConnect = { step = OnboardingStep.Gateway },
)
}
WelcomeScreen(
modifier = modifier,
onConnect = { step = OnboardingStep.Gateway },
)
OnboardingStep.Gateway ->
GatewaySetupScreen(
modifier = modifier,
@@ -244,8 +217,6 @@ fun OnboardingFlow(
token = token,
password = password,
nearbyGatewayName = gateways.firstOrNull()?.name,
discoveryStatusText = discoveryStatusText,
discoveryStarted = runtimeInitialized,
error = setupError,
onBack = { step = OnboardingStep.Welcome },
onScan = {
@@ -282,10 +253,8 @@ fun OnboardingFlow(
onPasswordChange = { password = it },
onUseNearby = {
val endpoint = gateways.firstOrNull() ?: return@GatewaySetupScreen
attemptedGatewayName = endpoint.name
attemptedConnect = true
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
viewModel.connectInBackground(endpoint)
viewModel.connect(endpoint)
step = OnboardingStep.Recovery
},
onPair = {
@@ -304,17 +273,23 @@ fun OnboardingFlow(
}
setupError = null
attemptedGatewayName = null
attemptedConnect = true
connectAttemptStartedAtMs = SystemClock.elapsedRealtime()
viewModel.saveGatewayConfigAndConnect(
host = config.host,
port = config.port,
tls = config.tls,
token = config.token,
bootstrapToken = config.bootstrapToken,
password = config.password,
resetSetupAuth = true,
// Setup-code pairing replaces any stale shared credentials before
// the bootstrap token is stored for the first authenticated connect.
viewModel.resetGatewaySetupAuth()
viewModel.setManualEnabled(true)
viewModel.setManualHost(config.host)
viewModel.setManualPort(config.port)
viewModel.setManualTls(config.tls)
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
viewModel.setGatewayToken(config.token)
viewModel.setGatewayPassword(config.password)
viewModel.connect(
GatewayEndpoint.manual(host = config.host, port = config.port),
token = config.token.ifEmpty { null },
bootstrapToken = config.bootstrapToken.ifEmpty { null },
password = config.password.ifEmpty { null },
)
step = OnboardingStep.Recovery
},
@@ -324,11 +299,11 @@ fun OnboardingFlow(
modifier = modifier,
statusText = statusText,
serverName = serverName,
attemptedGatewayName = attemptedGatewayName,
remoteAddress = remoteAddress,
ready = ready,
gatewayConnectionProblem = gatewayConnectionProblem,
attemptedConnect = attemptedConnect,
connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS,
onAutoRetry = viewModel::refreshGatewayConnection,
onBack = { step = OnboardingStep.Gateway },
onRetry = {
attemptedConnect = true
@@ -342,14 +317,11 @@ fun OnboardingFlow(
token = token,
password = password,
) ?: return@GatewayRecoveryScreen
viewModel.saveGatewayConfigAndConnect(
host = config.host,
port = config.port,
tls = config.tls,
token = config.token,
bootstrapToken = config.bootstrapToken,
password = config.password,
resetSetupAuth = false,
viewModel.connect(
GatewayEndpoint.manual(host = config.host, port = config.port),
token = config.token.ifEmpty { null },
bootstrapToken = config.bootstrapToken.ifEmpty { null },
password = config.password.ifEmpty { null },
)
},
onEdit = { step = OnboardingStep.Gateway },
@@ -374,39 +346,20 @@ private fun WelcomeScreen(
onConnect: () -> Unit,
modifier: Modifier = Modifier,
) {
val welcomeBackground =
Brush.verticalGradient(
colors =
listOf(
Color(0xFFFF4D4D),
Color(0xFFD73332),
Color(0xFF991B1B),
Color(0xFF260707),
),
)
Box(
modifier =
modifier
.fillMaxSize()
.background(welcomeBackground)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 24.dp, vertical = 18.dp),
) {
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 18.dp)) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(96.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(18.dp)) {
WelcomeLogo()
Text(
text = "OPENCLAW",
style = ClawTheme.type.display.copy(fontSize = 34.sp, lineHeight = 38.sp, fontWeight = FontWeight.Black),
color = ClawTheme.colors.text,
)
Text(
text = "Your personal AI assistant.\nExfoliate! Exfoliate!",
text = "Your AI command center.\nPrivate. Local. Under your control.",
style = ClawTheme.type.section,
color = ClawTheme.colors.text,
textAlign = TextAlign.Center,
@@ -417,26 +370,19 @@ private fun WelcomeScreen(
Spacer(modifier = Modifier.height(30.dp))
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
HeroPrimaryAction(title = "Connect Gateway", onClick = onConnect)
OutlinedAction(title = "Enter setup code", icon = Icons.AutoMirrored.Filled.KeyboardArrowRight, onClick = onConnect)
Surface(onClick = onConnect, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Text(text = "Already have a setup? ", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = "Sign in", style = ClawTheme.type.body.copy(fontWeight = FontWeight.SemiBold), color = ClawTheme.colors.text)
}
}
}
Spacer(modifier = Modifier.height(104.dp))
}
}
}
@Composable
private fun WelcomeLogo() {
Surface(
modifier = Modifier.size(82.dp),
shape = CircleShape,
color = Color.White.copy(alpha = 0.92f),
contentColor = Color.Unspecified,
) {
Box(modifier = Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) {
Image(painter = painterResource(id = R.drawable.openclaw_logo), contentDescription = "OpenClaw logo", modifier = Modifier.fillMaxSize())
}
}
}
@Composable
private fun WelcomeHorizon() {
Canvas(modifier = Modifier.fillMaxWidth().height(120.dp)) {
@@ -482,8 +428,6 @@ private fun GatewaySetupScreen(
token: String,
password: String,
nearbyGatewayName: String?,
discoveryStatusText: String,
discoveryStarted: Boolean,
error: String?,
onBack: () -> Unit,
onScan: () -> Unit,
@@ -498,29 +442,6 @@ private fun GatewaySetupScreen(
modifier: Modifier = Modifier,
) {
var advancedOpen by rememberSaveable { mutableStateOf(false) }
var nearbySearchTimedOut by remember { mutableStateOf(false) }
LaunchedEffect(nearbyGatewayName, discoveryStatusText, discoveryStarted) {
if (!nearbyGatewayName.isNullOrBlank()) {
nearbySearchTimedOut = false
return@LaunchedEffect
}
if (!discoveryStarted) {
nearbySearchTimedOut = false
return@LaunchedEffect
}
nearbySearchTimedOut = false
delay(5_000)
nearbySearchTimedOut = true
}
val nearbyGateway =
nearbyGatewayUiState(
nearbyGatewayName = nearbyGatewayName,
discoveryStatusText = discoveryStatusText,
discoveryStarted = discoveryStarted,
searchTimedOut = nearbySearchTimedOut,
)
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
Column(modifier = Modifier.fillMaxSize().imePadding(), verticalArrangement = Arrangement.SpaceBetween) {
@@ -540,9 +461,9 @@ private fun GatewaySetupScreen(
GatewayOption(
icon = Icons.Default.WifiTethering,
title = "Nearby gateway",
subtitle = nearbyGateway.subtitle,
status = nearbyGateway.status,
onClick = onUseNearby.takeIf { nearbyGateway.canConnect },
subtitle = nearbyGatewayName ?: "Discovery ready",
status = nearbyGatewayName?.let { "Found" },
onClick = onUseNearby,
)
}
item {
@@ -606,19 +527,20 @@ private fun GatewaySetupScreen(
private fun GatewayRecoveryScreen(
statusText: String,
serverName: String?,
attemptedGatewayName: String?,
remoteAddress: String?,
ready: Boolean,
gatewayConnectionProblem: GatewayConnectionProblem?,
attemptedConnect: Boolean,
connectSettling: Boolean,
onAutoRetry: () -> Unit,
onBack: () -> Unit,
onRetry: () -> Unit,
onEdit: () -> Unit,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling, gatewayConnectionProblem = gatewayConnectionProblem)
val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling)
val context = LocalContext.current
PairingAutoRetryEffect(enabled = recoveryState.canAutoRetry && attemptedConnect, onRetry = onAutoRetry)
ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) {
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
@@ -629,7 +551,6 @@ private fun GatewayRecoveryScreen(
imageVector =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle
GatewayRecoveryUiState.ApprovalRequired -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering
GatewayRecoveryUiState.Failed -> Icons.Default.ErrorOutline
@@ -639,7 +560,6 @@ private fun GatewayRecoveryScreen(
tint =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> ClawTheme.colors.success
GatewayRecoveryUiState.ApprovalRequired -> ClawTheme.colors.warning
GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text
GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text
GatewayRecoveryUiState.Failed -> ClawTheme.colors.warning
@@ -657,16 +577,12 @@ private fun GatewayRecoveryScreen(
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Last gateway", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
Text(text = recoveryGatewayName(serverName = serverName, attemptedGatewayName = attemptedGatewayName), style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText, gatewayConnectionProblem = gatewayConnectionProblem), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
recoveryGatewayApprovalCommand(gatewayConnectionProblem)?.let { command ->
ApprovalCommandBlock(command = command, onCopy = { copyApprovalCommand(context, command) })
}
Text(text = serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
ClawStatusPill(
text =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> "Healthy"
GatewayRecoveryUiState.ApprovalRequired -> "Needs approval"
GatewayRecoveryUiState.Pairing -> "Pairing"
GatewayRecoveryUiState.Finishing -> "Connecting"
GatewayRecoveryUiState.Failed -> "Needs attention"
@@ -674,7 +590,6 @@ private fun GatewayRecoveryScreen(
status =
when (recoveryState) {
GatewayRecoveryUiState.Connected -> ClawStatus.Success
GatewayRecoveryUiState.ApprovalRequired -> ClawStatus.Warning
GatewayRecoveryUiState.Pairing -> ClawStatus.Neutral
GatewayRecoveryUiState.Finishing -> ClawStatus.Neutral
GatewayRecoveryUiState.Failed -> ClawStatus.Warning
@@ -691,42 +606,7 @@ private fun GatewayRecoveryScreen(
modifier = Modifier.fillMaxWidth(),
)
OutlinedAction(title = "Edit connection", icon = Icons.Default.Edit, onClick = onEdit)
OutlinedAction(title = "Copy diagnostic", icon = Icons.Default.ContentCopy, onClick = { copyGatewayDiagnostic(context, statusText, serverName, remoteAddress, ready, gatewayConnectionProblem) })
}
}
}
}
@Composable
private fun ApprovalCommandBlock(
command: String,
onCopy: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surfacePressed,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 6.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SelectionContainer(modifier = Modifier.weight(1f)) {
Text(text = command, style = ClawTheme.type.body.copy(fontFamily = FontFamily.Monospace), color = ClawTheme.colors.text)
}
Surface(
onClick = onCopy,
modifier = Modifier.size(36.dp),
shape = RoundedCornerShape(8.dp),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Default.ContentCopy, contentDescription = "Copy approval command", modifier = Modifier.size(18.dp))
}
OutlinedAction(title = "Copy diagnostic", icon = Icons.Default.ContentCopy, onClick = { copyGatewayDiagnostic(context, statusText, serverName, remoteAddress, ready) })
}
}
}
@@ -823,7 +703,7 @@ private fun GatewayOption(
icon: ImageVector,
title: String,
subtitle: String,
onClick: (() -> Unit)?,
onClick: () -> Unit,
status: String? = null,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) {
@@ -832,12 +712,9 @@ private fun GatewayOption(
subtitle = subtitle,
metadata = status,
leading = { Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(22.dp), tint = ClawTheme.colors.text) },
trailing =
onClick?.let {
{
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open $title", modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text)
}
},
trailing = {
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open $title", modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text)
},
onClick = onClick,
)
}
@@ -1013,80 +890,38 @@ private fun PermissionContinueButton(onClick: () -> Unit) {
internal enum class GatewayRecoveryUiState(
val title: String,
val message: String,
val canAutoRetry: Boolean,
) {
Connected(
title = "Connected",
message = "Your Gateway is ready.",
),
ApprovalRequired(
title = "Pairing Gateway",
message = "Approve this phone on the gateway.\nThen retry the connection.",
canAutoRetry = false,
),
Pairing(
title = "Pairing Gateway",
message = "Approval is in progress.\nOpenClaw will reconnect automatically.",
canAutoRetry = true,
),
Finishing(
title = "Connecting Gateway",
message = "OpenClaw is checking gateway and node access.",
title = "Finishing Setup",
message = "Gateway approved this phone.\nOpenClaw is bringing the node online.",
canAutoRetry = true,
),
Failed(
title = "Connection issue",
message = "We could not reach your Gateway.\nLet's fix this.",
canAutoRetry = false,
),
}
internal data class NearbyGatewayUiState(
val subtitle: String,
val status: String?,
val canConnect: Boolean,
)
/** Maps best-effort discovery into row copy and clickability for onboarding. */
internal fun nearbyGatewayUiState(
nearbyGatewayName: String?,
discoveryStatusText: String,
discoveryStarted: Boolean = true,
searchTimedOut: Boolean = false,
): NearbyGatewayUiState {
val name = nearbyGatewayName?.trim().takeUnless { it.isNullOrEmpty() }
if (name != null) {
return NearbyGatewayUiState(subtitle = name, status = "Found", canConnect = true)
}
if (!discoveryStarted) {
return NearbyGatewayUiState(subtitle = "Starting discovery...", status = "Starting", canConnect = false)
}
val status = discoveryStatusText.trim()
val searching =
status.isEmpty() ||
status.equals("Searching…", ignoreCase = true) ||
status.contains("Searching", ignoreCase = true) ||
status.endsWith("?", ignoreCase = true)
return if (searching) {
if (searchTimedOut) {
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false)
} else {
NearbyGatewayUiState(subtitle = "Searching for gateways...", status = "Searching", canConnect = false)
}
} else {
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false)
}
}
/** Derives recovery screen state from gateway/node readiness and transient status text. */
internal fun gatewayRecoveryUiState(
ready: Boolean,
statusText: String,
connectSettling: Boolean,
gatewayConnectionProblem: GatewayConnectionProblem? = null,
): GatewayRecoveryUiState =
when {
ready -> GatewayRecoveryUiState.Connected
gatewayConnectionProblem?.isPairingRequired == true &&
!gatewayConnectionProblem.canAutoRetry -> GatewayRecoveryUiState.ApprovalRequired
gatewayConnectionProblem?.isPairingRequired == true -> GatewayRecoveryUiState.Pairing
gatewayConnectionProblem?.pauseReconnect == true -> GatewayRecoveryUiState.Failed
connectSettling -> GatewayRecoveryUiState.Finishing
gatewayStatusLooksLikePairing(statusText) -> GatewayRecoveryUiState.Pairing
gatewayStatusLooksLikePartialConnect(statusText) -> GatewayRecoveryUiState.Finishing
@@ -1099,18 +934,6 @@ internal fun gatewayStatusLooksLikePartialConnect(statusText: String): Boolean {
return lower.contains("operator offline") || lower.contains("node offline")
}
internal fun recoveryGatewayName(
serverName: String?,
attemptedGatewayName: String?,
): String =
serverName
?.trim()
?.takeIf { it.isNotEmpty() }
?: attemptedGatewayName
?.trim()
?.takeIf { it.isNotEmpty() }
?: "Home Gateway"
private data class GatewayConfig(
val host: String,
val port: Int,
@@ -1170,16 +993,11 @@ private fun recoveryGatewayDetail(
ready: Boolean,
remoteAddress: String?,
statusText: String,
gatewayConnectionProblem: GatewayConnectionProblem?,
): String =
remoteAddress
?.takeIf { it.isNotBlank() }
?: if (ready) {
"Ready for chat and voice"
} else if (gatewayConnectionProblem?.isPairingRequired == true && !gatewayConnectionProblem.canAutoRetry) {
recoveryGatewayApprovalCommand(gatewayConnectionProblem)
?.let { "Gateway approval is pending. Run this on the gateway host:" }
?: "Gateway approval is pending. Run openclaw devices list on the gateway host, approve this phone, then retry."
} else if (statusText.contains("operator offline", ignoreCase = true)) {
"Gateway paired. Waiting for operator access."
} else if (gatewayStatusLooksLikePairing(statusText)) {
@@ -1188,25 +1006,6 @@ private fun recoveryGatewayDetail(
"Gateway unreachable"
}
private fun recoveryGatewayApprovalCommand(gatewayConnectionProblem: GatewayConnectionProblem?): String? {
if (gatewayConnectionProblem?.isPairingRequired != true || gatewayConnectionProblem.canAutoRetry) return null
val requestId = gatewayConnectionProblem.requestId?.trim()?.takeIf { it.isNotEmpty() }
return if (requestId != null) {
"openclaw devices approve $requestId"
} else {
"openclaw devices list"
}
}
private fun copyApprovalCommand(
context: Context,
command: String,
) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OpenClaw pairing approval command", command))
Toast.makeText(context, "Approval command copied", Toast.LENGTH_SHORT).show()
}
/** Copies the onboarding recovery snapshot for support without including credentials. */
private fun copyGatewayDiagnostic(
context: Context,
@@ -1214,16 +1013,11 @@ private fun copyGatewayDiagnostic(
serverName: String?,
remoteAddress: String?,
ready: Boolean,
gatewayConnectionProblem: GatewayConnectionProblem?,
) {
val approvalCommand = recoveryGatewayApprovalCommand(gatewayConnectionProblem)
val diagnostic =
listOfNotNull(
listOf(
"OpenClaw Android gateway diagnostic",
"Status: $statusText",
gatewayConnectionProblem?.message?.let { "Gateway problem: $it" },
gatewayConnectionProblem?.requestId?.let { "Pairing request: $it" },
approvalCommand?.let { "Approval command: $it" },
"Gateway: ${serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway"}",
"Address: ${remoteAddress?.takeIf { it.isNotBlank() } ?: "Not available"}",
"Ready: ${if (ready) "yes" else "no"}",

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.ui
import ai.openclaw.app.AppearanceThemeMode
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -9,51 +8,34 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LocalOpenClawDarkTheme = staticCompositionLocalOf { true }
/**
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
*/
@Composable
fun OpenClawTheme(
themeMode: AppearanceThemeMode = AppearanceThemeMode.Dark,
content: @Composable () -> Unit,
) {
fun OpenClawTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val isDark = themeMode.isDark(systemDark = isSystemInDarkTheme())
val isDark = isSystemInDarkTheme()
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
OpenClawSystemBarAppearance(lightAppearance = !isDark)
CompositionLocalProvider(
LocalMobileColors provides mobileColors,
LocalOpenClawDarkTheme provides isDark,
) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
}
@Composable
internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
val window = (view.context as Activity).window
WindowCompat
.getInsetsController(window, window.decorView)
.isAppearanceLightStatusBars = lightAppearance
WindowCompat
.getInsetsController(window, window.decorView)
.isAppearanceLightNavigationBars = lightAppearance
.isAppearanceLightStatusBars = !isDark
}
}
CompositionLocalProvider(LocalMobileColors provides mobileColors) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
}
/**
@@ -62,9 +44,9 @@ internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
@Composable
fun overlayContainerColor(): Color {
val scheme = MaterialTheme.colorScheme
val isDark = LocalOpenClawDarkTheme.current
val isDark = isSystemInDarkTheme()
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
// Light mode keeps overlays away from pure-white glare on the app canvas.
// Light mode: background stays dark (canvas), so clamp overlays away from pure-white glare.
return if (isDark) base else base.copy(alpha = 0.88f)
}

View File

@@ -6,6 +6,7 @@ import ai.openclaw.app.MainViewModel
import ai.openclaw.app.providerDisplayName
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawTheme
@@ -16,20 +17,27 @@ 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.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
@@ -38,20 +46,25 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Android provider readiness screen backed by the configured gateway model view. */
/** Android providers/models browser backed by the gateway catalog. */
@Composable
internal fun ProvidersModelsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
onAddProvider: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val models by viewModel.modelCatalog.collectAsState()
@@ -59,6 +72,9 @@ internal fun ProvidersModelsScreen(
val refreshing by viewModel.modelCatalogRefreshing.collectAsState()
val errorText by viewModel.modelCatalogErrorText.collectAsState()
val providerRows = providerRows(providers = providers, models = models)
val modelGroups = sortedModelGroups(models)
val setupRows = providerSetupRows(providerRows)
var expandedModelProviders by rememberSaveable { mutableStateOf(emptyList<String>()) }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -84,11 +100,12 @@ internal fun ProvidersModelsScreen(
horizontalArrangement = Arrangement.SpaceBetween,
) {
ProviderHeaderIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
ProviderHeaderIconButton(icon = Icons.Default.Add, contentDescription = "Add provider", outlined = true, onClick = onAddProvider)
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Providers & Models", style = ClawTheme.type.display.copy(fontSize = 14.8.sp, lineHeight = 18.sp), color = ClawTheme.colors.text, maxLines = 1)
Text(
text = "Review provider readiness\nand configured models.",
text = "Connect and manage AI providers\nBrowse models and their capabilities.",
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
color = ClawTheme.colors.textMuted,
)
@@ -102,17 +119,26 @@ internal fun ProvidersModelsScreen(
providerRows = providerRows,
modelCount = models.size,
onRefresh = viewModel::refreshModelCatalog,
onSetup = onAddProvider,
refreshing = refreshing,
)
}
item {
ProviderSectionLabel(title = "Provider setup")
}
item {
ProviderSetupList(rows = setupRows, onSetup = onAddProvider)
}
item {
ProviderSectionLabel(title = "Connected providers")
}
item {
if (!isConnected && providerRows.isEmpty()) {
ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness.")
ClawEmptyState(title = "Gateway offline", body = "Connect your Gateway to load provider readiness and model catalog.")
} else {
ProviderList(rows = providerRows, refreshing = refreshing)
}
@@ -125,12 +151,50 @@ internal fun ProvidersModelsScreen(
}
}
}
item {
ProviderSectionLabel(title = "Model catalog")
}
if (modelGroups.isEmpty()) {
item {
ModelCatalogEmpty(
title = if (refreshing) "Loading models" else "No models loaded",
body = if (isConnected) "Refresh after configuring a provider on the Gateway." else "Connect the Gateway to browse models.",
)
}
} else {
items(modelGroups, key = { it.first }) { entry ->
val expanded = expandedModelProviders.contains(entry.first)
ModelGroup(
provider = entry.first,
models = entry.second,
expanded = expanded,
onToggle = {
expandedModelProviders =
if (expanded) {
expandedModelProviders - entry.first
} else {
expandedModelProviders + entry.first
}
},
)
}
}
}
ProviderAddButton(onClick = onAddProvider, modifier = Modifier.align(Alignment.BottomCenter))
}
}
}
internal data class ProviderRow(
private data class ProviderSetupRow(
val id: String,
val name: String,
val subtitle: String,
val ready: Boolean,
)
private data class ProviderRow(
val id: String,
val name: String,
val status: String,
@@ -138,28 +202,28 @@ internal data class ProviderRow(
val modelCount: Int,
)
/** Combines gateway auth-provider readiness with configured model providers. */
internal fun providerRows(
/** Combines auth-provider readiness rows with catalog-only providers. */
private fun providerRows(
providers: List<GatewayModelProviderSummary>,
models: List<GatewayModelSummary>,
): List<ProviderRow> {
val modelCounts = models.groupingBy { it.provider }.eachCount()
val authRows =
providers
.map { provider ->
val ready = modelProviderReady(provider.status)
ProviderRow(
id = provider.id,
name = provider.displayName,
status = if (ready) "Ready" else "Needs attention",
ready = ready,
modelCount = modelCounts[provider.id] ?: 0,
)
}
val authProviderIds = authRows.mapTo(mutableSetOf()) { it.id.trim().lowercase() }
val configuredModelRows =
providers.map { provider ->
val ready = modelProviderReady(provider.status)
ProviderRow(
id = provider.id,
name = provider.displayName,
status = if (ready) "Ready" else "Needs setup",
ready = ready,
modelCount = modelCounts[provider.id] ?: 0,
)
}
// Static/catalog-only providers may expose models without a matching auth
// provider row; keep them visible as ready providers.
val missingAuthRows =
modelCounts.keys
.filter { provider -> provider.trim().lowercase() !in authProviderIds }
.filter { provider -> authRows.none { it.id == provider } }
.map { provider ->
ProviderRow(
id = provider,
@@ -169,9 +233,33 @@ internal fun providerRows(
modelCount = modelCounts[provider] ?: 0,
)
}
return (authRows + configuredModelRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() }))
return (authRows + missingAuthRows).sortedWith(compareBy(::providerPriority, { it.name.lowercase() }))
}
private fun providerSetupRows(providerRows: List<ProviderRow>): List<ProviderSetupRow> {
val byId = providerRows.associateBy { it.id.trim().lowercase() }
return listOf("openai", "anthropic", "google", "openrouter", "ollama").map { id ->
val row = byId[id] ?: byId["ollama-local"].takeIf { id == "ollama" }
ProviderSetupRow(
id = id,
name = providerDisplayName(id),
subtitle = providerSetupSubtitle(id, row),
ready = row?.ready == true,
)
}
}
private fun providerSetupSubtitle(
id: String,
row: ProviderRow?,
): String =
when {
row?.ready == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Ready"
row != null -> "Finish setup to use ${row.name}"
id == "ollama" -> "Use models running on your network"
else -> "Add provider credentials on your Gateway"
}
/** Normalizes gateway provider status strings into a ready/not-ready boolean. */
internal fun modelProviderReady(status: String): Boolean {
val normalized = status.trim().lowercase()
@@ -182,6 +270,14 @@ internal fun modelProviderReady(status: String): Boolean {
normalized == "static"
}
/** Groups models by provider using the same display priority as provider rows. */
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
models
.groupBy { it.provider }
.entries
.sortedWith(compareBy({ providerPriority(it.key) }, { providerDisplayName(it.key).lowercase() }))
.map { it.key to it.value }
private fun providerPriority(row: ProviderRow): Int = providerPriority(row.id)
private fun providerPriority(provider: String): Int =
@@ -203,15 +299,7 @@ private fun ProviderList(
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
if (rows.isEmpty()) {
ProviderListRow(
ProviderRow(
id = "loading",
name = "Provider catalog",
status = if (refreshing) "Loading" else "No providers",
ready = false,
modelCount = 0,
),
)
ProviderListRow(ProviderRow(id = "loading", name = "Provider catalog", status = if (refreshing) "Loading" else "No providers", ready = false, modelCount = 0))
} else {
val visibleRows = rows.take(5)
visibleRows.forEachIndexed { index, row ->
@@ -232,6 +320,7 @@ private fun ProviderOverviewPanel(
modelCount: Int,
refreshing: Boolean,
onRefresh: () -> Unit,
onSetup: () -> Unit,
) {
val readyCount = providerRows.count { it.ready }
val needsSetupCount = providerRows.count { !it.ready }
@@ -240,14 +329,17 @@ private fun ProviderOverviewPanel(
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ProviderMetricTile(label = "Ready", value = readyCount.toString(), modifier = Modifier.weight(1f))
ProviderMetricTile(label = "Models", value = modelCount.toString(), modifier = Modifier.weight(1f))
ProviderMetricTile(label = "Needs", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
ProviderMetricTile(label = "Setup", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
}
Text(
text = if (isConnected) "Refresh to recheck provider readiness from your Gateway." else "Connect your Gateway to view provider readiness.",
text = if (isConnected) "Choose a provider below, then finish credentials on your Gateway." else "Connect your Gateway before adding model providers.",
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
ClawSecondaryButton(text = if (refreshing) "Refreshing" else "Refresh", onClick = onRefresh, enabled = isConnected && !refreshing, modifier = Modifier.fillMaxWidth())
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(text = if (refreshing) "Refreshing" else "Refresh", onClick = onRefresh, enabled = isConnected && !refreshing, modifier = Modifier.weight(1f))
ClawPrimaryButton(text = "Setup Provider", onClick = onSetup, enabled = isConnected, modifier = Modifier.weight(1f))
}
}
}
}
@@ -272,13 +364,55 @@ private fun ProviderMetricTile(
}
}
@Composable
private fun ProviderSetupList(
rows: List<ProviderSetupRow>,
onSetup: () -> Unit,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
rows.forEachIndexed { index, row ->
ProviderSetupListRow(row = row, onClick = onSetup)
if (index != rows.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
}
}
}
@Composable
private fun ProviderSetupListRow(
row: ProviderSetupRow,
onClick: () -> Unit,
) {
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
ProviderBadge(text = row.name)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(text = row.subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
Text(text = if (row.ready) "Ready" else "Setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open ${row.name}", modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text)
}
}
}
}
@Composable
private fun ProviderListRow(row: ProviderRow) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ProviderBadge(text = row.name)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = row.name, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "No configured models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "Provider setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
@@ -305,6 +439,78 @@ private fun providerInitials(value: String): String =
.joinToString("")
.ifBlank { "AI" }
@Composable
private fun ModelCatalogEmpty(
title: String,
body: String,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 11.dp, vertical = 10.dp)) {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = title, style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = body, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted)
}
}
}
@Composable
private fun ModelGroup(
provider: String,
models: List<GatewayModelSummary>,
expanded: Boolean,
onToggle: () -> Unit,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ProviderBadge(text = providerDisplayName(provider))
Text(text = providerDisplayName(provider), style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ProviderMiniTag(text = "${models.size} models")
Icon(imageVector = if (expanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = if (expanded) "Collapse ${providerDisplayName(provider)} models" else "Expand ${providerDisplayName(provider)} models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.textMuted)
}
}
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
val visibleModels = if (expanded) models else models.take(3)
visibleModels.forEachIndexed { index, model ->
ModelRow(model)
if (index != visibleModels.lastIndex || models.size > visibleModels.size) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
}
if (models.size > visibleModels.size) {
Surface(onClick = onToggle, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(text = "View all models", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, modifier = Modifier.weight(1f))
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "View all models", modifier = Modifier.size(14.dp), tint = ClawTheme.colors.text)
}
}
}
}
}
}
@Composable
private fun ModelRow(model: GatewayModelSummary) {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = model.name, style = ClawTheme.type.mono, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
modelCapabilityLabels(model).take(3).forEach { label ->
ProviderMiniTag(text = label)
}
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
}
}
/** Derives compact capability chips for model catalog rows. */
private fun modelCapabilityLabels(model: GatewayModelSummary): List<String> =
buildList {
if (model.supportsReasoning) add("Reasoning")
if (model.supportsVision) add("Vision")
if (model.supportsAudio) add("Voice")
if (model.supportsDocuments) add("Docs")
if ((model.contextTokens ?: 0L) >= 100_000L) add("Long context")
if (isEmpty()) add("Fast")
}
@Composable
private fun ProviderSectionLabel(title: String) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
@@ -332,3 +538,39 @@ private fun ProviderHeaderIconButton(
}
}
}
@Composable
private fun ProviderAddButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
modifier = modifier.fillMaxWidth().height(ClawTheme.spacing.touchTarget),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.primary,
contentColor = ClawTheme.colors.primaryText,
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Icon(imageVector = Icons.Default.Add, contentDescription = null, modifier = Modifier.size(17.dp))
Spacer(modifier = Modifier.width(7.dp))
Text(text = "Open Gateway Setup", style = ClawTheme.type.label, maxLines = 1)
}
}
}
@Composable
private fun ProviderMiniTag(text: String) {
Surface(
shape = RoundedCornerShape(5.dp),
color = Color.Transparent,
border = BorderStroke(1.dp, ClawTheme.colors.border),
contentColor = ClawTheme.colors.textMuted,
) {
Text(text = text, modifier = Modifier.padding(horizontal = 4.dp, vertical = 0.5.dp), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), maxLines = 1)
}
}

View File

@@ -217,7 +217,7 @@ private fun SessionRow(
compact: Boolean,
onClick: () -> Unit,
) {
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Surface(onClick = onClick, color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
Column {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(vertical = 5.dp),

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.ui
import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayCronJobSummary
@@ -9,6 +8,7 @@ import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.NotificationPackageFilterMode
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.node.DeviceNotificationListenerService
import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawIconBadge
@@ -147,7 +147,7 @@ internal fun SettingsDetailScreen(
SettingsRoute.Notifications -> NotificationSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.PhoneCapabilities -> PhoneCapabilitiesScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Gateway -> GatewaySettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Appearance -> AppearanceSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Appearance -> AppearanceSettingsScreen(onBack = onBack)
SettingsRoute.Health -> HealthLogsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.About -> AboutSettingsScreen(viewModel = viewModel, onBack = onBack)
}
@@ -897,14 +897,18 @@ private fun GatewaySettingsScreen(
.orEmpty()
.ifEmpty { passwordInput.trim() }
validationText = null
viewModel.saveGatewayConfigAndConnect(
host = endpointConfig.host,
port = endpointConfig.port,
tls = endpointConfig.tls,
token = token,
bootstrapToken = bootstrapToken,
password = password,
resetSetupAuth = setup != null,
viewModel.setManualEnabled(true)
viewModel.setManualHost(endpointConfig.host)
viewModel.setManualPort(endpointConfig.port)
viewModel.setManualTls(endpointConfig.tls)
viewModel.setGatewayBootstrapToken(bootstrapToken)
viewModel.setGatewayToken(token)
viewModel.setGatewayPassword(password)
viewModel.connect(
GatewayEndpoint.manual(host = endpointConfig.host, port = endpointConfig.port),
token = token.ifEmpty { null },
bootstrapToken = bootstrapToken.ifEmpty { null },
password = password.ifEmpty { null },
)
},
modifier = Modifier.fillMaxWidth(),
@@ -915,40 +919,22 @@ private fun GatewaySettingsScreen(
}
@Composable
private fun AppearanceSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val themeMode by viewModel.appearanceThemeMode.collectAsState()
private fun AppearanceSettingsScreen(onBack: () -> Unit) {
SettingsDetailFrame(title = "Appearance", subtitle = "A calm, high-contrast OpenClaw interface.", icon = Icons.Default.Palette, onBack = onBack) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Theme", appearanceThemeSummary(themeMode)),
SettingsMetric("Theme", "Dark"),
SettingsMetric("Contrast", "High"),
SettingsMetric("Typography", "Readable"),
),
)
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Theme", style = ClawTheme.type.section, color = ClawTheme.colors.text)
ClawSegmentedControl(
options = appearanceThemeOptions(),
selected = appearanceThemeSummary(themeMode),
onSelect = { selected -> viewModel.setAppearanceThemeMode(appearanceThemeModeForLabel(selected)) },
)
}
Text(text = "OpenClaw uses a fixed premium dark theme so it stays consistent across devices.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
internal fun appearanceThemeSummary(mode: AppearanceThemeMode): String = mode.displayLabel
internal fun appearanceThemeOptions(): List<String> = AppearanceThemeMode.entries.map { it.displayLabel }
internal fun appearanceThemeModeForLabel(label: String): AppearanceThemeMode = AppearanceThemeMode.fromDisplayLabel(label)
/** Converts raw gateway connection text into stable settings metric labels. */
private fun gatewayStatusLabel(
statusText: String,
@@ -985,7 +971,7 @@ private fun AboutSettingsScreen(
listOf(
SettingsMetric("Android App", BuildConfig.VERSION_NAME),
SettingsMetric("Build", BuildConfig.VERSION_CODE.toString()),
SettingsMetric("Channel", androidDistributionChannel()),
SettingsMetric("Channel", "Play"),
SettingsMetric("Gateway", currentGatewayVersion ?: "Not connected"),
),
)
@@ -1008,14 +994,6 @@ private fun AboutSettingsScreen(
}
}
internal fun androidDistributionChannel(flavor: String = BuildConfig.FLAVOR): String =
when (flavor.trim()) {
"play" -> "Play"
"thirdParty" -> "Third-party"
"" -> "Unknown"
else -> flavor.trim()
}
@Composable
private fun AboutStatusRow(
title: String,

View File

@@ -22,7 +22,6 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -104,10 +103,7 @@ private val shellNavTabs = listOf(Tab.Overview, Tab.Chat, Tab.Voice, Tab.Setting
private val shellContentInsets: WindowInsets
@Composable get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
internal fun shellBottomNavVisible(
keyboardVisible: Boolean,
commandOpen: Boolean,
): Boolean = !keyboardVisible && !commandOpen
internal fun shellBottomNavVisible(keyboardVisible: Boolean, commandOpen: Boolean): Boolean = !keyboardVisible && !commandOpen
/** Main post-onboarding shell that owns top-level Android navigation state. */
@Composable
@@ -115,18 +111,13 @@ fun ShellScreen(
viewModel: MainViewModel,
modifier: Modifier = Modifier,
) {
val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState()
val shellDark = appearanceThemeMode.isDark(systemDark = isSystemInDarkTheme())
OpenClawSystemBarAppearance(lightAppearance = !shellDark)
ClawDesignTheme(dark = shellDark) {
ClawDesignTheme {
var activeTab by rememberSaveable { mutableStateOf(Tab.Overview) }
var settingsRoute by rememberSaveable { mutableStateOf(SettingsRoute.Home) }
var returnToOverviewFromSettings by rememberSaveable { mutableStateOf(false) }
var commandOpen by rememberSaveable { mutableStateOf(false) }
var voiceScreenWasActive by rememberSaveable { mutableStateOf(false) }
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
LaunchedEffect(requestedHomeDestination) {
val destination = requestedHomeDestination ?: return@LaunchedEffect
@@ -147,12 +138,8 @@ fun ShellScreen(
viewModel.clearRequestedHomeDestination()
}
LaunchedEffect(activeTab, runtimeInitialized) {
val voiceScreenActive = activeTab == Tab.Voice
if (voiceScreenActive || voiceScreenWasActive || runtimeInitialized) {
viewModel.setVoiceScreenActive(voiceScreenActive)
}
voiceScreenWasActive = voiceScreenActive
LaunchedEffect(activeTab) {
viewModel.setVoiceScreenActive(activeTab == Tab.Voice)
}
BackHandler(enabled = activeTab != Tab.Overview) {
@@ -226,6 +213,11 @@ fun ShellScreen(
ProvidersModelsScreen(
viewModel = viewModel,
onBack = { activeTab = Tab.Overview },
onAddProvider = {
settingsRoute = SettingsRoute.Gateway
returnToOverviewFromSettings = false
activeTab = Tab.Settings
},
)
Tab.Sessions ->
SessionsScreen(
@@ -350,7 +342,7 @@ private fun OverviewScreen(
val cronStatus by viewModel.cronStatus.collectAsState()
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
val channelsSummary by viewModel.channelsSummary.collectAsState()
val readyProviderCount = providerRows(providers = providers, models = models).count { it.ready }
val readyProviderCount = providers.count { modelProviderReady(it.status) }
val attentionRows =
homeAttentionRows(
isConnected = isConnected,
@@ -463,12 +455,13 @@ private fun OverviewScreen(
ModuleRow("Sessions", "Conversation history", if (sessions.isEmpty()) "Empty" else "${sessions.size} recent", Icons.Outlined.AccessTime, Tab.Sessions),
ModuleRow(
title = "Providers & Models",
subtitle = "Provider readiness",
subtitle = "Model setup",
metadata =
when {
!isConnected -> "Offline"
readyProviderCount > 0 -> "$readyProviderCount ready"
else -> "No ready"
models.isNotEmpty() -> "${models.size} models"
else -> "Setup"
},
icon = Icons.Outlined.Inventory2,
tab = Tab.ProvidersModels,
@@ -548,7 +541,6 @@ internal fun homeAttentionRows(
channelsSummary: GatewayChannelsSummary,
nodesDevicesSummary: GatewayNodesDevicesSummary,
readyProviderCount: Int,
expiringProviderCount: Int = 0,
): List<HomeAttentionRow> =
listOfNotNull(
if (!isConnected) {
@@ -572,7 +564,7 @@ internal fun homeAttentionRows(
null
},
if (isConnected && readyProviderCount == 0) {
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.Settings, SettingsRoute.Gateway)
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.ProvidersModels)
} else {
null
},
@@ -755,7 +747,7 @@ private fun RecentSessionRowContent(
metadata: String,
onClick: () -> Unit,
) {
Surface(color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Surface(color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
Row(
modifier =
Modifier
@@ -853,7 +845,6 @@ private fun SettingsShellScreen(
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
val channelsSummary by viewModel.channelsSummary.collectAsState()
val dreamingSummary by viewModel.dreamingSummary.collectAsState()
val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState()
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -915,7 +906,7 @@ private fun SettingsShellScreen(
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
SettingsRow("Appearance", "Dark", Icons.Default.Palette, route = SettingsRoute.Appearance),
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
),

View File

@@ -97,7 +97,6 @@ fun VoiceScreen(
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
val talkModeListening by viewModel.talkModeListening.collectAsState()
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
val talkModeStatusText by viewModel.talkModeStatusText.collectAsState()
val talkModeConversation by viewModel.talkModeConversation.collectAsState()
var pendingAction by remember { mutableStateOf<VoiceAction?>(null) }
@@ -120,16 +119,6 @@ fun VoiceScreen(
val activeConversation = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) talkModeConversation else micConversation
val voiceActive = micEnabled || micIsSending || talkModeEnabled
val gatewayReady = gatewayStatus.isVoiceGatewayReady()
val voiceAttentionStatus =
voiceAttentionStatus(
talkModeStatusText = talkModeStatusText,
voiceCaptureMode = voiceCaptureMode,
micEnabled = micEnabled,
micIsSending = micIsSending,
talkModeEnabled = talkModeEnabled,
talkModeListening = talkModeListening,
talkModeSpeaking = talkModeSpeaking,
)
val activeStatus =
voiceStatusLabel(
gatewayStatus = gatewayStatus,
@@ -139,7 +128,6 @@ fun VoiceScreen(
micIsSending = micIsSending,
talkModeListening = talkModeListening,
talkModeSpeaking = talkModeSpeaking,
voiceAttentionStatus = voiceAttentionStatus,
)
if (talkModeEnabled) {
@@ -181,7 +169,7 @@ fun VoiceScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
VoiceHeader(
statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
statusText = if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
speakerEnabled = speakerEnabled,
onToggleSpeaker = { viewModel.setSpeakerEnabled(!speakerEnabled) },
onOpenCommand = onOpenCommand,
@@ -196,7 +184,6 @@ fun VoiceScreen(
talkModeSpeaking = talkModeSpeaking,
micLiveTranscript = micLiveTranscript,
gatewayReady = gatewayReady,
voiceAttentionStatus = voiceAttentionStatus,
onStartTalk = {
runVoiceAction(
action = VoiceAction.Talk,
@@ -255,9 +242,7 @@ private fun DictationScreen(
) {
val lastUserText = conversation.lastOrNull { it.role == VoiceConversationRole.User }?.text
val draftText = liveTranscript?.takeIf { it.isNotBlank() } ?: lastUserText.orEmpty()
val providerAttentionStatus = voiceRuntimeAttentionStatus(statusText)
val displayStatusText = providerAttentionStatus ?: statusText
val speechProviderReady = providerAttentionStatus == null && gatewayStatus.isVoiceGatewayReady()
val speechProviderReady = gatewayStatus.isVoiceGatewayReady()
Column(
modifier =
Modifier
@@ -293,7 +278,7 @@ private fun DictationScreen(
DictationWaveform(active = listening || sending)
Row(horizontalArrangement = Arrangement.spacedBy(7.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(imageVector = Icons.Default.Mic, contentDescription = null, modifier = Modifier.size(15.dp), tint = if (listening) ClawTheme.colors.success else ClawTheme.colors.textMuted)
Text(text = displayStatusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
@@ -313,20 +298,13 @@ private fun DictationScreen(
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = "Speech provider", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(
text = providerAttentionStatus ?: gatewayStatus.voiceGatewayLabel(),
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
text =
when {
sending -> "Sending"
providerAttentionStatus != null -> "Attention"
speechProviderReady -> "Ready"
else -> "Offline"
},
@@ -334,7 +312,6 @@ private fun DictationScreen(
color =
when {
sending -> ClawTheme.colors.warning
providerAttentionStatus != null -> ClawTheme.colors.warning
speechProviderReady -> ClawTheme.colors.success
else -> ClawTheme.colors.textMuted
},
@@ -347,7 +324,6 @@ private fun DictationScreen(
.background(
when {
sending -> ClawTheme.colors.warning
providerAttentionStatus != null -> ClawTheme.colors.warning
speechProviderReady -> ClawTheme.colors.success
else -> ClawTheme.colors.textSubtle
},
@@ -618,7 +594,6 @@ private fun VoiceHero(
talkModeSpeaking: Boolean,
micLiveTranscript: String?,
gatewayReady: Boolean,
voiceAttentionStatus: String?,
onStartTalk: () -> Unit,
onStartDictation: () -> Unit,
onConnectGateway: () -> Unit,
@@ -641,7 +616,6 @@ private fun VoiceHero(
Text(
text =
when {
voiceAttentionStatus != null -> voiceAttentionStatus
talkModeSpeaking -> "OpenClaw is replying"
talkModeListening -> "Listening"
talkModeEnabled -> "Talk is live"
@@ -698,7 +672,7 @@ private fun VoiceHero(
)
}
VoiceProviderCard(gatewayStatus = gatewayStatus, voiceAttentionStatus = voiceAttentionStatus)
VoiceProviderCard(gatewayStatus = gatewayStatus)
VoicePrimaryAction(
text =
@@ -760,11 +734,8 @@ private fun VoiceModeRow(
}
@Composable
private fun VoiceProviderCard(
gatewayStatus: String,
voiceAttentionStatus: String?,
) {
val ready = voiceAttentionStatus == null && gatewayStatus.isVoiceGatewayReady()
private fun VoiceProviderCard(gatewayStatus: String) {
val ready = gatewayStatus.isVoiceGatewayReady()
Surface(
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp),
shape = RoundedCornerShape(ClawTheme.radii.panel),
@@ -790,13 +761,7 @@ private fun VoiceProviderCard(
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = "Provider", style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(
text = voiceAttentionStatus ?: gatewayStatus.voiceGatewayLabel(),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(text = gatewayStatus.voiceGatewayLabel(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Box(
@@ -804,25 +769,9 @@ private fun VoiceProviderCard(
Modifier
.size(7.dp)
.clip(CircleShape)
.background(
when {
ready -> ClawTheme.colors.success
voiceAttentionStatus != null -> ClawTheme.colors.warning
else -> ClawTheme.colors.textSubtle
},
),
)
Text(
text =
when {
ready -> "Ready"
voiceAttentionStatus != null -> "Attention"
else -> "Offline"
},
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
maxLines = 1,
.background(if (ready) ClawTheme.colors.success else ClawTheme.colors.textSubtle),
)
Text(text = if (ready) "Ready" else "Offline", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1)
}
}
}
@@ -1019,7 +968,7 @@ private fun runVoiceAction(
}
}
internal fun voiceStatusLabel(
private fun voiceStatusLabel(
gatewayStatus: String,
voiceCaptureMode: VoiceCaptureMode,
micStatusText: String,
@@ -1027,10 +976,8 @@ internal fun voiceStatusLabel(
micIsSending: Boolean,
talkModeListening: Boolean,
talkModeSpeaking: Boolean,
voiceAttentionStatus: String?,
): String =
when {
voiceAttentionStatus != null -> voiceAttentionStatus
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "OpenClaw is speaking"
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Listening"
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk is live"
@@ -1041,69 +988,6 @@ internal fun voiceStatusLabel(
else -> "Ready to talk"
}
internal fun voiceAttentionStatus(
talkModeStatusText: String,
voiceCaptureMode: VoiceCaptureMode,
micEnabled: Boolean,
micIsSending: Boolean,
talkModeEnabled: Boolean,
talkModeListening: Boolean,
talkModeSpeaking: Boolean,
): String? {
if (voiceCaptureMode != VoiceCaptureMode.Off || micEnabled || micIsSending) return null
if (talkModeEnabled || talkModeListening || talkModeSpeaking) return null
val status = talkModeStatusText.trim()
if (status.isBlank()) return null
val lower = status.lowercase()
if (lower == "off" || lower == "ready" || lower == "listening" || lower == "connecting…") return null
return status
.takeIf {
lower.contains("failed") ||
lower.contains("unavailable") ||
lower.contains("permission required") ||
lower.contains("not connected") ||
lower.contains("error")
}?.let(::userFacingVoiceAttentionStatus)
}
internal fun voiceRuntimeAttentionStatus(statusText: String): String? {
val status = statusText.trim()
if (status.isBlank()) return null
val lower = status.lowercase()
return status
.takeIf {
lower.contains("transcription unavailable") ||
lower.contains("provider unavailable") ||
(lower.contains("provider") && lower.contains("not configured")) ||
lower.contains("no realtime transcription provider") ||
lower.contains("failed")
}?.let(::userFacingVoiceAttentionStatus)
}
private fun userFacingVoiceAttentionStatus(status: String): String {
val normalized =
status
.removePrefix("Start failed:")
.trim()
.removePrefix("Transcription unavailable:")
.trim()
.removePrefix("UNAVAILABLE:")
.trim()
.removePrefix("Error:")
.trim()
val lower = normalized.lowercase()
if (lower.contains("realtime voice provider") && lower.contains("not configured")) {
return "Realtime voice provider is not configured."
}
if (lower.contains("no realtime transcription provider")) {
return "Realtime transcription provider is not configured."
}
if (lower.contains("microphone permission required")) {
return "Microphone permission is required."
}
return if (normalized.length <= 90) normalized else "${normalized.take(87)}..."
}
private fun String.isVoiceGatewayReady(): Boolean {
val status = lowercase()
return !status.contains("offline") && !status.contains("not connected") && !status.contains("failed") && !status.contains("error")

View File

@@ -15,7 +15,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
@@ -40,19 +40,17 @@ fun ChatMessageListCard(
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
val timeline =
remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText) {
buildChatTimeline(
messages = messages,
pendingRunCount = pendingRunCount,
pendingToolCalls = pendingToolCalls,
streamingAssistantText = streamingAssistantText,
)
}
val displayMessages = remember(messages) { messages.asReversed() }
val stream = streamingAssistantText?.trim()
LaunchedEffect(timeline.scrollTargetIndex, timeline.items.size, pendingRunCount, pendingToolCalls.size) {
timeline.scrollTargetIndex?.let { index ->
listState.animateScrollToItem(index = index)
// New list items/tool rows should animate into view, but token streaming should not restart
// that animation on every delta.
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
listState.animateScrollToItem(index = 0)
}
LaunchedEffect(stream) {
if (!stream.isNullOrEmpty()) {
listState.scrollToItem(index = 0)
}
}
@@ -66,17 +64,32 @@ fun ChatMessageListCard(
androidx.compose.foundation.layout
.PaddingValues(bottom = 8.dp),
) {
itemsIndexed(items = timeline.items, key = { _, item -> chatTimelineItemKey(item) }) { _, item ->
when (item) {
is ChatTimelineItem.Message -> ChatMessageBubble(message = item.message)
is ChatTimelineItem.PendingTools -> ChatPendingToolsBubble(toolCalls = item.toolCalls)
is ChatTimelineItem.StreamingAssistant -> ChatStreamingAssistantBubble(text = item.text)
ChatTimelineItem.Thinking -> ChatTypingIndicatorBubble()
// With reverseLayout = true, index 0 renders at the BOTTOM.
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
if (!stream.isNullOrEmpty()) {
item(key = "stream") {
ChatStreamingAssistantBubble(text = stream)
}
}
if (pendingToolCalls.isNotEmpty()) {
item(key = "tools") {
ChatPendingToolsBubble(toolCalls = pendingToolCalls)
}
}
if (pendingRunCount > 0) {
item(key = "typing") {
ChatTypingIndicatorBubble()
}
}
items(items = displayMessages, key = { it.id }) { message ->
ChatMessageBubble(message = message)
}
}
if (timeline.items.isEmpty()) {
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
if (historyLoading) {
LoadingChatHint(modifier = Modifier.align(Alignment.Center))
} else {

View File

@@ -31,7 +31,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
@@ -406,19 +406,15 @@ private fun ChatMessageList(
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
val timeline =
remember(messages, pendingRunCount, pendingToolCalls, streamingAssistantText) {
buildChatTimeline(
messages = messages,
pendingRunCount = pendingRunCount,
pendingToolCalls = pendingToolCalls,
streamingAssistantText = streamingAssistantText,
)
}
val displayMessages = remember(messages) { messages.asReversed() }
val stream = streamingAssistantText?.trim()
LaunchedEffect(timeline.scrollTargetIndex, timeline.items.size, pendingRunCount, pendingToolCalls.size) {
timeline.scrollTargetIndex?.let { index ->
listState.animateScrollToItem(index = index)
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
listState.animateScrollToItem(index = 0)
}
LaunchedEffect(stream) {
if (!stream.isNullOrEmpty()) {
listState.scrollToItem(index = 0)
}
}
@@ -430,29 +426,30 @@ private fun ChatMessageList(
verticalArrangement = Arrangement.spacedBy(5.dp),
contentPadding = PaddingValues(top = 6.dp, bottom = 3.dp),
) {
itemsIndexed(items = timeline.items, key = { _, item -> chatTimelineItemKey(item) }) { _, item ->
when (item) {
is ChatTimelineItem.Message ->
ChatBubble(
role = item.message.role,
live = false,
content = item.message.content,
timestampMs = item.message.timestampMs,
)
is ChatTimelineItem.PendingTools -> ToolBubble(toolCalls = item.toolCalls)
is ChatTimelineItem.StreamingAssistant ->
ChatBubble(
role = "assistant",
live = true,
content = listOf(ChatMessageContent(text = item.text)),
timestampMs = null,
)
ChatTimelineItem.Thinking -> ChatThinkingBubble()
if (!stream.isNullOrEmpty()) {
item(key = "stream") {
ChatBubble(role = "assistant", live = true, content = listOf(ChatMessageContent(text = stream)), timestampMs = null)
}
}
if (pendingToolCalls.isNotEmpty()) {
item(key = "tools") {
ToolBubble(toolCalls = pendingToolCalls)
}
}
if (pendingRunCount > 0) {
item(key = "thinking") {
ChatThinkingBubble()
}
}
items(items = displayMessages, key = { it.id }) { message ->
ChatBubble(role = message.role, live = false, content = message.content, timestampMs = message.timestampMs)
}
}
if (timeline.items.isEmpty()) {
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && stream.isNullOrBlank()) {
if (historyLoading) {
ClawLoadingState(title = "Loading session", modifier = Modifier.align(Alignment.Center))
} else {

View File

@@ -1,69 +0,0 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatPendingToolCall
internal sealed class ChatTimelineItem {
data class Message(
val message: ChatMessage,
) : ChatTimelineItem()
data class StreamingAssistant(
val text: String,
) : ChatTimelineItem()
data class PendingTools(
val toolCalls: List<ChatPendingToolCall>,
) : ChatTimelineItem()
object Thinking : ChatTimelineItem()
}
internal data class ChatTimeline(
val items: List<ChatTimelineItem>,
val scrollTargetIndex: Int?,
)
internal fun buildChatTimeline(
messages: List<ChatMessage>,
pendingRunCount: Int,
pendingToolCalls: List<ChatPendingToolCall>,
streamingAssistantText: String?,
): ChatTimeline {
val stream = streamingAssistantText?.trim()?.takeIf { it.isNotEmpty() }
val hasActiveRun = pendingRunCount > 0 || pendingToolCalls.isNotEmpty() || stream != null
val items =
buildList {
if (stream != null) add(ChatTimelineItem.StreamingAssistant(stream))
if (pendingToolCalls.isNotEmpty()) add(ChatTimelineItem.PendingTools(pendingToolCalls))
if (pendingRunCount > 0) add(ChatTimelineItem.Thinking)
messages.asReversed().forEach { message -> add(ChatTimelineItem.Message(message)) }
}
if (items.isEmpty()) return ChatTimeline(items = items, scrollTargetIndex = null)
// In reverseLayout, index 0 is bottom-most. During an active run, keep the prompt
// anchored so streaming/tool rows do not immediately push the just-sent message away.
val activePromptIndex =
if (hasActiveRun) {
items.indexOfFirst { item ->
item is ChatTimelineItem.Message &&
item.message.role
.trim()
.equals("user", ignoreCase = true)
}
} else {
-1
}
return ChatTimeline(
items = items,
scrollTargetIndex = activePromptIndex.takeIf { it >= 0 } ?: 0,
)
}
internal fun chatTimelineItemKey(item: ChatTimelineItem): String =
when (item) {
is ChatTimelineItem.Message -> "message:${item.message.id}"
is ChatTimelineItem.PendingTools -> "tools"
is ChatTimelineItem.StreamingAssistant -> "stream"
ChatTimelineItem.Thinking -> "thinking"
}

View File

@@ -82,12 +82,7 @@ fun resolveCompactSessionChoices(
)
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
val pinnedRank =
listOf(mainKey, current)
.filter { it.isNotBlank() }
.distinct()
.withIndex()
.associate { it.value to it.index }
val pinnedRank = listOf(mainKey, current).filter { it.isNotBlank() }.distinct().withIndex().associate { it.value to it.index }
val unpinnedRank = pinnedRank.size
return allChoices

View File

@@ -1,8 +1,5 @@
package ai.openclaw.app.ui.design
import ai.openclaw.app.ui.LocalMobileColors
import ai.openclaw.app.ui.darkMobileColors
import ai.openclaw.app.ui.lightMobileColors
import ai.openclaw.app.ui.mobileFontFamily
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
@@ -113,22 +110,22 @@ private val ClawDarkColors =
private val ClawLightColors =
ClawColors(
canvas = Color(0xFFFAFBFC),
surface = Color(0xFFFFFEFB),
canvas = Color(0xFFF7F7F7),
surface = Color(0xFFFFFFFF),
surfaceRaised = Color(0xFFFFFFFF),
surfacePressed = Color(0xFFE9EDF3),
border = Color(0xFFDDE3EC),
borderStrong = Color(0xFFC7D0DC),
text = Color(0xFF111318),
textMuted = Color(0xFF505865),
textSubtle = Color(0xFF8993A2),
primary = Color(0xFF111827),
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(0xFF217747),
successSoft = Color(0xFFE9F7EF),
warning = Color(0xFFA56F17),
warningSoft = Color(0xFFFFF3DC),
danger = Color(0xFFB82929),
success = Color(0xFF157A3E),
successSoft = Color(0xFFEAF8EF),
warning = Color(0xFF9A6A12),
warningSoft = Color(0xFFFFF5DD),
danger = Color(0xFFB42323),
dangerSoft = Color(0xFFFFE9E9),
)
@@ -171,12 +168,10 @@ internal fun ClawDesignTheme(
content: @Composable () -> Unit,
) {
val colors = if (dark) ClawDarkColors else ClawLightColors
val mobileColors = if (dark) darkMobileColors() else lightMobileColors()
val typography = clawTypography(mobileFontFamily)
CompositionLocalProvider(
LocalClawColors provides colors,
LocalMobileColors provides mobileColors,
LocalClawSpacing provides ClawSpacing(),
LocalClawRadii provides ClawRadii(),
LocalClawTypography provides typography,

View File

@@ -104,7 +104,6 @@ class MicCaptureManager(
private val messageQueue = ArrayDeque<String>()
private val messageQueueLock = Any()
private var flushedPartialTranscript: String? = null
// Correlates chat events with the idempotency key generated before sendChat returns.
private var pendingRunId: String? = null
private var pendingAssistantEntryId: String? = null

View File

@@ -168,7 +168,6 @@ class TalkModeManager internal constructor(
@Volatile private var realtimeSessionId: String? = null
private var realtimeCaptureJob: Job? = null
private var realtimeAppendJob: Job? = null
// Realtime tool calls can complete before their chat final arrives; cache by call/run id until both sides meet.
private val realtimeToolRuns = LinkedHashMap<String, RealtimeToolRun>()
private val pendingRealtimeToolCalls = LinkedHashSet<String>()

View File

@@ -1,67 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="120dp"
android:height="120dp"
android:viewportWidth="120"
android:viewportHeight="120">
<path android:pathData="M60,10 C30,10 15,35 15,55 C15,75 30,95 45,100 L45,110 L55,110 L55,100 C55,100 60,102 65,100 L65,110 L75,110 L75,100 C90,95 105,75 105,55 C105,35 90,10 60,10Z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="120"
android:endY="120"
android:startColor="#ff4d4d"
android:startX="0"
android:startY="0"
android:type="linear"
android:endColor="#991b1b" />
</aapt:attr>
</path>
<path android:pathData="M20,45 C5,40 0,50 5,60 C10,70 20,65 25,55 C28,48 25,45 20,45Z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="120"
android:endY="120"
android:startColor="#ff4d4d"
android:startX="0"
android:startY="0"
android:type="linear"
android:endColor="#991b1b" />
</aapt:attr>
</path>
<path android:pathData="M100,45 C115,40 120,50 115,60 C110,70 100,65 95,55 C92,48 95,45 100,45Z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="120"
android:endY="120"
android:startColor="#ff4d4d"
android:startX="0"
android:startY="0"
android:type="linear"
android:endColor="#991b1b" />
</aapt:attr>
</path>
<path
android:fillColor="@android:color/transparent"
android:pathData="M45,15 Q35,5 30,8"
android:strokeColor="#ff4d4d"
android:strokeLineCap="round"
android:strokeWidth="3" />
<path
android:fillColor="@android:color/transparent"
android:pathData="M75,15 Q85,5 90,8"
android:strokeColor="#ff4d4d"
android:strokeLineCap="round"
android:strokeWidth="3" />
<path
android:fillColor="#050810"
android:pathData="M45,35 m-6,0 a6,6 0,1 0,12 0 a6,6 0,1 0,-12 0" />
<path
android:fillColor="#050810"
android:pathData="M75,35 m-6,0 a6,6 0,1 0,12 0 a6,6 0,1 0,-12 0" />
<path
android:fillColor="#00e5cc"
android:pathData="M46,34 m-2.5,0 a2.5,2.5 0,1 0,5 0 a2.5,2.5 0,1 0,-5 0" />
<path
android:fillColor="#00e5cc"
android:pathData="M76,34 m-2.5,0 a2.5,2.5 0,1 0,5 0 a2.5,2.5 0,1 0,-5 0" />
</vector>

View File

@@ -294,38 +294,6 @@ class GatewayBootstrapAuthTest {
assertEquals("aaaaaaaa", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
}
@Test
fun refreshGatewayConnection_reconnectsSavedManualEndpointAfterDisconnect() {
val app = RuntimeEnvironment.getApplication()
val securePrefs =
app.getSharedPreferences(
"openclaw.node.secure.test.${UUID.randomUUID()}",
android.content.Context.MODE_PRIVATE,
)
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
prefs.setManualEnabled(true)
prefs.setManualHost("127.0.0.1")
prefs.setManualPort(18789)
prefs.setManualTls(false)
prefs.setGatewayToken("shared-token")
val runtime = NodeRuntime(app, prefs)
runtime.connect(
GatewayEndpoint.manual(host = "127.0.0.1", port = 18789),
NodeRuntime.GatewayConnectAuth(token = "initial-token", bootstrapToken = null, password = null),
)
runtime.disconnect()
assertNull(desiredConnection(runtime, "nodeSession"))
runtime.refreshGatewayConnection()
val desired = desiredConnection(runtime, "nodeSession") ?: error("Expected desired node connection")
val endpoint = readField<GatewayEndpoint>(desired, "endpoint")
assertEquals("127.0.0.1", endpoint.host)
assertEquals(18789, endpoint.port)
assertEquals("shared-token", readField<String?>(desired, "token"))
}
@Test
fun connect_showsSecureEndpointGuidanceWhenTlsProbeFails() {
val app = RuntimeEnvironment.getApplication()

View File

@@ -77,31 +77,6 @@ class SecurePrefsTest {
assertTrue(plainPrefs.getBoolean("device.apps.sharing.enabled", false))
}
@Test
fun appearanceThemeMode_defaultsDarkForExistingInstalls() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
assertEquals(AppearanceThemeMode.Dark, prefs.appearanceThemeMode.value)
assertFalse(plainPrefs.contains("appearance.themeMode"))
}
@Test
fun setAppearanceThemeMode_persistsSelectedMode() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().commit()
val prefs = SecurePrefs(context)
prefs.setAppearanceThemeMode(AppearanceThemeMode.Light)
assertEquals(AppearanceThemeMode.Light, prefs.appearanceThemeMode.value)
assertEquals("light", plainPrefs.getString("appearance.themeMode", null))
assertEquals(AppearanceThemeMode.Light, SecurePrefs(context).appearanceThemeMode.value)
}
@Test
fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
val context = RuntimeEnvironment.getApplication()

View File

@@ -1,44 +1,10 @@
package ai.openclaw.app.chat
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ChatControllerMessageIdentityTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun parseChatMessageContentsReadsGatewayStringContent() {
val obj =
json
.parseToJsonElement(
"""
{"role":"user","content":"Hello","idempotencyKey":"run-1:user"}
""".trimIndent(),
).jsonObject
val content = parseChatMessageContents(obj)
assertEquals(listOf(ChatMessageContent(type = "text", text = "Hello")), content)
}
@Test
fun parseChatMessageContentsFallsBackToTopLevelText() {
val obj =
json
.parseToJsonElement(
"""
{"role":"assistant","text":"Hi there"}
""".trimIndent(),
).jsonObject
val content = parseChatMessageContents(obj)
assertEquals(listOf(ChatMessageContent(type = "text", text = "Hi there")), content)
}
@Test
fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() {
val previous =
@@ -135,62 +101,6 @@ class ChatControllerMessageIdentityTest {
assertEquals(listOf("local-user", "remote-assistant"), merged.map { it.id })
}
@Test
fun retainUnmatchedOptimisticMessagesKeepsOutgoingUserTurnWhenHistoryOmitsIt() {
val optimistic =
ChatMessage(
id = "local-user",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "Testing testing 1 2 3")),
timestampMs = 1000L,
)
val assistant =
ChatMessage(
id = "remote-assistant",
role = "assistant",
content = listOf(ChatMessageContent(type = "text", text = "Received.")),
timestampMs = 2000L,
)
val retained = retainUnmatchedOptimisticMessages(incoming = listOf(assistant), optimistic = listOf(optimistic))
assertEquals(listOf("local-user"), retained.map { it.id })
}
@Test
fun retainUnmatchedOptimisticMessagesDropsGatewayPersistedUserTurn() {
val optimistic =
ChatMessage(
id = "local-user",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
idempotencyKey = "run-1:user",
)
val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 500L)
val retained = retainUnmatchedOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic))
assertEquals(emptyList<String>(), retained.map { it.id })
}
@Test
fun retainUnmatchedOptimisticMessagesKeepsDistinctIdempotencyKey() {
val optimistic =
ChatMessage(
id = "local-user",
role = "user",
content = listOf(ChatMessageContent(type = "text", text = "hello")),
timestampMs = 1000L,
idempotencyKey = "run-2:user",
)
val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 2000L, idempotencyKey = "run-1:user")
val retained = retainUnmatchedOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic))
assertEquals(listOf("local-user"), retained.map { it.id })
}
@Test
fun mergeOptimisticMessagesDoesNotDuplicateHistoryTurns() {
val user =

View File

@@ -20,7 +20,6 @@ import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@@ -234,75 +233,7 @@ class GatewaySessionReconnectTest {
)
}
@Test
fun pairingRequiredFailureNotifiesPauseReconnectProblem() =
runBlocking {
val json = Json { ignoreUnknownKeys = true }
val connectFailure = CompletableDeferred<Pair<GatewaySession.ErrorShape, Boolean>>()
val server =
startGatewayServer(json = json) { webSocket, id, method ->
if (method == "connect") {
webSocket.send(
"""
{"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1"}}}
""".trimIndent(),
)
}
}
val harness =
createReconnectHarness { error, pauseReconnect ->
connectFailure.complete(error to pauseReconnect)
}
try {
connectNodeSession(harness.session, server.port)
val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() }
assertEquals("PAIRING_REQUIRED", error.details?.code)
assertEquals("not-paired", error.details?.reason)
assertEquals("request-1", error.details?.requestId)
assertTrue(pauseReconnect)
} finally {
shutdownReconnectHarness(harness, server)
}
}
@Test
fun pairingRequiredFailureDropsUnsafeRequestId() =
runBlocking {
val json = Json { ignoreUnknownKeys = true }
val connectFailure = CompletableDeferred<Pair<GatewaySession.ErrorShape, Boolean>>()
val server =
startGatewayServer(json = json) { webSocket, id, method ->
if (method == "connect") {
webSocket.send(
"""
{"type":"res","id":"$id","ok":false,"error":{"code":"NOT_PAIRED","message":"pairing required: device approval is required","details":{"code":"PAIRING_REQUIRED","reason":"not-paired","requestId":"request-1;echo unsafe"}}}
""".trimIndent(),
)
}
}
val harness =
createReconnectHarness { error, pauseReconnect ->
connectFailure.complete(error to pauseReconnect)
}
try {
connectNodeSession(harness.session, server.port)
val (error, pauseReconnect) = withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { connectFailure.await() }
assertEquals("PAIRING_REQUIRED", error.details?.code)
assertEquals("not-paired", error.details?.reason)
assertNull(error.details?.requestId)
assertTrue(pauseReconnect)
} finally {
shutdownReconnectHarness(harness, server)
}
}
private fun createReconnectHarness(
onConnectFailure: (GatewaySession.ErrorShape, Boolean) -> Unit = { _, _ -> },
): ReconnectHarness {
private fun createReconnectHarness(): ReconnectHarness {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val session =
@@ -312,7 +243,6 @@ class GatewaySessionReconnectTest {
deviceAuthStore = ReconnectDeviceAuthStore(),
onConnected = {},
onDisconnected = { _ -> },
onConnectFailure = onConnectFailure,
onEvent = { _, _ -> },
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
)

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayConnectionProblem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -27,53 +26,6 @@ class OnboardingFlowLogicTest {
assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true))
}
@Test
fun nearbyGatewayFoundStateIsConnectable() {
assertEquals(
NearbyGatewayUiState(subtitle = "Studio Gateway", status = "Found", canConnect = true),
nearbyGatewayUiState(nearbyGatewayName = "Studio Gateway", discoveryStatusText = "Searching…", discoveryStarted = false),
)
}
@Test
fun nearbyGatewayBeforeDiscoveryStartsIsNotConnectable() {
assertEquals(
NearbyGatewayUiState(subtitle = "Starting discovery...", status = "Starting", canConnect = false),
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching…", discoveryStarted = false, searchTimedOut = true),
)
}
@Test
fun nearbyGatewaySearchingStateIsNotConnectable() {
assertEquals(
NearbyGatewayUiState(subtitle = "Searching for gateways...", status = "Searching", canConnect = false),
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching for gateways…"),
)
}
@Test
fun nearbyGatewayTimedOutSearchShowsEmptyState() {
assertEquals(
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false),
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Searching for gateways…", searchTimedOut = true),
)
}
@Test
fun nearbyGatewayEmptyResultStateIsNotConnectable() {
assertEquals(
NearbyGatewayUiState(subtitle = "No gateway found", status = "Not found", canConnect = false),
nearbyGatewayUiState(nearbyGatewayName = null, discoveryStatusText = "Local: 0 • Wide: 0"),
)
}
@Test
fun recoveryGatewayNamePrefersServerThenAttemptedGateway() {
assertEquals("Server Gateway", recoveryGatewayName(serverName = "Server Gateway", attemptedGatewayName = "Discovered Gateway"))
assertEquals("Discovered Gateway", recoveryGatewayName(serverName = null, attemptedGatewayName = "Discovered Gateway"))
assertEquals("Home Gateway", recoveryGatewayName(serverName = " ", attemptedGatewayName = " "))
}
@Test
fun showsPairingStateForPairingRequiredGatewayStatus() {
assertEquals(
@@ -98,50 +50,6 @@ class OnboardingFlowLogicTest {
)
}
@Test
fun showsApprovalRequiredForPausedPairingProblem() {
assertEquals(
GatewayRecoveryUiState.ApprovalRequired,
gatewayRecoveryUiState(
ready = false,
statusText = "Connecting…",
connectSettling = false,
gatewayConnectionProblem =
GatewayConnectionProblem(
code = "PAIRING_REQUIRED",
message = "pairing required: device approval is required",
reason = "not-paired",
requestId = "request-1",
recommendedNextStep = null,
pauseReconnect = true,
retryable = false,
),
),
)
}
@Test
fun showsPairingForRetryablePairingProblem() {
assertEquals(
GatewayRecoveryUiState.Pairing,
gatewayRecoveryUiState(
ready = false,
statusText = "Connecting…",
connectSettling = false,
gatewayConnectionProblem =
GatewayConnectionProblem(
code = "PAIRING_REQUIRED",
message = "pairing required: device approval is required",
reason = "not-paired",
requestId = "request-1",
recommendedNextStep = "wait_then_retry",
pauseReconnect = false,
retryable = true,
),
),
)
}
@Test
fun showsFinishingStateWhileGatewayConnectionSettles() {
assertEquals(

View File

@@ -1,8 +1,5 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayModelProviderSummary
import ai.openclaw.app.GatewayModelSummary
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -13,55 +10,8 @@ class ProviderModelStatusTest {
assertTrue(modelProviderReady("static"))
}
@Test
fun expiringProviderStatusIsNotFullyReady() {
assertFalse(modelProviderReady("expiring"))
}
@Test
fun missingProviderStatusIsNotReady() {
assertFalse(modelProviderReady("missing"))
}
@Test
fun providerRowsIncludeConfiguredModelProvidersWithoutAuthRows() {
val rows =
providerRows(
providers =
listOf(
GatewayModelProviderSummary(
id = "openai",
displayName = "OpenAI",
status = "ok",
profileCount = 1,
),
),
models =
listOf(
model(provider = "openai", id = "gpt-5.5"),
model(provider = "byteplus", id = "seed-1-8-251228"),
),
)
assertEquals(listOf("openai", "byteplus"), rows.map { it.id })
assertEquals(1, rows.first { it.id == "openai" }.modelCount)
assertEquals(1, rows.first { it.id == "byteplus" }.modelCount)
assertTrue(rows.first { it.id == "byteplus" }.ready)
}
private fun model(
provider: String,
id: String,
): GatewayModelSummary =
GatewayModelSummary(
id = id,
name = id,
provider = provider,
supportsVision = false,
supportsAudio = false,
supportsDocuments = false,
supportsReasoning = false,
contextTokens = null,
available = null,
)
}

View File

@@ -1,13 +0,0 @@
package ai.openclaw.app.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class SettingsScreensTest {
@Test
fun androidDistributionChannelUsesBuildFlavorLabels() {
assertEquals("Play", androidDistributionChannel("play"))
assertEquals("Third-party", androidDistributionChannel("thirdParty"))
assertEquals("Unknown", androidDistributionChannel(""))
}
}

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.ui
import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.GatewayChannelSummary
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
@@ -18,28 +17,6 @@ class ShellScreenLogicTest {
assertFalse(shellBottomNavVisible(keyboardVisible = false, commandOpen = true))
}
@Test
fun appearanceThemeModeDefaultsToDarkForExistingInstalls() {
assertEquals(AppearanceThemeMode.Dark, AppearanceThemeMode.fromRawValue(null))
assertEquals(AppearanceThemeMode.Dark, AppearanceThemeMode.fromRawValue("unknown"))
}
@Test
fun appearanceThemeLabelsRoundTripFromSettingsOptions() {
assertEquals(listOf("System", "Dark", "Light"), appearanceThemeOptions())
assertEquals(AppearanceThemeMode.System, appearanceThemeModeForLabel("System"))
assertEquals(AppearanceThemeMode.Dark, appearanceThemeModeForLabel("Dark"))
assertEquals(AppearanceThemeMode.Light, appearanceThemeModeForLabel("Light"))
}
@Test
fun appearanceThemeModeResolvesAgainstSystemPreference() {
assertFalse(AppearanceThemeMode.System.isDark(systemDark = false))
assertTrue(AppearanceThemeMode.System.isDark(systemDark = true))
assertTrue(AppearanceThemeMode.Dark.isDark(systemDark = false))
assertFalse(AppearanceThemeMode.Light.isDark(systemDark = true))
}
@Test
fun homeAttentionRowsSurfaceGatewayWhenDisconnected() {
val rows =
@@ -99,9 +76,6 @@ class ShellScreenLogicTest {
)
assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title })
val providersRow = rows.single { it.title == "Providers" }
assertEquals(Tab.Settings, providersRow.tab)
assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute)
}
@Test

View File

@@ -1,75 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.VoiceCaptureMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class VoiceScreenLogicTest {
@Test
fun voiceAttentionStatusKeepsFailedTalkStartVisibleAfterModeStops() {
val attention =
voiceAttentionStatus(
talkModeStatusText = "Start failed: Error: Realtime voice provider \"openai\" is not configured",
voiceCaptureMode = VoiceCaptureMode.Off,
micEnabled = false,
micIsSending = false,
talkModeEnabled = false,
talkModeListening = false,
talkModeSpeaking = false,
)
assertEquals("Realtime voice provider is not configured.", attention)
assertEquals(
attention,
voiceStatusLabel(
gatewayStatus = "Online",
voiceCaptureMode = VoiceCaptureMode.Off,
micStatusText = "Mic off",
micQueuedMessages = 0,
micIsSending = false,
talkModeListening = false,
talkModeSpeaking = false,
voiceAttentionStatus = attention,
),
)
}
@Test
fun voiceAttentionStatusDoesNotOverrideActiveTalkState() {
assertNull(
voiceAttentionStatus(
talkModeStatusText = "Start failed: provider unavailable",
voiceCaptureMode = VoiceCaptureMode.TalkMode,
micEnabled = false,
micIsSending = false,
talkModeEnabled = true,
talkModeListening = false,
talkModeSpeaking = false,
),
)
}
@Test
fun voiceAttentionStatusDoesNotOverrideDictationState() {
assertNull(
voiceAttentionStatus(
talkModeStatusText = "Start failed: provider unavailable",
voiceCaptureMode = VoiceCaptureMode.ManualMic,
micEnabled = true,
micIsSending = false,
talkModeEnabled = false,
talkModeListening = false,
talkModeSpeaking = false,
),
)
}
@Test
fun voiceRuntimeAttentionStatusSanitizesTranscriptionProviderFailures() {
assertEquals(
"Realtime transcription provider is not configured.",
voiceRuntimeAttentionStatus("Transcription unavailable: UNAVAILABLE: Error: No realtime transcription provider registered"),
)
}
}

View File

@@ -1,94 +0,0 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.ChatPendingToolCall
import org.junit.Assert.assertEquals
import org.junit.Test
class ChatTimelineTest {
@Test
fun activeRunAnchorsNewestUserPromptInsteadOfThinkingRow() {
val user = textMessage(id = "user-1", role = "user", text = "hello")
val timeline =
buildChatTimeline(
messages = listOf(user),
pendingRunCount = 1,
pendingToolCalls = emptyList(),
streamingAssistantText = null,
)
assertEquals(listOf("thinking", "message:user-1"), timeline.items.map(::chatTimelineItemKey))
assertEquals(1, timeline.scrollTargetIndex)
}
@Test
fun activeRunAnchorsNewestUserPromptWhileAssistantStreams() {
val olderAssistant = textMessage(id = "assistant-1", role = "assistant", text = "previous")
val user = textMessage(id = "user-1", role = "user", text = "next")
val tool =
ChatPendingToolCall(
toolCallId = "tool-1",
name = "memory.search",
startedAtMs = 1000L,
)
val timeline =
buildChatTimeline(
messages = listOf(olderAssistant, user),
pendingRunCount = 1,
pendingToolCalls = listOf(tool),
streamingAssistantText = "streaming",
)
assertEquals(
listOf("stream", "tools", "thinking", "message:user-1", "message:assistant-1"),
timeline.items.map(::chatTimelineItemKey),
)
assertEquals(3, timeline.scrollTargetIndex)
}
@Test
fun finishedRunAnchorsNewestPersistedMessage() {
val user = textMessage(id = "user-1", role = "user", text = "hello")
val assistant = textMessage(id = "assistant-1", role = "assistant", text = "done")
val timeline =
buildChatTimeline(
messages = listOf(user, assistant),
pendingRunCount = 0,
pendingToolCalls = emptyList(),
streamingAssistantText = null,
)
assertEquals(listOf("message:assistant-1", "message:user-1"), timeline.items.map(::chatTimelineItemKey))
assertEquals(0, timeline.scrollTargetIndex)
}
@Test
fun emptyTimelineHasNoScrollTarget() {
val timeline =
buildChatTimeline(
messages = emptyList(),
pendingRunCount = 0,
pendingToolCalls = emptyList(),
streamingAssistantText = null,
)
assertEquals(emptyList<String>(), timeline.items.map(::chatTimelineItemKey))
assertEquals(null, timeline.scrollTargetIndex)
}
private fun textMessage(
id: String,
role: String,
text: String,
): ChatMessage =
ChatMessage(
id = id,
role = role,
content = listOf(ChatMessageContent(type = "text", text = text)),
timestampMs = null,
)
}

View File

@@ -1,9 +1,9 @@
[versions]
agp = "9.2.1"
agp = "9.2.0"
androidx-activity = "1.13.0"
androidx-benchmark = "1.4.1"
androidx-camera = "1.6.0"
androidx-compose-bom = "2026.05.01"
androidx-compose-bom = "2026.04.01"
androidx-core = "1.18.0"
androidx-exifinterface = "1.4.2"
androidx-lifecycle = "2.10.0"
@@ -13,14 +13,14 @@ androidx-uiautomator = "2.4.0-beta02"
androidx-webkit = "1.15.0"
bcprov = "1.84"
commonmark = "0.28.0"
coroutines = "1.11.0"
dnsjava = "3.6.5"
coroutines = "1.10.2"
dnsjava = "3.6.4"
junit = "4.13.2"
junit-vintage = "6.1.0"
junit-vintage = "6.0.3"
kotest = "6.1.11"
ktlint-gradle = "14.2.0"
kotlin = "2.3.21"
material = "1.14.0"
material = "1.13.0"
okhttp = "5.3.2"
play-services-code-scanner = "16.1.0"
robolectric = "4.16.1"

Binary file not shown.

View File

@@ -1,9 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.

View File

@@ -23,8 +23,8 @@
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,7 +65,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
goto fail
:execute
@rem Setup the command line
@@ -73,10 +73,21 @@ echo location of your Java installation. 1>&2
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -2,9 +2,7 @@
## 2026.6.2 - 2026-06-02
OpenClaw is now available on iPhone.
Connect to your OpenClaw Gateway to chat with your assistant, use realtime Talk mode, review approvals, share content from iOS, and bring device capabilities like camera, location, screen, and notifications into your private automation workflows.
Maintenance update for the current OpenClaw release.
## 2026.6.1 - 2026-06-01

View File

@@ -1,6 +1,6 @@
# OpenClaw iOS (Super Alpha)
This iOS app is super-alpha and internal-use only. The first public App Store release targets iPhone and connects to an OpenClaw Gateway as a `role: node`.
This iOS app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node` on iPhone and iPad.
## Distribution Status
@@ -34,7 +34,7 @@ open OpenClaw.xcodeproj
3. In Xcode:
- Scheme: `OpenClaw`
- Destination: connected iPhone (recommended for real behavior)
- Destination: connected iPhone or iPad (recommended for real behavior)
- Build configuration: `Debug`
- Run (`Product` -> `Run`)
4. If signing fails on a personal team:
@@ -251,7 +251,7 @@ gateway can only send pushes for iOS devices that paired with that gateway.
## Computer Use Relationship
The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits.
The iOS app is not a Codex Computer Use backend. Computer Use and `cua-driver mcp` are macOS desktop-control paths; iOS exposes device capabilities as OpenClaw node commands through the gateway. Agents can drive the iPhone or iPad canvas, camera, screen, location, voice, and other node capabilities with `node.invoke`, subject to iOS foreground/background limits.
## Location Automation Use Case (Testing)

View File

@@ -1,253 +0,0 @@
import Foundation
import OpenClawChatUI
import OpenClawProtocol
enum AppleReviewDemoMode {
static let setupCode = "APPLE-REVIEW-DEMO"
static let gatewayName = "Apple Review Demo Gateway"
static let gatewayAddress = "Local demo mode"
static let gatewayID = "apple-review-demo"
static func isSetupCode(_ value: String) -> Bool {
value.trimmingCharacters(in: .whitespacesAndNewlines)
.localizedCaseInsensitiveCompare(self.setupCode) == .orderedSame
}
static var agents: [AgentSummary] {
[
AgentSummary(
id: "main",
name: "Main",
identity: ["emoji": AnyCodable("OC")],
workspace: "Apple Review Demo",
model: ["provider": AnyCodable("demo"), "model": AnyCodable("local-demo")],
agentruntime: ["kind": AnyCodable("local")],
thinkinglevels: nil,
thinkingoptions: ["auto", "low", "medium"],
thinkingdefault: "auto"),
]
}
}
struct AppleReviewDemoChatTransport: OpenClawChatTransport {
private let store = AppleReviewDemoChatStore()
func createSession(
key: String,
label _: String?,
parentSessionKey _: String?) async throws -> OpenClawChatCreateSessionResponse
{
try await self.store.createSession(key: key)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
try await self.store.history(sessionKey: sessionKey)
}
func listModels() async throws -> [OpenClawChatModelChoice] {
[
OpenClawChatModelChoice(
modelID: "local-demo",
name: "Apple Review Demo",
provider: "demo",
contextWindow: 128_000),
]
}
func sendMessage(
sessionKey: String,
message: String,
thinking _: String,
idempotencyKey: String,
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
{
try await self.store.sendMessage(
sessionKey: sessionKey,
message: message,
runId: idempotencyKey)
}
func abortRun(sessionKey _: String, runId _: String) async throws {}
func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
try await self.store.sessions()
}
func setSessionModel(sessionKey _: String, model _: String?) async throws {}
func setSessionThinking(sessionKey _: String, thinkingLevel _: String) async throws {}
func requestHealth(timeoutMs _: Int) async throws -> Bool {
true
}
func waitForRunCompletion(runId _: String, timeoutMs _: Int) async -> Bool {
true
}
func events() -> AsyncStream<OpenClawChatTransportEvent> {
AsyncStream { continuation in
continuation.yield(.health(ok: true))
continuation.finish()
}
}
func setActiveSessionKey(_: String) async throws {}
func resetSession(sessionKey _: String) async throws {
await self.store.reset()
}
func compactSession(sessionKey _: String) async throws {}
}
private actor AppleReviewDemoChatStore {
private let sessionKey = "main"
private var messages: [OpenClawChatMessage]
init() {
self.messages = AppleReviewDemoChatStore.seedMessages()
}
func createSession(key: String) throws -> OpenClawChatCreateSessionResponse {
try Self.decode(
CreateSessionPayload(ok: true, key: key, sessionId: "apple-review-demo-\(key)"),
as: OpenClawChatCreateSessionResponse.self)
}
func history(sessionKey: String) throws -> OpenClawChatHistoryPayload {
let normalizedSessionKey = Self.normalizedSessionKey(sessionKey)
return try Self.decode(
HistoryPayload(
sessionKey: normalizedSessionKey,
sessionId: "apple-review-demo-\(normalizedSessionKey)",
messages: self.messages,
thinkingLevel: "auto"),
as: OpenClawChatHistoryPayload.self)
}
func sendMessage(sessionKey _: String, message: String, runId: String) throws -> OpenClawChatSendResponse {
let now = Date().timeIntervalSince1970 * 1000
self.messages.append(Self.message(role: "user", text: message, timestamp: now))
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
let subject = trimmed.isEmpty ? "that request" : "\"\(trimmed)\""
self.messages.append(
Self.message(
role: "assistant",
text: """
Demo mode is active. I can show the review flow locally for \(subject), including chat, agent \
selection, settings, and Gateway-connected UI states. Live automation requires pairing a real \
OpenClaw Gateway.
""",
timestamp: now + 1))
return try Self.decode(
SendPayload(runId: runId, status: "ok"),
as: OpenClawChatSendResponse.self)
}
func sessions() throws -> OpenClawChatSessionsListResponse {
let entry = OpenClawChatSessionEntry(
key: self.sessionKey,
kind: "chat",
displayName: "Apple Review Demo",
surface: "ios",
subject: "Gateway review flow",
room: nil,
space: nil,
updatedAt: Date().timeIntervalSince1970 * 1000,
sessionId: "apple-review-demo-main",
systemSent: true,
abortedLastRun: false,
thinkingLevel: "auto",
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: "demo",
model: "local-demo",
contextTokens: 128_000,
thinkingLevels: [
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
],
thinkingOptions: ["auto", "low", "medium"],
thinkingDefault: "auto")
return OpenClawChatSessionsListResponse(
ts: Date().timeIntervalSince1970 * 1000,
path: nil,
count: 1,
defaults: OpenClawChatSessionsDefaults(
modelProvider: "demo",
model: "local-demo",
contextTokens: 128_000,
thinkingLevels: [
OpenClawChatThinkingLevelOption(id: "auto", label: "Auto"),
OpenClawChatThinkingLevelOption(id: "low", label: "Low"),
OpenClawChatThinkingLevelOption(id: "medium", label: "Medium"),
],
thinkingOptions: ["auto", "low", "medium"],
thinkingDefault: "auto",
mainSessionKey: self.sessionKey),
sessions: [entry])
}
func reset() {
self.messages = Self.seedMessages()
}
private static func seedMessages() -> [OpenClawChatMessage] {
let now = Date().timeIntervalSince1970 * 1000
return [
self.message(
role: "assistant",
text: """
Apple Review demo mode is active. This local chat transport lets reviewers inspect the iOS app \
without a private Gateway.
""",
timestamp: now),
]
}
private static func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
OpenClawChatMessage(
role: role,
content: [
OpenClawChatMessageContent(
type: "text",
text: text,
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: timestamp)
}
private static func normalizedSessionKey(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "main" : trimmed
}
private static func decode<T: Decodable>(_ value: some Encodable, as type: T.Type) throws -> T {
let data = try JSONEncoder().encode(value)
return try JSONDecoder().decode(type, from: data)
}
private struct HistoryPayload: Encodable {
var sessionKey: String
var sessionId: String?
var messages: [OpenClawChatMessage]?
var thinkingLevel: String?
}
private struct SendPayload: Encodable {
var runId: String
var status: String
}
private struct CreateSessionPayload: Encodable {
var ok: Bool?
var key: String
var sessionId: String?
}
}

View File

@@ -332,14 +332,11 @@ struct ConfigPatchParams: Encodable {
}
enum SkillMutationError: LocalizedError {
case liveGatewayUnavailable
case missingConfigHash
case invalidPatchPayload
var errorDescription: String? {
switch self {
case .liveGatewayUnavailable:
"Connect a live gateway to edit agent skills."
case .missingConfigHash:
"Config hash missing; refresh and retry."
case .invalidPatchPayload:

View File

@@ -99,14 +99,14 @@ extension AgentProTab {
} label: {
Label("Run", systemImage: "play.fill")
}
.disabled(busy || !self.liveGatewayConnected)
.disabled(busy || !self.gatewayConnected)
Button {
Task { await self.setCronJob(job, enabled: !job.enabled) }
} label: {
Label(job.enabled ? "Pause" : "Enable", systemImage: job.enabled ? "pause.fill" : "checkmark")
}
.disabled(busy || !self.liveGatewayConnected)
.disabled(busy || !self.gatewayConnected)
}
.buttonStyle(.bordered)
.controlSize(.mini)
@@ -149,7 +149,7 @@ extension AgentProTab {
success: String,
action: () async throws -> Void) async
{
guard self.liveGatewayConnected else { return }
guard self.gatewayConnected else { return }
self.cronActionBusyIDs.insert(job.id)
self.cronActionStatusText = nil
defer { self.cronActionBusyIDs.remove(job.id) }

View File

@@ -58,9 +58,16 @@ extension AgentProTab {
}
func agentRosterState(for agent: AgentSummary) -> AgentRosterState {
guard self.gatewayConnected else { return .ready }
guard self.gatewayConnected else { return .idle }
if agent.id == self.activeAgentID { return .online }
return .ready
if self.cronJobsContain(agentID: agent.id) { return .busy }
return .idle
}
func cronJobsContain(agentID: String) -> Bool {
self.recentCronJobs.contains { job in
self.normalized(job.agentid) == agentID && job.enabled
}
}
func modelLabel(for agent: AgentSummary) -> String? {
@@ -117,7 +124,7 @@ extension AgentProTab {
@MainActor
func refreshOverview(force: Bool) async {
guard self.scenePhase == .active else { return }
guard self.liveGatewayConnected else {
guard self.appModel.isOperatorGatewayConnected else {
self.overview = nil
self.overviewErrorText = nil
self.overviewLoading = false

View File

@@ -285,7 +285,7 @@ extension AgentProTab {
Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1)
}
}
.accessibilityLabel(isActive ? "Default agent" : "Set default agent")
.accessibilityLabel(isActive ? "Active agent" : "Make active agent")
}
.padding(.vertical, 14)
.padding(.horizontal, 13)
@@ -514,8 +514,10 @@ extension AgentProTab {
true
case .online:
self.agentRosterState(for: agent) == .online
case .ready:
self.agentRosterState(for: agent) == .ready
case .busy:
self.agentRosterState(for: agent) == .busy
case .idle:
self.agentRosterState(for: agent) == .idle
}
guard matchesFilter else { return false }
@@ -542,12 +544,6 @@ extension AgentProTab {
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
var liveGatewayConnected: Bool {
!self.appModel.isAppleReviewDemoModeEnabled &&
self.gatewayConnected &&
self.appModel.isOperatorGatewayConnected
}
private var searchFieldFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.78)
}

View File

@@ -107,7 +107,7 @@ extension AgentProTab {
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(self.clawHubLoading || !self.liveGatewayConnected)
.disabled(self.clawHubLoading || !self.gatewayConnected)
.accessibilityLabel("Search ClawHub")
}
@@ -212,7 +212,6 @@ extension AgentProTab {
}
var skillPolicySummary: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "Demo mode keeps live skill changes disabled." }
guard self.gatewayConnected else { return "Connect a gateway to edit skills." }
guard let filter = self.agentSkillFilter else {
return "All available skills are allowed for this agent."
@@ -440,13 +439,14 @@ extension AgentProTab {
func skillEditorControls(_ skill: SkillStatusEntryLite) -> some View {
ProCard(radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
self.skillEditorToggleRow(
Toggle(
"Enabled globally",
isOn: skill.isGloballyEnabled,
disabled: self.isSkillConfigBusy(skill))
{ enabled in
Task { await self.updateSkillGlobalEnabled(skill, enabled: enabled) }
}
isOn: Binding(
get: { skill.isGloballyEnabled },
set: { enabled in
Task { await self.updateSkillGlobalEnabled(skill, enabled: enabled) }
}))
.disabled(self.isSkillConfigBusy(skill))
if let primaryEnv = skill.primaryEnv, !primaryEnv.isEmpty {
VStack(alignment: .leading, spacing: 8) {
@@ -480,43 +480,6 @@ extension AgentProTab {
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
func skillEditorToggleRow(
_ title: String,
isOn: Bool,
disabled: Bool,
onToggle: @escaping (Bool) -> Void) -> some View
{
// Native Toggle rows in this sheet can ignore visible-row taps on iOS 26.
// Keep the switch semantics explicit so the control always dispatches the mutation.
Button {
onToggle(!isOn)
} label: {
HStack {
Text(title)
Spacer(minLength: 8)
self.skillEditorSwitchIndicator(isOn: isOn)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(disabled)
.accessibilityLabel(title)
.accessibilityValue(isOn ? "On" : "Off")
}
func skillEditorSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.padding(2)
.shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1)
}
}
func skillEditorSetup(_ skill: SkillStatusEntryLite) -> some View {
ProCard(radius: AgentLayout.cardRadius) {
VStack(alignment: .leading, spacing: 10) {
@@ -602,7 +565,7 @@ extension AgentProTab {
@MainActor
func patchAgentSkills(_ skills: [String]?, busyKey: String) async {
guard self.liveGatewayConnected else { return }
guard self.gatewayConnected else { return }
self.skillMutationBusyKeys.insert(busyKey)
self.skillMutationErrorText = nil
self.skillMutationStatusText = nil
@@ -677,7 +640,7 @@ extension AgentProTab {
@MainActor
func installClawHubSkill(_ result: ClawHubSearchResultLite) async {
guard self.liveGatewayConnected else { return }
guard self.gatewayConnected else { return }
self.clawHubInstallSlug = result.slug
self.clawHubErrorText = nil
defer { self.clawHubInstallSlug = nil }
@@ -693,7 +656,7 @@ extension AgentProTab {
@MainActor
func searchClawHubSkills() async {
guard self.liveGatewayConnected else { return }
guard self.gatewayConnected else { return }
self.clawHubLoading = true
self.clawHubErrorText = nil
defer { self.clawHubLoading = false }
@@ -712,7 +675,6 @@ extension AgentProTab {
_ skill: SkillStatusEntryLite,
action: () async throws -> String) async
{
guard self.liveGatewayConnected else { return }
let key = skill.effectiveSkillKey
self.skillConfigBusyKeys.insert(key)
self.skillConfigMessages[key] = nil
@@ -735,9 +697,6 @@ extension AgentProTab {
params: some Encodable,
timeoutSeconds: Int) async throws -> Data
{
guard self.liveGatewayConnected else {
throw SkillMutationError.liveGatewayUnavailable
}
let data = try JSONEncoder().encode(params)
guard let json = String(data: data, encoding: .utf8) else {
throw SkillMutationError.invalidPatchPayload
@@ -749,9 +708,6 @@ extension AgentProTab {
}
func requestConfigSnapshot() async throws -> ConfigSnapshotLite {
guard self.liveGatewayConnected else {
throw SkillMutationError.liveGatewayUnavailable
}
let data = try await self.appModel.operatorSession.request(
method: "config.get",
paramsJSON: "{}",

View File

@@ -63,7 +63,8 @@ struct AgentProTab: View {
enum AgentRosterFilter: String, CaseIterable, Identifiable {
case all
case online
case ready
case busy
case idle
var id: Self {
self
@@ -73,7 +74,8 @@ struct AgentProTab: View {
switch self {
case .all: "All"
case .online: "Online"
case .ready: "Ready"
case .busy: "Busy"
case .idle: "Idle"
}
}
}
@@ -88,19 +90,22 @@ struct AgentProTab: View {
enum AgentRosterState: Equatable {
case online
case ready
case busy
case idle
var title: String {
switch self {
case .online: "Online"
case .ready: "Ready"
case .busy: "Busy"
case .idle: "Idle"
}
}
var color: Color {
switch self {
case .online: OpenClawBrand.ok
case .ready: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
case .busy: OpenClawBrand.warn
case .idle: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
}
}
}
@@ -137,6 +142,7 @@ struct AgentProTab: View {
.refreshable {
await self.refreshOverview(force: true)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
.navigationDestination(for: AgentRoute.self) { route in

View File

@@ -6,7 +6,6 @@ struct ChatProTab: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel: OpenClawChatViewModel?
@State private var viewModelUsesAppleReviewDemoTransport = false
var body: some View {
NavigationStack {
@@ -25,10 +24,8 @@ struct ChatProTab: View {
assistantAvatarTint: OpenClawBrand.accent,
showsAssistantAvatars: false,
composerChrome: .clean,
isComposerEnabled: self.gatewayConnected,
messagePlaceholder: self.messagePlaceholder,
messagePlaceholder: "Message \(self.agentDisplayName)...",
talkControl: self.talkControl)
.id(ObjectIdentifier(viewModel))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} else {
ProCard {
@@ -44,9 +41,7 @@ struct ChatProTab: View {
Spacer()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.navigationBarHidden(true)
}
.task {
@@ -55,10 +50,6 @@ struct ChatProTab: View {
.onChange(of: self.appModel.chatSessionKey) { _, _ in
self.syncChatViewModel()
}
.onChange(of: self.appModel.isAppleReviewDemoModeEnabled) { _, _ in
self.syncChatViewModel()
self.viewModel?.refresh()
}
.onChange(of: self.appModel.isOperatorGatewayConnected) { _, connected in
guard connected else { return }
self.syncChatViewModel()
@@ -108,29 +99,10 @@ struct ChatProTab: View {
private func syncChatViewModel() {
let sessionKey = self.appModel.chatSessionKey
let usesDemoTransport = self.appModel.isAppleReviewDemoModeEnabled
guard let viewModel else {
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
self.viewModel = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: usesDemoTransport
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
onSessionChanged: { sessionKey in
self.appModel.focusChatSession(sessionKey)
},
diagnosticsLog: { message in
GatewayDiagnostics.log(message)
})
return
}
if self.viewModelUsesAppleReviewDemoTransport != usesDemoTransport {
self.viewModelUsesAppleReviewDemoTransport = usesDemoTransport
self.viewModel = OpenClawChatViewModel(
sessionKey: sessionKey,
transport: usesDemoTransport
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
transport: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
onSessionChanged: { sessionKey in
self.appModel.focusChatSession(sessionKey)
},
@@ -140,7 +112,7 @@ struct ChatProTab: View {
return
}
guard viewModel.sessionKey != sessionKey else { return }
viewModel.syncSession(to: sessionKey)
viewModel.switchSession(to: sessionKey)
}
private var talkControl: OpenClawChatTalkControl {
@@ -158,7 +130,8 @@ struct ChatProTab: View {
}
private var activeAgentID: String {
self.normalized(self.appModel.chatAgentId)
self.normalized(self.appModel.selectedAgentId)
?? self.normalized(self.appModel.gatewayDefaultAgentId)
?? "main"
}
@@ -183,14 +156,8 @@ struct ChatProTab: View {
}
private var gatewayConnected: Bool {
guard GatewayStatusBuilder.build(appModel: self.appModel) == .connected else {
return false
}
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var messagePlaceholder: String {
self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway"
GatewayStatusBuilder.build(appModel: self.appModel) == .connected &&
self.appModel.isOperatorGatewayConnected
}
private var chatUserAccent: Color {
@@ -202,7 +169,7 @@ struct ChatProTab: View {
}
private var agentDisplayName: String {
self.normalized(self.activeAgent?.name) ?? self.appModel.chatAgentName
self.normalized(self.activeAgent?.name) ?? self.appModel.activeAgentName
}
private var agentBadge: String {

View File

@@ -126,7 +126,7 @@ struct CommandSessionRow: View {
}
private var progressLabel: String {
guard let progress = item.progress else {
guard let progress = self.item.progress else {
return self.item.state
}
if self.item.state == "offline" || self.item.state == "off" || self.item.state == "idle" {
@@ -144,31 +144,41 @@ struct CommandSessionRow: View {
}
}
struct CommandViewMoreRow: View {
@Environment(\.colorScheme) private var colorScheme
struct CommandApprovalRow: View {
let item: CommandCenterTab.ApprovalItem
var body: some View {
Text("View More")
.font(.subheadline.weight(.bold))
.foregroundStyle(OpenClawBrand.accent)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.rowFill)
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(self.rowBorder, lineWidth: 1)
}
HStack(spacing: 10) {
Image(systemName: self.item.icon)
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(self.item.color)
}
VStack(alignment: .leading, spacing: 2) {
Text(self.item.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.item.detail)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
private var rowFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
}
private var rowBorder: Color {
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
Spacer(minLength: 8)
Text(self.item.priority)
.font(.caption.weight(.bold))
.foregroundStyle(self.item.color)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background {
Capsule()
.fill(self.item.color.opacity(0.10))
}
}
.padding(.horizontal, 8)
.padding(.vertical, 7)
}
}
@@ -240,3 +250,32 @@ struct CommandTaskRow: View {
.padding(.vertical, 8)
}
}
struct CommandLiveActivityRow: View {
let title: String
let value: String
let color: Color
var body: some View {
HStack(spacing: 8) {
ProStatusDot(color: self.color)
Text(self.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 8)
Text(self.value)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.black.opacity(0.08))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 1)
}
}
}
}

View File

@@ -2,13 +2,10 @@ import OpenClawChatUI
import SwiftUI
struct CommandCenterTab: View {
fileprivate static let recentSessionsFetchLimit = 200
@Environment(NodeAppModel.self) private var appModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.scenePhase) private var scenePhase
@State private var defaultChatSessionEntry: OpenClawChatSessionEntry?
@State private var recentChatSessions: [OpenClawChatSessionEntry] = []
@State private var activeChatSessions: [OpenClawChatSessionEntry] = []
var openChat: () -> Void
var openSettings: () -> Void
@@ -29,6 +26,15 @@ struct CommandCenterTab: View {
let route: WorkRoute
}
struct ApprovalItem: Identifiable {
let id: String
let icon: String
let title: String
let detail: String
let priority: String
let color: Color
}
var body: some View {
NavigationStack {
ZStack {
@@ -38,8 +44,10 @@ struct CommandCenterTab: View {
VStack(alignment: .leading, spacing: 10) {
self.header
self.gatewayCard
self.defaultChatSessionSection
self.recentSessions
self.pendingApprovals
self.activeTasks
self.liveActivity
self.startWorkAction
}
.padding(.top, 16)
.padding(.bottom, 18)
@@ -48,8 +56,8 @@ struct CommandCenterTab: View {
}
.navigationBarHidden(true)
}
.task(id: self.recentSessionsRefreshID) {
await self.refreshRecentSessionsIfNeeded()
.task(id: self.activeSessionsRefreshID) {
await self.refreshActiveSessionsIfNeeded()
}
}
@@ -144,65 +152,162 @@ struct CommandCenterTab: View {
.padding(.horizontal, 10)
}
private var defaultChatSessionSection: some View {
CommandPanel(padding: 12) {
VStack(spacing: 10) {
self.cardHeader(
title: "Agent session",
value: nil,
color: OpenClawBrand.accent)
private var pendingApprovals: some View {
self.pendingApprovalsContent
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
Button {
self.open(.chat(nil))
} label: {
CommandSessionRow(item: self.defaultChatWorkItem)
private var pendingApprovalsContent: some View {
CommandPanel(
tint: self.pendingApproval == nil ? nil : OpenClawBrand.warn,
isProminent: self.pendingApproval != nil,
padding: self.pendingApproval == nil ? 11 : 13)
{
VStack(alignment: .leading, spacing: 10) {
self.cardHeader(
title: "Pending approvals",
value: self.pendingApproval == nil ? nil : "Review requests ",
color: OpenClawBrand.accentHot,
badgeValue: self.approvalItems.isEmpty ? nil : "\(self.approvalItems.count)")
if self.approvalItems.isEmpty {
CommandEmptyStateRow(
icon: "checkmark.shield.fill",
title: "No approvals waiting",
detail: self
.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway.")
} else {
VStack(spacing: 0) {
ForEach(Array(self.approvalItems.enumerated()), id: \.element.id) { index, item in
CommandApprovalRow(item: item)
if index < self.approvalItems.count - 1 {
Divider().padding(.leading, 48)
}
}
}
.padding(.vertical, 4)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.approvalRowsFill)
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
Color.primary.opacity(self.colorScheme == .dark ? 0.08 : 0.04),
lineWidth: 1)
}
}
}
.buttonStyle(.plain)
if let pendingApproval {
HStack(spacing: 8) {
Button {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
} label: {
Label("Allow", systemImage: "checkmark")
}
.buttonStyle(.borderedProminent)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
if pendingApproval.allowsAllowAlways {
Button {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
}
} label: {
Label("Always", systemImage: "checkmark.shield")
}
.buttonStyle(.bordered)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
}
Button(role: .destructive) {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
} label: {
Label("Deny", systemImage: "xmark")
}
.buttonStyle(.bordered)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
Spacer(minLength: 0)
}
.controlSize(.small)
}
}
}
}
private var activeTasks: some View {
CommandPanel(padding: 0) {
VStack(spacing: 0) {
self.cardHeader(
title: "Active sessions",
value: self.activeSessionsSummaryText,
color: .secondary)
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 3)
VStack(spacing: 8) {
ForEach(self.visibleActiveSessionRows) { item in
Button {
self.open(item.route)
} label: {
CommandSessionRow(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 10)
.padding(.bottom, 10)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var recentSessions: some View {
CommandPanel(padding: 12) {
VStack(spacing: 10) {
private var liveActivity: some View {
CommandPanel(padding: 0) {
VStack(spacing: 0) {
self.cardHeader(
title: "Recent sessions",
title: "Live activity",
value: nil,
color: .secondary)
color: OpenClawBrand.accent)
.padding(.horizontal, 12)
.padding(.top, 11)
.padding(.bottom, 3)
if self.recentSessionPreviewRows.isEmpty {
CommandEmptyStateRow(
icon: self.gatewayConnected ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash",
title: self.gatewayConnected ? "No recent sessions" : "Gateway offline",
detail: self
.gatewayConnected ? "Start a chat and it will appear here." : "Connect to the gateway.")
} else {
VStack(spacing: 8) {
ForEach(self.recentSessionPreviewRows) { item in
Button {
self.open(item.route)
} label: {
CommandSessionRow(item: item)
}
.buttonStyle(.plain)
}
if self.hasMoreRecentSessions {
NavigationLink {
CommandSessionsScreen(openChat: self.openChat)
} label: {
CommandViewMoreRow()
}
.buttonStyle(.plain)
}
}
}
CommandLiveActivityRow(
title: self.liveActivityTitle,
value: self.liveActivityValue,
color: self.liveActivityColor)
.padding(.horizontal, 14)
.padding(.bottom, 10)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var startWorkAction: some View {
CommandPanel(tint: OpenClawBrand.accent, isProminent: true, padding: 9) {
Button(action: self.openChat) {
Label("Start work", systemImage: "play.fill")
.font(.subheadline.weight(.bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background {
RoundedRectangle(cornerRadius: 13, style: .continuous)
.fill(LinearGradient(
colors: [OpenClawBrand.accentHot, OpenClawBrand.accent],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.shadow(color: OpenClawBrand.accentHot.opacity(0.34), radius: 18, y: 8)
}
}
.buttonStyle(.plain)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func cardHeader(
title: String,
value: String?,
@@ -273,69 +378,164 @@ struct CommandCenterTab: View {
return "\(self.appModel.gatewayAgents.count)"
}
private var defaultChatWorkItem: WorkItem {
let isOpen = self.appModel.chatSessionKey == self.appModel.defaultChatSessionKey
return WorkItem(
id: "default-chat",
icon: isOpen ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
title: self.appModel.activeAgentName,
detail: self.defaultChatActivityText,
state: isOpen ? "open" : "default",
trailing: "chat",
color: isOpen ? OpenClawBrand.accent : OpenClawBrand.ok,
progress: nil,
route: .chat(nil))
}
private var defaultChatActivityText: String {
guard let updatedAt = defaultChatSessionEntry?.updatedAt, updatedAt > 0 else {
return "No recent activity"
private var activeSessionsSummaryText: String {
let count = self.activeSessionRows.count
if count == 0 {
return self.gatewayConnected ? "No sessions" : "Offline"
}
return Self.relativeTimeText(forMilliseconds: updatedAt)
if self.sessionWorkItems.isEmpty {
return self.gatewayConnected ? "\(count) ready" : "Offline"
}
return "\(count) \(count == 1 ? "session" : "sessions")"
}
private var recentSessionRows: [WorkItem] {
private var approvalItems: [ApprovalItem] {
if let pendingApproval {
return [
ApprovalItem(
id: "pending-real",
icon: "terminal.fill",
title: pendingApproval.commandPreview ?? "Review gateway action",
detail: "Agent: \(self.appModel.activeAgentName)",
priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High",
color: OpenClawBrand.danger),
ApprovalItem(
id: "pending-context",
icon: "doc.text.fill",
title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval",
detail: "Gateway request",
priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review",
color: OpenClawBrand.warn),
]
}
return []
}
private var approvalRowsFill: Color {
self.colorScheme == .dark ? Color.black.opacity(0.12) : Color.black.opacity(0.022)
}
private var activeSessionRows: [WorkItem] {
self.sessionItems
}
private var recentSessionPreviewRows: [WorkItem] {
Array(self.recentSessionRows.prefix(3))
private var visibleActiveSessionRows: [WorkItem] {
Array(self.activeSessionRows.prefix(3))
}
private var hasMoreRecentSessions: Bool {
self.sessionWorkItems.count > self.recentSessionPreviewRows.count
private var liveActivityTitle: String {
if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }) {
return "\(Self.sessionTitle(session)) updated"
}
if self.pendingApproval != nil {
return "Approval waiting"
}
return self.gatewayConnected ? "Gateway connected" : self.gatewayStateText
}
private var recentSessionsRefreshID: String {
private var liveActivityValue: String {
if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }),
let updatedAt = session.updatedAt,
updatedAt > 0
{
return Self.relativeTimeText(forMilliseconds: updatedAt)
}
if self.pendingApproval != nil {
return "review"
}
return self.gatewayConnected ? self.gatewayAddressText : self.gatewayDisplayStatusValue
}
private var liveActivityColor: Color {
if self.pendingApproval != nil { return OpenClawBrand.warn }
return self.gatewayConnected ? OpenClawBrand.ok : .secondary
}
private var gatewayDisplayStatusValue: String {
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
return status.isEmpty ? self.gatewayStateText : status
}
private var activeSessionsRefreshID: String {
[
self.sessionListMode,
self.appModel.isOperatorGatewayConnected ? "connected" : "offline",
self.appModel.chatSessionKey,
self.scenePhase == .active ? "active" : "inactive",
].joined(separator: ":")
}
private var sessionListAvailable: Bool {
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
}
private var sessionListMode: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
}
private var sessionItems: [WorkItem] {
self.sessionWorkItems
let liveItems = self.sessionWorkItems
if !liveItems.isEmpty { return liveItems }
return self.defaultSessionItems
}
private var sessionWorkItems: [WorkItem] {
let currentSessionKey = self.appModel.chatSessionKey
return self.recentChatSessions
.filter { Self.isRecentChatSession($0.key, defaultSessionKey: self.appModel.defaultChatSessionKey) }
return self.activeChatSessions
.filter { !Self.isHiddenInternalSession($0.key) }
.prefix(4)
.map { session in
Self.sessionWorkItem(for: session, currentSessionKey: currentSessionKey)
let isCurrent = session.key == currentSessionKey
return WorkItem(
id: "chat-session-\(session.key)",
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
title: Self.sessionTitle(session),
detail: Self.sessionDetail(session),
state: isCurrent ? "current" : "recent",
trailing: "chat",
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
progress: nil,
route: .chat(session.key))
}
}
private var defaultSessionItems: [WorkItem] {
[
WorkItem(
id: "main-chat",
icon: "bubble.left.and.text.bubble.right.fill",
title: "Main chat",
detail: self.appModel.activeAgentName,
state: self.gatewayConnected ? "ready" : "offline",
trailing: "session",
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
progress: nil,
route: .chat(self.appModel.chatSessionKey)),
WorkItem(
id: "talk-mode",
icon: "waveform",
title: "Talk",
detail: self.appModel.talkMode.statusText,
state: self.appModel.talkMode.isEnabled ? "active" : "off",
trailing: "voice",
color: self.appModel.talkMode.isEnabled ? OpenClawBrand.ok : .secondary,
progress: nil,
route: .settings),
WorkItem(
id: "device-capture",
icon: self.appModel.screenRecordActive ? "record.circle.fill" : "display",
title: "Device capture",
detail: self.appModel.screenRecordActive ? "Screen capture is active" : "Screen and device tools",
state: self.appModel.screenRecordActive ? "running" : "idle",
trailing: "device",
color: self.appModel.screenRecordActive ? OpenClawBrand.warn : .secondary,
progress: nil,
route: .settings),
WorkItem(
id: "agent-roster",
icon: "person.2.fill",
title: "Agents",
detail: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count) available" : "Roster unavailable",
state: self.gatewayConnected ? "online" : "offline",
trailing: "gateway",
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
progress: nil,
route: .settings),
]
}
private func open(_ route: WorkRoute) {
switch route {
case let .chat(sessionKey):
@@ -346,55 +546,42 @@ struct CommandCenterTab: View {
}
}
private func refreshRecentSessionsIfNeeded() async {
private func refreshActiveSessionsIfNeeded() async {
guard self.scenePhase == .active else { return }
guard self.sessionListAvailable else {
if self.defaultChatSessionEntry != nil {
self.defaultChatSessionEntry = nil
}
if !self.recentChatSessions.isEmpty {
self.recentChatSessions = []
guard self.appModel.isOperatorGatewayConnected else {
if !self.activeChatSessions.isEmpty {
self.activeChatSessions = []
}
return
}
do {
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
let response = try await transport.listSessions(limit: Self.recentSessionsFetchLimit)
self.defaultChatSessionEntry = response.sessions.first {
$0.key == self.appModel.defaultChatSessionKey
}
self.recentChatSessions = Self.sessionChoices(
let transport = IOSGatewayChatTransport(gateway: appModel.operatorSession)
let response = try await transport.listSessions(limit: 12)
self.activeChatSessions = Self.sessionChoices(
response.sessions,
currentSessionKey: self.appModel.chatSessionKey,
defaultSessionKey: self.appModel.defaultChatSessionKey)
currentSessionKey: self.appModel.chatSessionKey)
} catch {
self.defaultChatSessionEntry = nil
self.recentChatSessions = []
self.activeChatSessions = []
}
}
private static func sessionChoices(
_ sessions: [OpenClawChatSessionEntry],
currentSessionKey: String,
defaultSessionKey: String) -> [OpenClawChatSessionEntry]
currentSessionKey: String) -> [OpenClawChatSessionEntry]
{
let sorted = sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
var result: [OpenClawChatSessionEntry] = []
var included = Set<String>()
if Self.isRecentChatSession(currentSessionKey, defaultSessionKey: defaultSessionKey),
let current = sorted.first(where: { $0.key == currentSessionKey })
{
if let current = sorted.first(where: { $0.key == currentSessionKey }) {
result.append(current)
included.insert(current.key)
}
for session in sorted {
guard !included.contains(session.key) else { continue }
guard Self.isRecentChatSession(session.key, defaultSessionKey: defaultSessionKey) else { continue }
guard !Self.isHiddenInternalSession(session.key) else { continue }
result.append(session)
included.insert(session.key)
if result.count >= 4 { break }
@@ -403,24 +590,7 @@ struct CommandCenterTab: View {
return result
}
fileprivate static func sessionWorkItem(
for session: OpenClawChatSessionEntry,
currentSessionKey: String) -> WorkItem
{
let isCurrent = session.key == currentSessionKey
return WorkItem(
id: "chat-session-\(session.key)",
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
title: Self.sessionTitle(session),
detail: Self.sessionDetail(session),
state: isCurrent ? "open" : "recent",
trailing: "chat",
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
progress: nil,
route: .chat(session.key))
}
fileprivate static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
private static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
if let title = redactedSessionTitle(for: session.key) {
return title
}
@@ -436,7 +606,7 @@ struct CommandCenterTab: View {
return session.key
}
fileprivate static func redactedSessionTitle(for key: String) -> String? {
private static func redactedSessionTitle(for key: String) -> String? {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
let lowercased = trimmed.lowercased()
guard !trimmed.isEmpty else { return nil }
@@ -455,7 +625,7 @@ struct CommandCenterTab: View {
return nil
}
fileprivate static func humanizedSessionKey(_ key: String) -> String? {
private static func humanizedSessionKey(_ key: String) -> String? {
let words = key
.replacingOccurrences(of: "_", with: "-")
.split(separator: "-")
@@ -475,14 +645,14 @@ struct CommandCenterTab: View {
.joined(separator: " ")
}
fileprivate static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String {
private static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String {
if let updatedAt = session.updatedAt, updatedAt > 0 {
return self.relativeTimeText(forMilliseconds: updatedAt)
}
return session.key
}
fileprivate static func relativeTimeText(forMilliseconds milliseconds: Double) -> String {
private static func relativeTimeText(forMilliseconds milliseconds: Double) -> String {
let date = Date(timeIntervalSince1970: milliseconds / 1000)
let formatter = RelativeDateTimeFormatter()
formatter.dateTimeStyle = .numeric
@@ -490,53 +660,12 @@ struct CommandCenterTab: View {
return formatter.localizedString(for: date, relativeTo: .now)
}
fileprivate nonisolated static func isHiddenInternalSession(_ key: String) -> Bool {
private static func isHiddenInternalSession(_ key: String) -> Bool {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
}
nonisolated static func isRecentChatSession(_ key: String, defaultSessionKey: String) -> Bool {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if trimmed == defaultSessionKey { return false }
let normalized = trimmed.lowercased()
let defaultBase = self.sessionBaseKey(defaultSessionKey)
if !normalized.contains(":"),
self.isDirectSessionBase(normalized, defaultBase: defaultBase)
{
return false
}
if self.isHiddenInternalSession(trimmed) { return false }
return !self.isAgentDeviceSession(trimmed, defaultSessionKey: defaultSessionKey)
}
private nonisolated static func isAgentDeviceSession(_ key: String, defaultSessionKey: String) -> Bool {
let parts = key
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: ":", omittingEmptySubsequences: false)
guard parts.count >= 3, parts[0].lowercased() == "agent" else { return false }
guard parts.count == 3 || parts[3].lowercased() == "thread" else { return false }
let base = String(parts[2]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let defaultKey = self.sessionBaseKey(defaultSessionKey)
return self.isDirectSessionBase(base, defaultBase: defaultKey)
}
private nonisolated static func isDirectSessionBase(_ base: String, defaultBase: String) -> Bool {
base == defaultBase || base == "main" || base == "global" || base.hasPrefix("node-")
}
private nonisolated static func sessionBaseKey(_ key: String) -> String {
let parts = key
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: ":", omittingEmptySubsequences: false)
guard parts.count >= 3, parts[0].lowercased() == "agent" else {
return key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
return String(parts[2]).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private var gatewaySubtitle: String {
if let server = normalized(appModel.gatewayServerName) {
return "\(self.appModel.activeAgentName) on \(server)"
@@ -547,6 +676,10 @@ struct CommandCenterTab: View {
return self.appModel.gatewayDisplayStatusText
}
private var pendingApproval: NodeAppModel.ExecApprovalPrompt? {
self.appModel.pendingExecApprovalPrompt
}
private func normalized(_ value: String?) -> String? {
Self.normalized(value)
}
@@ -557,166 +690,3 @@ struct CommandCenterTab: View {
return trimmed.isEmpty ? nil : trimmed
}
}
private struct CommandSessionsScreen: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
@State private var sessions: [OpenClawChatSessionEntry] = []
@State private var isLoading = false
@State private var loadErrorText: String?
let openChat: () -> Void
var body: some View {
ZStack {
CommandControlBackground()
ScrollView {
VStack(alignment: .leading, spacing: 10) {
self.header
self.sessionsPanel
}
.padding(.top, 16)
.padding(.bottom, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle("Sessions")
.navigationBarTitleDisplayMode(.inline)
.task(id: self.refreshID) {
await self.refreshSessions()
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Sessions")
.font(.system(size: 27, weight: .bold, design: .rounded))
Text(self.headerDetail)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var sessionsPanel: some View {
CommandPanel(padding: 0) {
VStack(spacing: 0) {
HStack(spacing: 8) {
Text("Recent sessions")
.font(.subheadline.weight(.bold))
Spacer(minLength: 8)
if self.isLoading {
ProgressView()
.controlSize(.small)
}
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 3)
if let loadErrorText {
CommandEmptyStateRow(
icon: "exclamationmark.triangle.fill",
title: "Sessions unavailable",
detail: loadErrorText)
.padding(.horizontal, 10)
.padding(.bottom, 10)
} else if self.sessionRows.isEmpty {
CommandEmptyStateRow(
icon: self.appModel
.isCommandSessionListAvailable ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash",
title: self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline",
detail: self.appModel
.isCommandSessionListAvailable ? "Start a chat and it will appear here." :
"Connect to the gateway.")
.padding(.horizontal, 10)
.padding(.bottom, 10)
} else {
VStack(spacing: 8) {
ForEach(self.sessionRows) { item in
Button {
self.open(item)
} label: {
CommandSessionRow(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 10)
.padding(.bottom, 10)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private var headerDetail: String {
if self.isLoading, self.sessions.isEmpty { return "Loading recent sessions" }
let count = self.sessionRows.count
if count == 0 {
return self.appModel.isCommandSessionListAvailable ? "No recent sessions" : "Gateway offline"
}
return "\(count) \(count == 1 ? "session" : "sessions")"
}
private var sessionRows: [CommandCenterTab.WorkItem] {
self.sessions
.filter { CommandCenterTab.isRecentChatSession(
$0.key,
defaultSessionKey: self.appModel.defaultChatSessionKey) }
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
.map {
CommandCenterTab.sessionWorkItem(
for: $0,
currentSessionKey: self.appModel.chatSessionKey)
}
}
private var refreshID: String {
self.appModel.commandSessionListMode
}
private func open(_ item: CommandCenterTab.WorkItem) {
switch item.route {
case let .chat(sessionKey):
self.appModel.openChat(sessionKey: sessionKey)
self.dismiss()
self.openChat()
case .settings:
break
}
}
private func refreshSessions() async {
guard self.appModel.isCommandSessionListAvailable else {
self.sessions = []
self.loadErrorText = nil
return
}
self.isLoading = true
self.loadErrorText = nil
defer { self.isLoading = false }
do {
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
? AppleReviewDemoChatTransport()
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
self.sessions = response.sessions
} catch {
self.sessions = []
self.loadErrorText = "Try again after the gateway reconnects."
}
}
}
extension NodeAppModel {
fileprivate var isCommandSessionListAvailable: Bool {
self.isAppleReviewDemoModeEnabled || self.isOperatorGatewayConnected
}
fileprivate var commandSessionListMode: String {
if self.isAppleReviewDemoModeEnabled { return "demo" }
return self.isOperatorGatewayConnected ? "operator" : "offline"
}
}

View File

@@ -22,12 +22,11 @@ struct OpenClawProBackground: View {
LinearGradient(
colors: [
OpenClawBrand.accent.opacity(0.05),
OpenClawBrand.accent.opacity(0.02),
.clear,
],
startPoint: .topTrailing,
endPoint: .bottomLeading)
.frame(height: 620)
.frame(height: 260)
.ignoresSafeArea()
}
}

View File

@@ -1,5 +1,7 @@
import OpenClawKit
import SwiftUI
import UIKit
import UserNotifications
struct SettingsProTab: View {
@Environment(NodeAppModel.self) var appModel
@@ -57,7 +59,6 @@ struct SettingsProTab: View {
@State var notificationActionText = "Request Access"
@State var diagnosticsLastRunText = "Not run"
@State var diagnosticsIssueCount: Int?
@State var showTalkIssueDetails = false
var body: some View {
NavigationStack {
@@ -70,9 +71,9 @@ struct SettingsProTab: View {
self.gatewaySection
self.settingsListSection
}
.padding(.top, 18)
.padding(.bottom, 18)
.padding(.vertical, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
.navigationDestination(for: SettingsRoute.self) { route in
@@ -130,20 +131,12 @@ struct SettingsProTab: View {
})
}
}
.sheet(isPresented: self.$showTalkIssueDetails) {
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
TalkRuntimeIssueDetailsSheet(issue: issue)
}
}
.sheet(isPresented: self.$showQRScanner) {
NavigationStack {
QRScannerView(
onGatewayLink: { link in
self.handleScannedGatewayLink(link)
},
onSetupCode: { code in
self.handleScannedSetupCode(code)
},
onError: { error in
self.showQRScanner = false
self.setupStatusText = "Scanner error: \(error)"

View File

@@ -42,9 +42,9 @@ extension SettingsProTab {
self.diagnosticCheckRow(
icon: "antenna.radiowaves.left.and.right",
title: "Gateway Link",
detail: self.gatewayStatusDetail,
value: self.gatewayStatusValue,
color: self.gatewayStatusColor)
detail: self.appModel.gatewayDisplayStatusText,
value: self.gatewayConnected ? "online" : "offline",
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
Divider().padding(.leading, 60)
self.diagnosticCheckRow(
icon: "dot.radiowaves.left.and.right",
@@ -56,9 +56,9 @@ extension SettingsProTab {
self.diagnosticCheckRow(
icon: "waveform",
title: "Talk Config",
detail: self.gatewayTalkConfigDetail,
value: self.gatewayTalkConfigValue,
color: self.gatewayTalkConfigColor)
detail: self.appModel.talkMode.gatewayTalkTransportLabel,
value: self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing",
color: self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary)
Divider().padding(.leading, 60)
self.diagnosticCheckRow(
icon: "bell",
@@ -132,7 +132,6 @@ extension SettingsProTab {
}
func reconnectGateway() async {
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
guard !self.isReconnectingGateway else { return }
self.isReconnectingGateway = true
defer { self.isReconnectingGateway = false }
@@ -154,18 +153,16 @@ extension SettingsProTab {
self.isRefreshingGateway = true
defer { self.isRefreshingGateway = false }
if !self.appModel.isAppleReviewDemoModeEnabled {
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
self.gatewayController.restartDiscovery()
await self.appModel.refreshGatewayOverviewIfConnected()
}
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
self.gatewayController.restartDiscovery()
await self.appModel.refreshGatewayOverviewIfConnected()
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
self.applyNotificationStatus(notificationSettings.authorizationStatus)
let issueCount = SettingsDiagnostics.issueCount(
gatewayConnected: self.gatewayDiagnosticConnected,
gatewayConnected: self.gatewayConnected,
discoveredGatewayCount: self.gatewayController.gateways.count,
talkConfigLoaded: self.gatewayDiagnosticTalkConfigLoaded,
talkConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded,
notificationStatusText: self.notificationStatusText)
self.diagnosticsIssueCount = issueCount
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
@@ -223,14 +220,6 @@ extension SettingsProTab {
return false
}
if AppleReviewDemoMode.isSetupCode(raw) {
self.stagedGatewaySetupLink = nil
self.setupCode = ""
self.setupStatusText = "Apple Review demo mode enabled."
self.appModel.enterAppleReviewDemoMode()
return false
}
guard let link = raw.isEmpty ? stagedLink : GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
return false
@@ -283,15 +272,6 @@ extension SettingsProTab {
Task { await self.connectAfterScannedGatewayLink() }
}
func handleScannedSetupCode(_ code: String) {
guard AppleReviewDemoMode.isSetupCode(code) else { return }
self.showQRScanner = false
self.setupCode = ""
self.stagedGatewaySetupLink = nil
self.setupStatusText = "Apple Review demo mode enabled."
self.appModel.enterAppleReviewDemoMode()
}
func connectAfterScannedGatewayLink() async {
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard let port = self.resolvedManualPort(host: host) else {
@@ -493,7 +473,6 @@ extension SettingsProTab {
func title(for route: SettingsRoute) -> String {
switch route {
case .gateway: "Gateway"
case .approvals: "Approvals"
case .permissions: "Permissions"
case .voice: "Voice & Talk"
case .diagnostics: "Diagnostics"
@@ -610,21 +589,6 @@ extension SettingsProTab {
return self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured"
}
var gatewayTalkActiveVoiceDetail: String {
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if title.isEmpty { return "Not active" }
if subtitle.isEmpty { return title }
return "\(title)\(subtitle)"
}
var gatewayTalkLastIssueDetail: String? {
let detail = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return detail.isEmpty ? nil : detail
}
func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
var lines: [String] = []
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
@@ -638,53 +602,7 @@ extension SettingsProTab {
}
var gatewayConnected: Bool {
!self.appModel.isAppleReviewDemoModeEnabled &&
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
var gatewayStatusDetail: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "Apple Review demo mode" }
return self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText
}
var gatewayStatusValue: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.gatewayConnected ? "online" : "offline"
}
var gatewayStatusColor: Color {
if self.appModel.isAppleReviewDemoModeEnabled { return OpenClawBrand.accent }
return self.gatewayConnected ? OpenClawBrand.ok : .secondary
}
var gatewayDiagnosticConnected: Bool {
self.appModel.isAppleReviewDemoModeEnabled || self.gatewayConnected
}
var gatewayDiagnosticTalkConfigLoaded: Bool {
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.talkMode.gatewayTalkConfigLoaded
}
var approvalEmptyDetail: String {
if self.appModel.isAppleReviewDemoModeEnabled {
return "Live gateway requests are disabled in demo mode."
}
return self.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway."
}
var gatewayTalkConfigDetail: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "Demo mode only" }
return self.appModel.talkMode.gatewayTalkTransportLabel
}
var gatewayTalkConfigValue: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
return self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing"
}
var gatewayTalkConfigColor: Color {
if self.appModel.isAppleReviewDemoModeEnabled { return .secondary }
return self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
var gatewayAddress: String {
@@ -703,34 +621,6 @@ extension SettingsProTab {
return "\(enabled) enabled"
}
var pendingApproval: NodeAppModel.ExecApprovalPrompt? {
self.appModel.pendingExecApprovalPrompt
}
var approvalsDetail: String {
self.pendingApproval == nil ? "No approvals waiting" : "1 request waiting"
}
var approvalItems: [SettingsApprovalItem] {
guard let pendingApproval else { return [] }
return [
SettingsApprovalItem(
id: "pending-real",
icon: "terminal.fill",
title: pendingApproval.commandPreview ?? "Review gateway action",
detail: "Agent: \(self.appModel.activeAgentName)",
priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High",
color: OpenClawBrand.danger),
SettingsApprovalItem(
id: "pending-context",
icon: "doc.text.fill",
title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval",
detail: "Gateway request",
priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review",
color: OpenClawBrand.warn),
]
}
var voiceDetail: String {
if self.talkEnabled, self.voiceWakeEnabled { return "Talk + Wake" }
if self.talkEnabled { return "Talk on" }
@@ -743,7 +633,6 @@ extension SettingsProTab {
}
var diagnosticsHealthValue: String {
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
if self.gatewayConnected { return "ready" }
if self.gatewayController.gateways.isEmpty { return "check" }
return "partial"

View File

@@ -37,8 +37,6 @@ extension SettingsProTab {
NavigationLink(value: SettingsRoute.gateway) {
self.gatewayConnectionRow
.padding(14)
.frame(maxWidth: .infinity, minHeight: SettingsLayout.rowHeight, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Divider()
@@ -60,14 +58,14 @@ extension SettingsProTab {
HStack(spacing: 12) {
ProIconBadge(
systemName: "antenna.radiowaves.left.and.right",
color: self.gatewayStatusColor)
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
VStack(alignment: .leading, spacing: 3) {
Text("Connection")
.font(.subheadline.weight(.semibold))
Text(self.gatewayStatusDetail)
Text(self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText)
.font(.caption)
.foregroundStyle(self.gatewayStatusColor)
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .secondary)
}
Spacer(minLength: 8)
@@ -100,8 +98,7 @@ extension SettingsProTab {
title: "Reconnect",
icon: "arrow.triangle.2.circlepath",
color: OpenClawBrand.warn,
isBusy: self.isReconnectingGateway,
isDisabled: self.appModel.isAppleReviewDemoModeEnabled)
isBusy: self.isReconnectingGateway)
{
Task { await self.reconnectGateway() }
}
@@ -119,13 +116,6 @@ extension SettingsProTab {
var settingsListSection: some View {
VStack(spacing: 10) {
self.settingsListRow(
icon: "checkmark.shield.fill",
title: "Approvals",
detail: self.approvalsDetail,
route: .approvals,
color: self.pendingApproval == nil ? .secondary : OpenClawBrand.warn,
badgeValue: self.pendingApproval == nil ? nil : "1")
self.settingsListRow(
icon: "person.2",
title: "Permissions",
@@ -164,13 +154,11 @@ extension SettingsProTab {
icon: String,
title: String,
detail: String,
route: SettingsRoute,
color: Color = .secondary,
badgeValue: String? = nil) -> some View
route: SettingsRoute) -> some View
{
NavigationLink(value: route) {
HStack(spacing: 12) {
ProIconBadge(systemName: icon, color: color)
ProIconBadge(systemName: icon, color: .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
@@ -180,9 +168,6 @@ extension SettingsProTab {
.lineLimit(1)
}
Spacer(minLength: 8)
if let badgeValue {
ProValuePill(value: badgeValue, color: color)
}
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
@@ -202,8 +187,6 @@ extension SettingsProTab {
switch route {
case .gateway:
self.gatewayDestination
case .approvals:
self.approvalsDestination
case .permissions:
self.permissionsDestination
case .voice:
@@ -218,9 +201,9 @@ extension SettingsProTab {
self.aboutDestination
}
}
.padding(.top, 18)
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
.padding(.vertical, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationTitle(self.title(for: route))
.navigationBarTitleDisplayMode(.inline)
@@ -235,9 +218,9 @@ extension SettingsProTab {
self.detailStatusCard(
icon: "antenna.radiowaves.left.and.right",
title: "Gateway",
detail: self.gatewayStatusDetail,
value: self.gatewayStatusValue,
color: self.gatewayStatusColor)
detail: self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText,
value: self.gatewayConnected ? "online" : "offline",
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
self.detailListCard {
self.detailRow("Address", value: self.gatewayAddress)
@@ -246,7 +229,7 @@ extension SettingsProTab {
Divider()
self.detailRow("Discovered", value: "\(self.gatewayController.gateways.count)")
Divider()
self.detailRow("Default Agent", value: self.appModel.activeAgentName)
self.detailRow("Active Agent", value: self.appModel.activeAgentName)
Divider()
self.detailRow("Agents", value: "\(self.appModel.gatewayAgents.count)")
}
@@ -256,98 +239,15 @@ extension SettingsProTab {
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.manualGatewayCard
self.deviceIdentityCard
self.agentSelectionCard
self.gatewaySetupCard
self.discoveredGatewaysCard
self.manualGatewayCard
self.gatewayAdvancedCard
}
}
var approvalsDestination: some View {
VStack(alignment: .leading, spacing: 14) {
self.detailStatusCard(
icon: "checkmark.shield.fill",
title: "Approvals",
detail: self.pendingApproval == nil ? "No gateway actions are waiting for review." :
"Review the pending gateway action.",
value: self.pendingApproval == nil ? "clear" : "1 waiting",
color: self.pendingApproval == nil ? OpenClawBrand.ok : OpenClawBrand.warn)
self.approvalsReviewCard
}
}
var approvalsReviewCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
if let pendingApproval {
VStack(spacing: 0) {
ForEach(Array(self.approvalItems.enumerated()), id: \.element.id) { index, item in
SettingsApprovalRow(item: item)
if index < self.approvalItems.count - 1 {
Divider().padding(.leading, 46)
}
}
}
if let errorText = self.appModel.pendingExecApprovalPromptErrorText {
Text(errorText)
.font(.caption2.weight(.medium))
.foregroundStyle(OpenClawBrand.danger)
}
HStack(spacing: 8) {
Button {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
} label: {
Label("Allow", systemImage: "checkmark")
}
.buttonStyle(.borderedProminent)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
if pendingApproval.allowsAllowAlways {
Button {
Task {
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
}
} label: {
Label("Always", systemImage: "checkmark.shield")
}
.buttonStyle(.bordered)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
}
Button(role: .destructive) {
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
} label: {
Label("Deny", systemImage: "xmark")
}
.buttonStyle(.bordered)
.disabled(self.appModel.pendingExecApprovalPromptResolving)
Spacer(minLength: 0)
}
.controlSize(.small)
} else {
HStack(spacing: 12) {
ProIconBadge(systemName: "checkmark.shield.fill", color: OpenClawBrand.ok)
VStack(alignment: .leading, spacing: 3) {
Text("No approvals waiting")
.font(.subheadline.weight(.semibold))
Text(self.approvalEmptyDetail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
var permissionsDestination: some View {
VStack(alignment: .leading, spacing: 14) {
self.toggleCard(
@@ -390,19 +290,7 @@ extension SettingsProTab {
title: "Health Check",
detail: "Run app, permission, and gateway-adjacent checks without editing setup.",
value: self.diagnosticsHealthValue,
color: self.gatewayDiagnosticConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
ProCard(radius: SettingsLayout.cardRadius) {
self.gatewayActionButton(
title: "Run Diagnostics",
icon: "cross.case",
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
color: self.gatewayConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
self.diagnosticChecksCard
@@ -416,6 +304,18 @@ extension SettingsProTab {
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
}
ProCard(radius: SettingsLayout.cardRadius) {
self.gatewayActionButton(
title: "Run Diagnostics",
icon: "cross.case",
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
self.diagnosticsAdvancedCard
}
}
@@ -504,7 +404,6 @@ extension SettingsProTab {
icon: String,
color: Color,
isBusy: Bool,
isDisabled: Bool = false,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
@@ -526,7 +425,7 @@ extension SettingsProTab {
}
}
.buttonStyle(.plain)
.disabled(isBusy || isDisabled)
.disabled(isBusy)
}
func toggleCard(
@@ -598,7 +497,7 @@ extension SettingsProTab {
var agentSelectionCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 10) {
Text("Default Agent")
Text("Active Agent")
.font(.subheadline.weight(.semibold))
Picker("Agent", selection: self.$selectedAgentPickerId) {
Text("Default").tag("")
@@ -609,7 +508,7 @@ extension SettingsProTab {
Text(name.isEmpty ? agent.id : name).tag(agent.id)
}
}
Text("Used for new Chat and Talk sessions.")
Text("Controls which agent Chat and Talk use.")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -711,7 +610,7 @@ extension SettingsProTab {
var manualGatewayCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
self.settingsButtonToggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
TextField("Host", text: self.$manualGatewayHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
@@ -719,7 +618,7 @@ extension SettingsProTab {
TextField("Port", text: self.manualPortBinding)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
self.settingsButtonToggle("Use TLS", isOn: self.$manualGatewayTLS)
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
self.gatewayActionButton(
title: "Connect Manual",
icon: "network",
@@ -738,21 +637,16 @@ extension SettingsProTab {
var gatewayAdvancedCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
self.settingsButtonToggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
SecureField("Gateway Auth Token", text: self.$gatewayToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
SecureField("Gateway Password", text: self.$gatewayPassword)
.textFieldStyle(.roundedBorder)
Button(role: .destructive) {
Button("Reset Onboarding", role: .destructive) {
self.showResetOnboardingAlert = true
} label: {
Label("Reset Onboarding", systemImage: "arrow.counterclockwise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
@@ -765,13 +659,8 @@ extension SettingsProTab {
self.appModel.setVoiceWakeEnabled(enabled)
}
self.settingsToggle("Talk Mode", isOn: self.$talkEnabled) { enabled in
guard !self.appModel.isAppleReviewDemoModeEnabled else {
self.talkEnabled = false
return
}
self.appModel.setTalkEnabled(enabled)
}
.disabled(self.appModel.isAppleReviewDemoModeEnabled)
Picker("Speech Language", selection: self.$talkSpeechLocale) {
ForEach(TalkSpeechLocale.supportedOptions()) { option in
Text(option.label).tag(option.id)
@@ -792,44 +681,26 @@ extension SettingsProTab {
}
var talkVoiceSettingsCard: some View {
VStack(alignment: .leading, spacing: 10) {
if self.gatewayConnected,
let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue
{
TalkRuntimeIssueBanner(
issue: issue,
onOpenSettings: nil,
onShowDetails: {
self.showTalkIssueDetails = true
})
}
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Picker("Provider", selection: self.talkProviderSelectionBinding) {
ForEach(TalkModeProviderSelection.allCases) { option in
Text(option.label).tag(option.rawValue)
}
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
Picker("Provider", selection: self.talkProviderSelectionBinding) {
ForEach(TalkModeProviderSelection.allCases) { option in
Text(option.label).tag(option.rawValue)
}
if self.shouldShowRealtimeVoicePicker {
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
Text("Gateway Default").tag("")
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).tag(voice)
}
}
}
self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
Divider()
self.detailRow("Active Voice", value: self.gatewayTalkActiveVoiceDetail)
if let issue = self.gatewayTalkLastIssueDetail {
Divider()
self.detailRow("Last Voice Issue", value: issue)
}
Divider()
self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
Divider()
self.detailRow("API Key", value: self.talkApiKeyStatus)
}
if self.shouldShowRealtimeVoicePicker {
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
Text("Gateway Default").tag("")
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).tag(voice)
}
}
}
self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
Divider()
self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
Divider()
self.detailRow("API Key", value: self.talkApiKeyStatus)
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
@@ -867,10 +738,11 @@ extension SettingsProTab {
var diagnosticsAdvancedCard: some View {
ProCard(radius: SettingsLayout.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
self.settingsButtonToggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) { enabled in
self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled)
}
self.settingsButtonToggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled)
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
.onChange(of: self.discoveryDebugLogsEnabled) { _, enabled in
self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled)
}
Toggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled)
NavigationLink {
GatewayDiscoveryDebugLogView()
} label: {
@@ -918,44 +790,6 @@ extension SettingsProTab {
}
}
func settingsButtonToggle(
_ title: String,
isOn: Binding<Bool>,
onChange: ((Bool) -> Void)? = nil) -> some View
{
// Settings switch rows need full-width taps; wrapping Toggle crashes this NavigationStack on iOS 26.
Button {
isOn.wrappedValue.toggle()
} label: {
HStack {
Text(title)
Spacer(minLength: 8)
self.settingsSwitchIndicator(isOn: isOn.wrappedValue)
}
.font(.subheadline)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityValue(isOn.wrappedValue ? "On" : "Off")
.onChange(of: isOn.wrappedValue) { _, enabled in
onChange?(enabled)
}
}
func settingsSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.padding(2)
.shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1)
}
}
func simpleSettingsRow(title: String, value: String) -> some View {
HStack {
Text(title)

View File

@@ -3,7 +3,6 @@ import SwiftUI
enum SettingsRoute: Hashable {
case gateway
case approvals
case permissions
case voice
case diagnostics
@@ -17,52 +16,6 @@ enum SettingsLayout {
static let rowHeight: CGFloat = 58
}
struct SettingsApprovalItem: Identifiable {
let id: String
let icon: String
let title: String
let detail: String
let priority: String
let color: Color
}
struct SettingsApprovalRow: View {
let item: SettingsApprovalItem
var body: some View {
HStack(spacing: 10) {
Image(systemName: self.item.icon)
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(self.item.color)
}
VStack(alignment: .leading, spacing: 2) {
Text(self.item.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(self.item.detail)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Text(self.item.priority)
.font(.caption.weight(.bold))
.foregroundStyle(self.item.color)
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background {
Capsule()
.fill(self.item.color.opacity(0.10))
}
}
.padding(.vertical, 7)
}
}
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
case gatewayOffline
case discoveryUnavailable

View File

@@ -8,16 +8,13 @@ struct TalkProTab: View {
TalkDefaults.speakerphoneEnabledByDefault
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
@State private var showPermissionPrompt = false
@State private var showTalkIssueDetails = false
var openSettings: () -> Void
private var state: TalkProState {
TalkProState(
gatewayConnected: self.gatewayConnected,
isDemoMode: self.appModel.isAppleReviewDemoModeEnabled,
isEnabled: self.appModel.talkMode.isEnabled || self.talkEnabled,
statusText: self.appModel.talkMode.statusText,
isConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded,
isListening: self.appModel.talkMode.isListening,
isSpeaking: self.appModel.talkMode.isSpeaking,
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
@@ -31,15 +28,6 @@ struct TalkProTab: View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
self.header
if let fallbackIssue = self.fallbackIssue {
TalkRuntimeIssueBanner(
issue: fallbackIssue,
onOpenSettings: self.openSettings,
onShowDetails: {
self.showTalkIssueDetails = true
})
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
self.voiceHeroCard
self.conversationCard
self.voiceModeCard
@@ -48,6 +36,7 @@ struct TalkProTab: View {
.padding(.top, 16)
.padding(.bottom, 18)
}
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
}
.navigationBarHidden(true)
}
@@ -72,14 +61,6 @@ struct TalkProTab: View {
.presentationDetents([.medium, .large])
.openClawSheetChrome()
}
.sheet(isPresented: self.$showTalkIssueDetails) {
if let fallbackIssue = self.fallbackIssue {
TalkRuntimeIssueDetailsSheet(
issue: fallbackIssue,
onOpenSettings: self.openSettings)
.openClawSheetChrome()
}
}
.onAppear { self.alignPersistedTalkState() }
}
@@ -167,7 +148,7 @@ struct TalkProTab: View {
.padding(.horizontal, 12)
.padding(.top, 11)
.padding(.bottom, 3)
self.infoRow(icon: "person.crop.circle.fill", title: "Agent", value: self.appModel.chatAgentName)
self.infoRow(icon: "person.crop.circle.fill", title: "Agent", value: self.appModel.activeAgentName)
Divider().padding(.leading, 54)
self.infoRow(
icon: "bubble.left.and.text.bubble.right.fill",
@@ -191,21 +172,9 @@ struct TalkProTab: View {
.padding(.horizontal, 12)
.padding(.top, 11)
.padding(.bottom, 3)
self.infoRow(
icon: "waveform",
title: "Configured",
value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
Divider().padding(.leading, 54)
self.infoRow(
icon: "waveform",
title: "Active now",
value: self.activeModeText)
self.infoRow(icon: "waveform", title: "Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
Divider().padding(.leading, 54)
self.infoRow(icon: "antenna.radiowaves.left.and.right", title: "Transport", value: self.transportText)
if let issueText = self.talkIssueText {
Divider().padding(.leading, 54)
self.infoRow(icon: "exclamationmark.triangle.fill", title: "Last issue", value: issueText)
}
Divider().padding(.leading, 54)
self.infoRow(icon: "key.fill", title: "Permission", value: self.permissionText)
Divider().padding(.leading, 54)
@@ -222,9 +191,13 @@ struct TalkProTab: View {
.padding(.horizontal, 12)
.padding(.top, 11)
.padding(.bottom, 3)
self.controlToggleRow("Speakerphone", isOn: self.talkSpeakerphoneBinding)
Toggle("Speakerphone", isOn: self.$talkSpeakerphoneEnabled)
.padding(.horizontal, 14)
.padding(.vertical, 10)
Divider().padding(.leading, 14)
self.controlToggleRow("Background listening", isOn: self.$talkBackgroundEnabled)
Toggle("Background listening", isOn: self.$talkBackgroundEnabled)
.padding(.horizontal, 14)
.padding(.vertical, 10)
Divider().padding(.leading, 14)
Button(action: self.openSettings) {
HStack {
@@ -244,25 +217,6 @@ struct TalkProTab: View {
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func controlToggleRow(_ title: String, isOn: Binding<Bool>) -> some View {
Toggle(title, isOn: isOn)
.contentShape(Rectangle())
.padding(.horizontal, 14)
.padding(.vertical, 10)
.overlay {
// Keep Toggle semantics for accessibility while making the full visual row tappable.
Button {
isOn.wrappedValue.toggle()
} label: {
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHidden(true)
}
}
private func cardHeader(
title: String,
value: String?,
@@ -313,18 +267,12 @@ struct TalkProTab: View {
}
private var gatewayConnected: Bool {
!self.appModel.isAppleReviewDemoModeEnabled &&
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
private var fallbackIssue: TalkRuntimeIssue? {
guard self.gatewayConnected else { return nil }
return self.appModel.talkMode.gatewayTalkCurrentFallbackIssue
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
}
private var headerSubtitle: String {
let mode = self.appModel.talkMode.gatewayTalkVoiceModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let agent = self.appModel.chatAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
let agent = self.appModel.activeAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
if mode.isEmpty || mode == "Not loaded" { return agent.isEmpty ? "Realtime voice" : agent }
if agent.isEmpty { return mode }
return "\(agent)\(mode)"
@@ -333,15 +281,11 @@ struct TalkProTab: View {
private var heroSubtitle: String {
if self.state
.prefersPermissionCopy { return "Gateway approval is required before this phone can capture voice." }
if self.appModel.isAppleReviewDemoModeEnabled { return "Voice is disabled in Apple Review demo mode." }
if !self.gatewayConnected { return "Connect to your gateway to start a voice conversation." }
if !self.appModel.talkMode.gatewayTalkConfigLoaded {
return "Open Voice settings after the gateway loads Talk configuration."
}
let subtitle = (self.appModel.talkMode.gatewayTalkVoiceModeSubtitle ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !subtitle.isEmpty { return subtitle }
return "Routes voice to \(self.appModel.chatAgentName)."
return "Routes voice to \(self.appModel.activeAgentName)."
}
private var transportText: String {
@@ -352,21 +296,6 @@ struct TalkProTab: View {
return "\(provider)\(transport)"
}
private var activeModeText: String {
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
if title.isEmpty { return "Not active" }
if subtitle.isEmpty { return title }
return "\(title)\(subtitle)"
}
private var talkIssueText: String? {
let text = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
private var permissionText: String {
if let failure = self.appModel.talkMode.gatewayTalkPermissionState.failureMessage {
return failure
@@ -380,28 +309,15 @@ struct TalkProTab: View {
}
private func alignPersistedTalkState() {
if self.appModel.isAppleReviewDemoModeEnabled,
if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction,
self.talkEnabled || self.appModel.talkMode.isEnabled
{
self.stopTalk()
} else if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction,
self.talkEnabled || self.appModel.talkMode.isEnabled
{
self.stopTalk()
} else if self.talkEnabled != self.appModel.talkMode.isEnabled {
self.appModel.setTalkEnabled(self.talkEnabled)
}
}
private var talkSpeakerphoneBinding: Binding<Bool> {
Binding(
get: { self.talkSpeakerphoneEnabled },
set: { enabled in
self.talkSpeakerphoneEnabled = enabled
self.appModel.setTalkSpeakerphoneEnabled(enabled)
})
}
private func handlePrimaryAction() {
switch self.state.primaryAction {
case .start:
@@ -419,9 +335,7 @@ struct TalkProTab: View {
}
private func startTalk() {
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
self.talkEnabled = true
self.appModel.talkMode.updateMainSessionKey(self.appModel.chatSessionKey)
self.appModel.setTalkEnabled(true)
}
@@ -449,10 +363,8 @@ enum TalkProWaveformMode: Equatable {
struct TalkProState: Equatable {
let gatewayConnected: Bool
let isDemoMode: Bool
let isEnabled: Bool
let statusText: String
let isConfigLoaded: Bool
let isListening: Bool
let isSpeaking: Bool
let isUserSpeechDetected: Bool
@@ -463,7 +375,6 @@ struct TalkProState: Equatable {
}
var title: String {
if self.isDemoMode { return "Demo mode only" }
if !self.gatewayConnected { return "Gateway offline" }
switch self.permissionState {
case .missingScope, .requestFailed:
@@ -479,7 +390,6 @@ struct TalkProState: Equatable {
default:
break
}
if !self.isConfigLoaded { return "Voice config unavailable" }
if self.isSpeaking { return "Speaking" }
if self.isListening { return "Listening" }
if self.normalizedStatus.contains("connecting") { return "Connecting" }
@@ -489,7 +399,6 @@ struct TalkProState: Equatable {
}
var chipText: String {
if self.isDemoMode { return "Demo" }
if !self.gatewayConnected { return "Offline" }
switch self.permissionState {
case .missingScope, .requestFailed:
@@ -503,7 +412,6 @@ struct TalkProState: Equatable {
default:
break
}
if !self.isConfigLoaded { return "Config" }
if self.isSpeaking { return "Speaking" }
if self.isListening { return "Listening" }
if self.isEnabled { return "Ready" }
@@ -511,7 +419,6 @@ struct TalkProState: Equatable {
}
var icon: String {
if self.isDemoMode { return "waveform.slash" }
if !self.gatewayConnected { return "wifi.slash" }
switch self.permissionState {
case .missingScope, .requestFailed:
@@ -525,7 +432,6 @@ struct TalkProState: Equatable {
default:
break
}
if !self.isConfigLoaded { return "exclamationmark.triangle.fill" }
if self.isSpeaking { return "speaker.wave.2.fill" }
if self.isListening { return "mic.fill" }
if self.normalizedStatus.contains("thinking") { return "sparkles" }
@@ -534,7 +440,6 @@ struct TalkProState: Equatable {
}
var color: Color {
if self.isDemoMode { return .secondary }
if !self.gatewayConnected { return .secondary }
switch self.permissionState {
case .requestFailed, .loadFailed:
@@ -542,13 +447,11 @@ struct TalkProState: Equatable {
case .missingScope, .requestingUpgrade, .upgradeRequested, .apiKeyMissing:
return OpenClawBrand.warn
default:
if !self.isConfigLoaded { return OpenClawBrand.warn }
return self.isEnabled ? OpenClawBrand.ok : OpenClawBrand.accentHot
}
}
var primaryAction: TalkProPrimaryAction {
if self.isDemoMode { return .waiting }
if !self.gatewayConnected { return .openSettings }
switch self.permissionState {
case .missingScope, .requestFailed:
@@ -568,7 +471,7 @@ struct TalkProState: Equatable {
case .stop: "Stop Talk"
case .enablePermission: "Enable Talk"
case .openSettings: self.gatewayConnected ? "Open Voice Settings" : "Open Gateway Settings"
case .waiting: self.isDemoMode ? "Demo Mode Only" : "Waiting for Approval"
case .waiting: "Waiting for Approval"
}
}
@@ -578,7 +481,7 @@ struct TalkProState: Equatable {
case .stop: "stop.fill"
case .enablePermission: "key.fill"
case .openSettings: "gearshape.fill"
case .waiting: self.isDemoMode ? "lock.fill" : "hourglass"
case .waiting: "hourglass"
}
}
@@ -606,7 +509,6 @@ struct TalkProState: Equatable {
}
func waveformMode(micLevel: Double) -> TalkProWaveformMode {
if self.isDemoMode { return .still }
if !self.gatewayConnected { return .still }
switch self.permissionState {
case .requestingUpgrade, .upgradeRequested:
@@ -616,7 +518,6 @@ struct TalkProState: Equatable {
default:
break
}
if !self.isConfigLoaded { return .still }
if self.isSpeaking { return .speaking }
if self.isListening, self.isUserSpeechDetected { return .inputSpeech }
if self.isListening { return .level(micLevel) }

View File

@@ -1,142 +0,0 @@
import SwiftUI
import UIKit
struct TalkRuntimeIssueBanner: View {
@Environment(\.colorScheme) private var colorScheme
let issue: TalkRuntimeIssue
var onOpenSettings: (() -> Void)?
var onShowDetails: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 10) {
Image(systemName: self.iconName)
.font(.headline.weight(.semibold))
.foregroundStyle(self.tint)
.frame(width: 20)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(self.issue.fallbackBannerTitle)
.font(.subheadline.weight(.semibold))
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
Text(self.issue.fallbackBannerOwnerLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
Text(self.issue.fallbackBannerMessage)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(self.issue.displayMessage)
.font(.caption.weight(.medium))
.foregroundStyle(self.tint)
.fixedSize(horizontal: false, vertical: true)
}
}
HStack(spacing: 10) {
if let onOpenSettings {
Button("Open Settings", action: onOpenSettings)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
if let onShowDetails {
Button("Details", action: onShowDetails)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(13)
.background {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThickMaterial)
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.primary.opacity(self.colorScheme == .dark ? 0.12 : 0.07), lineWidth: 1)
}
.shadow(color: .black.opacity(self.colorScheme == .dark ? 0.16 : 0.07), radius: 16, y: 7)
}
}
private var iconName: String {
"exclamationmark.triangle.fill"
}
private var tint: Color {
.orange
}
}
struct TalkRuntimeIssueDetailsSheet: View {
@Environment(\.dismiss) private var dismiss
let issue: TalkRuntimeIssue
var onOpenSettings: (() -> Void)?
@State private var copyFeedback: String?
var body: some View {
NavigationStack {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
Text(self.issue.fallbackBannerTitle)
.font(.title3.weight(.semibold))
Text(self.issue.fallbackBannerMessage)
.font(.body)
.foregroundStyle(.secondary)
Text(self.issue.displayMessage)
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 4)
}
Section("Technical details") {
Text(verbatim: self.issue.technicalDetails)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
Button("Copy diagnostics") {
UIPasteboard.general.string = self.issue.technicalDetails
self.copyFeedback = "Copied diagnostics"
}
}
if let copyFeedback {
Section {
Text(copyFeedback)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Talk fallback")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
if let onOpenSettings {
Button("Open Settings") {
self.dismiss()
onOpenSettings()
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
self.dismiss()
}
}
}
}
}
}

View File

@@ -95,7 +95,7 @@ struct GatewayQuickSetupSheet: View {
.buttonStyle(.bordered)
.disabled(self.connecting)
self.fullRowToggle("Dont show this again", isOn: self.$quickSetupDismissed)
Toggle("Dont show this again", isOn: self.$quickSetupDismissed)
.padding(.top, 4)
} else {
Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.")
@@ -135,23 +135,6 @@ struct GatewayQuickSetupSheet: View {
self.gatewayController.gateways.first
}
private func fullRowToggle(_ title: String, isOn: Binding<Bool>) -> some View {
Toggle(title, isOn: isOn)
.contentShape(Rectangle())
.overlay {
// Keep Toggle semantics for accessibility while making the full visual row tappable.
Button {
isOn.wrappedValue.toggle()
} label: {
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHidden(true)
}
}
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
problem.canTrustRotatedCertificate ? "Trust certificate" : "Connect"
}

View File

@@ -107,5 +107,12 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -111,7 +111,6 @@ final class NodeAppModel {
var gatewayStatusText: String = "Offline"
var nodeStatusText: String = "Offline"
var operatorStatusText: String = "Offline"
private(set) var isAppleReviewDemoModeEnabled: Bool = false
var isOperatorGatewayConnected: Bool {
self.operatorConnected
}
@@ -126,7 +125,6 @@ final class NodeAppModel {
var gatewayPairingPaused: Bool = false
var gatewayPairingRequestId: String?
private(set) var lastGatewayProblem: GatewayConnectionProblem?
private var operatorGatewayProblem: GatewayConnectionProblem?
var gatewayDisplayStatusText: String {
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
}
@@ -459,7 +457,6 @@ final class NodeAppModel {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
// Foreground recovery must actively restart the saved gateway config.
@@ -551,7 +548,6 @@ final class NodeAppModel {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
self.talkMode.updateGatewayConnected(false)
@@ -589,12 +585,6 @@ final class NodeAppModel {
}
func setTalkEnabled(_ enabled: Bool) {
if self.isAppleReviewDemoModeEnabled {
UserDefaults.standard.set(false, forKey: "talk.enabled")
self.talkMode.setEnabled(false)
self.talkMode.statusText = "Demo mode only"
return
}
UserDefaults.standard.set(enabled, forKey: "talk.enabled")
if enabled {
// Voice wake holds the microphone continuously; talk mode needs exclusive access for STT.
@@ -640,7 +630,6 @@ final class NodeAppModel {
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
let sessionBox = config.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
@@ -771,7 +760,6 @@ final class NodeAppModel {
let selected = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if !selected.isEmpty, !decoded.agents.contains(where: { $0.id == selected }) {
self.selectedAgentId = nil
self.focusedChatSessionKey = nil
}
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
@@ -789,19 +777,13 @@ final class NodeAppModel {
func setSelectedAgentId(_ agentId: String?) {
let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let nextSelectedAgentId = trimmed.isEmpty ? nil : trimmed
let currentSelectedAgentId = self.selectedAgentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let selectedAgentChanged = currentSelectedAgentId != nextSelectedAgentId
let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if stableID.isEmpty {
self.selectedAgentId = nextSelectedAgentId
self.selectedAgentId = trimmed.isEmpty ? nil : trimmed
} else {
self.selectedAgentId = nextSelectedAgentId
self.selectedAgentId = trimmed.isEmpty ? nil : trimmed
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId)
}
if selectedAgentChanged {
self.focusedChatSessionKey = nil
}
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
if let relay = ShareGatewayRelaySettings.loadConfig() {
@@ -920,7 +902,6 @@ final class NodeAppModel {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
self.setOperatorConnected(false)
self.gatewayConnected = false
self.gatewayStatusText = "Reconnecting…"
@@ -1854,13 +1835,9 @@ extension NodeAppModel {
{
return focused
}
return self.defaultChatSessionKey
}
var defaultChatSessionKey: String {
// Keep chat aligned with the gateway's resolved main session key.
// A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI.
self.mainSessionKey
return self.mainSessionKey
}
func openChat(sessionKey: String?) {
@@ -1874,30 +1851,11 @@ extension NodeAppModel {
self.talkMode.updateMainSessionKey(self.chatSessionKey)
}
var chatAgentId: String {
if let sessionAgentId = SessionKey.agentId(from: self.chatSessionKey) {
return sessionAgentId
}
return self.selectedOrDefaultAgentId
}
var chatAgentName: String {
self.agentDisplayName(for: self.chatAgentId, fallback: "Main")
}
var activeAgentName: String {
self.agentDisplayName(for: self.selectedOrDefaultAgentId, fallback: "Main")
}
private var selectedOrDefaultAgentId: String {
let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return agentId.isEmpty ? defaultId : agentId
}
private func agentDisplayName(for agentId: String, fallback: String) -> String {
let resolvedId = agentId.trimmingCharacters(in: .whitespacesAndNewlines)
if resolvedId.isEmpty { return fallback }
let resolvedId = agentId.isEmpty ? defaultId : agentId
if resolvedId.isEmpty { return "Main" }
if let match = self.gatewayAgents.first(where: { $0.id == resolvedId }) {
let name = (match.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? match.id : name
@@ -1968,7 +1926,6 @@ extension NodeAppModel {
/// Preferred entry-point: apply a single config object and start both sessions.
func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) {
self.isAppleReviewDemoModeEnabled = false
self.connectToGateway(
url: cfg.url,
// Preserve the caller-provided stableID (may be empty) and let connectToGateway
@@ -1992,12 +1949,10 @@ extension NodeAppModel {
}
func disconnectGateway() {
self.isAppleReviewDemoModeEnabled = false
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
@@ -2028,12 +1983,10 @@ extension NodeAppModel {
extension NodeAppModel {
private func prepareForGatewayConnect(url: URL, stableID: String) {
self.isAppleReviewDemoModeEnabled = false
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.operatorGatewayTask?.cancel()
self.gatewayHealthMonitor.stop()
@@ -2048,30 +2001,17 @@ extension NodeAppModel {
self.gatewayDefaultAgentId = nil
self.gatewayAgents = []
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
self.focusedChatSessionKey = nil
self.homeCanvasRevision &+= 1
self.apnsLastRegisteredTokenHex = nil
}
private func clearGatewayConnectionProblem() {
if let operatorGatewayProblem {
self.lastGatewayProblem = operatorGatewayProblem
if operatorGatewayProblem.needsPairingApproval {
self.gatewayPairingPaused = true
self.gatewayPairingRequestId = operatorGatewayProblem.requestId
} else {
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
}
return
}
self.lastGatewayProblem = nil
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
}
private func applyGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
guard !self.isAppleReviewDemoModeEnabled else { return }
self.lastGatewayProblem = problem
self.gatewayStatusText = problem.statusText
self.gatewayServerName = nil
@@ -2096,38 +2036,6 @@ extension NodeAppModel {
}
}
private func applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
guard !self.isAppleReviewDemoModeEnabled else { return }
self.operatorGatewayProblem = problem
self.lastGatewayProblem = problem
self.gatewayStatusText = problem.statusText
if problem.needsPairingApproval {
self.gatewayPairingPaused = true
self.gatewayPairingRequestId = problem.requestId
}
if problem.needsPairingApproval || problem.pauseReconnect {
LiveActivityManager.shared.showAttention(
statusText: problem.needsPairingApproval ? "Approval needed" : "Action required",
agentName: self.activeAgentName,
sessionKey: self.mainSessionKey)
}
}
private func clearOperatorGatewayConnectionProblemIfCurrent() {
guard let operatorGatewayProblem else { return }
self.operatorGatewayProblem = nil
guard self.lastGatewayProblem == operatorGatewayProblem else { return }
self.lastGatewayProblem = nil
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
if self.gatewayServerName != nil {
self.gatewayStatusText = "Connected"
}
if self.gatewayConnected {
LiveActivityManager.shared.handleReconnect()
}
}
private func shouldKeepGatewayProblemStatus(forDisconnectReason reason: String) -> Bool {
guard let lastGatewayProblem else { return false }
return GatewayConnectionProblemMapper.shouldPreserve(
@@ -2305,19 +2213,13 @@ extension NodeAppModel {
fallbackPassword: password)
let effectiveClientId =
GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? nodeOptions.clientId
let talkPermissionUpgradeRequest = self.forceOperatorTalkPermissionUpgradeRequest
let operatorOptions = self.makeOperatorConnectOptions(
clientId: effectiveClientId,
displayName: nodeOptions.clientDisplayName,
includeAdminScope: self.shouldRequestOperatorAdminScope(
token: reconnectAuth.token,
password: reconnectAuth.password,
forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest),
includeApprovalScope: self.shouldRequestOperatorApprovalScope(
token: reconnectAuth.token,
password: reconnectAuth.password,
forceTalkPermissionUpgradeRequest: talkPermissionUpgradeRequest),
forceExplicitScopes: talkPermissionUpgradeRequest)
password: reconnectAuth.password),
forceExplicitScopes: self.forceOperatorTalkPermissionUpgradeRequest)
do {
try await self.operatorGateway.connect(
@@ -2329,15 +2231,11 @@ extension NodeAppModel {
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
let shouldUseConnection = await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return false }
await MainActor.run {
self.setOperatorConnected(true)
self.clearOperatorGatewayConnectionProblemIfCurrent()
self.forceOperatorTalkPermissionUpgradeRequest = false
self.talkMode.updateGatewayConnected(true)
return true
}
guard shouldUseConnection else { return }
GatewayDiagnostics.log(
"operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
await self.talkMode.reloadConfig()
@@ -2352,7 +2250,6 @@ extension NodeAppModel {
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
@@ -2375,14 +2272,12 @@ extension NodeAppModel {
} catch {
attempt += 1
GatewayDiagnostics.log("operator gateway connect error: \(error.localizedDescription)")
let problem: GatewayConnectionProblem? = await MainActor.run {
let problem = await MainActor.run {
let nextProblem = GatewayConnectionProblemMapper.map(error: error)
guard !self.isAppleReviewDemoModeEnabled else { return nil }
if let nextProblem {
if nextProblem.needsPairingApproval || nextProblem.pauseReconnect {
self.applyOperatorGatewayConnectionProblem(nextProblem)
}
if talkPermissionUpgradeRequest, nextProblem.kind == .pairingScopeUpgradeRequired {
if nextProblem.kind == .pairingScopeUpgradeRequired {
self.gatewayPairingPaused = true
self.gatewayPairingRequestId = nextProblem.requestId
self.talkMode.markTalkPermissionUpgradeRequested(requestId: nextProblem.requestId)
}
}
@@ -2443,7 +2338,6 @@ extension NodeAppModel {
continue
}
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
@@ -2470,8 +2364,7 @@ extension NodeAppModel {
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
let shouldUseConnection = await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return false }
await MainActor.run {
self.clearGatewayConnectionProblem()
self.gatewayStatusText = "Connected"
self.gatewayServerName = url.host ?? "gateway"
@@ -2479,9 +2372,7 @@ extension NodeAppModel {
self.screen.errorText = nil
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
LiveActivityManager.shared.handleReconnect()
return true
}
guard shouldUseConnection else { return }
let usedBootstrapToken =
reconnectAuth.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false &&
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -2530,7 +2421,6 @@ extension NodeAppModel {
onDisconnected: { [weak self] reason in
guard let self else { return }
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
if self.shouldKeepGatewayProblemStatus(forDisconnectReason: reason),
let lastGatewayProblem = self.lastGatewayProblem
{
@@ -2576,11 +2466,10 @@ extension NodeAppModel {
}
attempt += 1
let problem: GatewayConnectionProblem? = await MainActor.run {
let problem = await MainActor.run {
let nextProblem = GatewayConnectionProblemMapper.map(
error: error,
preserving: self.lastGatewayProblem)
guard !self.isAppleReviewDemoModeEnabled else { return nil }
if let nextProblem {
self.applyGatewayConnectionProblem(nextProblem)
} else {
@@ -2621,7 +2510,6 @@ extension NodeAppModel {
}
await MainActor.run {
guard !self.isAppleReviewDemoModeEnabled else { return }
self.lastGatewayProblem = nil
self.gatewayStatusText = "Offline"
LiveActivityManager.shared.endActivity(reason: "gateway_loop_stopped")
@@ -2639,11 +2527,7 @@ extension NodeAppModel {
}
}
private func shouldRequestOperatorApprovalScope(
token: String?,
password: String?,
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
private func shouldRequestOperatorApprovalScope(token: String?, password: String?) -> Bool {
let identity = DeviceIdentityStore.loadOrCreate()
let storedOperatorScopes = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator")?
@@ -2651,19 +2535,14 @@ extension NodeAppModel {
return Self.shouldRequestOperatorApprovalScope(
token: token,
password: password,
storedOperatorScopes: storedOperatorScopes,
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
storedOperatorScopes: storedOperatorScopes)
}
fileprivate nonisolated static func shouldRequestOperatorApprovalScope(
token: String?,
password: String?,
storedOperatorScopes: [String],
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
storedOperatorScopes: [String]) -> Bool
{
if forceTalkPermissionUpgradeRequest {
return storedOperatorScopes.contains("operator.approvals")
}
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedToken.isEmpty {
return true
@@ -2675,53 +2554,13 @@ extension NodeAppModel {
return storedOperatorScopes.contains("operator.approvals")
}
private func shouldRequestOperatorAdminScope(
token: String?,
password: String?,
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
let identity = DeviceIdentityStore.loadOrCreate()
let storedOperatorScopes = DeviceAuthStore
.loadToken(deviceId: identity.deviceId, role: "operator")?
.scopes ?? []
return Self.shouldRequestOperatorAdminScope(
token: token,
password: password,
storedOperatorScopes: storedOperatorScopes,
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
}
fileprivate nonisolated static func shouldRequestOperatorAdminScope(
token: String?,
password: String?,
storedOperatorScopes: [String],
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
if forceTalkPermissionUpgradeRequest {
return false
}
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedToken.isEmpty {
return true
}
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPassword.isEmpty {
return true
}
return storedOperatorScopes.contains("operator.admin")
}
private func makeOperatorConnectOptions(
clientId: String,
displayName: String?,
includeAdminScope: Bool = false,
includeApprovalScope: Bool,
forceExplicitScopes: Bool = false) -> GatewayConnectOptions
{
var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
if includeAdminScope {
scopes.append("operator.admin")
}
// Preserve reconnect compatibility for older paired operator tokens that were
// approved before iOS requested operator.approvals by default.
if includeApprovalScope {
@@ -2760,52 +2599,6 @@ extension NodeAppModel {
}
}
extension NodeAppModel {
func enterAppleReviewDemoMode() {
self.isAppleReviewDemoModeEnabled = true
self.gatewayAutoReconnectEnabled = false
self.gatewayPairingPaused = false
self.gatewayPairingRequestId = nil
self.lastGatewayProblem = nil
self.operatorGatewayProblem = nil
self.nodeGatewayTask?.cancel()
self.nodeGatewayTask = nil
self.operatorGatewayTask?.cancel()
self.operatorGatewayTask = nil
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
self.gatewayHealthMonitor.stop()
LiveActivityManager.shared.endActivity(reason: "apple_review_demo")
Task {
await self.operatorGateway.disconnect()
await self.nodeGateway.disconnect()
}
self.gatewayStatusText = "Connected"
self.nodeStatusText = "Connected"
self.gatewayServerName = AppleReviewDemoMode.gatewayName
self.gatewayRemoteAddress = AppleReviewDemoMode.gatewayAddress
self.connectedGatewayID = AppleReviewDemoMode.gatewayID
self.activeGatewayConnectConfig = nil
self.gatewayConnected = true
self.setOperatorConnected(false)
UserDefaults.standard.set(false, forKey: "talk.enabled")
UserDefaults.standard.set(false, forKey: "talk.background.enabled")
self.talkMode.updateGatewayConnected(false)
self.talkMode.setEnabled(false)
self.talkMode.statusText = "Demo mode only"
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.selectedAgentId = nil
self.gatewayDefaultAgentId = "main"
self.gatewayAgents = AppleReviewDemoMode.agents
self.focusedChatSessionKey = nil
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
}
}
extension NodeAppModel {
private struct PendingForegroundNodeAction: Decodable {
var id: String
@@ -4632,10 +4425,6 @@ extension NodeAppModel {
self.gatewayConnected = connected
}
func _test_isGatewayConnected() -> Bool {
self.gatewayConnected
}
func _test_applyPendingForegroundNodeActions(
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
{
@@ -4652,14 +4441,12 @@ extension NodeAppModel {
func _test_makeOperatorConnectOptions(
clientId: String,
displayName: String?,
includeAdminScope: Bool = false,
includeApprovalScope: Bool,
forceExplicitScopes: Bool = false) -> GatewayConnectOptions
{
self.makeOperatorConnectOptions(
clientId: clientId,
displayName: displayName,
includeAdminScope: includeAdminScope,
includeApprovalScope: includeApprovalScope,
forceExplicitScopes: forceExplicitScopes)
}
@@ -4672,18 +4459,6 @@ extension NodeAppModel {
self.dismissPendingExecApprovalPrompt()
}
func _test_applyOperatorGatewayConnectionProblem(_ problem: GatewayConnectionProblem) {
self.applyOperatorGatewayConnectionProblem(problem)
}
func _test_clearOperatorGatewayConnectionProblemIfCurrent() {
self.clearOperatorGatewayConnectionProblemIfCurrent()
}
func _test_clearGatewayConnectionProblem() {
self.clearGatewayConnectionProblem()
}
func _test_pendingExecApprovalPrompt() -> ExecApprovalPrompt? {
self.pendingExecApprovalPrompt
}
@@ -4777,27 +4552,12 @@ extension NodeAppModel {
nonisolated static func _test_shouldRequestOperatorApprovalScope(
token: String?,
password: String?,
storedOperatorScopes: [String],
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
storedOperatorScopes: [String]) -> Bool
{
self.shouldRequestOperatorApprovalScope(
token: token,
password: password,
storedOperatorScopes: storedOperatorScopes,
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
}
nonisolated static func _test_shouldRequestOperatorAdminScope(
token: String?,
password: String?,
storedOperatorScopes: [String],
forceTalkPermissionUpgradeRequest: Bool = false) -> Bool
{
self.shouldRequestOperatorAdminScope(
token: token,
password: password,
storedOperatorScopes: storedOperatorScopes,
forceTalkPermissionUpgradeRequest: forceTalkPermissionUpgradeRequest)
storedOperatorScopes: storedOperatorScopes)
}
nonisolated static func _test_clearingBootstrapToken(

View File

@@ -255,13 +255,6 @@ private struct ManualEntryStep: View {
return
}
if AppleReviewDemoMode.isSetupCode(raw) {
self.setupCode = ""
self.setupStatusText = "Apple Review demo mode enabled."
self.appModel.enterAppleReviewDemoMode()
return
}
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
return

View File

@@ -7,7 +7,9 @@ struct OnboardingIntroStep: View {
VStack(spacing: 0) {
Spacer()
OpenClawProMark(size: 64, shadowRadius: 14)
Image(systemName: UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone.gen3")
.font(.system(size: 60, weight: .semibold))
.foregroundStyle(.tint)
.padding(.bottom, 18)
Text("Welcome to OpenClaw")
@@ -179,7 +181,6 @@ struct OnboardingModeRow: View {
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}

View File

@@ -72,8 +72,6 @@ struct OnboardingWizardView: View {
@State private var showGatewayProblemDetails: Bool = false
@State private var lastPairingAutoResumeAttemptAt: Date?
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State private var setupCode: String = ""
@State private var setupCodeStatus: String?
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
let allowSkip: Bool
@@ -174,9 +172,6 @@ struct OnboardingWizardView: View {
onGatewayLink: { link in
self.handleScannedLink(link)
},
onSetupCode: { code in
self.handleScannedSetupCode(code)
},
onError: { error in
self.showQRScanner = false
self.statusLine = "Scanner error: \(error)"
@@ -213,10 +208,6 @@ struct OnboardingWizardView: View {
self.handleScannedLink(link)
return
}
if AppleReviewDemoMode.isSetupCode(message) {
self.handleScannedSetupCode(message)
return
}
}
self.showQRScanner = false
self.scannerError = "No valid QR code found in the selected image."
@@ -281,7 +272,7 @@ struct OnboardingWizardView: View {
OnboardingStateStore.markCompleted(mode: selectedMode)
self.didMarkCompleted = true
}
self.step = .success
self.onClose()
}
.onChange(of: self.scenePhase) { _, newValue in
guard newValue == .active else { return }
@@ -310,8 +301,6 @@ struct OnboardingWizardView: View {
@ViewBuilder
private var modeStep: some View {
self.setupCodeSection
Section("Connection Mode") {
OnboardingModeRow(
title: OnboardingConnectionMode.homeNetwork.title,
@@ -329,7 +318,16 @@ struct OnboardingWizardView: View {
self.selectMode(.remoteDomain)
}
self.developerModeToggleRow
Toggle(
"Developer mode",
isOn: Binding(
get: { self.developerModeEnabled },
set: { newValue in
self.developerModeEnabled = newValue
if !newValue, self.selectedMode == .developerLocal {
self.selectedMode = nil
}
}))
if self.developerModeEnabled {
OnboardingModeRow(
@@ -350,49 +348,6 @@ struct OnboardingWizardView: View {
}
}
private var developerModeToggleRow: some View {
self.onboardingButtonToggle(
"Developer mode",
isOn: Binding(
get: { self.developerModeEnabled },
set: { enabled in
self.developerModeEnabled = enabled
if !enabled, self.selectedMode == .developerLocal {
self.selectedMode = nil
}
}))
}
private func onboardingButtonToggle(_ title: String, isOn: Binding<Bool>) -> some View {
// Onboarding Form switch rows need full-width taps; native Toggle only hits the switch edge on iOS 26.
Button {
isOn.wrappedValue.toggle()
} label: {
HStack {
Text(title)
Spacer(minLength: 8)
self.onboardingSwitchIndicator(isOn: isOn.wrappedValue)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityValue(isOn.wrappedValue ? "On" : "Off")
}
private func onboardingSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()
.fill(Color.white)
.frame(width: 28, height: 28)
.padding(2)
.shadow(color: Color.black.opacity(0.14), radius: 1, x: 0, y: 1)
}
}
@ViewBuilder
private var connectStep: some View {
if let selectedMode {
@@ -485,7 +440,7 @@ struct OnboardingWizardView: View {
.autocorrectionDisabled()
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
Toggle("Use TLS", isOn: self.$manualTLS)
self.manualConnectButton
} header: {
Text("Developer Local")
@@ -615,44 +570,6 @@ struct OnboardingWizardView: View {
}
extension OnboardingWizardView {
private var setupCodeSection: some View {
Section {
TextField("Paste setup code", text: self.$setupCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit {
Task { await self.applySetupCodeAndConnect() }
}
Button {
Task { await self.applySetupCodeAndConnect() }
} label: {
if self.connectingGatewayID == "setup-code" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Applying...")
}
} else {
Text("Apply Setup Code")
}
}
.disabled(
self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| self.connectingGatewayID != nil)
if let setupCodeStatus, !setupCodeStatus.isEmpty {
Text(setupCodeStatus)
.font(.footnote)
.foregroundStyle(.secondary)
}
} header: {
Text("Setup Code")
} footer: {
Text("Use this if you received a setup code instead of a QR code.")
}
}
private func manualConnectionFieldsSection(title: String) -> some View {
Section(title) {
TextField("Host", text: self.$manualHost)
@@ -660,7 +577,7 @@ extension OnboardingWizardView {
.autocorrectionDisabled()
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
self.onboardingButtonToggle("Use TLS", isOn: self.$manualTLS)
Toggle("Use TLS", isOn: self.$manualTLS)
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
@@ -691,49 +608,9 @@ extension OnboardingWizardView {
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
}
private func applySetupCodeAndConnect() async {
self.setupCodeStatus = nil
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
self.setupCodeStatus = "Paste a setup code to continue."
return
}
if AppleReviewDemoMode.isSetupCode(raw) {
self.setupCode = ""
self.setupCodeStatus = "Apple Review demo mode enabled."
self.handleScannedSetupCode(raw)
return
}
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupCodeStatus = "Setup code not recognized or uses an insecure ws:// gateway URL."
return
}
self.connectingGatewayID = "setup-code"
self.applyGatewayLink(link)
self.setupCode = ""
self.setupCodeStatus = "Setup code applied. Connecting..."
self.connectMessage = "Connecting via setup code..."
self.statusLine = "Setup code loaded. Connecting to \(link.host):\(link.port)..."
self.step = .connect
await self.connectManual()
}
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
self.applyGatewayLink(link)
self.setupCodeStatus = nil
self.showQRScanner = false
self.connectMessage = "Connecting via QR code..."
self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)..."
Task { await self.connectManual() }
}
private func applyGatewayLink(_ link: GatewayConnectDeepLink) {
self.manualHost = link.host
self.manualPort = link.port
self.manualPortText = String(link.port)
self.manualTLS = link.tls
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
if setupAuth.hasBootstrapToken {
@@ -750,19 +627,13 @@ extension OnboardingWizardView {
}
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
self.showQRScanner = false
self.connectMessage = "Connecting via QR code…"
self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)"
if self.selectedMode == nil {
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
}
}
private func handleScannedSetupCode(_ code: String) {
guard AppleReviewDemoMode.isSetupCode(code) else { return }
self.showQRScanner = false
self.connectingGatewayID = nil
self.connectMessage = "Apple Review demo mode enabled."
self.statusLine = "Apple Review demo mode enabled."
self.selectedMode = .homeNetwork
self.appModel.enterAppleReviewDemoMode()
Task { await self.connectManual() }
}
private func openQRScannerFromOnboarding() {

View File

@@ -4,7 +4,6 @@ import VisionKit
struct QRScannerView: UIViewControllerRepresentable {
let onGatewayLink: (GatewayConnectDeepLink) -> Void
let onSetupCode: (String) -> Void
let onError: (String) -> Void
let onDismiss: () -> Void
@@ -73,13 +72,6 @@ struct QRScannerView: UIViewControllerRepresentable {
}
return
}
if AppleReviewDemoMode.isSetupCode(payload) {
self.handled = true
Task { @MainActor in
self.parent.onSetupCode(payload)
}
return
}
}
}

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