Compare commits

..

2 Commits

Author SHA1 Message Date
Narahari Raghava
7514c823cb fix(ui): correct rounding boundary comment from 999,500 to 999,950 2026-06-22 15:51:05 +08:00
Narahari Raghava
8eb9faacef fix(ui): roll values near 1M over from k to M in compact token format 2026-06-22 15:51:05 +08:00
1039 changed files with 8704 additions and 30326 deletions

View File

@@ -4,14 +4,6 @@ set -euo pipefail
repo="openclaw/openclaw"
months="12"
include_global="0"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(git -C "$script_dir/../../../.." rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$repo_root" ]; then
repo_root="$(cd "$script_dir/../../../.." && pwd)"
fi
# shellcheck disable=SC1091
source "$repo_root/scripts/lib/plain-gh.sh"
usage() {
printf 'Usage: %s [--repo owner/repo] [--months N] [--global] <github-login> [login...]\n' "$0"
@@ -26,10 +18,6 @@ need() {
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
}
gh() {
gh_plain "$@"
}
date_utc_relative_months() {
local count="$1"
if date -u -v-"${count}"m +%Y-%m-%dT00:00:00Z >/dev/null 2>&1; then
@@ -143,8 +131,7 @@ done
exit 2
}
OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)" || die "missing required command: gh"
export OPENCLAW_GH_BIN
need gh
need jq
since_ts=$(date_utc_relative_months "$months")

View File

@@ -4,12 +4,12 @@
* Usage: node secret-scanning.mjs <command> [options]
*/
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { spawnPlainGh } from "../../../../scripts/lib/plain-gh.mjs";
const REPO = "openclaw/openclaw";
const REPO_URL = `https://github.com/${REPO}`;
@@ -29,7 +29,7 @@ function tmpFile(purpose) {
}
function gh(args, { json = true, allowFailure = false } = {}) {
const proc = spawnPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
if (proc.status !== 0 && !allowFailure) {
fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`);
}

View File

@@ -5,7 +5,6 @@
*/
import { execFileSync } from "node:child_process";
import process from "node:process";
import { plainGhEnv, resolvePlainGhBin } from "../../../../scripts/lib/plain-gh.mjs";
const runId = process.argv[2];
const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
@@ -16,9 +15,8 @@ if (!runId) {
}
function gh(args) {
return execFileSync(resolvePlainGhBin(), args, {
return execFileSync("gh", args, {
encoding: "utf8",
env: plainGhEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
}
@@ -34,15 +32,14 @@ function githubRestJson(pathSuffix) {
"-lc",
[
"set -euo pipefail",
'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"',
'token="$(gh auth token)"',
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
].join("\n"),
],
{
encoding: "utf8",
env: {
...plainGhEnv(),
OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(),
...process.env,
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
},
maxBuffer: 16 * 1024 * 1024,

View File

@@ -1,76 +0,0 @@
name: Docs bug report
description: Report documentation defects (incorrect, missing, outdated, or contradictory docs).
title: "[Docs Bug]: "
labels:
- bug
- docs
body:
- type: markdown
attributes:
value: |
Report a documentation defect with concrete evidence from current docs behavior/content.
Please only report one documentation defect per submission.
- type: textarea
id: summary
attributes:
label: Summary
description: One-sentence statement of what is wrong in the docs.
placeholder: The WhatsApp config example defines duplicate top-level keys in one JSON5 block.
validations:
required: true
- type: input
id: doc_paths
attributes:
label: Affected docs path(s) or URL(s)
description: Repo-relative docs file path(s) or published docs URL(s).
placeholder: docs/gateway/config-channels.md or https://docs.openclaw.ai/gateway/config-channels
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce / verify
description: Minimal steps to observe the docs defect in the current docs.
placeholder: |
1. Open docs/gateway/config-channels.md
2. Go to the WhatsApp example block
3. Observe duplicate top-level key definitions
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected docs behavior/content
description: What the docs should say/show instead.
placeholder: The example should use a single merged top-level object with no duplicate keys.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual docs behavior/content
description: What the docs currently say/show.
placeholder: The snippet defines the same top-level key twice in one object.
validations:
required: true
- type: textarea
id: impact
attributes:
label: Impact
description: Who is affected and practical consequence.
placeholder: Users who copy-paste the snippet can end up with ambiguous config behavior.
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Evidence
description: Links/snippets/screenshots proving the docs defect.
placeholder: Include exact file links and line ranges.
validations:
required: true
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Optional context, related issues/PRs, or constraints.

View File

@@ -41,32 +41,11 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# Keep the canonical main queue quiet long enough for a follow-up push to
# cancel this run before it registers the Blacksmith matrix.
runner-admission:
permissions:
contents: read
runs-on: ubuntu-24.04
timeout-minutes: 3
env:
OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS: "90"
steps:
- name: Debounce canonical main pushes
if: github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'
run: |
set -euo pipefail
echo "Waiting ${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}s for a superseding main push before Blacksmith admission"
sleep "${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}"
- name: Admit non-main CI runs immediately
if: github.event_name != 'push' || github.repository != 'openclaw/openclaw' || github.ref != 'refs/heads/main'
run: echo "No canonical main debounce required"
# Preflight: establish routing truth and job matrices once, then let real
# work fan out from a single source of truth.
preflight:
permissions:
contents: read
needs: [runner-admission]
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
@@ -293,23 +272,18 @@ jobs:
}
}
const compactPullRequest = isCanonicalRepository && eventName === "pull_request";
const nodeTestShards = runNodeFull
? createNodeTestShardBundles({
includeReleaseOnlyPluginShards: false,
compact: compactPullRequest,
}).map((shard) => ({
check_name: shard.checkName,
runtime: "node",
task: "test-shard",
shard_name: shard.shardName,
groups: shard.groups,
configs: shard.configs,
env: shard.env,
includePatterns: shard.includePatterns,
requires_dist: shard.requiresDist,
runner: shard.runner,
timeout_minutes: shard.timeoutMinutes,
}))
: [];
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
@@ -387,7 +361,6 @@ jobs:
security-fast:
permissions:
contents: read
needs: [runner-admission]
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
@@ -853,7 +826,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
steps:
- name: Checkout
@@ -943,7 +916,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
steps:
- name: Checkout
@@ -1024,7 +997,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
steps:
- name: Checkout
@@ -1174,10 +1147,10 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 12
max-parallel: 6
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout
@@ -1236,9 +1209,7 @@ jobs:
- name: Run Node test shard
env:
NODE_OPTIONS: --max-old-space-size=8192
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }}
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_ENV_JSON: ${{ toJson(matrix.env) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
@@ -1252,55 +1223,28 @@ jobs:
import { writeFileSync } from "node:fs";
import { join } from "node:path";
const groups = JSON.parse(process.env.OPENCLAW_NODE_TEST_GROUPS_JSON ?? "null");
const plans = Array.isArray(groups) && groups.length > 0
? groups
: [{
configs: JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"),
env: JSON.parse(process.env.OPENCLAW_NODE_TEST_ENV_JSON ?? "null"),
includePatterns: JSON.parse(
process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null",
),
shard_name: process.env.OPENCLAW_VITEST_SHARD_NAME,
}];
for (const plan of plans) {
const configs = plan.configs;
if (!Array.isArray(configs) || configs.length === 0) {
console.error("Missing node test shard configs");
process.exit(1);
}
const childEnv = {
...process.env,
...(plan.shard_name ? { OPENCLAW_VITEST_SHARD_NAME: plan.shard_name } : {}),
};
if (plan.env && typeof plan.env === "object" && !Array.isArray(plan.env)) {
for (const [key, value] of Object.entries(plan.env)) {
if (typeof value === "string") {
childEnv[key] = value;
}
}
}
if (Array.isArray(plan.includePatterns) && plan.includePatterns.length > 0) {
const includeFile = join(
process.env.RUNNER_TEMP ?? ".",
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
);
writeFileSync(includeFile, JSON.stringify(plan.includePatterns), "utf8");
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
} else {
delete childEnv.OPENCLAW_VITEST_INCLUDE_FILE;
}
const result = spawnSync(
"pnpm",
["exec", "node", "scripts/test-projects.mjs", ...configs],
{
env: childEnv,
stdio: "inherit",
},
const configs = JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]");
if (!Array.isArray(configs) || configs.length === 0) {
console.error("Missing node test shard configs");
process.exit(1);
}
const includePatterns = JSON.parse(process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null");
const childEnv = { ...process.env };
if (Array.isArray(includePatterns) && includePatterns.length > 0) {
const includeFile = join(
process.env.RUNNER_TEMP ?? ".",
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
);
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8");
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
}
const result = spawnSync("pnpm", ["exec", "node", "scripts/test-projects.mjs", ...configs], {
env: childEnv,
stdio: "inherit",
});
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
EOF
@@ -1315,7 +1259,7 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix:
include:
- check_name: check-guards
@@ -1457,7 +1401,7 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix:
include:
- check_name: check-additional-boundaries-a

View File

@@ -244,11 +244,6 @@ jobs:
exit 1
fi
if [[ -z "$ROLLBACK_DRILL_ID" || -z "$ROLLBACK_DRILL_DATE" ]]; then
if [[ "$EVENT_NAME" == "push" ]]; then
echo "::warning::Stable closeout skipped: rollback drill repository variables are missing; manual dispatch remains required to complete closeout."
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Stable closeout requires repository variables RELEASE_ROLLBACK_DRILL_ID and RELEASE_ROLLBACK_DRILL_DATE, or explicit manual overrides." >&2
exit 1
fi

View File

@@ -1,376 +0,0 @@
name: QA Profile Evidence
run-name: ${{ format('QA Profile Evidence {0} {1}', inputs.qa_profile, inputs.ref) }}
on:
workflow_dispatch:
inputs:
ref:
description: OpenClaw branch, tag, or SHA to run
required: true
default: main
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
qa_profile:
description: Taxonomy QA profile id to run
required: true
default: release
type: string
workflow_call:
inputs:
ref:
description: OpenClaw branch, tag, or SHA to run
required: true
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
qa_profile:
description: Taxonomy QA profile id to run
required: true
type: string
fail_on_qa_failure:
description: Fail the reusable workflow when the QA profile command exits non-zero
required: false
default: false
type: boolean
outputs:
artifact_name:
description: Uploaded QA profile evidence artifact name
value: ${{ jobs.run_qa_profile.outputs.artifact_name }}
qa_profile:
description: Taxonomy QA profile id that produced the evidence
value: ${{ jobs.run_qa_profile.outputs.qa_profile }}
qa_exit_code:
description: Exit code from the QA profile run; non-zero evidence is still uploaded
value: ${{ jobs.run_qa_profile.outputs.qa_exit_code }}
qa_passed:
description: Whether the QA profile command exited successfully
value: ${{ jobs.run_qa_profile.outputs.qa_passed }}
target_sha:
description: Resolved OpenClaw SHA that produced the evidence
value: ${{ jobs.run_qa_profile.outputs.target_sha }}
trusted_reason:
description: Trust reason accepted before the secret-bearing QA job
value: ${{ jobs.run_qa_profile.outputs.trusted_reason }}
qa_evidence_path:
description: Path to qa-evidence.json inside the uploaded artifact
value: ${{ jobs.run_qa_profile.outputs.qa_evidence_path }}
permissions:
contents: read
concurrency:
group: qa-profile-evidence-${{ inputs.qa_profile }}-${{ inputs.expected_sha || inputs.ref }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
if (context.eventName !== "workflow_dispatch") {
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
core.setOutput("authorized", "true");
return;
}
const allowed = new Set(["admin", "maintain", "write"]);
const { owner, repo } = context.repo;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: context.actor,
});
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
validate_selected_ref:
name: Validate selected ref
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_revision: ${{ steps.validate.outputs.selected_revision }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Validate selected ref
id: validate
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
selected_revision="$(git rev-parse HEAD)"
expected_sha="${EXPECTED_SHA,,}"
trusted_reason=""
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
exit 1
fi
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
trusted_reason="release-tag"
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
trusted_reason="release-branch-head"
fi
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing QA evidence run." >&2
echo "Allowed refs must be on main, point to a release tag, or match a release branch head." >&2
exit 1
fi
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
{
echo "### Target"
echo
echo "- Requested ref: \`${INPUT_REF}\`"
echo "- Resolved SHA: \`$selected_revision\`"
echo "- Trust reason: \`$trusted_reason\`"
} >> "$GITHUB_STEP_SUMMARY"
run_qa_profile:
name: Generate QA profile evidence
needs: validate_selected_ref
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
outputs:
artifact_name: ${{ steps.evidence.outputs.artifact_name }}
qa_profile: ${{ steps.profile.outputs.profile }}
qa_exit_code: ${{ steps.evidence.outputs.qa_exit_code }}
qa_passed: ${{ steps.evidence.outputs.qa_passed }}
target_sha: ${{ steps.evidence.outputs.target_sha }}
trusted_reason: ${{ steps.evidence.outputs.trusted_reason }}
qa_evidence_path: ${{ steps.evidence.outputs.qa_evidence_path }}
environment: qa-live-shared
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
- name: Validate QA profile input
id: profile
env:
QA_PROFILE: ${{ inputs.qa_profile }}
shell: bash
run: |
set -euo pipefail
node --import tsx --input-type=module <<'NODE'
import fs from "node:fs";
import { readQaScorecardTaxonomyReport } from "./extensions/qa-lab/src/scorecard-taxonomy.ts";
const requested = process.env.QA_PROFILE?.trim() ?? "";
if (!/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(requested)) {
throw new Error(`qa_profile must use a taxonomy profile id, got ${JSON.stringify(process.env.QA_PROFILE)}`);
}
const taxonomy = readQaScorecardTaxonomyReport([]);
const profile = taxonomy.profiles.find((entry) => entry.id === requested);
if (!profile) {
const available = taxonomy.profiles.map((entry) => entry.id).join(", ");
throw new Error(`Unknown QA profile ${requested}. Available profiles: ${available}`);
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `profile=${profile.id}\n`);
NODE
echo "QA profile: \`${QA_PROFILE}\`" >> "$GITHUB_STEP_SUMMARY"
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node scripts/build-all.mjs qaRuntime
- name: Run QA profile
id: run_profile
env:
QA_PROFILE: ${{ steps.profile.outputs.profile }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/profile-${QA_PROFILE}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
qa_exit_code=0
pnpm openclaw qa run \
--repo-root . \
--qa-profile "${QA_PROFILE}" \
--output-dir "${output_dir}" || qa_exit_code=$?
echo "qa_exit_code=${qa_exit_code}" >> "$GITHUB_OUTPUT"
- name: Validate QA profile evidence
id: evidence
if: always()
env:
ARTIFACT_NAME: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
OUTPUT_DIR: ${{ steps.run_profile.outputs.output_dir }}
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
QA_PROFILE: ${{ steps.profile.outputs.profile }}
REQUESTED_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_revision }}
TRUSTED_REASON: ${{ needs.validate_selected_ref.outputs.trusted_reason }}
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import fs from "node:fs";
import path from "node:path";
const outputDir = process.env.OUTPUT_DIR;
if (!outputDir) {
throw new Error("OUTPUT_DIR is required");
}
if (!process.env.QA_EXIT_CODE) {
throw new Error("QA_EXIT_CODE is required");
}
const evidencePath = path.join(outputDir, "qa-evidence.json");
const payload = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (payload.profile !== process.env.QA_PROFILE) {
throw new Error(`qa-evidence.json profile must be ${process.env.QA_PROFILE}, got ${JSON.stringify(payload.profile)}`);
}
if (!payload.scorecard || !Array.isArray(payload.scorecard.categoryReports)) {
throw new Error("QA profile qa-evidence.json must include scorecard.categoryReports");
}
if (payload.scorecard.categoryReports.length === 0) {
throw new Error("QA profile qa-evidence.json scorecard has no category reports");
}
const manifest = {
artifactName: process.env.ARTIFACT_NAME,
generatedAt: new Date().toISOString(),
qaProfile: process.env.QA_PROFILE,
qaExitCode: Number(process.env.QA_EXIT_CODE),
qaPassed: process.env.QA_EXIT_CODE === "0",
requestedRef: process.env.REQUESTED_REF,
targetSha: process.env.TARGET_SHA,
trustedReason: process.env.TRUSTED_REASON,
evidenceMode: payload.evidenceMode,
qaEvidencePath: "qa-evidence.json",
scorecard: {
categories: payload.scorecard.categories,
features: payload.scorecard.features,
categoryReports: payload.scorecard.categoryReports.length,
},
};
fs.writeFileSync(
path.join(outputDir, "qa-profile-evidence-manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
NODE
echo "artifact_name=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
echo "qa_profile=${QA_PROFILE}" >> "$GITHUB_OUTPUT"
echo "qa_exit_code=${QA_EXIT_CODE}" >> "$GITHUB_OUTPUT"
if [[ "$QA_EXIT_CODE" == "0" ]]; then
echo "qa_passed=true" >> "$GITHUB_OUTPUT"
else
echo "qa_passed=false" >> "$GITHUB_OUTPUT"
echo "::warning::QA profile '${QA_PROFILE}' completed with exit code ${QA_EXIT_CODE}; evidence was still validated and uploaded."
fi
echo "target_sha=${TARGET_SHA}" >> "$GITHUB_OUTPUT"
echo "trusted_reason=${TRUSTED_REASON}" >> "$GITHUB_OUTPUT"
echo "qa_evidence_path=qa-evidence.json" >> "$GITHUB_OUTPUT"
{
echo "### QA profile evidence"
echo
echo "- Artifact: \`${ARTIFACT_NAME}\`"
echo "- QA profile: \`${QA_PROFILE}\`"
echo "- QA exit code: \`${QA_EXIT_CODE}\`"
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Evidence path: \`${OUTPUT_DIR}/qa-evidence.json\`"
echo "- Manifest: \`${OUTPUT_DIR}/qa-profile-evidence-manifest.json\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload QA profile evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: qa-profile-evidence-${{ steps.profile.outputs.profile }}-${{ needs.validate_selected_ref.outputs.selected_revision }}
path: ${{ steps.run_profile.outputs.output_dir }}
retention-days: 30
if-no-files-found: error
- name: Fail if configured QA gate failed
if: always() && (github.event_name == 'workflow_dispatch' || inputs.fail_on_qa_failure)
env:
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
QA_PROFILE: ${{ steps.profile.outputs.profile }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${QA_EXIT_CODE:-}" ]]; then
echo "QA profile did not report an exit code." >&2
exit 1
fi
if [[ "$QA_EXIT_CODE" != "0" ]]; then
echo "QA profile '${QA_PROFILE}' failed with exit code ${QA_EXIT_CODE}." >&2
exit "$QA_EXIT_CODE"
fi

View File

@@ -24,9 +24,7 @@ jobs:
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# Old PR events can carry a stale base SHA that predates current
# trusted checker scripts. Use the workflow revision instead.
ref: ${{ github.workflow_sha }}
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
id: app-token

42
.github/workflows/tui-pty.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: TUI PTY
on:
pull_request:
paths:
- "src/tui/**"
- "scripts/dev/tui-pty-test-watch.ts"
- "scripts/test-projects.test-support.mjs"
- "package.json"
- "pnpm-lock.yaml"
- "test/scripts/test-projects.test.ts"
- "test/vitest/vitest.test-shards.mjs"
- "test/vitest/vitest.tui-pty.config.ts"
- ".github/workflows/tui-pty.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
tui-pty:
runs-on: ubuntu-24.04
timeout-minutes: 8
env:
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: "1"
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run TUI PTY tests
run: timeout --kill-after=30s 240s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts

View File

@@ -297,10 +297,6 @@ jobs:
if: ${{ always() && !cancelled() && inputs.require_wsl2 }}
run: |
if ($env:OPENCLAW_WSL2_PROBE_OK -ne "true") {
if ($env:OPENCLAW_WSL2_RESTART_REQUIRED -eq "true") {
Write-Error "WSL2 probe enabled required Windows features, but the runner needs a reboot before WSL2 can start."
exit 1
}
Write-Error "WSL2 probe failed or WSL2 is unavailable on this Windows runner."
exit 1
}

View File

@@ -8,7 +8,6 @@ Skills own workflows; root owns hard policy and routing.
- Repo: `https://github.com/openclaw/openclaw`
- Replies: repo-root refs only: `extensions/telegram/src/index.ts:80`. No absolute paths, no `~/`.
- Docs/user-visible work: `pnpm docs:list`, then read relevant docs only.
- Existing-solutions preflight: before proposing or building a custom system, feature, workflow, tool, integration, or automation, do a lightweight check for open-source projects, maintained libraries, existing OpenClaw plugins, or free platforms that already solve it well enough. Prefer those when adequate. Build custom only when existing options are unsuitable, too expensive, unmaintained, unsafe, non-compliant, or the user explicitly asks for custom. Avoid paid-service recommendations unless the user explicitly approves spend. Keep this to a brief preflight gate, not a broad research assignment.
- Fix/triage answers need source, tests, current/shipped behavior, and dependency contract proof.
- Reviews/answers: high confidence required. Default to exhaustive relevant codebase search/read, including owners, callers, siblings, tests, docs, and upstream/dependency contracts before verdict. Diff-only review is insufficient.
- Review default: read the whole changed function/module plus callers, callees, sibling implementations, adjacent tests, scoped docs, and dependency/Codex contracts before saying `good`, `bad`, `best fix`, `proof sufficient`, or posting a comment. If challenged, keep reading first; do not defend the earlier verdict until the missing path is checked.

View File

@@ -1,16 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="${applicationId}.permission.RUN_VOICE_E2E"
android:protectionLevel="signature" />
<uses-permission android:name="${applicationId}.permission.RUN_VOICE_E2E" />
<application>
<receiver
android:name=".VoiceE2eReceiver"
android:permission="${applicationId}.permission.RUN_VOICE_E2E"
android:exported="false">
android:exported="true">
<intent-filter>
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
</intent-filter>

View File

@@ -1,160 +0,0 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
data class GatewayExecApprovalSummary(
val id: String,
val commandText: String,
val commandPreview: String?,
val allowedDecisions: List<String>,
val host: String?,
val nodeId: String?,
val agentId: String?,
val createdAtMs: Long?,
val expiresAtMs: Long?,
val resolvingDecision: String? = null,
val errorText: String? = null,
)
internal fun parseGatewayExecApprovalListPayload(
payloadJson: String,
json: Json,
): List<GatewayExecApprovalSummary> =
try {
(json.parseToJsonElement(payloadJson) as? JsonArray)
?.mapNotNull(::parseGatewayExecApprovalListEntry)
?.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
.orEmpty()
} catch (_: Throwable) {
emptyList()
}
internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecApprovalSummary? {
val obj = item.asObjectOrNull() ?: return null
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
val request = obj["request"].asObjectOrNull()
val commandText = gatewayExecApprovalListCommandText(obj, request)
return GatewayExecApprovalSummary(
id = id,
commandText = commandText,
commandPreview = gatewayExecApprovalListCommandPreview(obj, request, commandText),
allowedDecisions = emptyList(),
host =
request
?.get("host")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
nodeId =
request
?.get("nodeId")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
agentId =
request
?.get("agentId")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
createdAtMs = obj.long("createdAtMs"),
expiresAtMs = obj.long("expiresAtMs"),
)
}
internal fun parseGatewayExecApprovalDetail(
obj: JsonObject,
createdAtMs: Long?,
): GatewayExecApprovalSummary? {
val id = obj["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
return GatewayExecApprovalSummary(
id = id,
commandText =
obj["commandText"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: "Command request",
commandPreview =
obj["commandPreview"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() },
allowedDecisions = gatewayExecApprovalAllowedDecisions(obj),
host = obj["host"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
nodeId = obj["nodeId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
agentId = obj["agentId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
createdAtMs = createdAtMs,
expiresAtMs = obj.long("expiresAtMs"),
)
}
private fun gatewayExecApprovalListCommandText(obj: JsonObject, request: JsonObject?): String =
obj["commandText"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: request
?.get("command")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: "Command request"
private fun gatewayExecApprovalListCommandPreview(
obj: JsonObject,
request: JsonObject?,
commandText: String,
): String? {
val preview =
obj["commandPreview"]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
?: request
?.get("commandPreview")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
return preview?.takeIf { it != commandText }
}
private fun gatewayExecApprovalAllowedDecisions(request: JsonObject?): List<String> {
val explicit = parseGatewayExecApprovalDecisions(request?.get("allowedDecisions") as? JsonArray)
if (explicit.isNotEmpty()) return explicit
val allowed =
if (request
?.get("ask")
.asStringOrNull()
?.trim()
?.lowercase() == "always"
) {
listOf("allow-once", "deny")
} else {
listOf("allow-once", "allow-always", "deny")
}
val unavailable = parseGatewayExecApprovalDecisions(request?.get("unavailableDecisions") as? JsonArray).toSet()
return allowed.filterNot { it == "allow-always" && it in unavailable }
}
private fun parseGatewayExecApprovalDecisions(items: JsonArray?): List<String> =
items
?.mapNotNull { item ->
when (item.asStringOrNull()?.trim()) {
"allow-once" -> "allow-once"
"allow-always" -> "allow-always"
"deny" -> "deny"
else -> null
}
}?.distinct()
.orEmpty()
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()

View File

@@ -204,9 +204,6 @@ class MainViewModel(
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls }
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = runtimeState(initial = emptyList()) { it.execApprovals }
val execApprovalsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.execApprovalsRefreshing }
val execApprovalsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.execApprovalsErrorText }
val canvas: CanvasController
get() = ensureRuntime().canvas
@@ -540,17 +537,6 @@ class MainViewModel(
ensureRuntime().refreshNodesDevices()
}
fun refreshExecApprovals() {
ensureRuntime().refreshExecApprovals()
}
fun resolveExecApproval(
id: String,
decision: String,
) {
ensureRuntime().resolveExecApproval(id = id, decision = decision)
}
fun refreshChannels() {
ensureRuntime().refreshChannels()
}

View File

@@ -14,7 +14,6 @@ import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
import ai.openclaw.app.gateway.parseChatSendAck
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
import ai.openclaw.app.node.A2UIHandler
import ai.openclaw.app.node.CalendarHandler
@@ -74,9 +73,7 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.util.Collections
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/**
@@ -402,15 +399,6 @@ class NodeRuntime(
private val _nodesDevicesErrorText = MutableStateFlow<String?>(null)
val nodesDevicesErrorText: StateFlow<String?> = _nodesDevicesErrorText.asStateFlow()
private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard()
private val _execApprovals = MutableStateFlow<List<GatewayExecApprovalSummary>>(emptyList())
val execApprovals: StateFlow<List<GatewayExecApprovalSummary>> = _execApprovals.asStateFlow()
private val _execApprovalsRefreshing = MutableStateFlow(false)
val execApprovalsRefreshing: StateFlow<Boolean> = _execApprovalsRefreshing.asStateFlow()
private val _execApprovalsErrorText = MutableStateFlow<String?>(null)
val execApprovalsErrorText: StateFlow<String?> = _execApprovalsErrorText.asStateFlow()
private val execApprovalsRefreshSeq = AtomicLong(0)
private val execApprovalsStateLock = Any()
private val resolvedExecApprovalIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
private val _channelsSummary = MutableStateFlow(GatewayChannelsSummary(channels = emptyList()))
val channelsSummary: StateFlow<GatewayChannelsSummary> = _channelsSummary.asStateFlow()
private val _channelsRefreshing = MutableStateFlow(false)
@@ -460,7 +448,6 @@ class NodeRuntime(
micCapture.onGatewayConnectionChanged(true)
scope.launch {
subscribeOperatorSessionEvents()
refreshExecApprovalsFromGateway()
refreshHomeCanvasOverviewIfConnected()
if (voiceReplySpeakerLazy.isInitialized()) {
voiceReplySpeaker.refreshConfig()
@@ -490,11 +477,6 @@ class NodeRuntime(
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
invalidateExecApprovalRefreshes()
resolvedExecApprovalIds.clear()
_execApprovals.value = emptyList()
_execApprovalsRefreshing.value = false
_execApprovalsErrorText.value = null
_channelsSummary.value = GatewayChannelsSummary(channels = emptyList())
_dreamingSummary.value = GatewayDreamingSummary()
_healthLogsSummary.value = GatewayHealthLogsSummary()
@@ -650,11 +632,7 @@ class NodeRuntime(
put("idempotencyKey", JsonPrimitive(idempotencyKey))
}
val response = operatorSession.request("chat.send", params.toString())
val ack = parseChatSendAck(json, response)
ack.copy(runId = ack.runId ?: idempotencyKey)
},
refreshAfterTerminalSuccess = {
chat.refresh()
parseChatSendRunId(response) ?: idempotencyKey
},
speakAssistantReply = { text ->
// Voice-tab replies should speak through the dedicated reply speaker.
@@ -842,24 +820,6 @@ class NodeRuntime(
}
}
fun refreshExecApprovals() {
scope.launch {
refreshExecApprovalsFromGateway()
}
}
fun resolveExecApproval(
id: String,
decision: String,
) {
val normalizedId = id.trim()
val normalizedDecision = decision.trim()
if (normalizedId.isEmpty() || normalizedDecision.isEmpty()) return
scope.launch {
resolveExecApprovalOnGateway(id = normalizedId, decision = normalizedDecision)
}
}
fun refreshChannels() {
scope.launch {
refreshChannelsFromGateway()
@@ -1035,9 +995,6 @@ class NodeRuntime(
_isForeground.value = value
if (value) {
reconnectPreferredGatewayOnForeground()
scope.launch {
refreshExecApprovalsFromGateway()
}
} else {
stopManualVoiceSession()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true)
@@ -1867,47 +1824,11 @@ class NodeRuntime(
if (event == "update.available") {
_gatewayUpdateAvailable.value = parseGatewayUpdateAvailable(payloadJson)
}
handleExecApprovalGatewayEvent(event = event, payloadJson = payloadJson)
micCapture.handleGatewayEvent(event, payloadJson)
talkMode.handleGatewayEvent(event, payloadJson)
chat.handleGatewayEvent(event, payloadJson)
}
private fun handleExecApprovalGatewayEvent(
event: String,
payloadJson: String?,
) {
when (event) {
"exec.approval.requested" -> {
val approvalId = parseExecApprovalEventId(payloadJson)
approvalId?.let(resolvedExecApprovalIds::remove)
scope.launch {
if (approvalId == null) {
refreshExecApprovalsFromGateway()
} else {
refreshExecApprovalFromGateway(approvalId)
}
}
}
"exec.approval.resolved" -> {
val approvalId = parseExecApprovalEventId(payloadJson) ?: return
markExecApprovalResolved(approvalId)
}
}
}
private fun parseExecApprovalEventId(payloadJson: String?): String? =
try {
payloadJson
?.let { json.parseToJsonElement(it).asObjectOrNull() }
?.get("id")
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
} catch (_: Throwable) {
null
}
private fun parseGatewayUpdateAvailable(payloadJson: String?): GatewayUpdateAvailableSummary? {
return try {
val root = payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() }
@@ -1922,6 +1843,15 @@ class NodeRuntime(
}
}
private fun parseChatSendRunId(response: String): String? {
return try {
val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
root["runId"].asStringOrNull()
} catch (_: Throwable) {
null
}
}
private fun parseTalkSessionId(response: String): String {
val root = json.parseToJsonElement(response).asObjectOrNull()
val sessionId =
@@ -2154,196 +2084,6 @@ class NodeRuntime(
}
}
private suspend fun refreshExecApprovalsFromGateway() {
val refreshGeneration = execApprovalsRefreshSeq.incrementAndGet()
_execApprovalsRefreshing.value = true
_execApprovalsErrorText.value = null
if (!operatorConnected) {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovals.value = emptyList()
_execApprovalsRefreshing.value = false
}
return
}
try {
val res = operatorSession.request("exec.approval.list", "{}")
val existing = _execApprovals.value.associateBy { it.id }
val rows =
parseGatewayExecApprovalListPayload(res, json)
.filterNot { it.id in resolvedExecApprovalIds }
.map { row ->
val hydrated =
try {
fetchExecApprovalDetailFromGateway(
id = row.id,
createdAtMs = row.createdAtMs ?: System.currentTimeMillis(),
)
} catch (_: Throwable) {
null
} ?: row.copy(errorText = "Could not load approval details. Refresh and try again.")
val current = existing[row.id]
if (current == null) {
hydrated
} else {
hydrated.copy(
resolvingDecision = current.resolvingDecision,
errorText = current.errorText ?: hydrated.errorText,
)
}
}
publishExecApprovalsIfCurrent(refreshGeneration, rows)
} catch (_: Throwable) {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovalsErrorText.value = "Could not load approvals."
}
} finally {
if (execApprovalsRefreshSeq.get() == refreshGeneration) {
_execApprovalsRefreshing.value = false
}
}
}
private suspend fun refreshExecApprovalFromGateway(id: String) {
if (!operatorConnected) return
if (id in resolvedExecApprovalIds) return
try {
val current = _execApprovals.value.firstOrNull { it.id == id }
val row =
fetchExecApprovalDetailFromGateway(
id = id,
createdAtMs = current?.createdAtMs ?: System.currentTimeMillis(),
) ?: return
if (id in resolvedExecApprovalIds) return
invalidateExecApprovalRefreshes()
upsertExecApproval(row)
} catch (_: Throwable) {
refreshExecApprovalsFromGateway()
}
}
private suspend fun fetchExecApprovalDetailFromGateway(
id: String,
createdAtMs: Long,
): GatewayExecApprovalSummary? {
val params = buildJsonObject { put("id", JsonPrimitive(id)) }.toString()
val res = operatorSession.request("exec.approval.get", params)
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
return parseGatewayExecApprovalDetail(root, createdAtMs = createdAtMs)
}
private suspend fun resolveExecApprovalOnGateway(
id: String,
decision: String,
) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || id in resolvedExecApprovalIds) return
val currentRows = _execApprovals.value
if (currentRows.none { it.id == id }) return
invalidateExecApprovalRefreshes()
_execApprovals.value =
currentRows.map { row ->
if (row.id == id) row.copy(resolvingDecision = decision, errorText = null) else row
}
}
try {
val params =
buildJsonObject {
put("id", JsonPrimitive(id))
put("decision", JsonPrimitive(decision))
}.toString()
operatorSession.request("exec.approval.resolve", params)
markExecApprovalResolved(id)
} catch (_: Throwable) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || id in resolvedExecApprovalIds) return
_execApprovals.value =
_execApprovals.value.map { row ->
if (row.id == id) {
row.copy(resolvingDecision = null, errorText = "Could not resolve approval. Refresh and try again.")
} else {
row
}
}
}
}
}
private fun upsertExecApproval(row: GatewayExecApprovalSummary) {
synchronized(execApprovalsStateLock) {
if (!operatorConnected || row.id in resolvedExecApprovalIds) return
if (row.isExpiredExecApproval()) return
val rows = _execApprovals.value
val replaced = rows.any { it.id == row.id }
val nextRows =
(
if (replaced) {
rows.map { current ->
if (current.id == row.id) {
row.copy(
resolvingDecision = current.resolvingDecision,
errorText = current.errorText,
)
} else {
current
}
}
} else {
rows + row
}
).filterActiveExecApprovals()
.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
_execApprovals.value = nextRows
scheduleExecApprovalExpiryPrune(nextRows)
}
}
private fun invalidateExecApprovalRefreshes() {
execApprovalsRefreshSeq.incrementAndGet()
_execApprovalsRefreshing.value = false
}
private fun markExecApprovalResolved(id: String) {
synchronized(execApprovalsStateLock) {
resolvedExecApprovalIds.add(id)
invalidateExecApprovalRefreshes()
_execApprovals.value = _execApprovals.value.filterNot { it.id == id }
}
}
private fun publishExecApprovalsIfCurrent(
refreshGeneration: Long,
rows: List<GatewayExecApprovalSummary>,
) {
synchronized(execApprovalsStateLock) {
if (execApprovalsRefreshSeq.get() == refreshGeneration && operatorConnected) {
val nextRows = rows.filterNot { it.id in resolvedExecApprovalIds }.filterActiveExecApprovals()
_execApprovals.value = nextRows
scheduleExecApprovalExpiryPrune(nextRows)
}
}
}
private fun scheduleExecApprovalExpiryPrune(rows: List<GatewayExecApprovalSummary>) {
val now = System.currentTimeMillis()
val nextExpiry = rows.mapNotNull { it.expiresAtMs }.filter { it > now }.minOrNull() ?: return
scope.launch {
delay((nextExpiry - now + 250).coerceAtLeast(0))
pruneExpiredExecApprovals()
}
}
private fun pruneExpiredExecApprovals() {
synchronized(execApprovalsStateLock) {
_execApprovals.value = _execApprovals.value.filterActiveExecApprovals()
}
}
private fun GatewayExecApprovalSummary.isExpiredExecApproval(nowMs: Long = System.currentTimeMillis()): Boolean = expiresAtMs?.let { it <= nowMs } == true
private fun List<GatewayExecApprovalSummary>.filterActiveExecApprovals(
nowMs: Long = System.currentTimeMillis(),
): List<GatewayExecApprovalSummary> = filterNot { it.isExpiredExecApproval(nowMs) }
private fun invalidateNodeCapabilityApprovalState() {
val refreshGeneration = nodeApprovalRefreshGuard.begin()
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
@@ -2458,19 +2198,12 @@ class NodeRuntime(
}.orEmpty()
private fun parseGatewayLogEntry(line: String): GatewayLogEntry {
val sanitizedLine = sanitizeGatewayLogText(line)
val root =
try {
json.parseToJsonElement(line).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return GatewayLogEntry(
time = null,
level = null,
subsystem = null,
message = sanitizedLine.trim().ifEmpty { "Empty log entry" },
raw = sanitizedLine,
)
} ?: return GatewayLogEntry(time = null, level = null, subsystem = null, message = line.trim().ifEmpty { "Empty log entry" })
val meta = root["_meta"].asObjectOrNull()
val time = root["time"].asStringOrNull() ?: meta?.get("date").asStringOrNull()
val level = normalizeLogLevel(meta?.get("logLevelName").asStringOrNull() ?: meta?.get("level").asStringOrNull())
@@ -2488,7 +2221,7 @@ class NodeRuntime(
?: root["message"].asStringOrNull()
?: line
val normalizedMessage =
sanitizeGatewayLogText(message)
message
.trim()
.replace(Regex("\\s+"), " ")
.take(240)
@@ -2496,9 +2229,8 @@ class NodeRuntime(
return GatewayLogEntry(
time = time,
level = level,
subsystem = subsystem?.let(::sanitizeGatewayLogText)?.trim()?.takeIf { it.isNotEmpty() },
subsystem = subsystem?.trim()?.takeIf { it.isNotEmpty() },
message = normalizedMessage,
raw = sanitizedLine,
)
}
@@ -2587,7 +2319,6 @@ class NodeRuntime(
if (name.isEmpty()) return@mapNotNull null
val missing = obj["missing"].asObjectOrNull()
GatewaySkillSummary(
skillKey = obj["skillKey"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: name,
name = name,
description = obj["description"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
source = obj["source"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "unknown",
@@ -3038,6 +2769,11 @@ internal fun resolveOperatorSessionConnectAuth(
)
}
internal fun shouldConnectOperatorSession(
auth: NodeRuntime.GatewayConnectAuth,
storedOperatorToken: String?,
): Boolean = resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
private enum class HomeCanvasGatewayState {
Connected,
Connecting,
@@ -3110,7 +2846,6 @@ data class GatewaySkillsSummary(
)
data class GatewaySkillSummary(
val skillKey: String,
val name: String,
val description: String?,
val source: String,
@@ -3308,19 +3043,8 @@ data class GatewayLogEntry(
val level: String?,
val subsystem: String?,
val message: String,
val raw: String,
)
private val gatewayAnsiControlPattern = Regex("\\u001B\\[[0-?]*[ -/]*[@-~]")
private val gatewayEscapedAnsiControlPattern = Regex("""\\u001[Bb]\[[0-?]*[ -/]*[@-~]""")
private val gatewayVisibleSgrPattern = Regex("\\[(?:0|\\d{1,3}(?:;\\d{1,3})*)m(?!])")
internal fun sanitizeGatewayLogText(value: String): String =
value
.replace(gatewayAnsiControlPattern, "")
.replace(gatewayEscapedAnsiControlPattern, "")
.replace(gatewayVisibleSgrPattern, "")
private fun JsonObject?.long(key: String): Long? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toLongOrNull()
private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonPrimitive)?.content?.trim()?.toDoubleOrNull()

View File

@@ -393,6 +393,12 @@ class SecurePrefs(
return stored?.takeIf { it.isNotEmpty() }
}
/** Saves the paired gateway token under the current Android instance id. */
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
securePrefs.edit { putString(key, token.trim()) }
}
/** Loads the bootstrap token used during gateway setup and device-token handoff. */
fun loadGatewayBootstrapToken(): String? {
val key = "gateway.bootstrapToken.${_instanceId.value}"

View File

@@ -6,6 +6,14 @@ internal fun normalizeMainKey(raw: String?): String {
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
/** Accepts only gateway session keys that can represent the main chat stream. */
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return false
if (trimmed == "global") return true
return trimmed.startsWith("agent:")
}
/** Extracts the agent id from canonical agent-scoped main session keys. */
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()

View File

@@ -1,7 +1,6 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.parseChatSendAck
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -20,21 +19,11 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
class ChatController internal constructor(
class ChatController(
private val scope: CoroutineScope,
private val session: GatewaySession,
private val json: Json,
private val requestGateway: suspend (method: String, paramsJson: String?) -> String,
) {
constructor(
scope: CoroutineScope,
session: GatewaySession,
json: Json,
) : this(
scope = scope,
json = json,
requestGateway = { method, paramsJson -> session.request(method, paramsJson) },
)
private var appliedMainSessionKey = "main"
private val _sessionKey = MutableStateFlow("main")
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
@@ -278,9 +267,8 @@ class ChatController internal constructor(
)
}
}
val res = requestGateway("chat.send", params.toString())
val ack = parseChatSendAck(json, res)
val actualRunId = ack.runId ?: runId
val res = session.request("chat.send", params.toString())
val actualRunId = parseRunId(res) ?: runId
if (actualRunId != runId) {
// Gateway may return a canonical run id; move all pending bookkeeping to that id.
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
@@ -291,24 +279,7 @@ class ChatController internal constructor(
_pendingRunCount.value = pendingRuns.size
}
}
if (ack.isTerminal) {
clearPendingRun(actualRunId)
removeOptimisticMessage(actualRunId)
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
if (ack.isTerminalSuccess) {
refreshCurrentHistoryBestEffort()
true
} else {
// Terminal timeout/error means the gateway did not accept a runnable turn.
// Surface failed acceptance instead of letting a cleared composer look successful.
_errorText.value = "Chat failed before the run started; try again."
false
}
} else {
true
}
true
} catch (err: Throwable) {
clearPendingRun(runId)
removeOptimisticMessage(runId)
@@ -332,7 +303,7 @@ class ChatController internal constructor(
put("sessionKey", JsonPrimitive(_sessionKey.value))
put("runId", JsonPrimitive(runId))
}
requestGateway("chat.abort", params.toString())
session.request("chat.abort", params.toString())
} catch (_: Throwable) {
// best-effort
}
@@ -385,7 +356,7 @@ class ChatController internal constructor(
) {
try {
val historyJson =
requestGateway(
session.request(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(),
)
@@ -420,7 +391,7 @@ class ChatController internal constructor(
put("includeUnknown", JsonPrimitive(false))
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
}
val res = requestGateway("sessions.list", params.toString())
val res = session.request("sessions.list", params.toString())
_sessions.value = parseSessions(res)
} catch (_: Throwable) {
// best-effort
@@ -437,7 +408,7 @@ class ChatController internal constructor(
if (!force && last != null && now - last < 10_000) return
lastHealthPollAtMs = now
try {
requestGateway("health", null)
session.request("health", null)
_healthOk.value = true
} catch (_: Throwable) {
_healthOk.value = false
@@ -480,7 +451,7 @@ class ChatController internal constructor(
val currentSessionKey = _sessionKey.value
val currentGeneration = historyLoadGeneration.get()
val historyJson =
requestGateway(
session.request(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
)
@@ -538,7 +509,8 @@ class ChatController internal constructor(
}
}
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? = payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
private fun parseEventSessionEntry(payload: JsonObject): ChatSessionEntry? =
payload["session"].asObjectOrNull()?.let(::parseSessionEntry) ?: parseSessionEntry(payload)
private fun handleAgentEvent(payloadJson: String) {
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
@@ -660,45 +632,6 @@ class ChatController internal constructor(
optimisticMessagesByRunId.entries.removeAll { entry -> entry.value !in retained }
}
private fun refreshCurrentHistoryBestEffort() {
scope.launch {
try {
val currentSessionKey = _sessionKey.value
val currentGeneration = historyLoadGeneration.get()
val historyJson =
requestGateway(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
)
if (
!isCurrentHistoryLoad(
currentSessionKey,
_sessionKey.value,
currentGeneration,
historyLoadGeneration.get(),
)
) {
return@launch
}
val history =
parseHistory(
historyJson,
sessionKey = currentSessionKey,
previousMessages = _messages.value,
)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
history.thinkingLevel
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { _thinkingLevel.value = it }
} catch (_: Throwable) {
// best-effort
}
}
}
private fun parseHistory(
historyJson: String,
sessionKey: String,
@@ -746,16 +679,9 @@ class ChatController internal constructor(
): ChatSessionEntry? {
if (obj == null) return null
val key =
obj["key"]
.asStringOrNull()
?.trim()
.orEmpty()
.ifEmpty {
obj["sessionKey"]
.asStringOrNull()
?.trim()
.orEmpty()
}.ifEmpty { fallbackKey?.trim().orEmpty() }
obj["key"].asStringOrNull()?.trim().orEmpty()
.ifEmpty { obj["sessionKey"].asStringOrNull()?.trim().orEmpty() }
.ifEmpty { fallbackKey?.trim().orEmpty() }
if (key.isEmpty()) return null
return ChatSessionEntry(
key = key,
@@ -802,6 +728,17 @@ class ChatController internal constructor(
_sessions.value = _sessions.value.filterNot { it.key == key }
}
private fun parseRunId(resJson: String): String? =
try {
json
.parseToJsonElement(resJson)
.asObjectOrNull()
?.get("runId")
.asStringOrNull()
} catch (_: Throwable) {
null
}
private fun normalizeThinking(raw: String): String =
when (raw.trim().lowercase()) {
"low" -> "low"

View File

@@ -1,46 +0,0 @@
package ai.openclaw.app.gateway
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
internal data class ChatSendAck(
val runId: String?,
val status: String?,
) {
val normalizedStatus: String
get() = status?.trim()?.lowercase().orEmpty()
val isTerminalSuccess: Boolean
get() = normalizedStatus == "ok"
val isTerminalFailure: Boolean
get() = normalizedStatus == "timeout" || normalizedStatus == "error"
val isTerminal: Boolean
get() = isTerminalSuccess || isTerminalFailure
}
internal fun chatSendAckHistorySinceSeconds(
ack: ChatSendAck,
startedAtSeconds: Double,
): Double? = if (ack.isTerminalSuccess) null else startedAtSeconds
internal fun parseChatSendAck(
json: Json,
responseJson: String,
): ChatSendAck =
try {
val obj = json.parseToJsonElement(responseJson).asObjectOrNull()
ChatSendAck(
runId = obj?.get("runId").asStringOrNull(),
status = obj?.get("status").asStringOrNull(),
)
} catch (_: Throwable) {
ChatSendAck(runId = null, status = null)
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content

View File

@@ -1,5 +1,6 @@
package ai.openclaw.app.gateway
import android.annotation.TargetApi
import android.content.Context
import android.net.ConnectivityManager
import android.net.DnsResolver
@@ -11,7 +12,6 @@ import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import androidx.annotation.RequiresApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -56,7 +56,7 @@ private fun createDnsResolver(context: Context): DnsResolver =
createLegacyDnsResolver()
}
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
@Suppress("DEPRECATION")
@@ -166,6 +166,14 @@ class GatewayDiscovery(
}
}
private fun stopLocalDiscovery() {
try {
nsd.stopServiceDiscovery(discoveryListener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun startUnicastDiscovery(domain: String) {
unicastJob =
scope.launch(Dispatchers.IO) {
@@ -189,7 +197,7 @@ class GatewayDiscovery(
}
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@TargetApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private fun resolveWithServiceInfoCallback(serviceInfo: NsdServiceInfo) {
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
val id = stableId(serviceName, "local.")

View File

@@ -260,6 +260,24 @@ class GatewaySession(
currentConnection?.closeQuietly()
}
fun currentCanvasHostUrl(): String? = pluginSurfaceUrls["canvas"]
/** Refreshes the canvas plugin surface URL and caches the normalized Android-reachable URL. */
suspend fun refreshCanvasHostUrl(timeoutMs: Long = 8_000): String? {
val refreshed =
refreshPluginSurfaceUrl(
method = "node.pluginSurface.refresh",
params = buildJsonObject { put("surface", JsonPrimitive("canvas")) },
timeoutMs = timeoutMs,
)
if (!refreshed.isNullOrBlank()) {
pluginSurfaceUrls = pluginSurfaceUrls + ("canvas" to refreshed)
}
return refreshed
}
fun currentMainSessionKey(): String? = mainSessionKey
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
suspend fun sendNodeEvent(
event: String,
@@ -279,6 +297,28 @@ class GatewaySession(
}
}
private suspend fun refreshPluginSurfaceUrl(
method: String,
params: JsonElement?,
timeoutMs: Long,
): String? {
val conn = currentConnection ?: return null
return try {
val res = conn.request(method, params, timeoutMs)
if (!res.ok) return null
val obj = res.payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() } ?: return null
val raw =
obj["pluginSurfaceUrls"]
.asObjectOrNull()
?.get("canvas")
.asStringOrNull()
normalizeCanvasHostUrl(raw, conn.endpoint, isTlsConnection = conn.tls != null)
} catch (err: Throwable) {
Log.d("OpenClawGateway", "$method failed: ${err.message ?: err::class.java.simpleName}")
null
}
}
/** Sends node.event and preserves the gateway RPC error shape for callers that need diagnostics. */
suspend fun sendNodeEventDetailed(
event: String,

View File

@@ -97,6 +97,8 @@ class CanvasController {
fun currentUrl(): String? = url
fun isDefaultCanvas(): Boolean = url == null
fun setDebugStatusEnabled(enabled: Boolean) {
debugStatusEnabled = enabled
applyDebugStatus()
@@ -203,6 +205,24 @@ class CanvasController {
}
}
suspend fun snapshotPngBase64(maxWidth: Int?): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
try {
val scaled = bmp.scaleForMaxWidth(maxWidth)
try {
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
} finally {
if (scaled !== bmp) scaled.recycle()
}
} finally {
bmp.recycle()
}
}
/** Captures the WebView as PNG/JPEG base64 with optional width and quality bounds. */
suspend fun snapshotBase64(
format: SnapshotFormat,

View File

@@ -4,7 +4,6 @@ import ai.openclaw.app.BuildConfig
import ai.openclaw.app.SensitiveFeatureConfig
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
@@ -64,7 +63,7 @@ private class AndroidDeviceAppSource(
val appInfos =
if (includeNonLaunchable) {
visibleInstalledApplications(packageManager)
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
} else {
launchablePackages.mapNotNull { packageName ->
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
@@ -91,13 +90,6 @@ private class AndroidDeviceAppSource(
.sortedWith(compareBy<DeviceAppEntry> { it.label.lowercase() }.thenBy { it.packageName })
.toList()
}
@SuppressLint("QueryPermissionsNeeded")
private fun visibleInstalledApplications(packageManager: PackageManager): List<ApplicationInfo> {
// Android package visibility intentionally bounds this result to packages the app can see.
// OpenClaw should not request QUERY_ALL_PACKAGES for this optional device-context surface.
return packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
}
}
private data class DeviceAppsRequest(

View File

@@ -109,3 +109,6 @@ fun normalizeMainKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
return if (trimmed.isEmpty()) null else trimmed
}
/** Returns true only for the canonical main-session key understood by gateway UI. */
fun isCanonicalMainSessionKey(key: String): Boolean = key == "main"

View File

@@ -5,7 +5,6 @@ import ai.openclaw.app.GatewayModelSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSeparatedColumn
import ai.openclaw.app.ui.design.ClawTextField
@@ -95,11 +94,7 @@ internal fun CommandPalette(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
ClawPlainIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close search",
onClick = onDismiss,
)
CommandIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search", onClick = onDismiss)
Text(text = "Search", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
CommandAvatar(text = "OC")
}
@@ -267,6 +262,19 @@ private fun CommandSessionListRow(
}
}
@Composable
private fun CommandIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun CommandAvatar(text: String) {
Surface(

View File

@@ -5,7 +5,8 @@ import ai.openclaw.app.GatewayDreamingSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatusRow
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
@@ -91,19 +92,19 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
ClawStatusRow(
DreamingHealthRow(
title = "Memory Store",
value = if (summary.storeHealthy) "Healthy" else "Needs attention",
healthy = summary.storeHealthy,
)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(
DreamingHealthRow(
title = "Signal Index",
value = if (summary.phaseSignalHealthy) "Healthy" else "Needs attention",
healthy = summary.phaseSignalHealthy,
)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(
DreamingHealthRow(
title = "Promoted",
value = "${summary.promotedToday} today · ${summary.promotedTotal} total",
healthy = true,
@@ -114,6 +115,23 @@ private fun DreamingPanel(summary: GatewayDreamingSummary) {
}
}
@Composable
private fun DreamingHealthRow(
title: String,
value: String,
healthy: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Box(modifier = Modifier.size(7.dp))
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
@Composable
private fun DreamDiaryPanel(summary: GatewayDreamingSummary) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {

View File

@@ -206,6 +206,9 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
}
/** Extracts a setup code from QR scanner text when the embedded endpoint is valid. */
internal fun resolveScannedSetupCode(rawInput: String): String? = resolveScannedSetupCodeResult(rawInput).setupCode
/** Resolves QR scanner text to setup-code or validation error for UI copy. */
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
val setupCode =

View File

@@ -7,10 +7,7 @@ import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawStatusRow
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -18,18 +15,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
@@ -51,7 +43,6 @@ internal fun HealthLogsSettingsScreen(
val logsSummary by viewModel.healthLogsSummary.collectAsState()
val logsRefreshing by viewModel.healthLogsRefreshing.collectAsState()
val logsErrorText by viewModel.healthLogsErrorText.collectAsState()
var selectedLogEntry by remember { mutableStateOf<GatewayLogEntry?>(null) }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -61,11 +52,6 @@ internal fun HealthLogsSettingsScreen(
}
}
selectedLogEntry?.let { entry ->
GatewayLogDetailSettingsScreen(entry = entry, onBack = { selectedLogEntry = null })
return
}
SettingsDetailFrame(
title = "Health",
subtitle = "Gateway status, phone node readiness, and recent log stream.",
@@ -107,46 +93,7 @@ internal fun HealthLogsSettingsScreen(
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary, onLogClick = { selectedLogEntry = it })
}
}
@Composable
private fun GatewayLogDetailSettingsScreen(
entry: GatewayLogEntry,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
SettingsDetailFrame(
title = "Log Entry",
subtitle = "Readable gateway log detail.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Time", compactLogTime(entry.time)),
SettingsMetric("Level", entry.level?.uppercase() ?: "LOG"),
SettingsMetric("Subsystem", entry.subsystem ?: "Unknown"),
),
)
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Message", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = entry.message, style = ClawTheme.type.body, color = ClawTheme.colors.text)
}
}
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Raw", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(
text = entry.raw.take(4_000),
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
GatewayLogsPanel(isConnected = isConnected, summary = logsSummary)
}
}
@@ -166,26 +113,41 @@ private fun HealthStatusPanel(
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
Column {
ClawStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
HealthStatusRow(title = "Gateway", value = gateway, healthy = isConnected)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
HealthStatusRow(title = "Phone Node", value = node, healthy = isNodeConnected)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
HealthStatusRow(title = "Chat", value = chat, healthy = chatHealthOk)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Models", value = models, healthy = modelsReady)
HealthStatusRow(title = "Models", value = models, healthy = modelsReady)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Voice", value = voice, healthy = voiceReady)
HealthStatusRow(title = "Voice", value = voice, healthy = voiceReady)
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
ClawStatusRow(title = "Runs", value = runs, healthy = true)
HealthStatusRow(title = "Runs", value = runs, healthy = true)
}
}
}
@Composable
private fun HealthStatusRow(
title: String,
value: String,
healthy: Boolean,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1)
ClawStatusPill(text = value, status = if (healthy) ClawStatus.Success else ClawStatus.Warning)
}
}
@Composable
private fun GatewayLogsPanel(
isConnected: Boolean,
summary: GatewayHealthLogsSummary,
onLogClick: (GatewayLogEntry) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
@@ -208,7 +170,7 @@ private fun GatewayLogsPanel(
val entries = summary.entries.takeLast(12)
Column {
entries.forEachIndexed { index, entry ->
GatewayLogRow(entry = entry, onClick = { onLogClick(entry) })
GatewayLogRow(entry = entry)
if (index != entries.lastIndex) {
HorizontalDivider(color = ClawTheme.colors.border, thickness = 1.dp)
}
@@ -223,16 +185,9 @@ private fun GatewayLogsPanel(
}
@Composable
private fun GatewayLogRow(
entry: GatewayLogEntry,
onClick: () -> Unit,
) {
private fun GatewayLogRow(entry: GatewayLogEntry) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClickLabel = "Open log entry", onClick = onClick)
.padding(horizontal = 10.dp, vertical = 7.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
@@ -244,11 +199,6 @@ private fun GatewayLogRow(
}
}
ClawStatusPill(text = entry.level?.uppercase() ?: "LOG", status = logLevelStatus(entry.level))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = ClawTheme.colors.textSubtle,
)
}
}

View File

@@ -1378,12 +1378,7 @@ private fun rememberPermissionState(
photosGranted = permissions[photosPermission] ?: photosGranted
contactsGranted = permissions[Manifest.permission.READ_CONTACTS] ?: contactsGranted
calendarGranted = permissions[Manifest.permission.READ_CALENDAR] ?: calendarGranted
notificationsGranted =
if (Build.VERSION.SDK_INT >= 33) {
permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
} else {
true
}
notificationsGranted = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: notificationsGranted
motionGranted = permissions[Manifest.permission.ACTIVITY_RECOGNITION] ?: motionGranted
smsGranted =
(permissions[Manifest.permission.SEND_SMS] ?: smsGranted) &&

View File

@@ -9,10 +9,14 @@ 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.
*/
@@ -30,6 +34,7 @@ fun OpenClawTheme(
CompositionLocalProvider(
LocalMobileColors provides mobileColors,
LocalOpenClawDarkTheme provides isDark,
) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
@@ -50,3 +55,21 @@ internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
}
}
}
/**
* Overlay background token tuned for panels floating over the mobile canvas.
*/
@Composable
fun overlayContainerColor(): Color {
val scheme = MaterialTheme.colorScheme
val isDark = LocalOpenClawDarkTheme.current
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
// Light mode keeps overlays away from pure-white glare on the app canvas.
return if (isDark) base else base.copy(alpha = 0.88f)
}
/**
* Overlay icon token kept next to overlayContainerColor for callers outside the design package.
*/
@Composable
fun overlayIconColor(): Color = MaterialTheme.colorScheme.onSurfaceVariant

View File

@@ -2,7 +2,6 @@ package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawTheme
@@ -56,7 +55,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/** Session browser for recent and current chat sessions. */
/** Session browser for recent and currently-live chat sessions. */
@Composable
internal fun SessionsScreen(
viewModel: MainViewModel,
@@ -74,7 +73,7 @@ internal fun SessionsScreen(
.let { rows ->
when (filter) {
SessionFilter.Recent -> rows
SessionFilter.Current -> rows.filter { it.key == chatSessionKey }
SessionFilter.Live -> rows.filter { it.key == chatSessionKey }
}
}.let { rows ->
if (recentFirst) {
@@ -93,12 +92,12 @@ internal fun SessionsScreen(
}
ClawScaffold(
contentPadding = PaddingValues(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 4.dp),
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(9.dp),
verticalArrangement = Arrangement.spacedBy(7.dp),
contentPadding = PaddingValues(bottom = 4.dp),
) {
item {
@@ -107,16 +106,16 @@ internal fun SessionsScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
ClawPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
Text(text = "Sessions", style = ClawTheme.type.display.copy(fontSize = 17.4.sp, lineHeight = 21.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
SessionPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search sessions", onClick = onOpenCommand)
SessionPlainIconButton(icon = Icons.Default.SwapVert, contentDescription = "Reverse session sort", onClick = { recentFirst = !recentFirst })
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
FilterPill(text = "Recent", icon = Icons.Outlined.AccessTime, active = filter == SessionFilter.Recent, onClick = { filter = SessionFilter.Recent })
FilterPill(text = "Current", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Current, showDot = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Current })
FilterPill(text = "Live", icon = Icons.Outlined.MicNone, active = filter == SessionFilter.Live, live = sessions.any { it.key == chatSessionKey }, onClick = { filter = SessionFilter.Live })
}
}
@@ -180,7 +179,7 @@ private fun FilterPill(
text: String,
icon: ImageVector? = null,
active: Boolean = false,
showDot: Boolean = false,
live: Boolean = false,
dropdown: Boolean = false,
onClick: (() -> Unit)? = null,
) {
@@ -199,7 +198,7 @@ private fun FilterPill(
) {
icon?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.text) }
Text(text = text, style = ClawTheme.type.label, color = ClawTheme.colors.text, maxLines = 1)
if (showDot) {
if (live) {
Box(modifier = Modifier.size(4.dp).clip(CircleShape).background(ClawTheme.colors.success))
}
if (dropdown) {
@@ -259,7 +258,7 @@ private fun SessionRow(
Text(text = subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
SessionMiniTag(text = "Workspace")
SessionMiniTag(text = if (active) "Current" else "OpenClaw")
SessionMiniTag(text = if (active) "Active" else "OpenClaw")
}
}
}
@@ -274,6 +273,19 @@ private fun SessionRow(
}
}
@Composable
private fun SessionPlainIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun SessionOutlineIconButton(
icon: ImageVector,
@@ -308,21 +320,21 @@ private fun SessionMiniTag(text: String) {
private enum class SessionFilter {
Recent,
Current,
Live,
}
/** Empty-state title selected by the active session browser filter. */
private fun emptySessionTitle(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "No sessions yet"
SessionFilter.Current -> "No current session"
SessionFilter.Live -> "No live session"
}
/** Empty-state body selected by the active session browser filter. */
private fun emptySessionBody(filter: SessionFilter): String =
when (filter) {
SessionFilter.Recent -> "Start a new conversation and it will show up here."
SessionFilter.Current -> "Open Chat to start or resume the current session."
SessionFilter.Live -> "Open Chat to start or resume the current session."
}
/** Formats session timestamps for compact mobile metadata. */

View File

@@ -4,7 +4,6 @@ import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayCronJobSummary
import ai.openclaw.app.GatewayExecApprovalSummary
import ai.openclaw.app.GatewayUsageProviderSummary
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
@@ -15,7 +14,6 @@ import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawIconBadge
import ai.openclaw.app.ui.design.ClawListPanel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSecondaryButton
@@ -92,6 +90,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
@@ -107,7 +106,6 @@ internal enum class SettingsRoute {
Profile,
Voice,
Agents,
ProvidersModels,
Approvals,
CronJobs,
Usage,
@@ -138,7 +136,6 @@ internal fun SettingsDetailScreen(
SettingsRoute.Profile -> ProfileSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Voice -> VoiceSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Agents -> AgentsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.ProvidersModels -> ProvidersModelsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Approvals -> ApprovalsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.CronJobs -> CronJobsSettingsScreen(viewModel = viewModel, onBack = onBack)
SettingsRoute.Usage -> UsageSettingsScreen(viewModel = viewModel, onBack = onBack)
@@ -302,62 +299,29 @@ private fun ApprovalsSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val execApprovals by viewModel.execApprovals.collectAsState()
val execApprovalsRefreshing by viewModel.execApprovalsRefreshing.collectAsState()
val execApprovalsErrorText by viewModel.execApprovalsErrorText.collectAsState()
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
val issueCount = execApprovals.count { it.errorText != null } + pendingToolCalls.count { it.isError == true }
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshExecApprovals()
}
}
val waitingCount = pendingToolCalls.count { it.isError != true }
val issueCount = pendingToolCalls.count { it.isError == true }
SettingsDetailFrame(title = "Approvals", subtitle = "Review actions that need your attention.", icon = Icons.Default.Lock, onBack = onBack) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Gateway Pending", execApprovals.size.toString()),
SettingsMetric("Session Activity", pendingToolCalls.size.toString()),
SettingsMetric("Pending", waitingCount.toString()),
SettingsMetric("Issues", issueCount.toString()),
SettingsMetric("Active Runs", pendingRunCount.toString()),
),
)
ClawSecondaryButton(
text = if (execApprovalsRefreshing) "Refreshing" else "Refresh",
onClick = viewModel::refreshExecApprovals,
enabled = isConnected && !execApprovalsRefreshing,
modifier = Modifier.fillMaxWidth(),
)
if (execApprovalsErrorText != null) {
ClawPanel {
Text(text = execApprovalsErrorText ?: "", style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
if (!isConnected) {
if (pendingToolCalls.isEmpty()) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Gateway disconnected.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Connect the gateway to load approval requests in the app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
} else if (execApprovals.isEmpty()) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No gateway approvals.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Exec approval requests will appear here while this phone is connected.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(text = "Nothing needs approval.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "OpenClaw will show action requests here when a session pauses for review.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
} else {
ExecApprovalsPanel(approvals = execApprovals, onResolve = viewModel::resolveExecApproval)
}
if (pendingToolCalls.isNotEmpty()) {
Text(text = "Session activity", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Chat tool calls waiting in the active session remain visible here.", style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
SessionToolCallsPanel(toolCalls = pendingToolCalls)
ApprovalsPanel(toolCalls = pendingToolCalls)
}
}
}
@@ -856,7 +820,6 @@ private fun GatewaySettingsScreen(
var bootstrapTokenInput by remember { mutableStateOf("") }
var passwordInput by remember { mutableStateOf("") }
var validationText by remember { mutableStateOf<String?>(null) }
var showSetupCodeHelp by remember { mutableStateOf(false) }
SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) {
SettingsMetricPanel(
@@ -877,17 +840,7 @@ private fun GatewaySettingsScreen(
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(text = "Pair New Gateway", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Clear this phone's saved gateway access and scan a fresh setup code.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.weight(1f), icon = Icons.Default.QrCode2)
ClawSecondaryButton(text = "Setup Code", onClick = { showSetupCodeHelp = !showSetupCodeHelp }, modifier = Modifier.weight(1f), icon = Icons.Default.Info)
}
if (showSetupCodeHelp) {
Text(
text = "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.",
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
ClawSecondaryButton(text = "Pair New Gateway", onClick = viewModel::pairNewGateway, modifier = Modifier.fillMaxWidth(), icon = Icons.Default.QrCode2)
}
}
ClawPanel {
@@ -1108,11 +1061,7 @@ internal fun SettingsDetailFrame(
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
item {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
ClawPlainIconButton(
icon = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
onClick = onBack,
)
SettingsBackButton(onClick = onBack)
Text(text = title, style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
SettingsIconMark(icon = icon)
}
@@ -1149,70 +1098,7 @@ internal data class SettingsMetric(
)
@Composable
private fun ExecApprovalsPanel(
approvals: List<GatewayExecApprovalSummary>,
onResolve: (String, String) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
approvals.forEach { approval ->
ExecApprovalCard(approval = approval, onResolve = onResolve)
}
}
}
@Composable
private fun ExecApprovalCard(
approval: GatewayExecApprovalSummary,
onResolve: (String, String) -> Unit,
) {
val resolving = approval.resolvingDecision != null
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(9.dp)) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = approval.commandText, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 2, overflow = TextOverflow.Ellipsis)
approval.commandPreview?.let { preview ->
Text(text = preview, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
ClawStatusPill(text = if (resolving) "Sending" else "Review", status = if (resolving) ClawStatus.Warning else ClawStatus.Success)
}
Text(text = execApprovalMetadata(approval), style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 2, overflow = TextOverflow.Ellipsis)
approval.errorText?.let { errorText ->
Text(text = errorText, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if ("allow-once" in approval.allowedDecisions) {
ClawPrimaryButton(
text = if (approval.resolvingDecision == "allow-once") "Allowing" else "Allow Once",
onClick = { onResolve(approval.id, "allow-once") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
if ("allow-always" in approval.allowedDecisions) {
ClawSecondaryButton(
text = if (approval.resolvingDecision == "allow-always") "Saving" else "Always",
onClick = { onResolve(approval.id, "allow-always") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
if ("deny" in approval.allowedDecisions) {
ClawSecondaryButton(
text = if (approval.resolvingDecision == "deny") "Denying" else "Deny",
onClick = { onResolve(approval.id, "deny") },
enabled = !resolving,
modifier = Modifier.weight(1f),
)
}
}
}
}
}
@Composable
private fun SessionToolCallsPanel(toolCalls: List<ChatPendingToolCall>) {
private fun ApprovalsPanel(toolCalls: List<ChatPendingToolCall>) {
ClawListPanel(items = toolCalls) { toolCall ->
ApprovalListRow(toolCall = toolCall)
}
@@ -1345,30 +1231,6 @@ private fun approvalSubtitle(
return if (minutes < 1) "Waiting for review" else "Waiting ${minutes}m"
}
private fun execApprovalMetadata(approval: GatewayExecApprovalSummary): String {
val target =
when {
approval.host == "node" && approval.nodeId != null -> "Node ${approval.nodeId.take(8)}"
approval.host != null -> approval.host.replaceFirstChar { it.uppercaseChar() }
else -> "Gateway"
}
val agent = approval.agentId?.let { "Agent ${it.take(8)}" }
val age = approval.createdAtMs?.let { "Waiting ${formatApprovalDuration(System.currentTimeMillis() - it)}" }
val expires = approval.expiresAtMs?.let { "Expires ${formatApprovalDuration(it - System.currentTimeMillis())}" }
return listOfNotNull(target, agent, age, expires).joinToString(" · ")
}
private fun formatApprovalDuration(deltaMs: Long): String {
val safeDelta = deltaMs.coerceAtLeast(0L)
val minutes = safeDelta / 60_000L
val hours = minutes / 60L
return when {
minutes < 1 -> "soon"
hours < 1 -> "${minutes}m"
else -> "${hours}h"
}
}
/** Builds the dense cron-job subtitle from schedule, next wake, and prompt preview. */
private fun cronJobSubtitle(job: GatewayCronJobSummary): String = "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}"
@@ -1532,6 +1394,15 @@ internal fun SettingsMetricPanel(rows: List<SettingsMetric>) {
}
}
@Composable
private fun SettingsBackButton(onClick: () -> Unit) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun SettingsIconMark(icon: ImageVector) {
Surface(

View File

@@ -1253,6 +1253,16 @@ private fun settingsPrimaryButtonColors() =
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Destructive button colors for permission and capability settings actions. */
@Composable
private fun settingsDangerButtonColors() =
ButtonDefaults.buttonColors(
containerColor = mobileDanger,
contentColor = Color.White,
disabledContainerColor = mobileDanger.copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Opens this app's Android settings page for permissions that require system UI. */
private fun openAppSettings(context: Context) {
val intent =

View File

@@ -10,24 +10,17 @@ import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTheme
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -44,7 +37,6 @@ internal fun SkillsSettingsScreen(
val skills = skillsSummary.skills
val readyCount = skills.count { skillReady(it) }
val needsSetupCount = skills.count { skillNeedsSetup(it) }
var selectedSkillKey by remember { mutableStateOf<String?>(null) }
LaunchedEffect(isConnected) {
if (isConnected) {
@@ -52,17 +44,6 @@ internal fun SkillsSettingsScreen(
}
}
selectedSkillKey?.let { skillKey ->
val selectedSkill = skills.firstOrNull { it.skillKey == skillKey }
SkillDetailSettingsScreen(
skill = selectedSkill,
skillKey = skillKey,
isConnected = isConnected,
onBack = { selectedSkillKey = null },
)
return
}
SettingsDetailFrame(
title = "Skills",
subtitle = "Installed capabilities available to OpenClaw.",
@@ -102,117 +83,25 @@ internal fun SkillsSettingsScreen(
Text(text = "Skills installed on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> SkillsPanel(skills = skills, onSkillClick = { selectedSkillKey = it.skillKey })
else -> SkillsPanel(skills = skills)
}
}
}
@Composable
private fun SkillDetailSettingsScreen(
skill: GatewaySkillSummary?,
skillKey: String,
isConnected: Boolean,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
SettingsDetailFrame(
title = skill?.name ?: skillKey,
subtitle = "Inspect installed skill capability and setup state.",
icon = Icons.Default.Settings,
onBack = onBack,
) {
skill?.let { summary ->
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Status", skillStatusText(summary)),
SettingsMetric("Source", skillSourceLabel(summary)),
SettingsMetric("Missing", summary.missingCount.toString()),
),
)
SkillSetupPanel(summary)
}
SkillDetailPanel(skill = skill, isConnected = isConnected)
}
}
@Composable
private fun SkillSetupPanel(skill: GatewaySkillSummary) {
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Setup", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = skillConfigurationText(skill), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
@Composable
private fun SkillDetailPanel(
skill: GatewaySkillSummary?,
isConnected: Boolean,
) {
if (!isConnected) {
ClawPanel {
Text(text = "Connect the gateway to load skill details.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
return
}
if (skill == null) {
ClawPanel {
Text(text = "Skill detail is not available in the current skills status.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
return
}
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Skill Key", skill.skillKey),
SettingsMetric("Display", skill.name),
SettingsMetric("Source", skillSourceLabel(skill)),
SettingsMetric("Install Options", skill.installCount.toString()),
),
)
skill.description?.let { description ->
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Description", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = description, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
}
}
@Composable
private fun SkillsPanel(
skills: List<GatewaySkillSummary>,
onSkillClick: (GatewaySkillSummary) -> Unit,
) {
private fun SkillsPanel(skills: List<GatewaySkillSummary>) {
ClawListPanel(items = skills) { skill ->
SkillListRow(skill = skill, onClick = { onSkillClick(skill) })
SkillListRow(skill = skill)
}
}
@Composable
private fun SkillListRow(
skill: GatewaySkillSummary,
onClick: () -> Unit,
) {
private fun SkillListRow(skill: GatewaySkillSummary) {
ClawDetailRow(
title = skill.name,
subtitle = skillSubtitle(skill),
modifier = Modifier.clickable(onClickLabel = "Open skill detail", onClick = onClick),
leading = { ClawTextBadge(text = skillBadge(skill)) },
trailing = {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = ClawTheme.colors.textSubtle,
)
}
},
trailing = { ClawStatusPill(text = skillStatusText(skill), status = skillStatus(skill)) },
)
}
@@ -246,15 +135,6 @@ private fun skillSubtitle(skill: GatewaySkillSummary): String {
return listOfNotNull(skill.description, skillSourceLabel(skill), issue).joinToString(" · ")
}
private fun skillConfigurationText(skill: GatewaySkillSummary): String =
when {
skill.disabled -> "This skill is disabled on the gateway. Android shows detail only; enable or configure it from desktop or CLI."
skill.blockedByAllowlist -> "This skill is blocked by the gateway allowlist. Android can inspect it, but allowlist changes stay on desktop or CLI."
skill.missingCount > 0 -> "This skill needs ${skill.missingCount} setup item(s). Android shows what is installed; setup/config changes stay on desktop or CLI."
!skill.eligible -> "This skill is installed but not currently eligible to run. Use desktop or CLI for configuration changes."
else -> "Ready on this gateway. Android detail is read-only; install, update, and configuration changes stay on desktop or CLI."
}
private fun skillSourceLabel(skill: GatewaySkillSummary): String =
when (skill.source) {
"openclaw-bundled" -> if (skill.bundled) "Built-in" else "Bundled"

View File

@@ -1,10 +1,8 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.VoiceCaptureMode
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPlainIconButton
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
@@ -70,7 +68,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -180,8 +177,8 @@ fun VoiceScreen(
Modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
VoiceHeader(
statusText = voiceAttentionStatus ?: if (voiceActive || !gatewayReady) activeStatus else "Your voice command center.",
@@ -270,12 +267,12 @@ private fun DictationScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onCancel)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = "Dictation", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Text(text = "Transcribe then send", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
ClawPlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
VoicePlainIconButton(icon = Icons.Default.Settings, contentDescription = "Dictation settings", onClick = onOpenVoiceSettings)
}
Surface(
@@ -407,7 +404,7 @@ private fun TalkSessionScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
ClawPlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
VoicePlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to voice", onClick = onEndTalk)
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Realtime Talk", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
@@ -426,7 +423,7 @@ private fun TalkSessionScreen(
)
}
}
ClawPlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
VoicePlainIconButton(icon = Icons.Default.Info, contentDescription = "Talk settings", onClick = onOpenVoiceSettings)
}
Surface(
@@ -550,19 +547,14 @@ private fun VoiceHeader(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
painter = painterResource(id = R.drawable.openclaw_logo),
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = ClawTheme.colors.text,
)
Text(
text = "OpenClaw",
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
text = "O P E N C L A W",
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
)
ClawPlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
VoicePlainIconButton(icon = Icons.Default.Search, contentDescription = "Search voice", onClick = onOpenCommand)
VoiceAvatar(text = "OC")
}
Row(
modifier = Modifier.fillMaxWidth(),
@@ -570,7 +562,7 @@ private fun VoiceHeader(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text)
Text(text = "Voice", style = ClawTheme.type.display.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text)
Text(
text = statusText,
style = ClawTheme.type.body,
@@ -579,7 +571,7 @@ private fun VoiceHeader(
overflow = TextOverflow.Ellipsis,
)
}
ClawPlainIconButton(
VoicePlainIconButton(
icon = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
onClick = onToggleSpeaker,
@@ -588,6 +580,34 @@ private fun VoiceHeader(
}
}
@Composable
private fun VoiceAvatar(text: String) {
Surface(
modifier = Modifier.size(34.dp),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
}
}
}
@Composable
private fun VoicePlainIconButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun VoiceHero(
gatewayStatus: String,
@@ -841,10 +861,8 @@ private fun VoiceOrb(
Surface(
modifier = Modifier.size(112.dp),
shape = CircleShape,
color = if (active || listening || speaking) Color(0xFF1976D2) else Color(0xFF123B63),
contentColor = Color.White,
tonalElevation = 3.dp,
shadowElevation = 7.dp,
color = if (active) ClawTheme.colors.surfacePressed else ClawTheme.colors.surface,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -857,7 +875,7 @@ private fun VoiceOrb(
},
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = Color.White,
tint = ClawTheme.colors.text,
)
Waveform(active = active)
}
@@ -874,7 +892,7 @@ private fun Waveform(active: Boolean) {
Modifier
.size(width = 2.dp, height = (if (active) height else 6 + index % 3 * 3).dp)
.clip(RoundedCornerShape(999.dp))
.background(if (active) Color.White else Color.White.copy(alpha = 0.52f)),
.background(if (active) ClawTheme.colors.text else ClawTheme.colors.textSubtle),
)
}
}

View File

@@ -1,7 +1,6 @@
package ai.openclaw.app.ui.chat
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatMessageContent
import ai.openclaw.app.chat.ChatPendingToolCall
@@ -40,7 +39,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Mic
@@ -65,7 +63,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -156,11 +153,12 @@ fun ChatScreen(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
.padding(horizontal = 18.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
ChatHeader(
sessionTitle = currentSessionTitle(sessionKey = sessionKey, sessions = sessions),
thinkingLevel = thinkingLevel,
healthOk = healthOk,
pendingRunCount = pendingRunCount,
onMore = {
@@ -263,11 +261,11 @@ private fun ChatSessionSwitcher(
if (sessions.size > choices.size) {
Surface(
onClick = onOpenSessions,
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
modifier = Modifier.heightIn(min = 36.dp),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
color = ClawTheme.colors.canvas,
contentColor = ClawTheme.colors.textMuted,
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.7f)),
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
@@ -290,11 +288,11 @@ private fun ChatSessionChip(
) {
Surface(
onClick = onClick,
modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget),
modifier = Modifier.heightIn(min = 36.dp),
shape = RoundedCornerShape(ClawTheme.radii.pill),
color = if (active) ClawTheme.colors.surfacePressed.copy(alpha = 0.9f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.72f),
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.7f)),
color = if (active) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
contentColor = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.text,
border = BorderStroke(1.dp, if (active) ClawTheme.colors.primary else ClawTheme.colors.border),
) {
Text(
text = text,
@@ -309,56 +307,48 @@ private fun ChatSessionChip(
@Composable
private fun ChatHeader(
sessionTitle: String,
thinkingLevel: String,
healthOk: Boolean,
pendingRunCount: Int,
onMore: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(modifier = Modifier.size(ClawTheme.spacing.touchTarget))
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(
painter = painterResource(id = R.drawable.openclaw_logo),
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = ClawTheme.colors.text,
)
Text(
text = "OpenClaw",
style = ClawTheme.type.title.copy(fontSize = 17.sp, lineHeight = 21.sp),
text = sessionTitle,
style = ClawTheme.type.title.copy(fontSize = 18.sp, lineHeight = 23.sp),
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
ModelPill(
text =
when {
pendingRunCount > 0 -> "Working"
healthOk -> "Ready"
else -> "Offline"
healthOk -> "auto"
else -> "offline"
},
status =
when {
pendingRunCount > 0 -> ClawStatus.Warning
healthOk -> ClawStatus.Success
healthOk -> ClawStatus.Neutral
else -> ClawStatus.Danger
},
)
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
}
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "Chat", style = ClawTheme.type.display.copy(fontSize = 24.sp, lineHeight = 28.sp), color = ClawTheme.colors.text, maxLines = 1)
Text(
text = sessionTitle,
style = ClawTheme.type.caption.copy(fontSize = 13.sp, lineHeight = 17.sp),
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
HeaderIcon(icon = Icons.Default.Refresh, contentDescription = "Refresh chat", onClick = onMore)
}
}
@@ -375,13 +365,7 @@ private fun ModelPill(
}
Surface(
shape = RoundedCornerShape(ClawTheme.radii.pill),
color =
when (status) {
ClawStatus.Success -> ClawTheme.colors.successSoft
ClawStatus.Warning -> ClawTheme.colors.warningSoft
ClawStatus.Danger -> ClawTheme.colors.dangerSoft
ClawStatus.Neutral -> ClawTheme.colors.surfaceRaised
},
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.textMuted,
border = BorderStroke(1.dp, borderColor),
) {
@@ -593,15 +577,13 @@ private fun ChatBubble(
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Surface(
modifier = Modifier.fillMaxWidth(if (isUser) 0.84f else 0.94f),
modifier = Modifier.fillMaxWidth(if (isUser) 0.64f else 0.56f),
shape = RoundedCornerShape(7.dp),
color = if (isUser) ClawTheme.colors.surfacePressed.copy(alpha = 0.86f) else ClawTheme.colors.surfaceRaised.copy(alpha = 0.84f),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border.copy(alpha = 0.45f)),
tonalElevation = 1.dp,
shadowElevation = 2.dp,
border = BorderStroke(1.dp, if (live) ClawTheme.colors.borderStrong else ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(horizontal = 11.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Column(modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.5.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text =
when {
@@ -782,7 +764,7 @@ private fun ChatContextMeter(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle)
Icon(imageVector = Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(12.dp), tint = ClawTheme.colors.textSubtle)
Text(
text = contextMeterLabel(contextUsage, thinkingLevel),
style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp),
@@ -954,7 +936,7 @@ internal fun resolveChatContextUsage(
sessionKey = sessionKey,
mainSessionKey = mainSessionKey,
)
}
}
return ChatContextUsage(
totalTokens = entry?.totalTokens,
totalTokensFresh = entry?.totalTokensFresh,
@@ -991,6 +973,24 @@ private fun userFacingChatError(error: String): String {
}
}
/** Normalizes persisted thinking values into compact UI labels. */
private fun thinkingDisplay(value: String): String =
when (value.lowercase(Locale.US)) {
"low" -> "Low"
"medium" -> "Medium"
"high" -> "High"
else -> "Off"
}
/** Converts displayed thinking labels back to gateway request values. */
private fun thinkingValue(display: String): String =
when (display.lowercase(Locale.US)) {
"low" -> "low"
"medium" -> "medium"
"high" -> "high"
else -> "off"
}
/** Cycles through context budget presets from the compact composer control. */
private fun nextThinkingValue(value: String): String =
when (value.lowercase(Locale.US)) {

View File

@@ -185,53 +185,6 @@ internal fun ClawIconButton(
}
}
/** Transparent circular icon button for low-emphasis toolbar actions. */
@Composable
internal fun ClawPlainIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(
onClick = onClick,
modifier = Modifier.size(ClawTheme.spacing.touchTarget),
shape = CircleShape,
color = Color.Transparent,
contentColor = ClawTheme.colors.text,
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
/** Compact label/value row for health and readiness summaries. */
@Composable
internal fun ClawStatusRow(
title: String,
value: String,
healthy: Boolean,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(
text = title,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
modifier = Modifier.weight(1f),
maxLines = 1,
)
ClawStatusPill(
text = value,
status = if (healthy) ClawStatus.Success else ClawStatus.Warning,
)
}
}
/** Compact status chip with a semantic color dot. */
@Composable
internal fun ClawStatusPill(

View File

@@ -95,17 +95,15 @@ internal fun ClawBottomNav(
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = ClawTheme.colors.surface.copy(alpha = 0.92f),
border = BorderStroke(1.dp, ClawTheme.colors.border.copy(alpha = 0.42f)),
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
border = BorderStroke(1.dp, ClawTheme.colors.border),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
tonalElevation = 2.dp,
shadowElevation = 8.dp,
) {
Row(
modifier =
Modifier
.windowInsetsPadding(safeInsets)
.padding(horizontal = 8.dp, vertical = 6.dp),
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -133,13 +131,13 @@ private fun ClawBottomNavItem(
onClick = onClick,
modifier = modifier.heightIn(min = 48.dp),
shape = RoundedCornerShape(ClawTheme.radii.control),
color = if (selected) ClawTheme.colors.surfacePressed.copy(alpha = 0.72f) else Color.Transparent,
contentColor = if (selected) ClawTheme.colors.text else ClawTheme.colors.textMuted,
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
) {
Column(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(imageVector = item.icon, contentDescription = item.label, modifier = Modifier.size(18.dp))
Text(text = item.label, style = ClawTheme.type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)

View File

@@ -27,11 +27,31 @@ internal fun ClawPanel(
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surfaceRaised.copy(alpha = 0.82f),
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = null,
tonalElevation = 2.dp,
shadowElevation = 4.dp,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(contentPadding)) {
content()
}
}
}
/**
* Bottom-sheet container with the app surface treatment and top-only rounding.
*/
@Composable
internal fun ClawSheetSurface(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(18.dp),
content: @Composable () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
color = ClawTheme.colors.surface,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(contentPadding)) {
content()

View File

@@ -4,6 +4,7 @@ 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
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
@@ -189,6 +190,12 @@ internal fun ClawDesignTheme(
}
}
/**
* Returns the system dark-mode preference for callers that expose theme selection.
*/
@Composable
internal fun rememberClawDarkPreference(): Boolean = isSystemInDarkTheme()
private fun clawTypography(fontFamily: FontFamily) =
ClawTypography(
display =

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
@@ -44,7 +43,7 @@ data class VoiceConversationEntry(
)
/** Coordinates live mic transcription, queued sends, and assistant audio replies. */
internal class MicCaptureManager(
class MicCaptureManager(
private val context: Context,
private val scope: CoroutineScope,
private val createTranscriptionSession: suspend () -> String,
@@ -55,12 +54,11 @@ internal class MicCaptureManager(
) -> Unit,
private val closeTranscriptionSession: suspend (sessionId: String) -> Unit,
/**
* Send [message] to the gateway and return the full chat.send ACK.
* Send [message] to the gateway and return the run ID.
* [onRunIdKnown] is called with the idempotency key *before* the network
* round-trip so [pendingRunId] is set before any chat events can arrive.
*/
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> ChatSendAck,
private val refreshAfterTerminalSuccess: suspend () -> Unit = {},
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> String?,
private val speakAssistantReply: suspend (String) -> Unit = {},
) {
companion object {
@@ -485,30 +483,24 @@ internal class MicCaptureManager(
scope.launch {
try {
val ack =
val runId =
sendToGateway(next) { earlyRunId ->
// Called with the idempotency key before chat.send fires so that
// pendingRunId is populated before any chat events can arrive.
pendingRunId = earlyRunId
}
val runId = ack.runId
// Update to the real runId if the gateway returned a different one.
if (runId != null && runId != pendingRunId) pendingRunId = runId
when {
ack.isTerminalSuccess -> {
completePendingTurn()
refreshAfterTerminalSuccess()
}
ack.isTerminalFailure -> {
completePendingTurn()
_statusText.value = "Send failed: Chat failed before the run started; try again."
}
runId == null -> {
completePendingTurn()
}
else -> {
armPendingRunTimeout(runId)
}
if (runId == null) {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
removeFirstQueuedMessage()
publishQueue()
_isSending.value = false
pendingAssistantEntryId = null
sendQueuedIfIdle()
} else {
armPendingRunTimeout(runId)
}
} catch (err: Throwable) {
pendingRunTimeoutJob?.cancel()

View File

@@ -1,9 +1,6 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.chatSendAckHistorySinceSeconds
import ai.openclaw.app.gateway.parseChatSendAck
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
@@ -111,6 +108,7 @@ class TalkModeManager internal constructor(
private const val tag = "TalkMode"
private const val realtimeSampleRateHz = 24_000
private const val realtimeAudioFrameMs = 100
private const val listenWatchdogMs = 12_000L
private const val chatFinalWaitMs = 45_000L
private const val maxCachedRunCompletions = 128
private const val maxConversationEntries = 40
@@ -383,20 +381,11 @@ class TalkModeManager internal constructor(
reloadConfig()
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
val prompt = buildPrompt(command)
val ack = sendChat(prompt, session)
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
if (ack.isTerminalFailure) {
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
return@launch
}
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
val runId = sendChat(prompt, session)
val ok = waitForChatFinal(runId)
val assistant =
consumeRunText(runId)
?: waitForAssistantText(
session,
chatSendAckHistorySinceSeconds(ack, startedAt),
if (ok) 12_000 else 25_000,
)
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
if (!assistant.isNullOrBlank()) {
val playbackToken = playbackGeneration.incrementAndGet()
cancelActivePlayback()
@@ -409,9 +398,8 @@ class TalkModeManager internal constructor(
}
} catch (err: Throwable) {
Log.w(tag, "speakWakeCommand failed: ${err.message}")
} finally {
onComplete()
}
onComplete()
}
}
@@ -1616,26 +1604,16 @@ class TalkModeManager internal constructor(
try {
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
val ack = sendChat(prompt, session)
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
Log.d(tag, "chat.send ok runId=$runId status=${ack.status}")
if (ack.isTerminalFailure) {
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
start()
return
}
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
val runId = sendChat(prompt, session)
Log.d(tag, "chat.send ok runId=$runId")
val ok = waitForChatFinal(runId)
if (!ok) {
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
}
// Use text cached from the final event first — avoids chat.history polling
val assistant =
consumeRunText(runId)
?: waitForAssistantText(
session,
chatSendAckHistorySinceSeconds(ack, startedAt),
if (ok) 12_000 else 25_000,
)
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
if (assistant.isNullOrBlank()) {
_statusText.value = "No reply"
Log.w(tag, "assistant text timeout runId=$runId")
@@ -1701,7 +1679,7 @@ class TalkModeManager internal constructor(
private suspend fun sendChat(
message: String,
session: GatewaySession,
): ChatSendAck {
): String {
val runId = UUID.randomUUID().toString()
armPendingRun(runId)
val params =
@@ -1714,15 +1692,11 @@ class TalkModeManager internal constructor(
}
try {
val res = session.request("chat.send", params.toString())
val parsed = parseChatSendAck(json, res)
val actualRunId = parsed.runId ?: runId
if (actualRunId != runId) {
pendingRunId = actualRunId
val parsed = parseRunId(res) ?: runId
if (parsed != runId) {
pendingRunId = parsed
}
if (parsed.isTerminal) {
clearPendingRun(actualRunId)
}
return parsed.copy(runId = actualRunId)
return parsed
} catch (err: Throwable) {
clearPendingRun(runId)
throw err
@@ -1803,7 +1777,7 @@ class TalkModeManager internal constructor(
private suspend fun waitForAssistantText(
session: GatewaySession,
sinceSeconds: Double?,
sinceSeconds: Double,
timeoutMs: Long,
): String? {
val deadline = SystemClock.elapsedRealtime() + timeoutMs

View File

@@ -3,11 +3,13 @@
<uses-permission
android:name="android.permission.READ_MEDIA_IMAGES"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_MEDIA_VIDEO"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
tools:node="remove" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:node="remove" />
</manifest>

View File

@@ -33,44 +33,44 @@ class GatewayBootstrapAuthTest {
@Test
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
assertFalse(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
storedOperatorToken = "",
) != null,
),
)
assertFalse(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
) != null,
),
)
}
@Test
fun connectsOperatorSessionWhenSharedPasswordOrStoredAuthExists() {
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = "shared-password"),
storedOperatorToken = null,
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = "stored-token",
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
storedOperatorToken = null,
) != null,
),
)
}

View File

@@ -1,101 +0,0 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GatewayExecApprovalParsingTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun parsesGatewayExecApprovalListPayload() {
val rows =
parseGatewayExecApprovalListPayload(
"""
[
{
"id": "approval-2",
"createdAtMs": 20,
"expiresAtMs": 120,
"request": {
"host": "node",
"nodeId": "node-1",
"agentId": "agent-1",
"command": "Sanitized command",
"commandPreview": "Sanitized preview",
"systemRunPlan": {
"commandText": "/bin/sh -lc 'echo secret'",
"commandPreview": "echo secret"
},
"allowedDecisions": ["allow-once", "deny"]
}
},
{
"id": "approval-1",
"createdAtMs": 10,
"expiresAtMs": 110,
"request": {
"host": "gateway",
"command": "pnpm test --token secret",
"commandPreview": "pnpm test",
"unavailableDecisions": ["allow-always"]
}
}
]
""".trimIndent(),
json,
)
assertEquals(listOf("approval-1", "approval-2"), rows.map { it.id })
assertEquals("pnpm test --token secret", rows[0].commandText)
assertEquals("pnpm test", rows[0].commandPreview)
assertEquals(emptyList<String>(), rows[0].allowedDecisions)
assertEquals("Sanitized command", rows[1].commandText)
assertEquals("Sanitized preview", rows[1].commandPreview)
assertEquals("node-1", rows[1].nodeId)
assertEquals("agent-1", rows[1].agentId)
}
@Test
fun parsesGatewayExecApprovalGetPayload() {
val root =
json
.parseToJsonElement(
"""
{
"id": "approval-1",
"commandText": "rm -rf build",
"commandPreview": "rm build",
"allowedDecisions": ["allow-once", "allow-always", "deny"],
"host": "gateway",
"nodeId": null,
"agentId": "agent-main",
"expiresAtMs": 200
}
""".trimIndent(),
).asObjectOrNull()
requireNotNull(root)
val row = parseGatewayExecApprovalDetail(root, createdAtMs = 100)
requireNotNull(row)
assertEquals("approval-1", row.id)
assertEquals("rm -rf build", row.commandText)
assertEquals("rm build", row.commandPreview)
assertEquals(listOf("allow-once", "allow-always", "deny"), row.allowedDecisions)
assertEquals("gateway", row.host)
assertNull(row.nodeId)
assertEquals("agent-main", row.agentId)
assertEquals(100L, row.createdAtMs)
assertEquals(200L, row.expiresAtMs)
}
@Test
fun ignoresMalformedGatewayExecApprovalListPayload() {
assertTrue(parseGatewayExecApprovalListPayload("""{"approvals":[]}""", json).isEmpty())
assertTrue(parseGatewayExecApprovalListPayload("not json", json).isEmpty())
}
}

View File

@@ -1,46 +0,0 @@
package ai.openclaw.app
import org.junit.Assert.assertEquals
import org.junit.Test
class GatewayLogTextTest {
@Test
fun sanitizeGatewayLogTextRemovesAnsiSgrSequences() {
assertEquals(
"hindsight: Skipping retain",
sanitizeGatewayLogText("\u001B[38;5;103mhindsight:\u001B[0m Skipping retain"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesVisibleSgrFragments() {
assertEquals(
"hindsight: Skipping retain",
sanitizeGatewayLogText("[38;5;103mhindsight:[0m Skipping retain"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesSingleParameterVisibleSgrFragments() {
assertEquals(
"error and bold",
sanitizeGatewayLogText("[31merror[0m and [1mbold[0m"),
)
}
@Test
fun sanitizeGatewayLogTextRemovesJsonEscapedAnsiSgrSequences() {
assertEquals(
"""{"1":"hindsight: Skipping retain"}""",
sanitizeGatewayLogText("""{"1":"\u001b[38;5;103mhindsight:\u001b[0m Skipping retain"}"""),
)
}
@Test
fun sanitizeGatewayLogTextKeepsPlainBracketedText() {
assertEquals(
"cache ttl [5m] expired",
sanitizeGatewayLogText("cache ttl [5m] expired"),
)
}
}

View File

@@ -1,144 +0,0 @@
package ai.openclaw.app.chat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatControllerTerminalAckTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalTimeoutAckRemovesOptimisticUserEchoAndSurfacesFailedAcceptance() =
runTest {
var requestedMethod: String? = null
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
requestedMethod = method
"""{"runId":"run-timeout","status":"timeout"}"""
},
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that times out before start",
thinkingLevel = "off",
attachments = emptyList(),
)
assertFalse(accepted)
assertEquals("chat.send", requestedMethod)
assertEquals(0, controller.pendingRunCount.value)
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that times out before start"))
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun nonTerminalStartedAckRetainsOptimisticUserEchoAndPendingRun() =
runTest {
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { _, _ -> """{"runId":"run-started","status":"started"}""" },
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that started",
thinkingLevel = "off",
attachments = emptyList(),
)
assertTrue(accepted)
assertEquals(1, controller.pendingRunCount.value)
assertNull(controller.errorText.value)
assertTrue(controller.messages.value.hasUserText("message that started"))
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalOkAckClearsOptimisticUserEchoAndRefreshesHistory() =
runTest {
val requestedMethods = mutableListOf<String>()
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
requestedMethods += method
when (method) {
"chat.send" -> """{"runId":"run-ok","status":"ok"}"""
"chat.history" ->
"""
{
"sessionId": "session-1",
"messages": [
{ "role": "assistant", "content": "cached success reply", "timestamp": 1 }
]
}
""".trimIndent()
else -> "{}"
}
},
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that already completed",
thinkingLevel = "off",
attachments = emptyList(),
)
advanceUntilIdle()
assertTrue(accepted)
assertEquals(listOf("chat.send", "chat.history"), requestedMethods)
assertEquals(0, controller.pendingRunCount.value)
assertNull(controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that already completed"))
assertTrue(controller.messages.value.any { message -> message.role == "assistant" && message.content.any { part -> part.text == "cached success reply" } })
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalErrorAckRemovesOptimisticUserEchoAndSurfacesErrorText() =
runTest {
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { _, _ -> """{"runId":"run-error","status":"error"}""" },
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that errors before start",
thinkingLevel = "off",
attachments = emptyList(),
)
assertFalse(accepted)
assertEquals(0, controller.pendingRunCount.value)
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that errors before start"))
}
private fun List<ChatMessage>.hasUserText(text: String): Boolean =
any { message ->
message.role == "user" && message.content.any { part -> part.text == text }
}
}

View File

@@ -1,68 +0,0 @@
package ai.openclaw.app.gateway
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatSendAckTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun parseChatSendAckPreservesNonTerminalStartedStatus() {
val ack = parseChatSendAck(json, """{"runId":"run-1","status":"started"}""")
assertEquals("run-1", ack.runId)
assertEquals("started", ack.normalizedStatus)
assertFalse(ack.isTerminal)
}
@Test
fun parseChatSendAckMarksOkAsTerminalSuccess() {
val ack = parseChatSendAck(json, """{"runId":"run-ok","status":" ok "}""")
assertEquals("run-ok", ack.runId)
assertEquals("ok", ack.normalizedStatus)
assertTrue(ack.isTerminal)
assertTrue(ack.isTerminalSuccess)
assertFalse(ack.isTerminalFailure)
}
@Test
fun parseChatSendAckMarksTimeoutAndErrorAsTerminalFailures() {
val timeout = parseChatSendAck(json, """{"runId":"run-timeout","status":"timeout"}""")
val error = parseChatSendAck(json, """{"runId":"run-error","status":" error "}""")
assertEquals("run-timeout", timeout.runId)
assertTrue(timeout.isTerminal)
assertFalse(timeout.isTerminalSuccess)
assertTrue(timeout.isTerminalFailure)
assertEquals("run-error", error.runId)
assertTrue(error.isTerminal)
assertFalse(error.isTerminalSuccess)
assertTrue(error.isTerminalFailure)
}
@Test
fun cachedOkAckUsesUnfilteredHistoryFallback() {
val startedAt = 123.0
val ok = parseChatSendAck(json, """{"runId":"run-ok","status":"ok"}""")
val started = parseChatSendAck(json, """{"runId":"run-started","status":"started"}""")
assertNull(chatSendAckHistorySinceSeconds(ok, startedAt))
assertEquals(startedAt, chatSendAckHistorySinceSeconds(started, startedAt) ?: -1.0, 0.0)
}
@Test
fun parseChatSendAckToleratesMalformedPayloads() {
val ack = parseChatSendAck(json, "not-json")
assertNull(ack.runId)
assertEquals("", ack.normalizedStatus)
assertFalse(ack.isTerminal)
assertFalse(ack.isTerminalSuccess)
assertFalse(ack.isTerminalFailure)
}
}

View File

@@ -204,18 +204,17 @@ class GatewayConfigResolverTest {
}
@Test
fun resolveScannedSetupCodeResultAcceptsRawSetupCode() {
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeResultAcceptsQrJsonPayload() {
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val qrJson =
@@ -228,55 +227,49 @@ class GatewayConfigResolverTest {
}
""".trimIndent()
val resolved = resolveScannedSetupCodeResult(qrJson)
val resolved = resolveScannedSetupCode(qrJson)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsInvalidInput() {
val resolved = resolveScannedSetupCodeResult("not-a-valid-setup-code")
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
fun resolveScannedSetupCodeRejectsInvalidInput() {
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsJsonWithInvalidSetupCode() {
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
val qrJson = """{"setupCode":"invalid"}"""
val resolved = resolveScannedSetupCodeResult(qrJson)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsJsonWithNonStringSetupCode() {
fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
val qrJson = """{"setupCode":{"nested":"value"}}"""
val resolved = resolveScannedSetupCodeResult(qrJson)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsNonLoopbackCleartextGateway() {
fun resolveScannedSetupCodeRejectsNonLoopbackCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultAcceptsPrivateLanCleartextGateway() {
fun resolveScannedSetupCodeAcceptsPrivateLanCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test

View File

@@ -1,14 +1,12 @@
package ai.openclaw.app.ui
import ai.openclaw.app.AppearanceThemeMode
import ai.openclaw.app.GatewayAgentSummary
import ai.openclaw.app.GatewayChannelSummary
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.GatewayNodeApprovalState
import ai.openclaw.app.GatewayNodeSummary
import ai.openclaw.app.GatewayNodesDevicesSummary
import ai.openclaw.app.GatewayPendingDeviceSummary
import ai.openclaw.app.ui.design.ClawStatus
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import org.junit.Assert.assertEquals
@@ -107,7 +105,7 @@ 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.ProvidersModels, providersRow.settingsRoute)
assertEquals(SettingsRoute.Gateway, providersRow.settingsRoute)
}
@Test
@@ -159,206 +157,10 @@ class ShellScreenLogicTest {
assertEquals("Node approval pending", rows.single().subtitle)
}
@Test
fun overviewHeaderStateReflectsGatewayConnectionAndAttention() {
assertEquals(OverviewHeaderState("Offline", ClawStatus.Neutral), overviewHeaderState(isConnected = false, hasAttention = true))
assertEquals(OverviewHeaderState("Needs attention", ClawStatus.Warning), overviewHeaderState(isConnected = true, hasAttention = true))
assertEquals(OverviewHeaderState("Online", ClawStatus.Success), overviewHeaderState(isConnected = true, hasAttention = false))
}
@Test
fun overviewHeaderRouteUsesFirstAttentionDestination() {
assertEquals(SettingsRoute.Gateway, overviewHeaderRoute(emptyList()))
assertEquals(
SettingsRoute.Approvals,
overviewHeaderRoute(
listOf(
HomeAttentionRow("Approvals", "2 pending", Icons.Default.Settings, Tab.Settings, SettingsRoute.Approvals),
HomeAttentionRow("Nodes & Devices", "Review node access", Icons.Default.Settings, Tab.Settings, SettingsRoute.NodesDevices),
),
),
)
}
@Test
fun overviewMetricCardsUseRealGatewayNodeApprovalAndSessionCounts() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = true,
nodesDevicesSummary =
GatewayNodesDevicesSummary(
nodes =
listOf(
GatewayNodeSummary(
id = "android-node",
displayName = "Android",
remoteIp = null,
version = null,
deviceFamily = "Android",
paired = true,
connected = true,
approvalState = GatewayNodeApprovalState.PendingReapproval,
pendingRequestId = "node-request",
capabilities = emptyList(),
commands = emptyList(),
),
),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
),
pendingApprovals = 2,
sessionCount = 4,
)
assertEquals(listOf("Gateway", "Nodes", "Approvals", "Sessions"), cards.map { it.title })
assertEquals("Online", cards.single { it.title == "Gateway" }.value)
assertEquals("Review highlighted items", cards.single { it.title == "Gateway" }.subtitle)
assertEquals("1/1", cards.single { it.title == "Nodes" }.value)
assertEquals("Review node access", cards.single { it.title == "Nodes" }.subtitle)
assertEquals(ClawStatus.Warning, cards.single { it.title == "Nodes" }.status)
assertEquals(1f, cards.single { it.title == "Nodes" }.progressFraction ?: 0f, 0.001f)
assertEquals("2", cards.single { it.title == "Approvals" }.value)
assertEquals("4", cards.single { it.title == "Sessions" }.value)
}
@Test
fun overviewNodeCardShowsRoundedOnlinePercentWhenNoNodeApprovalIsPending() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = false,
nodesDevicesSummary =
GatewayNodesDevicesSummary(
nodes =
(1..3).map { index ->
GatewayNodeSummary(
id = "node-$index",
displayName = "Node $index",
remoteIp = null,
version = null,
deviceFamily = null,
paired = true,
connected = index <= 2,
approvalState = GatewayNodeApprovalState.Approved,
pendingRequestId = null,
capabilities = emptyList(),
commands = emptyList(),
)
},
pendingDevices = emptyList(),
pairedDevices = emptyList(),
),
pendingApprovals = 0,
sessionCount = 0,
)
val nodes = cards.single { it.title == "Nodes" }
assertEquals("2/3", nodes.value)
assertEquals("67% online", nodes.subtitle)
assertEquals(2f / 3f, nodes.progressFraction ?: 0f, 0.001f)
}
@Test
fun overviewGatewayCardOnlyClaimsNominalWhenNoAttentionExists() {
val cards =
overviewMetricCardSpecs(
isConnected = true,
hasAttention = false,
nodesDevicesSummary = emptyNodesDevices(),
pendingApprovals = 0,
sessionCount = 0,
)
val gateway = cards.single { it.title == "Gateway" }
assertEquals("Healthy", gateway.value)
assertEquals("All systems nominal", gateway.subtitle)
assertEquals(ClawStatus.Success, gateway.status)
}
@Test
fun overviewAgentNameUsesDefaultAgentWhenPresent() {
val agents =
listOf(
GatewayAgentSummary(id = "main", name = "Main", emoji = null),
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
)
assertEquals("Scout", overviewAgentName(agents = agents, defaultAgentId = "scout"))
assertEquals("Main", overviewAgentName(agents = agents, defaultAgentId = null))
assertEquals("OpenClaw", overviewAgentName(agents = emptyList(), defaultAgentId = null))
}
@Test
fun overviewAgentBadgeUsesEmojiBeforeInitials() {
val agents =
listOf(
GatewayAgentSummary(id = "main", name = "Main Agent", emoji = null),
GatewayAgentSummary(id = "scout", name = "Scout", emoji = "🦾"),
)
assertEquals("🦾", overviewAgentBadgeText(agents = agents, defaultAgentId = "scout"))
assertEquals("MA", overviewAgentBadgeText(agents = agents, defaultAgentId = "main"))
assertEquals("OC", overviewAgentBadgeText(agents = emptyList(), defaultAgentId = null))
}
@Test
fun overviewAgentActivityTextUsesRealRuntimeCounts() {
assertEquals(
"Working · 2 active runs",
overviewAgentActivityText(isConnected = true, pendingRunCount = 2, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
)
assertEquals(
"Monitoring · 50 sessions",
overviewAgentActivityText(isConnected = true, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Online and ready"),
)
assertEquals(
"Gateway offline",
overviewAgentActivityText(isConnected = false, pendingRunCount = 0, sessionCount = 50, cronJobCount = 19, statusText = "Gateway offline"),
)
}
@Test
fun sessionSourceLabelDerivesCompactSourceFromRealSessionKey() {
assertEquals("Telegram", sessionSourceLabel("telegram:8227096397"))
assertEquals("Discord", sessionSourceLabel("discord:1465779285020381361#daily-inf"))
assertEquals("Cron", sessionSourceLabel("Cron: nightly-reflection"))
assertEquals("Telegram", sessionSourceLabel("agent:main:telegram:direct:584667058"))
assertEquals("Discord", sessionSourceLabel("agent:main:discord:channel:1001"))
assertEquals("Slack", sessionSourceLabel("agent:main:slack:channel:C123"))
assertEquals("OpenClaw", sessionSourceLabel("agent:main:node-android"))
assertEquals("OpenClaw", sessionSourceLabel("agent:main:main"))
assertEquals("OpenClaw", sessionSourceLabel("Daily standup"))
}
@Test
fun sessionSourceLabelUsesGatewayChannelLabelsForFutureSources() {
val channels =
GatewayChannelsSummary(
channels =
listOf(
GatewayChannelSummary(
id = "matrix",
label = "Matrix",
accountCount = 1,
enabled = true,
configured = true,
linked = true,
running = true,
connected = true,
error = null,
),
),
)
assertEquals("Matrix", sessionSourceLabel("agent:main:matrix:room:abc", channels))
}
@Test
fun settingsSectionTitlesGroupPowerSettingsByMeaning() {
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.Gateway))
assertEquals("Connection", settingsSectionTitleForRoute(SettingsRoute.NodesDevices))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.ProvidersModels))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.Approvals))
assertEquals("Agents & automation", settingsSectionTitleForRoute(SettingsRoute.CronJobs))
assertEquals("Phone context & privacy", settingsSectionTitleForRoute(SettingsRoute.PhoneCapabilities))

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import android.Manifest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -35,7 +34,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
ChatSendAck(runId = "run-1", status = "started")
null
},
)
@@ -85,7 +84,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
ChatSendAck(runId = "run-1", status = "started")
"run-1"
},
)
@@ -112,7 +111,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-voice-e2e")
ChatSendAck(runId = "run-voice-e2e", status = "started")
"run-voice-e2e"
},
)
@@ -135,88 +134,6 @@ class MicCaptureManagerTest {
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayTimeoutSendDoesNotAcceptDelayedOldRunEvents() =
runTest {
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-terminal")
ChatSendAck(runId = "run-terminal", status = "timeout")
},
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal ack message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-terminal", text = "stale reply"))
advanceUntilIdle()
assertEquals(
listOf(VoiceConversationRole.User),
manager.conversation.value.map { it.role },
)
assertEquals(
"terminal ack message",
manager.conversation.value
.single()
.text,
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayErrorSurfacesFailureWithoutWaitingForRunEvents() =
runTest {
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-error")
ChatSendAck(runId = "run-error", status = "error")
},
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal error message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayOkRefreshesHistoryWithoutWaitingForRunEvents() =
runTest {
var refreshCalls = 0
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-ok")
ChatSendAck(runId = "run-ok", status = "ok")
},
refreshAfterTerminalSuccess = { refreshCalls += 1 },
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal ok message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals(1, refreshCalls)
}
@Test
fun pcm16FramesAreEncodedAsPcmuFrames() {
val manager = createManager()
@@ -313,11 +230,10 @@ class MicCaptureManagerTest {
scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
createTranscriptionSession: suspend () -> String = { "transcription-1" },
closeTranscriptionSession: suspend (String) -> Unit = { _ -> },
sendToGateway: suspend (String, (String) -> Unit) -> ChatSendAck = { _, onRunIdKnown ->
sendToGateway: suspend (String, (String) -> Unit) -> String? = { _, onRunIdKnown ->
onRunIdKnown("run-1")
ChatSendAck(runId = "run-1", status = "started")
"run-1"
},
refreshAfterTerminalSuccess: suspend () -> Unit = {},
): MicCaptureManager =
MicCaptureManager(
context =
@@ -329,7 +245,6 @@ class MicCaptureManagerTest {
appendTranscriptionAudio = { _, _, _ -> },
closeTranscriptionSession = closeTranscriptionSession,
sendToGateway = sendToGateway,
refreshAfterTerminalSuccess = refreshAfterTerminalSuccess,
)
private fun setPrivateField(

View File

@@ -164,7 +164,7 @@ run_mode() {
no_connect_flag=false
fi
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 \
adb shell am broadcast \
-a "$RUN_ACTION" \
-n "$RECEIVER" \
--es mode "$test_mode" \
@@ -224,7 +224,7 @@ adb logcat -d -v time |
tail -250 >"$ARTIFACT_DIR/logcat.txt" || true
if [[ "$CLEANUP" -eq 1 ]]; then
adb shell run-as "$PACKAGE_NAME" am broadcast --user 0 -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
adb shell am broadcast -a "$RUN_ACTION" -n "$RECEIVER" --es mode stop >/dev/null
fi
echo "$ARTIFACT_DIR"

View File

@@ -2,24 +2,8 @@ parent_config: ../../config/swiftlint.yml
included:
- Sources
- ShareExtension
- ActivityWidget
- WatchApp
- ../shared/OpenClawKit/Sources/OpenClawChatUI
excluded:
- ../macos
- ../shared/ClawdisNodeKit/Sources
type_body_length:
warning: 900
error: 1300
custom_rules:
openclaw_design_colors:
name: "OpenClaw design colors"
excluded:
- Sources/Design/OpenClawBrand.swift
- ../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift
regex: '(Color\.accentColor|(^|[^A-Za-z0-9_])\.accentColor\b|Color\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|\.(foregroundStyle|tint|fill|stroke|strokeBorder|background)\(\s*\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|Color\(red:\s*0\s*/\s*255\.0,\s*green:\s*122\s*/\s*255\.0,\s*blue:\s*255\s*/\s*255\.0\))'
message: "Use OpenClawBrand or OpenClawChatTheme design tokens instead of raw accent/status colors."
severity: error

View File

@@ -74,30 +74,23 @@ struct OpenClawLiveActivity: Widget {
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
if state.isConnecting {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(OpenClawActivityStyle.info)
.foregroundStyle(.cyan)
} else if state.isDisconnected {
Image(systemName: "wifi.slash")
.foregroundStyle(OpenClawActivityStyle.danger)
.foregroundStyle(.red)
} else if state.isIdle {
Image(systemName: "checkmark")
.foregroundStyle(OpenClawActivityStyle.ok)
.foregroundStyle(.green)
} else {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(OpenClawActivityStyle.warn)
.foregroundStyle(.orange)
}
}
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
if state.isDisconnected { return OpenClawActivityStyle.danger }
if state.isConnecting { return OpenClawActivityStyle.info }
if state.isIdle { return OpenClawActivityStyle.ok }
return OpenClawActivityStyle.warn
if state.isDisconnected { return .red }
if state.isConnecting { return .cyan }
if state.isIdle { return .green }
return .orange
}
}
private enum OpenClawActivityStyle {
static let info = Color(red: 0, green: 122 / 255.0, blue: 1)
static let danger = Color(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0)
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
}

View File

@@ -12,7 +12,7 @@
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS", "APP_ATTEST"],
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
},
{

View File

@@ -6,7 +6,6 @@ OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp

View File

@@ -67,7 +67,7 @@ Release behavior:
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, `OpenClawPushRelayProfile=production`, `OpenClawPushProofPolicy=appleStrict`, and the App-Attest-capable entitlement file.
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, and a production `aps-environment` entitlement.
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
@@ -102,7 +102,6 @@ Release-owner secrets:
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
- Relay registration requires the App Attest capability on the main app ID before App Store profiles are regenerated.
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
@@ -158,7 +157,7 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
- `ai.openclawfoundation.app.activitywidget`
- `ai.openclawfoundation.app.watchkitapp`
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`. The main app must also have App Attest enabled.
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
@@ -244,7 +243,6 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
- The app calls `registerForRemoteNotifications()` at launch.
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
- App Attest relay builds use `apps/ios/Sources/OpenClawAppAttest.entitlements`; local/direct builds do not require App Attest provisioning.
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
@@ -261,7 +259,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
- Production relay mode uses the `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
- Relay mode requires a reachable relay base URL and uses App Attest plus a StoreKit app transaction JWS during registration.
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
## Official Build Relay Trust Model

View File

@@ -6,7 +6,6 @@
OPENCLAW_CODE_SIGN_STYLE = Manual
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_CODE_SIGN_ENTITLEMENTS = Sources/OpenClaw.entitlements
OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app

View File

@@ -506,7 +506,7 @@ extension AgentProTab {
func skillEditorSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()

View File

@@ -105,7 +105,7 @@ struct AgentProTab: View {
var color: Color {
switch self {
case .online: OpenClawBrand.ok
case .ready: OpenClawBrand.info
case .ready: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
}
}
}

View File

@@ -277,7 +277,7 @@ struct ChatProTab: View {
}
private var chatUserAccent: Color {
self.colorScheme == .light ? OpenClawBrand.info : OpenClawBrand.accent
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
}
private var activeAgent: AgentSummary? {

View File

@@ -1036,7 +1036,7 @@ struct IPadSkillProposalRow: View {
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
self.isSelected ? OpenClawBrand.danger.opacity(0.08) : Color.clear,
self.isSelected ? Color.red.opacity(0.08) : Color.clear,
in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}

View File

@@ -47,13 +47,11 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
}
enum OpenClawBrand {
static let uiAccent = UIColor { traits in
static let accent = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
}
static let accent = Color(uiColor: Self.uiAccent)
})
static let accentHot = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
@@ -66,7 +64,6 @@ enum OpenClawBrand {
})
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
static let info = Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
static let graphite = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)

View File

@@ -819,11 +819,8 @@ extension SettingsProTab {
var notificationRelayDetail: String {
if PushBuildConfig.current.usesOpenClawHostedRelay {
let host = PushBuildConfig.current.relayBaseURL.flatMap {
URLComponents(url: $0, resolvingAgainstBaseURL: false)?.host
} ?? "ios-push-relay.openclaw.ai"
return """
This build uses OpenClaw's hosted push relay at \(host) for notification \
This build uses OpenClaw's hosted push relay at ios-push-relay.openclaw.ai for notification \
delivery data.
"""
}

View File

@@ -119,7 +119,7 @@ extension SettingsProTab {
self.gatewayActionButton(
title: "Diagnose",
icon: "cross.case",
color: OpenClawBrand.info,
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
@@ -476,7 +476,7 @@ extension SettingsProTab {
self.gatewayActionButton(
title: "Run Diagnostics",
icon: "cross.case",
color: OpenClawBrand.info,
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
@@ -1040,7 +1040,7 @@ extension SettingsProTab {
func settingsSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()

View File

@@ -90,7 +90,7 @@ private struct ExecApprovalPromptCard: View {
if let errorText = self.normalized(self.errorText) {
Text(errorText)
.font(.footnote)
.foregroundStyle(OpenClawBrand.danger)
.foregroundStyle(.red)
}
if self.isResolving {

View File

@@ -86,12 +86,8 @@
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
<key>OpenClawPushDistribution</key>
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
<key>OpenClawPushProofPolicy</key>
<string>$(OPENCLAW_PUSH_PROOF_POLICY)</string>
<key>OpenClawPushRelayBaseURL</key>
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
<key>OpenClawPushRelayProfile</key>
<string>$(OPENCLAW_PUSH_RELAY_PROFILE)</string>
<key>OpenClawPushTransport</key>
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
<key>UIApplicationSceneManifest</key>

View File

@@ -40,7 +40,7 @@ struct OnboardingIntroStep: View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title3.weight(.semibold))
.foregroundStyle(OpenClawBrand.warn)
.foregroundStyle(.orange)
.frame(width: 24)
.padding(.top, 2)
@@ -177,7 +177,7 @@ struct OnboardingModeRow: View {
}
Spacer()
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(self.selected ? OpenClawBrand.accent : Color.secondary)
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
}
.contentShape(Rectangle())
}

View File

@@ -378,7 +378,7 @@ struct OnboardingWizardView: View {
private func onboardingSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()
@@ -575,7 +575,7 @@ struct OnboardingWizardView: View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(OpenClawBrand.ok)
.foregroundStyle(.green)
.padding(.bottom, 20)
Text("Connected")

View File

@@ -632,7 +632,6 @@ struct OpenClawApp: App {
var body: some Scene {
WindowGroup {
RootTabs()
.tint(OpenClawBrand.accent)
.preferredColorScheme(self.appearancePreference.colorScheme)
.environment(self.appModel)
.environment(self.appModel.voiceWake)
@@ -687,7 +686,6 @@ struct OpenClawApp: App {
.flatMap(\.windows)
.forEach { window in
window.overrideUserInterfaceStyle = style
window.tintColor = OpenClawBrand.uiAccent
}
}
}

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
<key>com.apple.developer.devicecheck.appattest-environment</key>
<string>$(OPENCLAW_APP_ATTEST_ENVIRONMENT)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -15,29 +15,14 @@ enum PushAPNsEnvironment: String {
case production
}
enum PushRelayProfile: String {
case production
case deviceSandbox
case simulatorSandbox
}
enum PushProofPolicy: String {
case appleStrict
case appleDevelopment
case internalSimulator
}
struct PushBuildConfig {
let transport: PushTransportMode
let distribution: PushDistributionMode
let relayBaseURL: URL?
let apnsEnvironment: PushAPNsEnvironment
let relayProfile: PushRelayProfile
let proofPolicy: PushProofPolicy
static let current = PushBuildConfig()
static let openClawHostedRelayHost = "ios-push-relay.openclaw.ai"
static let openClawSandboxRelayHost = "ios-push-relay-sandbox.openclaw.ai"
var usesOpenClawHostedRelay: Bool {
guard self.transport == .relay, self.distribution == .official else { return false }
@@ -47,8 +32,7 @@ struct PushBuildConfig {
return false
}
return components.scheme?.lowercased() == "https"
&& [Self.openClawHostedRelayHost, Self.openClawSandboxRelayHost]
.contains(components.host?.lowercased() ?? "")
&& components.host?.lowercased() == Self.openClawHostedRelayHost
&& components.user == nil
&& components.password == nil
}
@@ -66,14 +50,6 @@ struct PushBuildConfig {
bundle: bundle,
key: "OpenClawPushAPNsEnvironment",
fallback: Self.defaultAPNsEnvironment)
self.relayProfile = Self.readEnum(
bundle: bundle,
key: "OpenClawPushRelayProfile",
fallback: Self.defaultRelayProfile(apnsEnvironment: self.apnsEnvironment))
self.proofPolicy = Self.readEnum(
bundle: bundle,
key: "OpenClawPushProofPolicy",
fallback: Self.defaultProofPolicy(relayProfile: self.relayProfile))
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
}
@@ -101,24 +77,9 @@ struct PushBuildConfig {
fallback: T)
-> T where T.RawValue == String {
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return T(rawValue: trimmed) ?? fallback
}
private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
private static func defaultRelayProfile(apnsEnvironment: PushAPNsEnvironment) -> PushRelayProfile {
apnsEnvironment == .production ? .production : .deviceSandbox
}
private static func defaultProofPolicy(relayProfile: PushRelayProfile) -> PushProofPolicy {
switch relayProfile {
case .production:
.appleStrict
case .deviceSandbox:
.appleDevelopment
case .simulatorSandbox:
.internalSimulator
}
}
}

View File

@@ -71,10 +71,10 @@ actor PushRegistrationManager {
throw PushRelayError.relayMisconfigured(
"Relay transport requires OpenClawPushDistribution=official")
}
try Self.validateRelayContract(
relayProfile: self.buildConfig.relayProfile,
apnsEnvironment: self.buildConfig.apnsEnvironment,
proofPolicy: self.buildConfig.proofPolicy)
guard self.buildConfig.apnsEnvironment == .production else {
throw PushRelayError.relayMisconfigured(
"Relay transport requires OpenClawPushAPNsEnvironment=production")
}
guard let relayClient = self.relayClient else {
throw PushRelayError.relayBaseURLMissing
}
@@ -96,9 +96,6 @@ actor PushRegistrationManager {
stored.installationId == installationId,
stored.gatewayDeviceId == gatewayIdentity.deviceId,
stored.relayOrigin == relayOrigin,
stored.apnsEnvironment == self.buildConfig.apnsEnvironment.rawValue,
stored.relayProfile == self.buildConfig.relayProfile.rawValue,
stored.proofPolicy == self.buildConfig.proofPolicy.rawValue,
stored.lastAPNsTokenHashHex == tokenHashHex,
!Self.isExpired(stored.relayHandleExpiresAtMs)
{
@@ -115,16 +112,14 @@ actor PushRegistrationManager {
tokenDebugSuffix: stored.tokenDebugSuffix))
}
let response = try await relayClient.register(PushRelayRegistrationInput(
let response = try await relayClient.register(
installationId: installationId,
bundleId: bundleId,
appVersion: DeviceInfoHelper.appVersion(),
environment: self.buildConfig.apnsEnvironment,
relayProfile: self.buildConfig.relayProfile,
proofPolicy: self.buildConfig.proofPolicy,
distribution: self.buildConfig.distribution,
apnsTokenHex: apnsTokenHex,
gatewayIdentity: gatewayIdentity))
gatewayIdentity: gatewayIdentity)
let registrationState = PushRelayRegistrationStore.RegistrationState(
relayHandle: response.relayHandle,
sendGrant: response.sendGrant,
@@ -134,10 +129,7 @@ actor PushRegistrationManager {
tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
lastAPNsTokenHashHex: tokenHashHex,
installationId: installationId,
lastTransport: self.buildConfig.transport.rawValue,
apnsEnvironment: self.buildConfig.apnsEnvironment.rawValue,
relayProfile: self.buildConfig.relayProfile.rawValue,
proofPolicy: self.buildConfig.proofPolicy.rawValue)
lastTransport: self.buildConfig.transport.rawValue)
_ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
return try Self.encodePayload(
RelayGatewayPushRegistrationPayload(
@@ -159,30 +151,6 @@ actor PushRegistrationManager {
return expiresAtMs <= nowMs + 60000
}
private static func validateRelayContract(
relayProfile: PushRelayProfile,
apnsEnvironment: PushAPNsEnvironment,
proofPolicy: PushProofPolicy)
throws {
switch relayProfile {
case .production:
guard apnsEnvironment == .production, proofPolicy == .appleStrict else {
throw PushRelayError.relayMisconfigured(
"production relay profile requires production APNs and appleStrict proof")
}
case .deviceSandbox:
guard apnsEnvironment == .sandbox, proofPolicy == .appleDevelopment else {
throw PushRelayError.relayMisconfigured(
"deviceSandbox relay profile requires sandbox APNs and appleDevelopment proof")
}
case .simulatorSandbox:
guard apnsEnvironment == .sandbox, proofPolicy == .internalSimulator else {
throw PushRelayError.relayMisconfigured(
"simulatorSandbox relay profile requires sandbox APNs and internalSimulator proof")
}
}
}
private static func sha256Hex(_ value: String) -> String {
let digest = SHA256.hash(data: Data(value.utf8))
return digest.map { String(format: "%02x", $0) }.joined()

View File

@@ -40,9 +40,6 @@ private struct PushRelayRegisterSignedPayload: Encodable {
var installationId: String
var bundleId: String
var environment: String
var relayProfile: String
var apnsEnvironment: String
var proofPolicy: String
var distribution: String
var gateway: PushRelayGatewayIdentity
var appVersion: String
@@ -66,16 +63,12 @@ private struct PushRelayRegisterRequest: Encodable {
var installationId: String
var bundleId: String
var environment: String
var relayProfile: String
var apnsEnvironment: String
var proofPolicy: String
var distribution: String
var gateway: PushRelayGatewayIdentity
var appVersion: String
var apnsToken: String
var appAttest: PushRelayAppAttestPayload?
var receipt: PushRelayReceiptPayload?
var simulatorProof: PushRelaySimulatorProofPayload?
var appAttest: PushRelayAppAttestPayload
var receipt: PushRelayReceiptPayload
}
struct PushRelayRegisterResponse: Decodable {
@@ -100,34 +93,23 @@ private struct PushRelayAppAttestProof {
var signedPayloadBase64: String
}
private struct PushRelaySimulatorProofPayload: Encodable {
var signedPayloadBase64: String
var hmacSha256Base64Url: String
}
private final class PushRelayAppAttestService {
func createProof(
challenge: String,
signedPayload: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> PushRelayAppAttestProof {
func createProof(challenge: String, signedPayload: Data) async throws -> PushRelayAppAttestProof {
let service = DCAppAttestService.shared
guard service.isSupported else {
throw PushRelayError.unsupportedAppAttest
}
let keyID = try await self.loadOrCreateKeyID(using: service, scope: scope)
let keyID = try await self.loadOrCreateKeyID(using: service)
let attestationObject = try await self.attestKeyIfNeeded(
service: service,
keyID: keyID,
challenge: challenge,
scope: scope)
challenge: challenge)
let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
let assertion = try await self.generateAssertion(
service: service,
keyID: keyID,
signedPayloadHash: signedPayloadHash,
scope: scope)
signedPayloadHash: signedPayloadHash)
return PushRelayAppAttestProof(
keyId: keyID,
@@ -137,27 +119,21 @@ private final class PushRelayAppAttestService {
signedPayloadBase64: signedPayload.base64EncodedString())
}
private func loadOrCreateKeyID(
using service: DCAppAttestService,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> String {
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(scope: scope),
!existing.isEmpty
{
private func loadOrCreateKeyID(using service: DCAppAttestService) async throws -> String {
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(), !existing.isEmpty {
return existing
}
let keyID = try await service.generateKey()
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID, scope: scope)
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID)
return keyID
}
private func attestKeyIfNeeded(
service: DCAppAttestService,
keyID: String,
challenge: String,
scope: PushRelayRegistrationStore.AppAttestScope)
challenge: String)
async throws -> String? {
if PushRelayRegistrationStore.loadAttestedKeyID(scope: scope) == keyID {
if PushRelayRegistrationStore.loadAttestedKeyID() == keyID {
return nil
}
let challengeData = Data(challenge.utf8)
@@ -166,21 +142,20 @@ private final class PushRelayAppAttestService {
// Apple treats App Attest key attestation as a one-time operation. Save the
// attested marker immediately so later receipt/network failures do not cause a
// permanently broken re-attestation loop on the same key.
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID, scope: scope)
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID)
return attestation.base64EncodedString()
}
private func generateAssertion(
service: DCAppAttestService,
keyID: String,
signedPayloadHash: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
signedPayloadHash: Data)
async throws -> Data {
do {
return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
} catch {
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: scope)
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: scope)
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
_ = PushRelayRegistrationStore.clearAttestedKeyID()
throw error
}
}
@@ -215,47 +190,6 @@ private final class PushRelayReceiptProvider {
}
}
private final class PushRelaySimulatorProofProvider {
func createProof(signedPayload: Data) throws -> PushRelaySimulatorProofPayload {
#if targetEnvironment(simulator)
guard let secret = ProcessInfo.processInfo.environment["OPENCLAW_SIMULATOR_PUSH_PROOF_SECRET"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!secret.isEmpty
else {
throw PushRelayError.relayMisconfigured("Simulator push proof secret missing")
}
let signedPayloadBase64 = signedPayload.base64EncodedString()
let signature = HMAC<SHA256>.authenticationCode(
for: Data(signedPayloadBase64.utf8),
using: SymmetricKey(data: Data(secret.utf8)))
return PushRelaySimulatorProofPayload(
signedPayloadBase64: signedPayloadBase64,
hmacSha256Base64Url: Self.base64URL(Data(signature)))
#else
throw PushRelayError.relayMisconfigured("Simulator proof is only available in iOS Simulator")
#endif
}
private static func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
struct PushRelayRegistrationInput {
var installationId: String
var bundleId: String
var appVersion: String
var environment: PushAPNsEnvironment
var relayProfile: PushRelayProfile
var proofPolicy: PushProofPolicy
var distribution: PushDistributionMode
var apnsTokenHex: String
var gatewayIdentity: PushRelayGatewayIdentity
}
/// The client is constructed once and used behind PushRegistrationManager actor isolation.
final class PushRelayClient: @unchecked Sendable {
private let baseURL: URL
@@ -264,7 +198,6 @@ final class PushRelayClient: @unchecked Sendable {
private let jsonEncoder = JSONEncoder()
private let appAttest = PushRelayAppAttestService()
private let receiptProvider = PushRelayReceiptProvider()
private let simulatorProofProvider = PushRelaySimulatorProofProvider()
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
@@ -275,57 +208,46 @@ final class PushRelayClient: @unchecked Sendable {
Self.normalizeBaseURLString(self.baseURL)
}
func register(_ input: PushRelayRegistrationInput) async throws -> PushRelayRegisterResponse {
func register(
installationId: String,
bundleId: String,
appVersion: String,
environment: PushAPNsEnvironment,
distribution: PushDistributionMode,
apnsTokenHex: String,
gatewayIdentity: PushRelayGatewayIdentity)
async throws -> PushRelayRegisterResponse {
let challenge = try await self.fetchChallenge()
let signedPayload = PushRelayRegisterSignedPayload(
challengeId: challenge.challengeId,
installationId: input.installationId,
bundleId: input.bundleId,
environment: input.environment.rawValue,
relayProfile: input.relayProfile.rawValue,
apnsEnvironment: input.environment.rawValue,
proofPolicy: input.proofPolicy.rawValue,
distribution: input.distribution.rawValue,
gateway: input.gatewayIdentity,
appVersion: input.appVersion,
apnsToken: input.apnsTokenHex)
installationId: installationId,
bundleId: bundleId,
environment: environment.rawValue,
distribution: distribution.rawValue,
gateway: gatewayIdentity,
appVersion: appVersion,
apnsToken: apnsTokenHex)
let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
let appAttestScope = PushRelayRegistrationStore.AppAttestScope(
relayOrigin: self.normalizedBaseURLString,
apnsEnvironment: input.environment.rawValue,
relayProfile: input.relayProfile.rawValue,
proofPolicy: input.proofPolicy.rawValue)
let appAttest = try await self.createAppAttestProofIfNeeded(
proofPolicy: input.proofPolicy,
let appAttest = try await self.appAttest.createProof(
challenge: challenge.challenge,
signedPayloadData: signedPayloadData,
scope: appAttestScope)
let receipt = try await self.createReceiptIfNeeded(proofPolicy: input.proofPolicy)
let simulatorProof = try self.createSimulatorProofIfNeeded(
proofPolicy: input.proofPolicy,
signedPayloadData: signedPayloadData)
signedPayload: signedPayloadData)
let receiptBase64 = try await self.receiptProvider.loadReceiptBase64()
let requestBody = PushRelayRegisterRequest(
challengeId: signedPayload.challengeId,
installationId: signedPayload.installationId,
bundleId: signedPayload.bundleId,
environment: signedPayload.environment,
relayProfile: signedPayload.relayProfile,
apnsEnvironment: signedPayload.apnsEnvironment,
proofPolicy: signedPayload.proofPolicy,
distribution: signedPayload.distribution,
gateway: signedPayload.gateway,
appVersion: signedPayload.appVersion,
apnsToken: signedPayload.apnsToken,
appAttest: appAttest.map {
PushRelayAppAttestPayload(
keyId: $0.keyId,
attestationObject: $0.attestationObject,
assertion: $0.assertion,
clientDataHash: $0.clientDataHash,
signedPayloadBase64: $0.signedPayloadBase64)
},
receipt: receipt,
simulatorProof: simulatorProof)
appAttest: PushRelayAppAttestPayload(
keyId: appAttest.keyId,
attestationObject: appAttest.attestationObject,
assertion: appAttest.assertion,
clientDataHash: appAttest.clientDataHash,
signedPayloadBase64: appAttest.signedPayloadBase64),
receipt: PushRelayReceiptPayload(base64: receiptBase64))
let endpoint = self.baseURL.appending(path: "v1/push/register")
var request = URLRequest(url: endpoint)
@@ -340,8 +262,8 @@ final class PushRelayClient: @unchecked Sendable {
if status == 401 {
// If the relay rejects registration, drop local App Attest state so the next
// attempt re-attests instead of getting stuck without an attestation object.
_ = PushRelayRegistrationStore.clearAppAttestKeyID(scope: appAttestScope)
_ = PushRelayRegistrationStore.clearAttestedKeyID(scope: appAttestScope)
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
_ = PushRelayRegistrationStore.clearAttestedKeyID()
}
throw PushRelayError.requestFailed(
status: status,
@@ -350,43 +272,6 @@ final class PushRelayClient: @unchecked Sendable {
return try self.decode(PushRelayRegisterResponse.self, from: data)
}
private func createAppAttestProofIfNeeded(
proofPolicy: PushProofPolicy,
challenge: String,
signedPayloadData: Data,
scope: PushRelayRegistrationStore.AppAttestScope)
async throws -> PushRelayAppAttestProof? {
guard proofPolicy != .internalSimulator else { return nil }
return try await self.appAttest.createProof(
challenge: challenge,
signedPayload: signedPayloadData,
scope: scope)
}
private func createReceiptIfNeeded(
proofPolicy: PushProofPolicy)
async throws -> PushRelayReceiptPayload? {
switch proofPolicy {
case .appleStrict:
return try await PushRelayReceiptPayload(base64: self.receiptProvider.loadReceiptBase64())
case .appleDevelopment:
guard let receiptBase64 = try? await self.receiptProvider.loadReceiptBase64() else {
return nil
}
return PushRelayReceiptPayload(base64: receiptBase64)
case .internalSimulator:
return nil
}
}
private func createSimulatorProofIfNeeded(
proofPolicy: PushProofPolicy,
signedPayloadData: Data)
throws -> PushRelaySimulatorProofPayload? {
guard proofPolicy == .internalSimulator else { return nil }
return try self.simulatorProofProvider.createProof(signedPayload: signedPayloadData)
}
private func fetchChallenge() async throws -> PushRelayChallengeResponse {
let endpoint = self.baseURL.appending(path: "v1/push/challenge")
var request = URLRequest(url: endpoint)

View File

@@ -1,4 +1,3 @@
import CryptoKit
import Foundation
private struct StoredPushRelayRegistrationState: Codable {
@@ -11,9 +10,6 @@ private struct StoredPushRelayRegistrationState: Codable {
var lastAPNsTokenHashHex: String
var installationId: String
var lastTransport: String
var apnsEnvironment: String?
var relayProfile: String?
var proofPolicy: String?
}
enum PushRelayRegistrationStore {
@@ -22,13 +18,6 @@ enum PushRelayRegistrationStore {
private static let appAttestKeyIDAccount = "app-attest-key-id"
private static let appAttestedKeyIDAccount = "app-attested-key-id"
struct AppAttestScope {
var relayOrigin: String
var apnsEnvironment: String
var relayProfile: String
var proofPolicy: String
}
struct RegistrationState: Codable {
var relayHandle: String
var sendGrant: String
@@ -39,9 +28,6 @@ enum PushRelayRegistrationStore {
var lastAPNsTokenHashHex: String
var installationId: String
var lastTransport: String
var apnsEnvironment: String
var relayProfile: String
var proofPolicy: String
}
static func loadRegistrationState() -> RegistrationState? {
@@ -62,10 +48,7 @@ enum PushRelayRegistrationStore {
tokenDebugSuffix: decoded.tokenDebugSuffix,
lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
installationId: decoded.installationId,
lastTransport: decoded.lastTransport,
apnsEnvironment: decoded.apnsEnvironment ?? "production",
relayProfile: decoded.relayProfile ?? "production",
proofPolicy: decoded.proofPolicy ?? "appleStrict")
lastTransport: decoded.lastTransport)
}
@discardableResult
@@ -79,10 +62,7 @@ enum PushRelayRegistrationStore {
tokenDebugSuffix: state.tokenDebugSuffix,
lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,
installationId: state.installationId,
lastTransport: state.lastTransport,
apnsEnvironment: state.apnsEnvironment,
relayProfile: state.relayProfile,
proofPolicy: state.proofPolicy)
lastTransport: state.lastTransport)
guard let data = try? JSONEncoder().encode(stored),
let raw = String(data: data, encoding: .utf8)
else {
@@ -91,66 +71,37 @@ enum PushRelayRegistrationStore {
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
}
static func loadAppAttestKeyID(scope: AppAttestScope) -> String? {
let value = KeychainStore.loadString(
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))?
static func loadAppAttestKeyID() -> String? {
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
@discardableResult
static func saveAppAttestKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
KeychainStore.saveString(
keyID,
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
static func saveAppAttestKeyID(_ keyID: String) -> Bool {
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestKeyIDAccount)
}
@discardableResult
static func clearAppAttestKeyID(scope: AppAttestScope) -> Bool {
KeychainStore.delete(
service: self.service,
account: self.scopedAccount(self.appAttestKeyIDAccount, scope: scope))
static func clearAppAttestKeyID() -> Bool {
KeychainStore.delete(service: self.service, account: self.appAttestKeyIDAccount)
}
static func loadAttestedKeyID(scope: AppAttestScope) -> String? {
let value = KeychainStore.loadString(
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))?
static func loadAttestedKeyID() -> String? {
let value = KeychainStore.loadString(service: self.service, account: self.appAttestedKeyIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
return nil
}
@discardableResult
static func saveAttestedKeyID(_ keyID: String, scope: AppAttestScope) -> Bool {
KeychainStore.saveString(
keyID,
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
static func saveAttestedKeyID(_ keyID: String) -> Bool {
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestedKeyIDAccount)
}
@discardableResult
static func clearAttestedKeyID(scope: AppAttestScope) -> Bool {
KeychainStore.delete(
service: self.service,
account: self.scopedAccount(self.appAttestedKeyIDAccount, scope: scope))
}
private static func scopedAccount(_ baseAccount: String, scope: AppAttestScope) -> String {
let raw = [
scope.relayOrigin,
scope.apnsEnvironment,
scope.relayProfile,
scope.proofPolicy,
].joined(separator: "\n")
let digest = SHA256.hash(data: Data(raw.utf8))
.map { String(format: "%02x", $0) }
.joined()
// A relay sees an App Attest key as attested only after receiving that
// key's attestation object, so keep key state isolated per relay context.
return "\(baseAccount)-\(digest)"
static func clearAttestedKeyID() -> Bool {
KeychainStore.delete(service: self.service, account: self.appAttestedKeyIDAccount)
}
}

View File

@@ -1008,61 +1008,40 @@ final class TalkModeManager: NSObject {
self.logger.info(
"chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
GatewayDiagnostics.log("talk: chat.send start sessionKey=\(sessionKey) chars=\(prompt.count)")
let ack = try await self.sendChat(prompt, gateway: gateway)
let runId = ack.runId
let normalizedStatus = Self.normalizedChatSendStatus(ack.status)
self.logger.info(
"chat.send ok runId=\(runId, privacy: .public) status=\(normalizedStatus, privacy: .public)")
GatewayDiagnostics.log("talk: chat.send ok runId=\(runId) status=\(normalizedStatus)")
if Self.isTerminalChatSendFailure(ack.status) {
self.statusText = normalizedStatus == "error" ? "Chat error" : "Aborted"
self.logger.warning(
"chat.send terminal ack runId=\(runId, privacy: .public) status=\(normalizedStatus, privacy: .public)")
GatewayDiagnostics.log(
"talk: chat.send terminal ack runId=\(runId) status=\(normalizedStatus)")
if restartAfter {
await self.start()
}
return
}
let runId = try await self.sendChat(prompt, gateway: gateway)
self.logger.info("chat.send ok runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat.send ok runId=\(runId)")
let shouldIncremental = self.shouldUseIncrementalTTS()
var streamingTask: Task<Void, Never>?
let completion: ChatCompletionResult
if Self.isTerminalChatSendSuccess(ack.status) {
GatewayDiagnostics.log("talk: chat.send terminal ok runId=\(runId); using history fallback")
completion = ChatCompletionResult(state: .final, assistantText: nil)
} else {
if shouldIncremental {
self.resetIncrementalSpeech()
streamingTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.streamAssistant(runId: runId, gateway: gateway)
}
}
completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120)
if completion.state == .timeout {
self.logger.warning(
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
GatewayDiagnostics.log("talk: chat completion timeout runId=\(runId)")
} else if completion.state == .aborted {
self.statusText = "Aborted"
self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion aborted runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
} else if completion.state == .error {
self.statusText = "Chat error"
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion error runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
if shouldIncremental {
self.resetIncrementalSpeech()
streamingTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.streamAssistant(runId: runId, gateway: gateway)
}
}
let completion = await waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120)
if completion.state == .timeout {
self.logger.warning(
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
GatewayDiagnostics.log("talk: chat completion timeout runId=\(runId)")
} else if completion.state == .aborted {
self.statusText = "Aborted"
self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion aborted runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
} else if completion.state == .error {
self.statusText = "Chat error"
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion error runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
}
var assistantText = completion.assistantText
if assistantText == nil, shouldIncremental {
@@ -1074,7 +1053,7 @@ final class TalkModeManager: NSObject {
if assistantText == nil {
assistantText = try await self.waitForAssistantTextFromHistory(
gateway: gateway,
since: Self.chatSendHistorySince(response: ack, startedAt: startedAt),
since: startedAt,
timeoutSeconds: completion.state == .final ? 12 : 25)
}
guard let assistantText else {
@@ -1364,27 +1343,8 @@ final class TalkModeManager: NSObject {
var assistantText: String?
}
private static func normalizedChatSendStatus(_ status: String) -> String {
status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private static func isTerminalChatSendSuccess(_ status: String) -> Bool {
self.normalizedChatSendStatus(status) == "ok"
}
private static func isTerminalChatSendFailure(_ status: String) -> Bool {
let normalized = self.normalizedChatSendStatus(status)
return normalized == "timeout" || normalized == "error"
}
private static func chatSendHistorySince(
response: OpenClawChatSendResponse,
startedAt: Double) -> Double?
{
self.isTerminalChatSendSuccess(response.status) ? nil : startedAt
}
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> OpenClawChatSendResponse {
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String {
struct SendResponse: Decodable { let runId: String }
let payload: [String: Any] = [
"sessionKey": mainSessionKey,
"message": message,
@@ -1400,7 +1360,8 @@ final class TalkModeManager: NSObject {
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"])
}
let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
return decoded.runId
}
private func waitForChatCompletion(
@@ -1479,7 +1440,7 @@ final class TalkModeManager: NSObject {
private func waitForAssistantTextFromHistory(
gateway: GatewayNodeSession,
since: Double?,
since: Double,
timeoutSeconds: Int) async throws -> String?
{
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))

View File

@@ -35,7 +35,7 @@ struct TalkPermissionPromptView: View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: self.iconSystemName)
.font(.title3.weight(.semibold))
.foregroundStyle(self.requestIsPending ? OpenClawBrand.warn : OpenClawBrand.accent)
.foregroundStyle(self.requestIsPending ? Color.orange : Color.accentColor)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 6) {
@@ -51,7 +51,7 @@ struct TalkPermissionPromptView: View {
if let failureMessage = self.state.failureMessage {
Label(failureMessage, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(OpenClawBrand.danger)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
@@ -99,7 +99,7 @@ struct TalkPermissionPromptView: View {
.overlay {
if self.style == .card || self.style == .sheet {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(OpenClawBrand.accent.opacity(0.20), lineWidth: 1)
.stroke(Color.accentColor.opacity(0.20), lineWidth: 1)
}
}
.task(id: self.pollTaskKey) {

View File

@@ -495,9 +495,6 @@ def produce_services_for_target(target)
if target.fetch("capabilities").include?("APP_GROUPS")
services[:app_group] = "on"
end
if target.fetch("capabilities").include?("APP_ATTEST")
services[:app_attest] = "on"
end
services
end
@@ -608,15 +605,6 @@ def validate_match_profile_capabilities!(target)
)
end
end
if capabilities.include?("APP_ATTEST")
app_attest_environments = profile_plist_array_values(profile_path, "Entitlements:com.apple.developer.devicecheck.appattest-environment")
unless app_attest_environments.include?("production")
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production App Attest entitlement; actual environments: #{app_attest_environments.empty? ? "missing" : app_attest_environments.join(", ")}."
)
end
end
end
def sync_app_store_signing!(readonly:)

View File

@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
pnpm ios:release:signing:setup
```
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app also requires App Attest, and the main app and share extension both require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
Shared encrypted signing storage:

View File

@@ -107,7 +107,7 @@ targets:
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_ENTITLEMENTS: "$(OPENCLAW_CODE_SIGN_ENTITLEMENTS)"
CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
@@ -120,23 +120,17 @@ targets:
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
configs:
Debug:
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
OPENCLAW_PUSH_TRANSPORT: direct
OPENCLAW_PUSH_DISTRIBUTION: local
OPENCLAW_PUSH_RELAY_BASE_URL: ""
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
OPENCLAW_PUSH_RELAY_PROFILE: deviceSandbox
OPENCLAW_PUSH_PROOF_POLICY: appleDevelopment
Release:
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
OPENCLAW_PUSH_TRANSPORT: direct
OPENCLAW_PUSH_DISTRIBUTION: local
OPENCLAW_PUSH_RELAY_BASE_URL: ""
OPENCLAW_PUSH_APNS_ENVIRONMENT: production
OPENCLAW_PUSH_RELAY_PROFILE: production
OPENCLAW_PUSH_PROOF_POLICY: appleStrict
info:
path: Sources/Info.plist
properties:
@@ -182,8 +176,6 @@ targets:
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
OpenClawPushRelayProfile: "$(OPENCLAW_PUSH_RELAY_PROFILE)"
OpenClawPushProofPolicy: "$(OPENCLAW_PUSH_PROOF_POLICY)"
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown

View File

@@ -680,6 +680,83 @@ struct GeneralSettings: View {
case .missingNode, .missingGateway, .incompatible, .error: .orange
}
}
private var healthCard: some View {
let snapshot = self.healthStore.snapshot
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Circle()
.fill(self.healthStore.state.tint)
.frame(width: 10, height: 10)
Text(self.healthStore.summaryLine)
.font(.callout.weight(.semibold))
}
if let snap = snapshot {
let linkId = snap.channelOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
})
let linkLabel =
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ??
"Link channel"
let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
.font(.caption)
.foregroundStyle(.secondary)
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
.font(.caption)
.foregroundStyle(.secondary)
if let recent = snap.sessions.recent.first {
let lastActivity = recent.updatedAt != nil
? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000))
: "unknown"
Text("Last activity: \(recent.key) \(lastActivity)")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Last check: \(relativeAge(from: self.healthStore.lastSuccess))")
.font(.caption)
.foregroundStyle(.secondary)
} else if let error = self.healthStore.lastError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
} else {
Text("Health check pending…")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.healthStore.refresh(onDemand: true) }
} label: {
if self.healthStore.isRefreshing {
ProgressView().controlSize(.small)
} else {
Label("Run Health Check", systemImage: "arrow.clockwise")
}
}
.disabled(self.healthStore.isRefreshing)
Divider().frame(height: 18)
Button {
self.revealLogs()
} label: {
Label("Reveal Logs", systemImage: "doc.text.magnifyingglass")
}
}
}
.padding(12)
.background(Color.gray.opacity(0.08))
.cornerRadius(10)
}
}
private enum RemoteStatus: Equatable {
@@ -762,6 +839,11 @@ extension GeneralSettings {
}
}
private func healthAgeString(_ ms: Double?) -> String {
guard let ms else { return "unknown" }
return msToAge(ms)
}
#if DEBUG
struct GeneralSettings_Previews: PreviewProvider {
static var previews: some View {

View File

@@ -221,6 +221,16 @@ final class InstancesStore {
}
}
private func decodeAndApplyPresenceData(_ data: Data) {
do {
let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data)
self.applyPresence(decoded)
} catch {
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
}
}
func handlePresenceEventPayload(_ payload: OpenClawProtocol.AnyCodable) {
do {
let wrapper = try GatewayPayloadDecoding.decode(payload, as: PresenceEventPayload.self)

View File

@@ -16,6 +16,7 @@ struct OpenClawApp: App {
private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@@ -33,7 +34,6 @@ struct OpenClawApp: App {
init() {
OpenClawLogging.bootstrapIfNeeded()
GatewayConnectivityCoordinator.shared.start()
Self.applyAttachOnlyOverrideIfNeeded()
_state = State(initialValue: AppStateStore.shared)

View File

@@ -1045,6 +1045,16 @@ extension MenuSessionsInjector {
return item
}
private func formatVersionLabel(_ version: String) -> String {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return version }
if trimmed.hasPrefix("v") { return trimmed }
if let first = trimmed.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) {
return "v\(trimmed)"
}
return trimmed
}
@objc
private func patchThinking(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any],

View File

@@ -217,6 +217,18 @@ extension String? {
}
}
extension [String] {
fileprivate func dedupedPreserveOrder() -> [String] {
var seen = Set<String>()
var result: [String] = []
for item in self where !seen.contains(item) {
seen.insert(item)
result.append(item)
}
return result
}
}
enum SessionLoadError: LocalizedError {
case gatewayUnavailable(String)
case decodeFailed(String)

View File

@@ -391,40 +391,20 @@ actor TalkModeRuntime {
idempotencyKey: runId,
attachments: [])
guard self.isCurrent(gen) else { return }
let normalizedStatus = Self.normalizedChatSendStatus(response.status)
self.logger.info(
"talk chat.send ok runId=\(response.runId, privacy: .public) " +
"status=\(normalizedStatus, privacy: .public) " +
"session=\(sessionKey, privacy: .public)")
if Self.isTerminalChatSendFailure(response.status) {
self.logger.warning(
"talk chat.send terminal ack runId=\(response.runId, privacy: .public) " +
"status=\(normalizedStatus, privacy: .public)")
await self.resumeListeningIfNeeded()
return
}
var assistantText: String?
if Self.isTerminalChatSendSuccess(response.status) {
self.logger.info(
"talk chat.send terminal ok runId=\(response.runId, privacy: .public); " +
"using history fallback")
var assistantText = await self.waitForAssistantEventText(
sessionKey: sessionKey,
runId: response.runId,
timeoutSeconds: 45)
if assistantText == nil {
self.logger.warning("talk assistant event text missing; using history fallback")
assistantText = await self.waitForAssistantTextFromHistory(
sessionKey: sessionKey,
since: nil,
since: startedAt,
timeoutSeconds: 12)
} else {
assistantText = await self.waitForAssistantEventText(
sessionKey: sessionKey,
runId: response.runId,
timeoutSeconds: 45)
if assistantText == nil {
self.logger.warning("talk assistant event text missing; using history fallback")
assistantText = await self.waitForAssistantTextFromHistory(
sessionKey: sessionKey,
since: startedAt,
timeoutSeconds: 12)
}
}
guard let assistantText
else {
@@ -519,19 +499,6 @@ actor TalkModeRuntime {
}
}
private static func normalizedChatSendStatus(_ status: String) -> String {
status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private static func isTerminalChatSendSuccess(_ status: String) -> Bool {
self.normalizedChatSendStatus(status) == "ok"
}
private static func isTerminalChatSendFailure(_ status: String) -> Bool {
let normalized = self.normalizedChatSendStatus(status)
return normalized == "timeout" || normalized == "error"
}
private static func matchesSessionKey(_ incoming: String, _ current: String) -> Bool {
let incoming = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let current = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
@@ -542,7 +509,7 @@ actor TalkModeRuntime {
private func waitForAssistantTextFromHistory(
sessionKey: String,
since: Double?,
since: Double,
timeoutSeconds: Int) async -> String?
{
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))

View File

@@ -23,6 +23,7 @@ struct VoiceWakeSettings: View {
@State private var meterStartupTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview

View File

@@ -368,6 +368,31 @@ final class VoiceWakeTester {
}
}
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
Task { [weak self] in
guard let self else { return }
let detectedAt = Date()
let hardStop = detectedAt.addingTimeInterval(6) // cap overall listen after trigger
while !self.isStopping {
let now = Date()
if now >= hardStop { break }
if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceWindow {
break
}
try? await Task.sleep(nanoseconds: 200_000_000)
}
if !self.isStopping {
self.stop()
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
if let detectedText {
self.logger.info("voice wake hold finished; len=\(detectedText.count)")
Task { @MainActor in onUpdate(.detected(detectedText)) }
}
}
}
}
private func scheduleSilenceCheck(
triggers: [String],
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void)

View File

@@ -283,7 +283,7 @@ struct OpenClawChatComposer: View {
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(OpenClawChatTheme.accent.opacity(0.08))
.background(Color.accentColor.opacity(0.08))
.clipShape(Capsule())
}
}
@@ -550,7 +550,7 @@ struct OpenClawChatComposer: View {
.frame(width: self.sendButtonSize, height: self.sendButtonSize)
.background(
RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous)
.fill(OpenClawChatTheme.danger))
.fill(Color.red))
.contentShape(RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous))
.accessibilityLabel("Stop response")
.disabled(self.viewModel.isAborting)

View File

@@ -57,7 +57,7 @@ private struct ChatMarkdownStyle: ViewModifier {
}
private var inlineStyle: InlineStyle {
let linkColor: Color = self.context == .user ? self.textColor : OpenClawChatTheme.accent
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
return InlineStyle()
.code(.monospaced, .fontScale(codeScale))

View File

@@ -25,7 +25,7 @@ struct ChatAgentAvatar: View {
.fill(
LinearGradient(
colors: [
(self.tint ?? OpenClawChatTheme.accent).opacity(0.95),
(self.tint ?? Color.accentColor).opacity(0.95),
Color(red: 38 / 255.0, green: 40 / 255.0, blue: 43 / 255.0),
],
startPoint: .topLeading,
@@ -33,7 +33,7 @@ struct ChatAgentAvatar: View {
.overlay(
Circle()
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
.shadow(color: (self.tint ?? OpenClawChatTheme.accent).opacity(0.18), radius: 8, y: 4)
.shadow(color: (self.tint ?? Color.accentColor).opacity(0.18), radius: 8, y: 4)
.accessibilityLabel(self.name.map { "\($0) avatar" } ?? "Agent avatar")
}

View File

@@ -152,18 +152,6 @@ enum OpenClawChatTheme {
#endif
}
static var accent: Color {
self.userBubble
}
static var danger: Color {
#if os(macOS)
Color(nsColor: .systemRed)
#else
Color(uiColor: .systemRed)
#endif
}
static var assistantBubble: Color {
#if os(macOS)
Color(nsColor: self.assistantBubbleDynamicNSColor)

View File

@@ -235,7 +235,7 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
showsSessionSwitcher: false,
style: .onboarding,
markdownVariant: .standard,
userAccent: OpenClawChatTheme.accent)
userAccent: .blue)
}
private struct OpenClawChatPreview: View {
@@ -250,7 +250,7 @@ private struct OpenClawChatPreview: View {
showsSessionSwitcher: true,
style: .standard,
markdownVariant: .standard,
userAccent: OpenClawChatTheme.accent,
userAccent: .blue,
showsAssistantTrace: true)
}
}

View File

@@ -372,7 +372,7 @@ public struct OpenClawChatView: View {
systemImage: "bubble.left.and.bubble.right.fill",
title: self.emptyStateTitle,
message: self.emptyStateMessage,
tint: OpenClawChatTheme.accent,
tint: .accentColor,
actionTitle: nil,
action: nil)
.padding(.horizontal, 24)

View File

@@ -868,32 +868,25 @@ public final class OpenClawChatViewModel {
self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = pendingUserMessageID
self.armPendingRunTimeout(runId: response.runId)
}
if response.status == "ok" {
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
await self.refreshHistoryAfterRun(historyRequest: historyContext)
guard self.isCurrentSession(sessionSnapshot) else { return }
self.finishPendingRunAfterTerminalOkSendAck(response)
} else if !self.finishPendingRunIfTerminalSendAck(response) {
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
await self.refreshHistoryAfterRun(historyRequest: historyContext)
guard self.isCurrentSession(sessionSnapshot) else { return }
if !self.clearPendingRunIfAssistantMessagePresent(
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
await self.refreshHistoryAfterRun(historyRequest: historyContext)
guard self.isCurrentSession(sessionSnapshot) else { return }
if !self.clearPendingRunIfAssistantMessagePresent(
runId: response.runId,
after: userMessageTimestamp)
{
self.armPostSendRefreshFallback(
runId: response.runId,
after: userMessageTimestamp)
{
self.armPostSendRefreshFallback(
runId: response.runId,
sessionSnapshot: sessionSnapshot,
userMessageTimestamp: userMessageTimestamp)
self.armRunCompletionRefresh(
runId: response.runId,
sessionSnapshot: sessionSnapshot,
userMessageTimestamp: userMessageTimestamp)
}
sessionSnapshot: sessionSnapshot,
userMessageTimestamp: userMessageTimestamp)
self.armRunCompletionRefresh(
runId: response.runId,
sessionSnapshot: sessionSnapshot,
userMessageTimestamp: userMessageTimestamp)
}
} catch {
guard self.isCurrentSession(sessionSnapshot) else { return }
self.removePendingLocalUserEcho(for: runId)
self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
self.clearPendingRun(runId)
self.errorText = error.localizedDescription
self.logDiagnostic(
@@ -1724,48 +1717,6 @@ public final class OpenClawChatViewModel {
return "Chat failed"
}
private func finishPendingRunAfterTerminalOkSendAck(_ response: OpenClawChatSendResponse) {
self.clearPendingRun(response.runId)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.logDiagnostic(
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
+ "runId=\(response.runId) status=ok")
}
private func finishPendingRunIfTerminalSendAck(_ response: OpenClawChatSendResponse) -> Bool {
switch response.status {
case "timeout":
self.removePendingLocalUserEcho(for: response.runId)
self.clearPendingRun(response.runId)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.errorText = "Chat failed before the run started; try again."
self.logDiagnostic(
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
+ "runId=\(response.runId) status=timeout")
return true
case "error":
self.removePendingLocalUserEcho(for: response.runId)
self.clearPendingRun(response.runId)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.errorText = "Chat failed before the run started; try again."
self.logDiagnostic(
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
+ "runId=\(response.runId) status=error")
return true
default:
return false
}
}
private func removePendingLocalUserEcho(for runId: String) {
guard let messageID = self.pendingLocalUserEchoMessageIDsByRunID[runId] else { return }
self.messages.removeAll { $0.id == messageID }
self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
}
private func armPostSendRefreshFallback(
runId: String,
sessionSnapshot: SessionSnapshot,

View File

@@ -21,15 +21,6 @@ private func chatErrorMessage(role: String, errorMessage: String, timestamp: Dou
])
}
fileprivate extension Array where Element == OpenClawChatMessage {
func containsUserText(_ text: String) -> Bool {
self.contains { message in
message.role == "user" &&
message.content.contains { $0.text == text }
}
}
}
private func historyPayload(
sessionKey: String = "main",
sessionId: String? = "sess-main",
@@ -113,7 +104,6 @@ private func makeViewModel(
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)? = nil,
waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil,
healthResponses: [Bool] = [true],
initialThinkingLevel: String? = nil,
@@ -132,7 +122,6 @@ private func makeViewModel(
compactSessionHook: compactSessionHook,
setSessionModelHook: setSessionModelHook,
setSessionThinkingHook: setSessionThinkingHook,
sendMessageHook: sendMessageHook,
waitForRunCompletionHook: waitForRunCompletionHook,
healthResponses: healthResponses)
let vm = await MainActor.run {
@@ -358,7 +347,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
private let compactSessionHook: (@Sendable (String) async throws -> Void)?
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
private let sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)?
private let waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)?
private let healthResponses: [Bool]
@@ -376,7 +364,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)? = nil,
waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil,
healthResponses: [Bool] = [true])
{
@@ -390,7 +377,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
self.compactSessionHook = compactSessionHook
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
self.sendMessageHook = sendMessageHook
self.waitForRunCompletionHook = waitForRunCompletionHook
self.healthResponses = healthResponses
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
@@ -449,9 +435,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
await self.state.sentSessionKeysAppend(sessionKey)
await self.state.sentRunIdsAppend(idempotencyKey)
await self.state.sentThinkingLevelsAppend(thinking)
if let sendMessageHook {
return try await sendMessageHook(idempotencyKey)
}
return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
}
@@ -897,50 +880,6 @@ struct ChatViewModelTests {
#expect(await MainActor.run { vm.input } == "second")
}
@Test func terminalOkSendAckClearsPendingRunWithoutWaitingForCompletion() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId, messages: [])
let (transport, vm) = await makeViewModel(historyResponses: [history, history])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "cached")
try await waitUntil("terminal ok ack clears pending run") {
await MainActor.run { vm.pendingRunCount == 0 && !vm.isSending }
}
#expect(await MainActor.run { vm.errorText } == nil)
#expect(await transport.waitCompletionRunIds().isEmpty)
#expect(await MainActor.run { vm.messages.containsUserText("cached") })
}
@Test func terminalTimeoutSendAckSurfacesErrorAndAllowsNextSend() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId, messages: [])
let sendCount = AsyncCounter()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sendMessageHook: { runId in
let count = await sendCount.increment()
return OpenClawChatSendResponse(
runId: runId,
status: count == 1 ? "timeout" : "ok")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "first")
try await waitUntil("timeout ack clears pending run") {
await MainActor.run { vm.pendingRunCount == 0 && !vm.isSending }
}
#expect(await transport.sentRunIds().count == 1)
#expect(await MainActor.run { vm.errorText } == "Chat failed before the run started; try again.")
#expect(await MainActor.run { !vm.messages.containsUserText("first") })
await sendUserMessage(vm, text: "second")
try await waitUntil("second send is accepted after timeout ack") {
await transport.sentRunIds().count == 2
}
}
@Test func `keeps optimistic user message when final refresh returns only assistant history`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000

View File

@@ -138,7 +138,6 @@ const config = {
entry: rootEntries,
ignoreDependencies: [
"@openclaw/*",
"cross-spawn",
"file-type",
"playwright-core",
"sqlite-vec",

View File

@@ -1,4 +1,4 @@
ee542300a1f9d5c23e772d47f2acfcc92ee0a4da210974306790bf2220b80277 config-baseline.json
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
de674ef01dad2828bb711a4648dc5a00f696f71c3c59004131d9475769bc1ff8 config-baseline.channel.json
ce2a731077f0f0135b7eaf01b00a60abfa0d2776aba4be237491d492af0c8a02 config-baseline.plugin.json
3ac3be8b7e201eb577854806a9806ba90acbfb2616e14b3ffd1169f188620303 config-baseline.json
2923c1120c0369aeca6646cd67f7264590c6a1f4e5bc3157a04d7661324c6868 config-baseline.core.json
769899651e2769833ae7e9c8fbf402e55f3d5e32da6bfe21a9659cc35d1f07bb config-baseline.channel.json
d2e2114f1cd43dc894fe1a4836677b42a2a5af825537d6c4a932da832d58a590 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
8d38dc64627e6bfcebc25215d499a30841953799133dea16ffce902cf301273f plugin-sdk-api-baseline.json
d7927d51588fd006d743fc56cc22d779141b487f82655d88054d2be12f3093ff plugin-sdk-api-baseline.jsonl
172fe4e143964c0a20525428ff3e6c7631856a7d51c6ad48959a35c72363a410 plugin-sdk-api-baseline.json
a4c18ea9f0b0d2c22183bf8c082e757b7f9852b4c518c8b8cb62a21a9dd766e9 plugin-sdk-api-baseline.jsonl

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